mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: update normal handling to use area-weighted buffer normals for improved surface rendering
This commit is contained in:
+11
-11
@@ -136,20 +136,20 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
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);
|
if (userExcluded && excludedPosSet) excludedPosSet.add(k);
|
||||||
// Use the geometric face normal (faceNrm = cross product, length ∝ 2×area)
|
// Use the buffer normal (from subdivision) weighted by face area.
|
||||||
// instead of the buffer normal. The subdivision pipeline interpolates
|
// The subdivision pipeline splits indexed vertices at sharp dihedral
|
||||||
// smooth normals at midpoints, which propagates the 45° edge tilt deep
|
// edges (>30°), so the interpolated buffer normals are smooth across
|
||||||
// into the face interior across iterations. Using the true face normal
|
// soft edges (cylinder, sphere) but sharp across hard edges (cube).
|
||||||
// ensures interior vertices on flat faces get a perfectly perpendicular
|
// This eliminates visible faceting steps on round surfaces while still
|
||||||
// smooth normal, limiting the angled displacement to the single outermost
|
// preserving hard edges.
|
||||||
// vertex row at each hard edge — matching addSmoothNormals() in main.js.
|
tmpNrm.fromBufferAttribute(nrmAttr, t + v);
|
||||||
const existing = smoothNrmMap.get(k);
|
const existing = smoothNrmMap.get(k);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing[0] += faceNrm.x;
|
existing[0] += tmpNrm.x * faceArea;
|
||||||
existing[1] += faceNrm.y;
|
existing[1] += tmpNrm.y * faceArea;
|
||||||
existing[2] += faceNrm.z;
|
existing[2] += tmpNrm.z * faceArea;
|
||||||
} else {
|
} else {
|
||||||
smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]);
|
smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]);
|
||||||
}
|
}
|
||||||
if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) {
|
if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) {
|
||||||
const za = zoneAreaMap.get(k);
|
const za = zoneAreaMap.get(k);
|
||||||
|
|||||||
+16
-9
@@ -1132,8 +1132,14 @@ function addSmoothNormals(geometry) {
|
|||||||
const key = (x, y, z) =>
|
const key = (x, y, z) =>
|
||||||
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
|
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
|
||||||
|
|
||||||
// Accumulate area-weighted face normals per unique position
|
// Accumulate area-weighted buffer normals per unique position.
|
||||||
|
// The subdivision pipeline splits indexed vertices at sharp dihedral edges
|
||||||
|
// (>30°) so the interpolated buffer normals are smooth across soft edges
|
||||||
|
// (cylinder, sphere) but sharp across hard edges (cube). Using these buffer
|
||||||
|
// normals instead of geometric face normals eliminates visible faceting steps
|
||||||
|
// on round surfaces while still preserving hard edges.
|
||||||
const nrmMap = new Map();
|
const nrmMap = new Map();
|
||||||
|
const nrm = geometry.attributes.normal.array;
|
||||||
const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
|
const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
|
||||||
const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), fn = new THREE.Vector3();
|
const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), fn = new THREE.Vector3();
|
||||||
|
|
||||||
@@ -1143,19 +1149,20 @@ function addSmoothNormals(geometry) {
|
|||||||
vC.set(pos[(i + 2) * 3], pos[(i + 2) * 3 + 1], pos[(i + 2) * 3 + 2]);
|
vC.set(pos[(i + 2) * 3], pos[(i + 2) * 3 + 1], pos[(i + 2) * 3 + 2]);
|
||||||
e1.subVectors(vB, vA);
|
e1.subVectors(vB, vA);
|
||||||
e2.subVectors(vC, vA);
|
e2.subVectors(vC, vA);
|
||||||
fn.crossVectors(e1, e2); // length = 2 × triangle area
|
fn.crossVectors(e1, e2);
|
||||||
const area = fn.length();
|
const area = fn.length();
|
||||||
if (area < 1e-12) continue;
|
if (area < 1e-12) continue;
|
||||||
fn.divideScalar(area); // unit face normal
|
for (let v = 0; v < 3; v++) {
|
||||||
for (const v of [vA, vB, vC]) {
|
const vi = i + v;
|
||||||
const k = key(v.x, v.y, v.z);
|
const nx = nrm[vi * 3], ny = nrm[vi * 3 + 1], nz = nrm[vi * 3 + 2];
|
||||||
|
const k = key(pos[vi * 3], pos[vi * 3 + 1], pos[vi * 3 + 2]);
|
||||||
const prev = nrmMap.get(k);
|
const prev = nrmMap.get(k);
|
||||||
if (prev) {
|
if (prev) {
|
||||||
prev[0] += fn.x * area;
|
prev[0] += nx * area;
|
||||||
prev[1] += fn.y * area;
|
prev[1] += ny * area;
|
||||||
prev[2] += fn.z * area;
|
prev[2] += nz * area;
|
||||||
} else {
|
} else {
|
||||||
nrmMap.set(k, [fn.x * area, fn.y * area, fn.z * area]);
|
nrmMap.set(k, [nx * area, ny * area, nz * area]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-34
@@ -88,6 +88,17 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
|
|||||||
const maxSq = maxEdgeLength * maxEdgeLength;
|
const maxSq = maxEdgeLength * maxEdgeLength;
|
||||||
const midCache = new Map();
|
const midCache = new Map();
|
||||||
|
|
||||||
|
// Position-based edge key for split detection. toIndexed() splits indexed
|
||||||
|
// vertices at sharp dihedral edges (>30°), so two faces sharing a geometric
|
||||||
|
// edge may reference different index pairs. Using quantised positions as
|
||||||
|
// the key guarantees both sides see the same split decision, preventing
|
||||||
|
// T-junctions at the boundary between textured and angle-masked faces.
|
||||||
|
const _posEdgeKey = (a, b) => {
|
||||||
|
const ka = `${Math.round(positions[a*3]*QUANTISE)}_${Math.round(positions[a*3+1]*QUANTISE)}_${Math.round(positions[a*3+2]*QUANTISE)}`;
|
||||||
|
const kb = `${Math.round(positions[b*3]*QUANTISE)}_${Math.round(positions[b*3+1]*QUANTISE)}_${Math.round(positions[b*3+2]*QUANTISE)}`;
|
||||||
|
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Step 1: globally mark edges that need splitting ─────────────────────
|
// ── Step 1: globally mark edges that need splitting ─────────────────────
|
||||||
// Excluded triangles do NOT proactively mark their own edges – their
|
// Excluded triangles do NOT proactively mark their own edges – their
|
||||||
// interior edges will never be split, saving triangles on untextured
|
// interior edges will never be split, saving triangles on untextured
|
||||||
@@ -97,9 +108,9 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
|
|||||||
for (let t = 0; t < indices.length; t += 3) {
|
for (let t = 0; t < indices.length; t += 3) {
|
||||||
if (faceExcluded && faceExcluded[t / 3]) continue; // skip excluded faces
|
if (faceExcluded && faceExcluded[t / 3]) continue; // skip excluded faces
|
||||||
const a = indices[t], b = indices[t + 1], c = indices[t + 2];
|
const a = indices[t], b = indices[t + 1], c = indices[t + 2];
|
||||||
if (edgeLenSq(positions, a, b) > maxSq) splitEdges.add(edgeKey(a, b));
|
if (edgeLenSq(positions, a, b) > maxSq) splitEdges.add(_posEdgeKey(a, b));
|
||||||
if (edgeLenSq(positions, b, c) > maxSq) splitEdges.add(edgeKey(b, c));
|
if (edgeLenSq(positions, b, c) > maxSq) splitEdges.add(_posEdgeKey(b, c));
|
||||||
if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(edgeKey(c, a));
|
if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(_posEdgeKey(c, a));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (splitEdges.size === 0) return { newIndices: indices, newFaceExcluded: faceExcluded, changed: false };
|
if (splitEdges.size === 0) return { newIndices: indices, newFaceExcluded: faceExcluded, changed: false };
|
||||||
@@ -122,9 +133,9 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
|
|||||||
const a = indices[t], b = indices[t + 1], c = indices[t + 2];
|
const a = indices[t], b = indices[t + 1], c = indices[t + 2];
|
||||||
const fIdx = t / 3;
|
const fIdx = t / 3;
|
||||||
const excl = faceExcluded ? faceExcluded[fIdx] : 0;
|
const excl = faceExcluded ? faceExcluded[fIdx] : 0;
|
||||||
const sAB = splitEdges.has(edgeKey(a, b));
|
const sAB = splitEdges.has(_posEdgeKey(a, b));
|
||||||
const sBC = splitEdges.has(edgeKey(b, c));
|
const sBC = splitEdges.has(_posEdgeKey(b, c));
|
||||||
const sCA = splitEdges.has(edgeKey(c, a));
|
const sCA = splitEdges.has(_posEdgeKey(c, a));
|
||||||
const n = (sAB ? 1 : 0) + (sBC ? 1 : 0) + (sCA ? 1 : 0);
|
const n = (sAB ? 1 : 0) + (sBC ? 1 : 0) + (sCA ? 1 : 0);
|
||||||
|
|
||||||
if (n === 0) {
|
if (n === 0) {
|
||||||
@@ -258,43 +269,93 @@ function getMidpoint(positions, normals, weights, cache, a, b) {
|
|||||||
// weight wins (conservative: any excluded face marks the shared vertex).
|
// weight wins (conservative: any excluded face marks the shared vertex).
|
||||||
function toIndexed(geometry, nonIndexedWeights = null) {
|
function toIndexed(geometry, nonIndexedWeights = null) {
|
||||||
const posAttr = geometry.attributes.position;
|
const posAttr = geometry.attributes.position;
|
||||||
const nrmAttr = geometry.attributes.normal;
|
|
||||||
|
|
||||||
const positions = [];
|
|
||||||
const normals = [];
|
|
||||||
const normalSums = [];
|
|
||||||
const weights = nonIndexedWeights ? [] : null;
|
|
||||||
const indices = [];
|
|
||||||
const vertMap = new Map();
|
|
||||||
|
|
||||||
const n = posAttr.count;
|
const n = posAttr.count;
|
||||||
|
|
||||||
|
// ── Pre-compute per-face normals (unit + raw cross product) ──────────────
|
||||||
|
const faceNrmUnit = new Float32Array(n * 3);
|
||||||
|
const faceNrmRaw = new Float32Array(n * 3);
|
||||||
|
for (let t = 0; t < n; t += 3) {
|
||||||
|
const ax = posAttr.getX(t), ay = posAttr.getY(t), az = posAttr.getZ(t);
|
||||||
|
const bx = posAttr.getX(t+1), by = posAttr.getY(t+1), bz = posAttr.getZ(t+1);
|
||||||
|
const cx = posAttr.getX(t+2), cy = posAttr.getY(t+2), cz = posAttr.getZ(t+2);
|
||||||
|
const e1x = bx-ax, e1y = by-ay, e1z = bz-az;
|
||||||
|
const e2x = cx-ax, e2y = cy-ay, e2z = cz-az;
|
||||||
|
const rx = e1y*e2z - e1z*e2y;
|
||||||
|
const ry = e1z*e2x - e1x*e2z;
|
||||||
|
const rz = e1x*e2y - e1y*e2x;
|
||||||
|
const len = Math.sqrt(rx*rx + ry*ry + rz*rz) || 1;
|
||||||
|
const ux = rx/len, uy = ry/len, uz = rz/len;
|
||||||
|
for (let v = 0; v < 3; v++) {
|
||||||
|
faceNrmUnit[(t+v)*3] = ux;
|
||||||
|
faceNrmUnit[(t+v)*3+1] = uy;
|
||||||
|
faceNrmUnit[(t+v)*3+2] = uz;
|
||||||
|
faceNrmRaw[(t+v)*3] = rx;
|
||||||
|
faceNrmRaw[(t+v)*3+1] = ry;
|
||||||
|
faceNrmRaw[(t+v)*3+2] = rz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merge vertices, splitting at sharp dihedral edges ───────────────────
|
||||||
|
// Two vertices at the same position merge into one indexed vertex only when
|
||||||
|
// their face normals are within SHARP_ANGLE of each other. This keeps
|
||||||
|
// smooth-surface normals averaged across facet boundaries (cylinder, sphere)
|
||||||
|
// while preventing the 45° edge-normal tilt from propagating into flat-face
|
||||||
|
// interiors during subdivision (cube, box).
|
||||||
|
const SHARP_COS = Math.cos(30 * Math.PI / 180);
|
||||||
|
|
||||||
|
const positions = [];
|
||||||
|
const normals = [];
|
||||||
|
const normalSums = [];
|
||||||
|
const weights = nonIndexedWeights ? [] : null;
|
||||||
|
const indices = [];
|
||||||
|
const vertMap = new Map(); // posKey → [{idx, fnU: [x,y,z]}]
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const px = posAttr.getX(i);
|
const px = posAttr.getX(i);
|
||||||
const py = posAttr.getY(i);
|
const py = posAttr.getY(i);
|
||||||
const pz = posAttr.getZ(i);
|
const pz = posAttr.getZ(i);
|
||||||
const nx_ = nrmAttr ? nrmAttr.getX(i) : 0;
|
const fnUx = faceNrmUnit[i*3], fnUy = faceNrmUnit[i*3+1], fnUz = faceNrmUnit[i*3+2];
|
||||||
const ny_ = nrmAttr ? nrmAttr.getY(i) : 0;
|
const fnRx = faceNrmRaw[i*3], fnRy = faceNrmRaw[i*3+1], fnRz = faceNrmRaw[i*3+2];
|
||||||
const nz_ = nrmAttr ? nrmAttr.getZ(i) : 1;
|
|
||||||
|
|
||||||
const key = `${Math.round(px * QUANTISE)}_${Math.round(py * QUANTISE)}_${Math.round(pz * QUANTISE)}`;
|
const key = `${Math.round(px * QUANTISE)}_${Math.round(py * QUANTISE)}_${Math.round(pz * QUANTISE)}`;
|
||||||
let idx = vertMap.get(key);
|
const clusters = vertMap.get(key);
|
||||||
if (idx === undefined) {
|
if (clusters) {
|
||||||
idx = positions.length / 3;
|
let matched = false;
|
||||||
positions.push(px, py, pz);
|
for (const cl of clusters) {
|
||||||
normals.push(nx_, ny_, nz_);
|
const dot = cl.fnU[0]*fnUx + cl.fnU[1]*fnUy + cl.fnU[2]*fnUz;
|
||||||
normalSums.push(nx_, ny_, nz_);
|
if (dot >= SHARP_COS) {
|
||||||
if (weights) weights.push(nonIndexedWeights[i]);
|
// Same smooth group – accumulate area-weighted face normal
|
||||||
vertMap.set(key, idx);
|
const idx = cl.idx;
|
||||||
} else {
|
normalSums[idx*3] += fnRx;
|
||||||
normalSums[idx * 3] += nx_;
|
normalSums[idx*3+1] += fnRy;
|
||||||
normalSums[idx * 3 + 1] += ny_;
|
normalSums[idx*3+2] += fnRz;
|
||||||
normalSums[idx * 3 + 2] += nz_;
|
if (weights && nonIndexedWeights[i] > weights[idx]) {
|
||||||
if (weights && nonIndexedWeights[i] > weights[idx]) {
|
weights[idx] = nonIndexedWeights[i];
|
||||||
// MAX: if any incident original face was excluded, the shared vertex is excluded
|
}
|
||||||
weights[idx] = nonIndexedWeights[i];
|
indices.push(idx);
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (!matched) {
|
||||||
|
// New cluster at this position (sharp-edge split)
|
||||||
|
const idx = positions.length / 3;
|
||||||
|
positions.push(px, py, pz);
|
||||||
|
normals.push(fnRx, fnRy, fnRz);
|
||||||
|
normalSums.push(fnRx, fnRy, fnRz);
|
||||||
|
if (weights) weights.push(nonIndexedWeights[i]);
|
||||||
|
clusters.push({idx, fnU: [fnUx, fnUy, fnUz]});
|
||||||
|
indices.push(idx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const idx = positions.length / 3;
|
||||||
|
positions.push(px, py, pz);
|
||||||
|
normals.push(fnRx, fnRy, fnRz);
|
||||||
|
normalSums.push(fnRx, fnRy, fnRz);
|
||||||
|
if (weights) weights.push(nonIndexedWeights[i]);
|
||||||
|
vertMap.set(key, [{idx, fnU: [fnUx, fnUy, fnUz]}]);
|
||||||
|
indices.push(idx);
|
||||||
}
|
}
|
||||||
indices.push(idx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < positions.length / 3; i++) {
|
for (let i = 0; i < positions.length / 3; i++) {
|
||||||
|
|||||||
Reference in New Issue
Block a user