mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
+18
-1
@@ -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();
|
const geo = new THREE.BufferGeometry();
|
||||||
geo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
|
geo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
|
||||||
geo.computeVertexNormals();
|
geo.setAttribute('normal', new THREE.BufferAttribute(nrmArray, 3));
|
||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-8
@@ -64,8 +64,19 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
const maskedFracMap = new Map();
|
const maskedFracMap = new Map();
|
||||||
|
|
||||||
// Optional per-vertex exclusion weights threaded through by subdivision.js.
|
// 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;
|
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) {
|
for (let t = 0; t < count; t += 3) {
|
||||||
vA.fromBufferAttribute(posAttr, t);
|
vA.fromBufferAttribute(posAttr, t);
|
||||||
@@ -90,11 +101,17 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
const userExcluded = ewAttr
|
const userExcluded = ewAttr
|
||||||
? (ewAttr.getX(t) + ewAttr.getX(t + 1) + ewAttr.getX(t + 2)) / 3 > 0.99
|
? (ewAttr.getX(t) + ewAttr.getX(t + 1) + ewAttr.getX(t + 2)) / 3 > 0.99
|
||||||
: false;
|
: 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++) {
|
for (let v = 0; v < 3; v++) {
|
||||||
tmpPos.fromBufferAttribute(posAttr, t + v);
|
tmpPos.fromBufferAttribute(posAttr, t + v);
|
||||||
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
||||||
|
if (userExcluded && excludedPosSet) excludedPosSet.add(k);
|
||||||
const existing = smoothNrmMap.get(k);
|
const existing = smoothNrmMap.get(k);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing[0] += faceNrm.x;
|
existing[0] += faceNrm.x;
|
||||||
@@ -157,12 +174,16 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
const sn = smoothNrmMap.get(k);
|
const sn = smoothNrmMap.get(k);
|
||||||
const grey = dispCache.get(k);
|
const grey = dispCache.get(k);
|
||||||
|
|
||||||
// Smooth blend: displacement scaled by the unmasked fraction of surrounding
|
// User-excluded faces get zero displacement; only angle-based masking uses
|
||||||
// face area. Boundary vertices (shared by masked + unmasked faces) get a
|
// the smooth per-vertex blend so neighbours are never unintentionally dimmed.
|
||||||
// proportionally reduced displacement instead of a hard on/off cutoff.
|
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 mf = maskedFracMap.get(k) || [0, 1];
|
||||||
const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0;
|
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 newX = tmpPos.x + sn[0] * disp;
|
||||||
const newY = tmpPos.y + sn[1] * 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);
|
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();
|
const out = new THREE.BufferGeometry();
|
||||||
out.setAttribute('position', new THREE.BufferAttribute(newPos, 3));
|
out.setAttribute('position', new THREE.BufferAttribute(newPos, 3));
|
||||||
out.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3));
|
out.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3));
|
||||||
// Recompute face normals for correct lighting in exported STL
|
|
||||||
out.computeVertexNormals();
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-6
@@ -16,7 +16,8 @@ import { buildAdjacency, bucketFill,
|
|||||||
|
|
||||||
let currentGeometry = null; // original loaded geometry
|
let currentGeometry = null; // original loaded geometry
|
||||||
let currentBounds = null; // bounds of the original 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 previewMaterial = null;
|
||||||
let isExporting = false;
|
let isExporting = false;
|
||||||
let previewDebounce = null;
|
let previewDebounce = null;
|
||||||
@@ -198,6 +199,7 @@ function wireEvents() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
activeMapEntry = await loadCustomTexture(file);
|
activeMapEntry = await loadCustomTexture(file);
|
||||||
|
activeMapEntry.isCustom = true;
|
||||||
activeMapName.textContent = file.name;
|
activeMapName.textContent = file.name;
|
||||||
document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active'));
|
document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active'));
|
||||||
updatePreview();
|
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) {
|
function pickTriangle(e) {
|
||||||
const mesh = getCurrentMesh();
|
const mesh = getCurrentMesh();
|
||||||
if (!mesh) return -1;
|
if (!mesh) return -1;
|
||||||
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
|
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
|
||||||
const hits = _raycaster.intersectObject(mesh);
|
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) {
|
function paintAt(e) {
|
||||||
@@ -445,9 +463,10 @@ function paintAt(e) {
|
|||||||
if (!mesh) return;
|
if (!mesh) return;
|
||||||
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
|
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
|
||||||
const hits = _raycaster.intersectObject(mesh);
|
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) {
|
if (brushIsRadius) {
|
||||||
const hitPt = hits[0].point;
|
const hitPt = hits[0].point;
|
||||||
@@ -583,6 +602,7 @@ async function handleSTL(file) {
|
|||||||
const { geometry, bounds } = await loadSTLFile(file);
|
const { geometry, bounds } = await loadSTLFile(file);
|
||||||
currentGeometry = geometry;
|
currentGeometry = geometry;
|
||||||
currentBounds = bounds;
|
currentBounds = bounds;
|
||||||
|
currentStlName = file.name.replace(/\.stl$/i, '');
|
||||||
|
|
||||||
// Dispose old preview material and reset state for the new mesh
|
// Dispose old preview material and reset state for the new mesh
|
||||||
if (previewMaterial) {
|
if (previewMaterial) {
|
||||||
@@ -799,13 +819,29 @@ async function handleExport() {
|
|||||||
if (posArr[i] < bottomZ) posArr[i] = bottomZ;
|
if (posArr[i] < bottomZ) posArr[i] = bottomZ;
|
||||||
}
|
}
|
||||||
finalGeometry.attributes.position.needsUpdate = true;
|
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…');
|
setProgress(0.97, 'Writing STL…');
|
||||||
await yieldFrame();
|
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!');
|
setProgress(1.0, 'Done!');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
+11
-4
@@ -60,7 +60,7 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights
|
|||||||
if (!changed || safetyCapHit) break;
|
if (!changed || safetyCapHit) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { geometry: toNonIndexed(positions, normals, weights, currentIndices), safetyCapHit };
|
return { geometry: toNonIndexed(positions, normals, weights, currentIndices, currentFaceExcluded), safetyCapHit };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── One subdivision pass ──────────────────────────────────────────────────────
|
// ── One subdivision pass ──────────────────────────────────────────────────────
|
||||||
@@ -295,13 +295,20 @@ function toIndexed(geometry, nonIndexedWeights = null) {
|
|||||||
|
|
||||||
// ── Indexed → non-indexed ────────────────────────────────────────────────────
|
// ── Indexed → non-indexed ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function toNonIndexed(positions, normals, weights, indices) {
|
function toNonIndexed(positions, normals, weights, indices, faceExcluded = null) {
|
||||||
const triCount = indices.length / 3;
|
const triCount = indices.length / 3;
|
||||||
const posArray = new Float32Array(triCount * 9);
|
const posArray = new Float32Array(triCount * 9);
|
||||||
const nrmArray = 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++) {
|
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++) {
|
for (let v = 0; v < 3; v++) {
|
||||||
const vidx = indices[t * 3 + v];
|
const vidx = indices[t * 3 + v];
|
||||||
posArray[t * 9 + v * 3] = positions[vidx * 3];
|
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 + 1] = normals[vidx * 3 + 1];
|
||||||
nrmArray[t * 9 + v * 3 + 2] = normals[vidx * 3 + 2];
|
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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user