mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
1d3e756245
- Introduced a new section in the UI for surface exclusions, allowing users to exclude triangles from displacement using brush and bucket fill tools. - Implemented brush type switching (single and radius) and radius control for the brush tool. - Added functionality for bucket fill with a threshold angle to control the fill area. - Integrated exclusion weights into the displacement algorithm to ensure excluded faces are handled correctly during subdivision. - Created adjacency and centroid calculations for triangles to support the bucket fill operation. - Developed overlay geometries for visual feedback on excluded faces and hover previews. - Enhanced the CSS for the new exclusion tools and their layout in the UI.
230 lines
9.4 KiB
JavaScript
230 lines
9.4 KiB
JavaScript
import * as THREE from 'three';
|
||
import { computeUV } from './mapping.js';
|
||
|
||
/**
|
||
* Apply displacement to every vertex of a non-indexed BufferGeometry.
|
||
*
|
||
* For each vertex:
|
||
* 1. Compute UV with the same math used in the GLSL preview shader (mapping.js).
|
||
* 2. Bilinear-sample the greyscale ImageData at that UV.
|
||
* 3. Move the vertex along its normal by: grey * amplitude
|
||
*
|
||
* @param {THREE.BufferGeometry} geometry – non-indexed (from subdivide())
|
||
* @param {ImageData} imageData – raw pixel data from Canvas2D
|
||
* @param {number} imgWidth
|
||
* @param {number} imgHeight
|
||
* @param {object} settings – { mappingMode, scaleU, scaleV, amplitude, offsetU, offsetV }
|
||
* @param {object} bounds – { min, max, center, size } (THREE.Vector3)
|
||
* @param {function} [onProgress]
|
||
* @returns {THREE.BufferGeometry} new non-indexed geometry with displaced positions
|
||
*/
|
||
export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, settings, bounds, onProgress) {
|
||
const posAttr = geometry.attributes.position;
|
||
const nrmAttr = geometry.attributes.normal;
|
||
const count = posAttr.count;
|
||
|
||
const newPos = new Float32Array(count * 3);
|
||
const newNrm = new Float32Array(count * 3);
|
||
|
||
const tmpPos = new THREE.Vector3();
|
||
const tmpNrm = new THREE.Vector3();
|
||
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 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();
|
||
// maskedFracMap: posKey → [maskedArea, totalArea]
|
||
// Tracks the fraction of surrounding face area that is masked so boundary
|
||
// vertices get a smooth displacement blend instead of a hard on/off cutoff.
|
||
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.
|
||
const ewAttr = geometry.attributes.excludeWeight || null;
|
||
|
||
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
|
||
|
||
// Determine if this face is masked (used to build the per-vertex blend weight).
|
||
// Combines angle-based masking with optional user-painted exclusion.
|
||
const faceArea = faceNrm.length(); // ∝ 2× triangle area
|
||
const faceNzNorm = faceArea > 1e-12 ? faceNrm.z / faceArea : 0; // unit-normal Z component
|
||
const faceAngle = Math.acos(Math.abs(faceNzNorm)) * (180 / Math.PI);
|
||
const angleMasked = faceNzNorm < 0
|
||
? (settings.bottomAngleLimit > 0 && faceAngle <= settings.bottomAngleLimit)
|
||
: (settings.topAngleLimit > 0 && faceAngle <= settings.topAngleLimit);
|
||
// Threshold >0.99 (not 0.5) prevents shared-vertex MAX-propagation from
|
||
// accidentally marking adjacent faces as excluded on closed meshes (e.g. a
|
||
// cube): adjacent faces have 2/3 vertices at weight 1.0 → avg ≈ 0.67 which
|
||
// would wrongly trigger the old 0.5 threshold.
|
||
const userExcluded = ewAttr
|
||
? (ewAttr.getX(t) + ewAttr.getX(t + 1) + ewAttr.getX(t + 2)) / 3 > 0.99
|
||
: false;
|
||
const faceMasked = angleMasked || userExcluded;
|
||
|
||
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]);
|
||
}
|
||
const mf = maskedFracMap.get(k);
|
||
if (mf) {
|
||
if (faceMasked) mf[0] += faceArea;
|
||
mf[1] += faceArea;
|
||
} else {
|
||
maskedFracMap.set(k, [faceMasked ? faceArea : 0, faceArea]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
||
if (dispCache.has(k)) continue;
|
||
|
||
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) {
|
||
grey = 0;
|
||
for (const s of uvResult.samples) {
|
||
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, s.u, s.v) * s.w;
|
||
}
|
||
} 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);
|
||
|
||
// 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.
|
||
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 newX = tmpPos.x + sn[0] * disp;
|
||
const newY = tmpPos.y + sn[1] * disp;
|
||
let newZ = tmpPos.z + sn[2] * disp;
|
||
|
||
// Prevent boundary vertices from poking through the masked surface in Z.
|
||
// Only triggers for vertices that are partly masked (maskedFrac > 0) and
|
||
// whose displacement would push them toward the masked surface direction.
|
||
if (maskedFrac > 0) {
|
||
if (settings.bottomAngleLimit > 0 && newZ < tmpPos.z) newZ = tmpPos.z;
|
||
if (settings.topAngleLimit > 0 && newZ > tmpPos.z) newZ = tmpPos.z;
|
||
}
|
||
|
||
newPos[i*3] = newX;
|
||
newPos[i*3+1] = newY;
|
||
newPos[i*3+2] = newZ;
|
||
|
||
// 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);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ── Bilinear sampler ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Sample a greyscale value (0–1) from raw RGBA ImageData using
|
||
* bilinear interpolation. UV is tiled via mod 1.
|
||
*/
|
||
function sampleBilinear(data, w, h, u, v) {
|
||
// Ensure [0,1) — guard against floating-point edge cases
|
||
u = ((u % 1) + 1) % 1;
|
||
v = ((v % 1) + 1) % 1;
|
||
|
||
const fx = u * (w - 1);
|
||
const fy = v * (h - 1);
|
||
const x0 = Math.floor(fx);
|
||
const y0 = Math.floor(fy);
|
||
const x1 = Math.min(x0 + 1, w - 1);
|
||
const y1 = Math.min(y0 + 1, h - 1);
|
||
const tx = fx - x0;
|
||
const ty = fy - y0;
|
||
|
||
// Red channel — image is greyscale so R == G == B
|
||
const v00 = data[(y0 * w + x0) * 4] / 255;
|
||
const v10 = data[(y0 * w + x1) * 4] / 255;
|
||
const v01 = data[(y1 * w + x0) * 4] / 255;
|
||
const v11 = data[(y1 * w + x1) * 4] / 255;
|
||
|
||
return v00 * (1-tx) * (1-ty)
|
||
+ v10 * tx * (1-ty)
|
||
+ v01 * (1-tx) * ty
|
||
+ v11 * tx * ty;
|
||
}
|