diff --git a/js/decimation.js b/js/decimation.js index 9736470..6b5c23a 100644 --- a/js/decimation.js +++ b/js/decimation.js @@ -569,9 +569,26 @@ function buildOutput(positions, faces, faceCount) { } } + // Compute exact per-face normals from the final positions so winding order + // always agrees with the stored normals (computeVertexNormals averages across + // shared positions and can flip normals on excluded surfaces). + const nrmArray = new Float32Array(posArray.length); + for (let i = 0; i < posArray.length; i += 9) { + const ax = posArray[i], ay = posArray[i+1], az = posArray[i+2]; + const bx = posArray[i+3], by = posArray[i+4], bz = posArray[i+5]; + const cx = posArray[i+6], cy = posArray[i+7], cz = posArray[i+8]; + const ux = bx-ax, uy = by-ay, uz = bz-az; + const vx = cx-ax, vy = cy-ay, vz = cz-az; + const nx = uy*vz - uz*vy, ny = uz*vx - ux*vz, nz = ux*vy - uy*vx; + const len = Math.sqrt(nx*nx + ny*ny + nz*nz) || 1; + nrmArray[i] = nrmArray[i+3] = nrmArray[i+6] = nx / len; + nrmArray[i+1] = nrmArray[i+4] = nrmArray[i+7] = ny / len; + nrmArray[i+2] = nrmArray[i+5] = nrmArray[i+8] = nz / len; + } + const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); - geo.computeVertexNormals(); + geo.setAttribute('normal', new THREE.BufferAttribute(nrmArray, 3)); return geo; } diff --git a/js/displacement.js b/js/displacement.js index 4dc1b5a..58b1ef1 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -64,8 +64,19 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const maskedFracMap = new Map(); // Optional per-vertex exclusion weights threaded through by subdivision.js. - // A face's user-exclusion flag = average of its 3 vertex weights > 0.5. + // A face's user-exclusion flag = average of its 3 vertex weights > 0.99. const ewAttr = geometry.attributes.excludeWeight || null; + // Per-face user-exclusion flag: stored separately from maskedFracMap so that + // user-excluded faces do NOT bleed reduced displacement into adjacent faces + // via shared vertices (maskedFracMap is only for angle-based blending). + const userExcludedFaces = ewAttr ? new Uint8Array(count / 3) : null; + // Positions that belong to at least one user-excluded face. Any included-face + // vertex whose original position is in this set sits on the seam boundary; we + // pin it to zero displacement so both sides of the seam end up at the same + // final position. Without this the mesh has an open crack at the mask + // boundary, which causes the QEM decimator to treat the excluded patch as an + // isolated open-mesh island and collapse it to nothing (missing triangles). + const excludedPosSet = ewAttr ? new Set() : null; for (let t = 0; t < count; t += 3) { vA.fromBufferAttribute(posAttr, t); @@ -90,11 +101,17 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const userExcluded = ewAttr ? (ewAttr.getX(t) + ewAttr.getX(t + 1) + ewAttr.getX(t + 2)) / 3 > 0.99 : false; - const faceMasked = angleMasked || userExcluded; + // maskedFracMap is ONLY used for angle-based blending at surface boundaries. + // User exclusion is tracked per-face in userExcludedFaces and applied + // directly in Pass 3, so excluded faces don't reduce displacement on their + // neighbours through shared boundary vertices. + const faceMasked = angleMasked; + if (userExcluded && userExcludedFaces) userExcludedFaces[t / 3] = 1; for (let v = 0; v < 3; v++) { tmpPos.fromBufferAttribute(posAttr, t + v); const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); + if (userExcluded && excludedPosSet) excludedPosSet.add(k); const existing = smoothNrmMap.get(k); if (existing) { existing[0] += faceNrm.x; @@ -157,12 +174,16 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const sn = smoothNrmMap.get(k); const grey = dispCache.get(k); - // Smooth blend: displacement scaled by the unmasked fraction of surrounding - // face area. Boundary vertices (shared by masked + unmasked faces) get a - // proportionally reduced displacement instead of a hard on/off cutoff. + // User-excluded faces get zero displacement; only angle-based masking uses + // the smooth per-vertex blend so neighbours are never unintentionally dimmed. + const isFaceExcluded = userExcludedFaces && userExcludedFaces[Math.floor(i / 3)]; + // Pin included-face vertices that share a position with an excluded face. + // This seals the open crack at the mask boundary so the mesh stays watertight + // and the decimator cannot collapse the excluded patch to zero faces. + const isSealedBoundary = !isFaceExcluded && excludedPosSet && excludedPosSet.has(k); const mf = maskedFracMap.get(k) || [0, 1]; const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0; - const disp = (1 - maskedFrac) * grey * settings.amplitude; + const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * grey * settings.amplitude; const newX = tmpPos.x + sn[0] * disp; const newY = tmpPos.y + sn[1] * disp; @@ -188,11 +209,30 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count); } + // Compute exact per-face normals from the displaced positions. + // Using computeVertexNormals() would average across shared positions, which + // can flip normals on excluded faces whose neighbours were displaced outward. + // A direct cross-product per triangle is unambiguous and matches winding order. + const eA = new THREE.Vector3(); + const eB = new THREE.Vector3(); + const fn = new THREE.Vector3(); + for (let t = 0; t < count; t += 3) { + const ax = newPos[t*3], ay = newPos[t*3+1], az = newPos[t*3+2]; + const bx = newPos[t*3+3], by = newPos[t*3+4], bz = newPos[t*3+5]; + const cx = newPos[t*3+6], cy = newPos[t*3+7], cz = newPos[t*3+8]; + eA.set(bx - ax, by - ay, bz - az); + eB.set(cx - ax, cy - ay, cz - az); + fn.crossVectors(eA, eB).normalize(); + for (let v = 0; v < 3; v++) { + newNrm[(t + v) * 3] = fn.x; + newNrm[(t + v) * 3 + 1] = fn.y; + newNrm[(t + v) * 3 + 2] = fn.z; + } + } + const out = new THREE.BufferGeometry(); out.setAttribute('position', new THREE.BufferAttribute(newPos, 3)); out.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3)); - // Recompute face normals for correct lighting in exported STL - out.computeVertexNormals(); return out; } diff --git a/js/main.js b/js/main.js index 3c4aad5..a7c8f35 100644 --- a/js/main.js +++ b/js/main.js @@ -16,7 +16,8 @@ import { buildAdjacency, bucketFill, let currentGeometry = null; // original loaded geometry let currentBounds = null; // bounds of the original geometry -let activeMapEntry = null; // { name, texture, imageData, width, height } +let currentStlName = 'model'; // base filename of the loaded STL (no extension) +let activeMapEntry = null; // { name, texture, imageData, width, height, isCustom? } let previewMaterial = null; let isExporting = false; let previewDebounce = null; @@ -198,6 +199,7 @@ function wireEvents() { if (!file) return; try { activeMapEntry = await loadCustomTexture(file); + activeMapEntry.isCustom = true; activeMapName.textContent = file.name; document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); updatePreview(); @@ -432,12 +434,28 @@ function _canvasNDC(e) { ); } +// The preview material uses THREE.DoubleSide, so the raycaster can return +// back-face hits of adjacent triangles that are marginally closer than the +// intended front-facing triangle. This helper returns the first hit whose +// face normal (in world space) points toward the camera ray origin. +const _normalMatrix = new THREE.Matrix3(); +function getFrontFaceHit(hits, mesh) { + if (!hits.length) return null; + _normalMatrix.getNormalMatrix(mesh.matrixWorld); + for (const hit of hits) { + const wn = hit.face.normal.clone().applyMatrix3(_normalMatrix).normalize(); + if (wn.dot(_raycaster.ray.direction) < 0) return hit; + } + return hits[0]; // fallback — should not happen with a closed mesh +} + function pickTriangle(e) { const mesh = getCurrentMesh(); if (!mesh) return -1; _raycaster.setFromCamera(_canvasNDC(e), getCamera()); const hits = _raycaster.intersectObject(mesh); - return hits.length > 0 ? hits[0].faceIndex : -1; + const hit = getFrontFaceHit(hits, mesh); + return hit ? hit.faceIndex : -1; } function paintAt(e) { @@ -445,9 +463,10 @@ function paintAt(e) { if (!mesh) return; _raycaster.setFromCamera(_canvasNDC(e), getCamera()); const hits = _raycaster.intersectObject(mesh); - if (hits.length === 0) return; + const hit = getFrontFaceHit(hits, mesh); + if (!hit) return; - const triIdx = hits[0].faceIndex; + const triIdx = hit.faceIndex; if (brushIsRadius) { const hitPt = hits[0].point; @@ -583,6 +602,7 @@ async function handleSTL(file) { const { geometry, bounds } = await loadSTLFile(file); currentGeometry = geometry; currentBounds = bounds; + currentStlName = file.name.replace(/\.stl$/i, ''); // Dispose old preview material and reset state for the new mesh if (previewMaterial) { @@ -799,13 +819,29 @@ async function handleExport() { if (posArr[i] < bottomZ) posArr[i] = bottomZ; } finalGeometry.attributes.position.needsUpdate = true; - finalGeometry.computeVertexNormals(); + // Recompute normals via cross product so they always match winding order. + const pa = finalGeometry.attributes.position.array; + const na = finalGeometry.attributes.normal ? finalGeometry.attributes.normal.array : new Float32Array(pa.length); + for (let i = 0; i < pa.length; i += 9) { + const ux = pa[i+3]-pa[i], uy = pa[i+4]-pa[i+1], uz = pa[i+5]-pa[i+2]; + const vx = pa[i+6]-pa[i], vy = pa[i+7]-pa[i+1], vz = pa[i+8]-pa[i+2]; + const nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx; + const len = Math.sqrt(nx*nx+ny*ny+nz*nz) || 1; + na[i] = na[i+3] = na[i+6] = nx/len; + na[i+1] = na[i+4] = na[i+7] = ny/len; + na[i+2] = na[i+5] = na[i+8] = nz/len; + } + if (!finalGeometry.attributes.normal) finalGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(na, 3)); + else finalGeometry.attributes.normal.needsUpdate = true; } setProgress(0.97, 'Writing STL…'); await yieldFrame(); - exportSTL(finalGeometry, 'textured.stl'); + const texLabel = activeMapEntry.isCustom ? 'custom' : activeMapEntry.name.replace(/\s+/g, '-'); + const ampLabel = settings.amplitude.toFixed(2).replace('.', 'p'); + const exportName = `${currentStlName}_${texLabel}_amp${ampLabel}.stl`; + exportSTL(finalGeometry, exportName); setProgress(1.0, 'Done!'); setTimeout(() => { diff --git a/js/subdivision.js b/js/subdivision.js index 954219f..f057cda 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -60,7 +60,7 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights if (!changed || safetyCapHit) break; } - return { geometry: toNonIndexed(positions, normals, weights, currentIndices), safetyCapHit }; + return { geometry: toNonIndexed(positions, normals, weights, currentIndices, currentFaceExcluded), safetyCapHit }; } // ── One subdivision pass ────────────────────────────────────────────────────── @@ -295,13 +295,20 @@ function toIndexed(geometry, nonIndexedWeights = null) { // ── Indexed → non-indexed ──────────────────────────────────────────────────── -function toNonIndexed(positions, normals, weights, indices) { +function toNonIndexed(positions, normals, weights, indices, faceExcluded = null) { const triCount = indices.length / 3; const posArray = new Float32Array(triCount * 9); const nrmArray = new Float32Array(triCount * 9); - const wgtArray = weights ? new Float32Array(triCount * 3) : null; + const wgtArray = (faceExcluded || weights) ? new Float32Array(triCount * 3) : null; for (let t = 0; t < triCount; t++) { + // Use the binary faceExcluded flag (tracked accurately through subdivision) + // rather than the interpolated weights[vidx]. The interpolated weights can + // be pushed to 1.0 on included faces via the MAX-merge in toIndexed: if an + // included face shares edges with TWO excluded neighbours all three of its + // vertices are merged to weight 1.0, making its average exceed the 0.99 + // threshold and falsely excluding it from displacement. + const faceW = faceExcluded ? (faceExcluded[t] ? 1.0 : 0.0) : null; for (let v = 0; v < 3; v++) { const vidx = indices[t * 3 + v]; posArray[t * 9 + v * 3] = positions[vidx * 3]; @@ -312,7 +319,7 @@ function toNonIndexed(positions, normals, weights, indices) { nrmArray[t * 9 + v * 3 + 1] = normals[vidx * 3 + 1]; nrmArray[t * 9 + v * 3 + 2] = normals[vidx * 3 + 2]; - if (wgtArray) wgtArray[t * 3 + v] = weights[vidx]; + if (wgtArray) wgtArray[t * 3 + v] = faceW !== null ? faceW : weights[vidx]; } } diff --git a/noise.jpg b/noise.jpg new file mode 100644 index 0000000..07a51dd Binary files /dev/null and b/noise.jpg differ