mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
Add wireframe toggle in UI and implement wireframe rendering functionality
This commit is contained in:
@@ -44,6 +44,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="viewport-footer">
|
<div id="viewport-footer">
|
||||||
<span id="mesh-info" class="mesh-info"></span>
|
<span id="mesh-info" class="mesh-info"></span>
|
||||||
|
<label class="wireframe-toggle">
|
||||||
|
<input type="checkbox" id="wireframe-toggle" />
|
||||||
|
Wireframe
|
||||||
|
</label>
|
||||||
<div class="viewport-controls-hint">Left drag: orbit · Right drag: pan · Scroll: zoom</div>
|
<div class="viewport-controls-hint">Left drag: orbit · Right drag: pan · Scroll: zoom</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+3
-8
@@ -10,15 +10,10 @@ const exporter = new STLExporter();
|
|||||||
* @param {string} [filename]
|
* @param {string} [filename]
|
||||||
*/
|
*/
|
||||||
export function exportSTL(geometry, filename = 'textured.stl') {
|
export function exportSTL(geometry, filename = 'textured.stl') {
|
||||||
// The geometry was rotated -90° around X on load to convert Z-up → Y-up for the viewer.
|
// Geometry is already in the original Z-up orientation (the loader never rotates it;
|
||||||
// Undo that rotation before export so the STL lands back in the original Z-up orientation
|
// the viewer uses a Z-up camera instead). Export as-is so slicers receive the correct pose.
|
||||||
// that 3D-print slicers expect.
|
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial());
|
||||||
const exportGeom = geometry.clone();
|
|
||||||
exportGeom.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(exportGeom, new THREE.MeshBasicMaterial());
|
|
||||||
const result = exporter.parse(mesh, { binary: true });
|
const result = exporter.parse(mesh, { binary: true });
|
||||||
exportGeom.dispose();
|
|
||||||
|
|
||||||
// result is an ArrayBuffer in binary mode
|
// result is an ArrayBuffer in binary mode
|
||||||
const blob = new Blob([result], { type: 'application/octet-stream' });
|
const blob = new Blob([result], { type: 'application/octet-stream' });
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
import { initViewer, loadGeometry, setMeshMaterial } from './viewer.js';
|
import { initViewer, loadGeometry, setMeshMaterial, setWireframe } from './viewer.js';
|
||||||
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
|
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
|
||||||
import { PRESETS, loadCustomTexture } from './presetTextures.js';
|
import { PRESETS, loadCustomTexture } from './presetTextures.js';
|
||||||
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
|
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
|
||||||
@@ -42,6 +42,7 @@ const exportProgress = document.getElementById('export-progress');
|
|||||||
const exportProgBar = document.getElementById('export-progress-bar');
|
const exportProgBar = document.getElementById('export-progress-bar');
|
||||||
const exportProgLbl = document.getElementById('export-progress-label');
|
const exportProgLbl = document.getElementById('export-progress-label');
|
||||||
const triLimitWarning = document.getElementById('tri-limit-warning');
|
const triLimitWarning = document.getElementById('tri-limit-warning');
|
||||||
|
const wireframeToggle = document.getElementById('wireframe-toggle');
|
||||||
|
|
||||||
const mappingSelect = document.getElementById('mapping-mode');
|
const mappingSelect = document.getElementById('mapping-mode');
|
||||||
const scaleUSlider = document.getElementById('scale-u');
|
const scaleUSlider = document.getElementById('scale-u');
|
||||||
@@ -192,6 +193,9 @@ function wireEvents() {
|
|||||||
|
|
||||||
// ── Export ──
|
// ── Export ──
|
||||||
exportBtn.addEventListener('click', handleExport);
|
exportBtn.addEventListener('click', handleExport);
|
||||||
|
|
||||||
|
// ── Wireframe ──
|
||||||
|
wireframeToggle.addEventListener('change', () => setWireframe(wireframeToggle.checked));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Slider helper ─────────────────────────────────────────────────────────────
|
// ── Slider helper ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+19
-4
@@ -51,12 +51,27 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case MODE_CYLINDRICAL: {
|
case MODE_CYLINDRICAL: {
|
||||||
// Z is up: wrap around Z axis, height along Z
|
// Cylindrical around Z axis with automatic caps.
|
||||||
|
//
|
||||||
|
// Side: V arc-length-normalised by circumference C = 2πr so that
|
||||||
|
// scaleU = scaleV gives un-stretched square texels on the surface.
|
||||||
|
//
|
||||||
|
// Cap (|normalZ| > 0.5): planar XY centred on the axis, scaled to the
|
||||||
|
// diameter so one tile covers the full cap disc.
|
||||||
|
const r = Math.max(size.x, size.y) * 0.5;
|
||||||
|
const C = TWO_PI * Math.max(r, 1e-6);
|
||||||
const rx = pos.x - center.x;
|
const rx = pos.x - center.x;
|
||||||
const ry = pos.y - center.y;
|
const ry = pos.y - center.y;
|
||||||
const theta = Math.atan2(ry, rx); // [-PI, PI]
|
if (Math.abs(normal.z) > 0.5) {
|
||||||
u = (theta / TWO_PI) + 0.5; // [0, 1]
|
// Cap face — normalise by C so one tile = same world size as on the side
|
||||||
v = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
u = rx / C + 0.5;
|
||||||
|
v = ry / C + 0.5;
|
||||||
|
} else {
|
||||||
|
// Side face
|
||||||
|
const theta = Math.atan2(ry, rx);
|
||||||
|
u = (theta / TWO_PI) + 0.5;
|
||||||
|
v = (pos.z - min.z) / C;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -77,10 +77,27 @@ const fragmentShader = /* glsl */`
|
|||||||
return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
||||||
|
|
||||||
} else if (mappingMode == 3) {
|
} else if (mappingMode == 3) {
|
||||||
// Cylindrical around Z axis (Z is up)
|
// Cylindrical around Z axis (Z is up) with automatic caps.
|
||||||
float u = atan(rel.y, rel.x) / TWO_PI + 0.5;
|
//
|
||||||
float v = (pos.z - boundsMin.z) / max(boundsSize.z, 1e-4);
|
// Side: V is arc-length-normalised (divided by circumference C = 2πr)
|
||||||
return sampleMap(vec2(u, v));
|
// so that scaleU = scaleV gives square, un-stretched texels on the surface.
|
||||||
|
//
|
||||||
|
// Cap (|normalZ| > 0.5): planar XY centred on the cylinder axis, one tile
|
||||||
|
// fills the diameter × diameter square so the disc looks fully textured.
|
||||||
|
float r = max(boundsSize.x, boundsSize.y) * 0.5;
|
||||||
|
float C = TWO_PI * max(r, 1e-4);
|
||||||
|
if (abs(vModelNormal.z) > 0.5) {
|
||||||
|
// Cap face — normalise by C so one tile = same world size as on the side
|
||||||
|
return sampleMap(vec2(
|
||||||
|
rel.x / C + 0.5,
|
||||||
|
rel.y / C + 0.5
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Side face
|
||||||
|
return sampleMap(vec2(
|
||||||
|
atan(rel.y, rel.x) / TWO_PI + 0.5,
|
||||||
|
(pos.z - boundsMin.z) / C
|
||||||
|
));
|
||||||
|
|
||||||
} else if (mappingMode == 4) {
|
} else if (mappingMode == 4) {
|
||||||
// Spherical — Z is up
|
// Spherical — Z is up
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
|
||||||
|
import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
|
||||||
|
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
||||||
|
|
||||||
let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid;
|
let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid;
|
||||||
let currentMesh = null;
|
let currentMesh = null;
|
||||||
let axesGroup = null;
|
let axesGroup = null;
|
||||||
|
let wireframeLines = null; // LineSegments overlay, or null when hidden
|
||||||
|
let wireframeVisible = false;
|
||||||
|
|
||||||
// Build a labelled coordinate axes indicator scaled to `size`.
|
// Build a labelled coordinate axes indicator scaled to `size`.
|
||||||
// X = red, Y = green, Z = blue (up).
|
// X = red, Y = green, Z = blue (up).
|
||||||
@@ -131,6 +136,13 @@ function onResize() {
|
|||||||
camera.left = -halfH * aspect;
|
camera.left = -halfH * aspect;
|
||||||
camera.right = halfH * aspect;
|
camera.right = halfH * aspect;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
|
// LineMaterial needs the actual pixel resolution to compute linewidth correctly
|
||||||
|
if (wireframeLines) {
|
||||||
|
wireframeLines.material.resolution.set(
|
||||||
|
w * renderer.getPixelRatio(),
|
||||||
|
h * renderer.getPixelRatio(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,6 +173,11 @@ export function loadGeometry(geometry, material) {
|
|||||||
currentMesh.receiveShadow = true;
|
currentMesh.receiveShadow = true;
|
||||||
meshGroup.add(currentMesh);
|
meshGroup.add(currentMesh);
|
||||||
|
|
||||||
|
// Rebuild wireframe overlay to match the new geometry
|
||||||
|
// (old overlay is already gone because meshGroup was cleared above)
|
||||||
|
wireframeLines = null;
|
||||||
|
if (wireframeVisible) _buildWireframe(geometry);
|
||||||
|
|
||||||
// Position grid at mesh bottom (Z-up: move grid along Z)
|
// Position grid at mesh bottom (Z-up: move grid along Z)
|
||||||
geometry.computeBoundingBox();
|
geometry.computeBoundingBox();
|
||||||
const box = geometry.boundingBox;
|
const box = geometry.boundingBox;
|
||||||
@@ -232,3 +249,51 @@ export function getRenderer() { return renderer; }
|
|||||||
export function getCamera() { return camera; }
|
export function getCamera() { return camera; }
|
||||||
export function getScene() { return scene; }
|
export function getScene() { return scene; }
|
||||||
export function getCurrentMesh() { return currentMesh; }
|
export function getCurrentMesh() { return currentMesh; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the triangle-edge wireframe overlay.
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
export function setWireframe(enabled) {
|
||||||
|
wireframeVisible = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
if (!wireframeLines && currentMesh) _buildWireframe(currentMesh.geometry);
|
||||||
|
if (wireframeLines) wireframeLines.visible = true;
|
||||||
|
} else {
|
||||||
|
if (wireframeLines) wireframeLines.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildWireframe(geometry) {
|
||||||
|
// Dispose any stale overlay
|
||||||
|
if (wireframeLines) {
|
||||||
|
if (wireframeLines.parent) wireframeLines.parent.remove(wireframeLines);
|
||||||
|
wireframeLines.geometry.dispose();
|
||||||
|
wireframeLines.material.dispose();
|
||||||
|
wireframeLines = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EdgesGeometry gives one segment per unique triangle edge
|
||||||
|
const edgesGeo = new THREE.EdgesGeometry(geometry, 1);
|
||||||
|
|
||||||
|
// Convert to LineSegmentsGeometry (required by LineMaterial / LineSegments2)
|
||||||
|
const lsGeo = new LineSegmentsGeometry().fromEdgesGeometry(edgesGeo);
|
||||||
|
edgesGeo.dispose();
|
||||||
|
|
||||||
|
const lsMat = new LineMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
opacity: 0.75,
|
||||||
|
transparent: true,
|
||||||
|
linewidth: 1.5, // pixels — works on all desktop GPUs
|
||||||
|
depthTest: true,
|
||||||
|
resolution: new THREE.Vector2(
|
||||||
|
renderer.domElement.width * renderer.getPixelRatio(),
|
||||||
|
renderer.domElement.height * renderer.getPixelRatio(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
wireframeLines = new LineSegments2(lsGeo, lsMat);
|
||||||
|
wireframeLines.renderOrder = 1;
|
||||||
|
// Add to meshGroup so it's automatically removed when a new model is loaded
|
||||||
|
meshGroup.add(wireframeLines);
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,16 @@ main {
|
|||||||
color: #555566;
|
color: #555566;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wireframe-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Settings panel ──────────────────────────────────────────────────── */
|
/* ── Settings panel ──────────────────────────────────────────────────── */
|
||||||
#settings-panel {
|
#settings-panel {
|
||||||
width: var(--sidebar-w);
|
width: var(--sidebar-w);
|
||||||
|
|||||||
Reference in New Issue
Block a user