diff --git a/index.html b/index.html index 4800c88..a1ca840 100644 --- a/index.html +++ b/index.html @@ -44,6 +44,10 @@ diff --git a/js/exporter.js b/js/exporter.js index 53b5dc1..6e881b7 100644 --- a/js/exporter.js +++ b/js/exporter.js @@ -10,15 +10,10 @@ const exporter = new STLExporter(); * @param {string} [filename] */ 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. - // Undo that rotation before export so the STL lands back in the original Z-up orientation - // that 3D-print slicers expect. - const exportGeom = geometry.clone(); - exportGeom.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - - const mesh = new THREE.Mesh(exportGeom, new THREE.MeshBasicMaterial()); + // Geometry is already in the original Z-up orientation (the loader never rotates it; + // the viewer uses a Z-up camera instead). Export as-is so slicers receive the correct pose. + const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial()); const result = exporter.parse(mesh, { binary: true }); - exportGeom.dispose(); // result is an ArrayBuffer in binary mode const blob = new Blob([result], { type: 'application/octet-stream' }); diff --git a/js/main.js b/js/main.js index 561742e..0301982 100644 --- a/js/main.js +++ b/js/main.js @@ -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 { PRESETS, loadCustomTexture } from './presetTextures.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 exportProgLbl = document.getElementById('export-progress-label'); const triLimitWarning = document.getElementById('tri-limit-warning'); +const wireframeToggle = document.getElementById('wireframe-toggle'); const mappingSelect = document.getElementById('mapping-mode'); const scaleUSlider = document.getElementById('scale-u'); @@ -192,6 +193,9 @@ function wireEvents() { // ── Export ── exportBtn.addEventListener('click', handleExport); + + // ── Wireframe ── + wireframeToggle.addEventListener('change', () => setWireframe(wireframeToggle.checked)); } // ── Slider helper ───────────────────────────────────────────────────────────── diff --git a/js/mapping.js b/js/mapping.js index 66b925f..d4d681d 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -51,12 +51,27 @@ export function computeUV(pos, normal, mode, settings, bounds) { } 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 ry = pos.y - center.y; - const theta = Math.atan2(ry, rx); // [-PI, PI] - u = (theta / TWO_PI) + 0.5; // [0, 1] - v = (pos.z - min.z) / Math.max(size.z, 1e-6); + if (Math.abs(normal.z) > 0.5) { + // Cap face — normalise by C so one tile = same world size as on the side + 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; } diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 495a215..d8b7787 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -77,10 +77,27 @@ const fragmentShader = /* glsl */` return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4))); } else if (mappingMode == 3) { - // Cylindrical around Z axis (Z is up) - float u = atan(rel.y, rel.x) / TWO_PI + 0.5; - float v = (pos.z - boundsMin.z) / max(boundsSize.z, 1e-4); - return sampleMap(vec2(u, v)); + // Cylindrical around Z axis (Z is up) with automatic caps. + // + // Side: V is arc-length-normalised (divided by circumference C = 2πr) + // 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) { // Spherical — Z is up diff --git a/js/viewer.js b/js/viewer.js index 42254e2..1dfa0d0 100644 --- a/js/viewer.js +++ b/js/viewer.js @@ -1,9 +1,14 @@ import * as THREE from 'three'; 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 currentMesh = 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`. // X = red, Y = green, Z = blue (up). @@ -131,6 +136,13 @@ function onResize() { camera.left = -halfH * aspect; camera.right = halfH * aspect; 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; 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) geometry.computeBoundingBox(); const box = geometry.boundingBox; @@ -232,3 +249,51 @@ export function getRenderer() { return renderer; } export function getCamera() { return camera; } export function getScene() { return scene; } 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); +} diff --git a/style.css b/style.css index 2901843..cbe9cdf 100644 --- a/style.css +++ b/style.css @@ -145,6 +145,16 @@ main { 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 { width: var(--sidebar-w);