diff --git a/index.html b/index.html index cb9ad57..04fe463 100644 --- a/index.html +++ b/index.html @@ -80,8 +80,8 @@ - - + + diff --git a/js/displacement.js b/js/displacement.js index 1e814e2..a9e0cae 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -28,7 +28,6 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett 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(); @@ -36,34 +35,72 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const edge2 = new THREE.Vector3(); const faceNrm = new THREE.Vector3(); - const REPORT_EVERY = 5000; + const QUANT = 1e4; + const posKey = (x, y, z) => + `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; + + // ── WHY GAPS HAPPEN ─────────────────────────────────────────────────────── + // The mesh is non-indexed (unrolled): every triangle has its own copy of + // each vertex. At a shared edge two triangles have the same position but + // different face normals. Displacing each copy along its own face normal + // moves them to DIFFERENT final positions → crack / gap. + // + // THE FIX: every copy of the same position must arrive at the exact same + // displaced point. We achieve this by computing a single *smooth* (area- + // weighted average) normal per unique position and using that both for the + // texture UV lookup and for the displacement direction. All copies of the + // same position then move by the same vector → watertight result. + // + // The tradeoff is that displaced normals are smooth at hard edges, but the + // underlying geometry is still faceted (the subdivision didn't change it), + // so printed edges remain sharp. + + // ── Pass 1: accumulate area-weighted face normals per unique position ───── + // Map: posKey → { nx, ny, nz } (unnormalised sum) + const smoothNrmMap = new Map(); + + for (let t = 0; t < count; t += 3) { + vA.fromBufferAttribute(posAttr, t); + vB.fromBufferAttribute(posAttr, t + 1); + vC.fromBufferAttribute(posAttr, t + 2); + edge1.subVectors(vB, vA); + edge2.subVectors(vC, vA); + faceNrm.crossVectors(edge1, edge2); // length = 2× triangle area → natural area weighting + + for (let v = 0; v < 3; v++) { + tmpPos.fromBufferAttribute(posAttr, t + v); + const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); + const existing = smoothNrmMap.get(k); + if (existing) { + existing[0] += faceNrm.x; + existing[1] += faceNrm.y; + existing[2] += faceNrm.z; + } else { + smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]); + } + } + } + + // Normalise each accumulated normal + smoothNrmMap.forEach((n) => { + const len = Math.sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]) || 1; + n[0] /= len; n[1] /= len; n[2] /= len; + }); + + // ── Pass 2: sample displacement texture once per unique position ────────── + const dispCache = new Map(); // posKey → grey [0, 1] for (let i = 0; i < count; i++) { tmpPos.fromBufferAttribute(posAttr, i); - tmpNrm.fromBufferAttribute(nrmAttr, i); + const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); + if (dispCache.has(k)) continue; - // 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); + const sn = smoothNrmMap.get(k); + tmpNrm.set(sn[0], sn[1], sn[2]); + const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds); let grey; if (uvResult.triplanar) { - // Weighted blend of three samples grey = 0; for (const s of uvResult.samples) { grey += sampleBilinear(imageData.data, imgWidth, imgHeight, s.u, s.v) * s.w; @@ -71,16 +108,32 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett } else { grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v); } + dispCache.set(k, grey); + } + // ── Pass 3: displace every vertex copy by the same vector ───────────────── + // Using the smooth normal for the displacement direction ensures all copies + // of the same position land at exactly the same 3-D point. + + const REPORT_EVERY = 5000; + + for (let i = 0; i < count; i++) { + tmpPos.fromBufferAttribute(posAttr, i); + tmpNrm.fromBufferAttribute(nrmAttr, i); + + const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); + const sn = smoothNrmMap.get(k); + const grey = dispCache.get(k); const disp = grey * settings.amplitude; - 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; + newPos[i*3] = tmpPos.x + sn[0] * disp; + newPos[i*3+1] = tmpPos.y + sn[1] * disp; + newPos[i*3+2] = tmpPos.z + sn[2] * disp; - newNrm[i*3] = useNrm.x; - newNrm[i*3+1] = useNrm.y; - newNrm[i*3+2] = useNrm.z; + // Keep per-face normal for shading (recomputed below anyway) + newNrm[i*3] = tmpNrm.x; + newNrm[i*3+1] = tmpNrm.y; + newNrm[i*3+2] = tmpNrm.z; if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count); } diff --git a/js/main.js b/js/main.js index b932862..69c5513 100644 --- a/js/main.js +++ b/js/main.js @@ -15,7 +15,7 @@ let previewMaterial = null; let isExporting = false; const settings = { - mappingMode: 5, // Triplanar default — covers all faces of any shape + mappingMode: 6, // Cubic default scaleU: 1.0, scaleV: 1.0, amplitude: 0.5, @@ -228,6 +228,16 @@ async function handleSTL(file) { previewMaterial = null; } + // Auto-select Brick preset (index 5) on first load + const brickIdx = PRESETS.findIndex(p => p.name === 'Brick'); + if (brickIdx >= 0 && !activeMapEntry) { + activeMapEntry = PRESETS[brickIdx]; + activeMapName.textContent = PRESETS[brickIdx].name; + const swatches = document.querySelectorAll('.preset-swatch'); + swatches.forEach((s, i) => s.classList.toggle('active', i === brickIdx)); + } + mappingSelect.value = String(settings.mappingMode); + // Show mesh with a default material until a map is selected loadGeometry(geometry); dropHint.classList.add('hidden'); diff --git a/js/mapping.js b/js/mapping.js index ddb7913..66b925f 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -51,12 +51,12 @@ export function computeUV(pos, normal, mode, settings, bounds) { } case MODE_CYLINDRICAL: { - // Wrap around Y axis (vertical axis after Z-up → Y-up rotation) + // Z is up: wrap around Z axis, height along Z const rx = pos.x - center.x; - const rz = pos.z - center.z; - const theta = Math.atan2(rz, rx); // [-PI, PI] + const ry = pos.y - center.y; + const theta = Math.atan2(ry, rx); // [-PI, PI] u = (theta / TWO_PI) + 0.5; // [0, 1] - v = (pos.y - min.y) / Math.max(size.y, 1e-6); + v = (pos.z - min.z) / Math.max(size.z, 1e-6); break; } @@ -65,8 +65,8 @@ export function computeUV(pos, normal, mode, settings, bounds) { const ry = pos.y - center.y; const rz = pos.z - center.z; const r = Math.sqrt(rx*rx + ry*ry + rz*rz); - const phi = Math.acos(Math.max(-1, Math.min(1, ry / Math.max(r, 1e-6)))); // [0, PI], Y is up - const theta = Math.atan2(rz, rx); // [-PI, PI] + const phi = Math.acos(Math.max(-1, Math.min(1, rz / Math.max(r, 1e-6)))); // [0, PI], Z is up + const theta = Math.atan2(ry, rx); // [-PI, PI] u = (theta / TWO_PI) + 0.5; v = phi / Math.PI; break; diff --git a/js/previewMaterial.js b/js/previewMaterial.js index a3fd768..495a215 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -77,16 +77,16 @@ const fragmentShader = /* glsl */` return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4))); } else if (mappingMode == 3) { - // Cylindrical around Y (vertical axis after Z-up → Y-up rotation) - float u = atan(rel.z, rel.x) / TWO_PI + 0.5; - float v = (pos.y - boundsMin.y) / max(boundsSize.y, 1e-4); + // 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)); } else if (mappingMode == 4) { - // Spherical + // Spherical — Z is up float r = length(rel); - float phi = acos(clamp(rel.y / max(r, 1e-4), -1.0, 1.0)); - float theta = atan(rel.z, rel.x); + float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0)); + float theta = atan(rel.y, rel.x); return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI)); } else if (mappingMode == 5) { diff --git a/js/stlLoader.js b/js/stlLoader.js index 2621f27..e5d50c2 100644 --- a/js/stlLoader.js +++ b/js/stlLoader.js @@ -35,8 +35,6 @@ function setupGeometry(geometry) { const centre = new THREE.Vector3(); box.getCenter(centre); geometry.translate(-centre.x, -centre.y, -centre.z); - // Convert Z-up (3D-print convention) to Y-up (Three.js convention) - geometry.rotateX(-Math.PI / 2); geometry.computeBoundingBox(); if (!geometry.attributes.normal) geometry.computeVertexNormals(); } diff --git a/js/viewer.js b/js/viewer.js index 0d34013..42254e2 100644 --- a/js/viewer.js +++ b/js/viewer.js @@ -3,56 +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; +let axesGroup = null; -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); +// Build a labelled coordinate axes indicator scaled to `size`. +// X = red, Y = green, Z = blue (up). +function buildAxesIndicator(size) { + const group = new THREE.Group(); 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 }), - )); + const r = size; + // Shaft + const pts = [new THREE.Vector3(0, 0, 0), dir.clone().multiplyScalar(r * 0.78)]; + const line = new THREE.Line( + new THREE.BufferGeometry().setFromPoints(pts), + new THREE.LineBasicMaterial({ color: hex, depthTest: false, transparent: true, opacity: 0.9 }), + ); + line.renderOrder = 999; + group.add(line); - // Arrow-head cone + // Cone arrowhead const cone = new THREE.Mesh( - new THREE.ConeGeometry(0.10, 0.24, 8), + new THREE.ConeGeometry(r * 0.07, r * 0.22, 8), new THREE.MeshBasicMaterial({ color: hex, depthTest: false }), ); - cone.position.copy(dir.clone().multiplyScalar(0.92)); + cone.renderOrder = 999; + cone.position.copy(dir.clone().multiplyScalar(r * 0.89)); cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir); - gizmoScene.add(cone); + group.add(cone); - // Text label sprite + // Text sprite label 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.font = 'bold 48px 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); + sprite.renderOrder = 999; + sprite.position.copy(dir.clone().multiplyScalar(r * 1.18)); + sprite.scale.set(r * 0.32, r * 0.32, 1); + group.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'); + addAxis(new THREE.Vector3(1, 0, 0), 0xff3333, 'X'); + addAxis(new THREE.Vector3(0, 1, 0), 0x33dd55, 'Y'); + addAxis(new THREE.Vector3(0, 0, 1), 0x4488ff, 'Z'); + + return group; } export function initViewer(canvas) { @@ -69,14 +70,16 @@ export function initViewer(canvas) { scene = new THREE.Scene(); scene.background = new THREE.Color(0x111114); - // Grid helper (subtle) + // Grid helper — in XY plane (Z-up) grid = new THREE.GridHelper(200, 40, 0x222228, 0x1e1e24); - grid.position.y = 0; + grid.rotation.x = Math.PI / 2; // rotate to XY plane for Z-up + grid.position.z = 0; scene.add(grid); - // Camera - camera = new THREE.PerspectiveCamera(45, 1, 0.01, 5000); - camera.position.set(0, 80, 120); + // Camera — orthographic (parallel projection), Z-up + camera = new THREE.OrthographicCamera(-150, 150, 150, -150, -10000, 10000); + camera.up.set(0, 0, 1); + camera.position.set(120, -200, 100); camera.lookAt(0, 0, 0); // Lights @@ -101,12 +104,8 @@ export function initViewer(canvas) { controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; - controls.minDistance = 1; - controls.maxDistance = 3000; controls.screenSpacePanning = true; - buildGizmo(); - // Resize observer const resizeObserver = new ResizeObserver(() => onResize()); resizeObserver.observe(canvas.parentElement); @@ -117,28 +116,7 @@ export function initViewer(canvas) { 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); })(); } @@ -147,7 +125,11 @@ function onResize() { const w = el.clientWidth; const h = el.clientHeight; renderer.setSize(w, h, false); - camera.aspect = w / h; + // Orthographic: keep the frustum half-height, update left/right for new aspect + const aspect = w / h; + const halfH = camera.top; + camera.left = -halfH * aspect; + camera.right = halfH * aspect; camera.updateProjectionMatrix(); } @@ -179,17 +161,26 @@ export function loadGeometry(geometry, material) { currentMesh.receiveShadow = true; meshGroup.add(currentMesh); - // Position grid at mesh bottom + // Position grid at mesh bottom (Z-up: move grid along Z) geometry.computeBoundingBox(); const box = geometry.boundingBox; - const centerY = (box.min.y + box.max.y) / 2; - grid.position.y = box.min.y - 0.01; + const groundZ = box.min.z - 0.01; + grid.position.z = groundZ; // Fit camera const sphere = new THREE.Sphere(); geometry.computeBoundingSphere(); sphere.copy(geometry.boundingSphere); fitCamera(sphere); + + // Place coordinate axes away from the part corner + if (axesGroup) scene.remove(axesGroup); + const axisSize = sphere.radius * 0.30; + axesGroup = buildAxesIndicator(axisSize); + // Offset from the bounding box corner by ~1 axis-length so it doesn't overlap the mesh + const axisPad = axisSize * 1.8; + axesGroup.position.set(box.min.x - axisPad, box.min.y - axisPad, groundZ); + scene.add(axesGroup); } /** @@ -215,15 +206,26 @@ export function setMeshMaterial(material) { export function getGrid() { return grid; } function fitCamera(sphere) { - const fov = THREE.MathUtils.degToRad(camera.fov); - const dist = (sphere.radius * 2.2) / Math.tan(fov / 2); - const dir = camera.position.clone().sub(controls.target).normalize(); - controls.target.copy(sphere.center); - camera.position.copy(sphere.center).addScaledVector(dir, dist); - controls.update(); - camera.near = dist * 0.001; - camera.far = dist * 10; + const sz = renderer.getSize(new THREE.Vector2()); + const aspect = sz.x / sz.y; + const halfH = sphere.radius * 1.4; + + camera.left = -halfH * aspect; + camera.right = halfH * aspect; + camera.top = halfH; + camera.bottom = -halfH; + camera.near = -sphere.radius * 200; + camera.far = sphere.radius * 200; + camera.zoom = 1; camera.updateProjectionMatrix(); + + // Isometric-ish view from front-right-above in Z-up space + const dir = new THREE.Vector3(0.6, -1.2, 0.8).normalize(); + controls.target.copy(sphere.center); + camera.position.copy(sphere.center).addScaledVector(dir, halfH * 4); + camera.up.set(0, 0, 1); + camera.lookAt(sphere.center); + controls.update(); } export function getRenderer() { return renderer; }