diff --git a/js/displacement.js b/js/displacement.js index 3c2affe..1e814e2 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -26,8 +26,15 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const newPos = new Float32Array(count * 3); const newNrm = new Float32Array(count * 3); - const tmpPos = new THREE.Vector3(); - const tmpNrm = new THREE.Vector3(); + const tmpPos = new THREE.Vector3(); + const tmpNrm = new THREE.Vector3(); + // Reusable vectors for per-face normal computation + const vA = new THREE.Vector3(); + const vB = new THREE.Vector3(); + const vC = new THREE.Vector3(); + const edge1 = new THREE.Vector3(); + const edge2 = new THREE.Vector3(); + const faceNrm = new THREE.Vector3(); const REPORT_EVERY = 5000; @@ -35,7 +42,24 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett tmpPos.fromBufferAttribute(posAttr, i); tmpNrm.fromBufferAttribute(nrmAttr, i); - const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds); + // Compute a stable face normal from the triangle's own vertex positions. + // The subdivider deduplicates vertices by position only, so shared corner + // vertices pick up whichever face's normal happened to be stored first. + // For hard-edged meshes (e.g. a cube) this corrupts the stored normals at + // edges/corners. Recomputing from the triangle geometry is always correct + // for the flat-shaded STL source data and gives the right normal for both + // displacement direction and UV projection. + const base = Math.floor(i / 3) * 3; + vA.fromBufferAttribute(posAttr, base); + vB.fromBufferAttribute(posAttr, base + 1); + vC.fromBufferAttribute(posAttr, base + 2); + edge1.subVectors(vB, vA); + edge2.subVectors(vC, vA); + faceNrm.crossVectors(edge1, edge2); + // Fall back to the stored vertex normal for degenerate triangles + const useNrm = faceNrm.lengthSq() > 1e-10 ? faceNrm.normalize() : tmpNrm; + + const uvResult = computeUV(tmpPos, useNrm, settings.mappingMode, settings, bounds); let grey; if (uvResult.triplanar) { @@ -50,13 +74,13 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const disp = grey * settings.amplitude; - newPos[i*3] = tmpPos.x + tmpNrm.x * disp; - newPos[i*3+1] = tmpPos.y + tmpNrm.y * disp; - newPos[i*3+2] = tmpPos.z + tmpNrm.z * disp; + newPos[i*3] = tmpPos.x + useNrm.x * disp; + newPos[i*3+1] = tmpPos.y + useNrm.y * disp; + newPos[i*3+2] = tmpPos.z + useNrm.z * disp; - newNrm[i*3] = tmpNrm.x; - newNrm[i*3+1] = tmpNrm.y; - newNrm[i*3+2] = tmpNrm.z; + newNrm[i*3] = useNrm.x; + newNrm[i*3+1] = useNrm.y; + newNrm[i*3+2] = useNrm.z; if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count); } diff --git a/js/main.js b/js/main.js index 90420b1..b932862 100644 --- a/js/main.js +++ b/js/main.js @@ -245,7 +245,7 @@ async function handleSTL(file) { // Default edge length = 1/100 of the largest bounding box dimension const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z); - const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 100).toFixed(2))); + const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 200).toFixed(2))); settings.refineLength = defaultEdge; refineLenSlider.value = defaultEdge; refineLenVal.textContent = `${defaultEdge.toFixed(2)} mm`; diff --git a/js/mapping.js b/js/mapping.js index 03ba126..ddb7913 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -78,9 +78,9 @@ export function computeUV(pos, normal, mode, settings, bounds) { const az = Math.abs(normal.z); let uRaw, vRaw; if (ax >= ay && ax >= az) { - // ±X dominant → project onto YZ - uRaw = (pos.y - min.y) / Math.max(size.y, 1e-6); - vRaw = (pos.z - min.z) / Math.max(size.z, 1e-6); + // ±X dominant → project onto ZY (U=Z, V=Y keeps texture upright on side faces) + uRaw = (pos.z - min.z) / Math.max(size.z, 1e-6); + vRaw = (pos.y - min.y) / Math.max(size.y, 1e-6); } else if (ay >= ax && ay >= az) { // ±Y dominant → project onto XZ uRaw = (pos.x - min.x) / Math.max(size.x, 1e-6); diff --git a/js/previewMaterial.js b/js/previewMaterial.js index d99370c..a3fd768 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -106,8 +106,8 @@ const fragmentShader = /* glsl */` // Picks the single planar projection whose axis is most aligned with the face normal. vec3 absN = abs(MN); if (absN.x >= absN.y && absN.x >= absN.z) { - // ±X dominant → project onto YZ plane - return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4))); + // ±X dominant → project onto ZY plane (U=Z, V=Y keeps texture upright on side faces) + return sampleMap((pos.zy - boundsMin.zy) / max(boundsSize.zy, vec2(1e-4))); } else if (absN.y >= absN.x && absN.y >= absN.z) { // ±Y dominant → project onto XZ plane return sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4))); diff --git a/js/viewer.js b/js/viewer.js index 7561b72..0d34013 100644 --- a/js/viewer.js +++ b/js/viewer.js @@ -3,6 +3,57 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid; let currentMesh = null; +let gizmoScene, gizmoCamera; + +const GIZMO_PX = 90; // gizmo viewport size in CSS pixels +const GIZMO_MARGIN = 14; + +function buildGizmo() { + gizmoScene = new THREE.Scene(); + gizmoCamera = new THREE.OrthographicCamera(-1.6, 1.6, 1.6, -1.6, 0.1, 10); + gizmoCamera.position.set(0, 0, 3); + + const addAxis = (dir, hex, label) => { + // Shaft line + const shaft = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(0, 0, 0), + dir.clone().multiplyScalar(0.78), + ]); + gizmoScene.add(new THREE.Line( + shaft, + new THREE.LineBasicMaterial({ color: hex, depthTest: false }), + )); + + // Arrow-head cone + const cone = new THREE.Mesh( + new THREE.ConeGeometry(0.10, 0.24, 8), + new THREE.MeshBasicMaterial({ color: hex, depthTest: false }), + ); + cone.position.copy(dir.clone().multiplyScalar(0.92)); + cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir); + gizmoScene.add(cone); + + // Text label sprite + const c = document.createElement('canvas'); + c.width = c.height = 64; + const ctx = c.getContext('2d'); + ctx.fillStyle = `#${hex.toString(16).padStart(6, '0')}`; + ctx.font = 'bold 46px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, 32, 32); + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), depthTest: false }), + ); + sprite.position.copy(dir.clone().multiplyScalar(1.26)); + sprite.scale.set(0.42, 0.42, 1); + gizmoScene.add(sprite); + }; + + addAxis(new THREE.Vector3(1, 0, 0), 0xff4040, 'X'); + addAxis(new THREE.Vector3(0, 1, 0), 0x44dd44, 'Y'); + addAxis(new THREE.Vector3(0, 0, 1), 0x5599ff, 'Z'); +} export function initViewer(canvas) { // Renderer @@ -54,6 +105,8 @@ export function initViewer(canvas) { controls.maxDistance = 3000; controls.screenSpacePanning = true; + buildGizmo(); + // Resize observer const resizeObserver = new ResizeObserver(() => onResize()); resizeObserver.observe(canvas.parentElement); @@ -63,7 +116,29 @@ export function initViewer(canvas) { (function animate() { requestAnimationFrame(animate); controls.update(); + + const cw = renderer.domElement.clientWidth; + const ch = renderer.domElement.clientHeight; + + // 1. Main scene — full viewport + renderer.setScissorTest(false); + renderer.setViewport(0, 0, cw, ch); renderer.render(scene, camera); + + // 2. Gizmo overlay — upper-right corner + // WebGL y=0 is at bottom, so upper-right means large y. + const gx = cw - GIZMO_PX - GIZMO_MARGIN; + const gy = ch - GIZMO_PX - GIZMO_MARGIN; + gizmoCamera.quaternion.copy(camera.quaternion); + renderer.setScissorTest(true); + renderer.setScissor(gx, gy, GIZMO_PX, GIZMO_PX); + renderer.setViewport(gx, gy, GIZMO_PX, GIZMO_PX); + renderer.autoClear = false; + renderer.clearDepth(); + renderer.render(gizmoScene, gizmoCamera); + renderer.autoClear = true; + renderer.setScissorTest(false); + renderer.setViewport(0, 0, cw, ch); })(); }