mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
498581d8cf
- Added a language dropdown selector to replace the previous button-based language toggle. - Integrated boundary falloff settings into the preview material, allowing for smoother transitions between masked and unmasked areas. - Updated shaders to utilize boundary falloff attributes for improved visual fidelity in bump-only previews. - Refactored related CSS styles for the new dropdown and adjusted layout for better usability. - Introduced new functions for computing boundary attributes and managing edge data textures.
489 lines
23 KiB
JavaScript
489 lines
23 KiB
JavaScript
import * as THREE from 'three';
|
||
import { computeUV, getDominantCubicAxis, getCubicBlendWeights } 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 − 0.5) × 2 × amplitude
|
||
* so 50% grey = no displacement, white = outward, black = inward.
|
||
*
|
||
* @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();
|
||
|
||
// Texture aspect correction so non-square textures keep their proportions.
|
||
// The shorter axis gets aspect > 1 so it tiles faster, making each tile
|
||
// proportionally shorter in world-space to match the texture's content.
|
||
const tmax = Math.max(imgWidth, imgHeight, 1);
|
||
const aspectU = tmax / Math.max(imgWidth, 1);
|
||
const aspectV = tmax / Math.max(imgHeight, 1);
|
||
const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV };
|
||
|
||
const QUANT = 1e4;
|
||
|
||
// ── 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.
|
||
|
||
// ── Vertex dedup pass: position → numeric ID via one-time string-map pass ─
|
||
const _dedupMap = new Map();
|
||
let _nextId = 0;
|
||
const vertexId = new Uint32Array(count);
|
||
for (let i = 0; i < count; i++) {
|
||
const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i);
|
||
const key = `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
|
||
let id = _dedupMap.get(key);
|
||
if (id === undefined) { id = _nextId++; _dedupMap.set(key, id); }
|
||
vertexId[i] = id;
|
||
}
|
||
const uniqueCount = _nextId;
|
||
|
||
// ── Pass 1: accumulate area-weighted smooth normals per unique position ───
|
||
// Flat arrays indexed by vertex dedup ID (replaces Map<string, ...>)
|
||
const smoothNrmX = new Float64Array(uniqueCount);
|
||
const smoothNrmY = new Float64Array(uniqueCount);
|
||
const smoothNrmZ = new Float64Array(uniqueCount);
|
||
|
||
// zoneArea: per-axis face area for cubic mapping (replaces zoneAreaMap)
|
||
const zoneAreaX = new Float64Array(uniqueCount);
|
||
const zoneAreaY = new Float64Array(uniqueCount);
|
||
const zoneAreaZ = new Float64Array(uniqueCount);
|
||
|
||
// maskedFrac: [maskedArea, totalArea] per unique vertex (replaces maskedFracMap)
|
||
const maskedFracMasked = new Float64Array(uniqueCount);
|
||
const maskedFracTotal = new Float64Array(uniqueCount);
|
||
|
||
// Optional per-vertex exclusion weights threaded through by subdivision.js.
|
||
// 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 maskedFrac so that
|
||
// user-excluded faces do NOT bleed reduced displacement into adjacent faces
|
||
// via shared vertices (maskedFrac is only for angle-based blending).
|
||
const userExcludedFaces = ewAttr ? new Uint8Array(count / 3) : null;
|
||
// Positions that belong to at least one user-excluded face (replaces excludedPosSet).
|
||
const excludedPos = ewAttr ? new Uint8Array(uniqueCount) : null;
|
||
|
||
// Displacement cache: one sample per unique vertex (replaces dispCache Map)
|
||
const dispCacheVal = new Float64Array(uniqueCount);
|
||
const dispCacheSet = new Uint8Array(uniqueCount);
|
||
|
||
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;
|
||
// 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 cubic mapping: distribute this face's area across projection zones
|
||
// proportionally to its blend weights. When blend=0, getCubicBlendWeights
|
||
// returns a one-hot vector (same as the old argmax), preserving sharp seams.
|
||
// When blend>0, faces near a zone boundary contribute partial area to
|
||
// adjacent zones, creating a smooth multi-vertex-wide gradient that matches
|
||
// the preview shader. The old single-zone approach only blended at the
|
||
// one-vertex-wide boundary, leaving an abrupt seam in the export.
|
||
let czX = 0, czY = 0, czZ = 0;
|
||
if (settings.mappingMode === 6 && faceArea > 1e-12) {
|
||
const cubicBlend = settings.mappingBlend ?? 0;
|
||
const cubicBandWidth = settings.seamBandWidth ?? 0.35;
|
||
const unitFaceNrm = { x: faceNrm.x / faceArea, y: faceNrm.y / faceArea, z: faceNrm.z / faceArea };
|
||
const w = getCubicBlendWeights(unitFaceNrm, cubicBlend, cubicBandWidth);
|
||
czX = w.x * faceArea;
|
||
czY = w.y * faceArea;
|
||
czZ = w.z * faceArea;
|
||
}
|
||
|
||
for (let v = 0; v < 3; v++) {
|
||
const vid = vertexId[t + v];
|
||
if (userExcluded && excludedPos) excludedPos[vid] = 1;
|
||
// Use the buffer normal (from subdivision) weighted by face area.
|
||
// 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).
|
||
// This eliminates visible faceting steps on round surfaces while still
|
||
// preserving hard edges.
|
||
tmpNrm.fromBufferAttribute(nrmAttr, t + v);
|
||
smoothNrmX[vid] += tmpNrm.x * faceArea;
|
||
smoothNrmY[vid] += tmpNrm.y * faceArea;
|
||
smoothNrmZ[vid] += tmpNrm.z * faceArea;
|
||
if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) {
|
||
zoneAreaX[vid] += czX;
|
||
zoneAreaY[vid] += czY;
|
||
zoneAreaZ[vid] += czZ;
|
||
}
|
||
if (faceMasked) maskedFracMasked[vid] += faceArea;
|
||
maskedFracTotal[vid] += faceArea;
|
||
}
|
||
}
|
||
|
||
// Normalise each accumulated normal
|
||
for (let id = 0; id < uniqueCount; id++) {
|
||
const len = Math.sqrt(smoothNrmX[id]*smoothNrmX[id] + smoothNrmY[id]*smoothNrmY[id] + smoothNrmZ[id]*smoothNrmZ[id]) || 1;
|
||
smoothNrmX[id] /= len; smoothNrmY[id] /= len; smoothNrmZ[id] /= len;
|
||
}
|
||
|
||
// ── Boundary falloff distance field ──────────────────────────────────────────
|
||
// When boundaryFalloff > 0, identify boundary positions (vertices adjacent to
|
||
// both masked and unmasked faces, or on the user-exclusion seam) and compute
|
||
// the Euclidean distance from every fully-textured vertex to its nearest
|
||
// boundary position. The result is falloffArr: Float64Array[uniqueCount]
|
||
// where 0 means "at the boundary" and 1 means "at or beyond the falloff distance".
|
||
const boundaryFalloff = settings.boundaryFalloff ?? 0;
|
||
let falloffArr = null;
|
||
|
||
if (boundaryFalloff > 0) {
|
||
// Build position lookup per unique vertex ID (first occurrence)
|
||
const idPosX = new Float64Array(uniqueCount);
|
||
const idPosY = new Float64Array(uniqueCount);
|
||
const idPosZ = new Float64Array(uniqueCount);
|
||
const idPosSeen = new Uint8Array(uniqueCount);
|
||
for (let i = 0; i < count; i++) {
|
||
const vid = vertexId[i];
|
||
if (!idPosSeen[vid]) {
|
||
idPosSeen[vid] = 1;
|
||
idPosX[vid] = posAttr.getX(i);
|
||
idPosY[vid] = posAttr.getY(i);
|
||
idPosZ[vid] = posAttr.getZ(i);
|
||
}
|
||
}
|
||
|
||
const boundaryPositions = []; // [[x, y, z], ...]
|
||
|
||
// Collect boundary positions: vertices where maskedFrac is between 0 and 1,
|
||
// or that sit on the user-exclusion seam.
|
||
for (let id = 0; id < uniqueCount; id++) {
|
||
const mfTotal = maskedFracTotal[id];
|
||
const maskedFrac = mfTotal > 0 ? maskedFracMasked[id] / mfTotal : 0;
|
||
const isOnExclBoundary = excludedPos && excludedPos[id] === 1;
|
||
if (isOnExclBoundary || (maskedFrac > 0 && maskedFrac < 1)) {
|
||
boundaryPositions.push([idPosX[id], idPosY[id], idPosZ[id]]);
|
||
}
|
||
}
|
||
|
||
if (boundaryPositions.length > 0) {
|
||
// Build a spatial grid of boundary positions for fast nearest-neighbor lookup
|
||
let gMinX = Infinity, gMinY = Infinity, gMinZ = Infinity;
|
||
let gMaxX = -Infinity, gMaxY = -Infinity, gMaxZ = -Infinity;
|
||
for (const bp of boundaryPositions) {
|
||
if (bp[0] < gMinX) gMinX = bp[0]; if (bp[0] > gMaxX) gMaxX = bp[0];
|
||
if (bp[1] < gMinY) gMinY = bp[1]; if (bp[1] > gMaxY) gMaxY = bp[1];
|
||
if (bp[2] < gMinZ) gMinZ = bp[2]; if (bp[2] > gMaxZ) gMaxZ = bp[2];
|
||
}
|
||
const gPad = boundaryFalloff + 1e-3;
|
||
gMinX -= gPad; gMinY -= gPad; gMinZ -= gPad;
|
||
gMaxX += gPad; gMaxY += gPad; gMaxZ += gPad;
|
||
|
||
const gRes = Math.max(4, Math.min(128, Math.ceil(Math.cbrt(boundaryPositions.length) * 2)));
|
||
const gDx = (gMaxX - gMinX) / gRes || 1;
|
||
const gDy = (gMaxY - gMinY) / gRes || 1;
|
||
const gDz = (gMaxZ - gMinZ) / gRes || 1;
|
||
const bGrid = new Map();
|
||
const bCellKey = (ix, iy, iz) => (ix * gRes + iy) * gRes + iz;
|
||
|
||
for (const bp of boundaryPositions) {
|
||
const ix = Math.max(0, Math.min(gRes - 1, Math.floor((bp[0] - gMinX) / gDx)));
|
||
const iy = Math.max(0, Math.min(gRes - 1, Math.floor((bp[1] - gMinY) / gDy)));
|
||
const iz = Math.max(0, Math.min(gRes - 1, Math.floor((bp[2] - gMinZ) / gDz)));
|
||
const ck = bCellKey(ix, iy, iz);
|
||
const cell = bGrid.get(ck);
|
||
if (cell) cell.push(bp); else bGrid.set(ck, [bp]);
|
||
}
|
||
|
||
// How many grid cells to search in each direction to cover boundaryFalloff distance
|
||
const searchX = Math.ceil(boundaryFalloff / gDx);
|
||
const searchY = Math.ceil(boundaryFalloff / gDy);
|
||
const searchZ = Math.ceil(boundaryFalloff / gDz);
|
||
|
||
falloffArr = new Float64Array(uniqueCount);
|
||
falloffArr.fill(1); // default: full displacement
|
||
for (let id = 0; id < uniqueCount; id++) {
|
||
const mfTotal = maskedFracTotal[id];
|
||
const maskedFrac = mfTotal > 0 ? maskedFracMasked[id] / mfTotal : 0;
|
||
const isOnExclBoundary = excludedPos && excludedPos[id] === 1;
|
||
// Only compute falloff for fully-textured, non-boundary positions
|
||
if (maskedFrac > 0 || isOnExclBoundary) continue;
|
||
|
||
const px = idPosX[id], py = idPosY[id], pz = idPosZ[id];
|
||
const cix = Math.max(0, Math.min(gRes - 1, Math.floor((px - gMinX) / gDx)));
|
||
const ciy = Math.max(0, Math.min(gRes - 1, Math.floor((py - gMinY) / gDy)));
|
||
const ciz = Math.max(0, Math.min(gRes - 1, Math.floor((pz - gMinZ) / gDz)));
|
||
|
||
let minDist2 = boundaryFalloff * boundaryFalloff;
|
||
for (let dix = -searchX; dix <= searchX; dix++) {
|
||
const nix = cix + dix;
|
||
if (nix < 0 || nix >= gRes) continue;
|
||
for (let diy = -searchY; diy <= searchY; diy++) {
|
||
const niy = ciy + diy;
|
||
if (niy < 0 || niy >= gRes) continue;
|
||
for (let diz = -searchZ; diz <= searchZ; diz++) {
|
||
const niz = ciz + diz;
|
||
if (niz < 0 || niz >= gRes) continue;
|
||
const cell = bGrid.get(bCellKey(nix, niy, niz));
|
||
if (!cell) continue;
|
||
for (const bp of cell) {
|
||
const dx = px - bp[0], dy = py - bp[1], dz = pz - bp[2];
|
||
const d2 = dx * dx + dy * dy + dz * dz;
|
||
if (d2 < minDist2) minDist2 = d2;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const dist = Math.sqrt(minDist2);
|
||
const factor = Math.min(1, dist / boundaryFalloff);
|
||
falloffArr[id] = factor;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pass 2: sample displacement texture once per unique position ──────────
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const vid = vertexId[i];
|
||
if (dispCacheSet[vid]) continue;
|
||
dispCacheSet[vid] = 1;
|
||
|
||
tmpPos.fromBufferAttribute(posAttr, i);
|
||
|
||
// Cubic: zone-area-weighted sampling with a stable per-face dominant axis.
|
||
// Non-seam vertices use their single zone purely; seam-edge vertices that
|
||
// adjoin two zones get a face-area-proportional blend. This guarantees all
|
||
// three vertices of every triangle receive consistent displacement, making
|
||
// the mesh watertight with no mixed-projection artefact rows at the seam.
|
||
//
|
||
// Always use this path regardless of mappingBlend. The smooth normals from
|
||
// subdivision can be wrong on thin structures (e.g. a flat base plate) where
|
||
// top (0,0,1) and bottom (0,0,-1) face normals cancel at shared edge vertices,
|
||
// leaving a horizontal smooth normal. computeUV would then pick the wrong
|
||
// cubic projection axis, making those faces appear untextured. The face-
|
||
// normal-based zoneArea arrays are immune to this because they classify faces
|
||
// by their geometric cross-product normal, not the averaged vertex normal.
|
||
if (settings.mappingMode === 6 /* MODE_CUBIC */) {
|
||
const zaX = zoneAreaX[vid], zaY = zoneAreaY[vid], zaZ = zoneAreaZ[vid];
|
||
const total = zaX + zaY + zaZ;
|
||
if (total > 0) {
|
||
const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6);
|
||
const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
|
||
let grey = 0;
|
||
if (zaX > 0) { // X-dominant zone → YZ projection
|
||
let rawU = (tmpPos.y-bounds.min.y)/md;
|
||
if (smoothNrmX[vid] < 0) rawU = -rawU; // flip U for -X faces
|
||
const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV);
|
||
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaX/total);
|
||
}
|
||
if (zaY > 0) { // Y-dominant zone → XZ projection
|
||
let rawU = (tmpPos.x-bounds.min.x)/md;
|
||
if (smoothNrmY[vid] > 0) rawU = -rawU; // flip U for +Y faces
|
||
const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV);
|
||
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaY/total);
|
||
}
|
||
if (zaZ > 0) { // Z-dominant zone → XY projection
|
||
let rawU = (tmpPos.x-bounds.min.x)/md;
|
||
if (smoothNrmZ[vid] < 0) rawU = -rawU; // flip U for -Z faces
|
||
const uv = _cubicUV(rawU, (tmpPos.y-bounds.min.y)/md, settings, rotRad, aspectU, aspectV);
|
||
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaZ/total);
|
||
}
|
||
dispCacheVal[vid] = grey;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
tmpNrm.set(smoothNrmX[vid], smoothNrmY[vid], smoothNrmZ[vid]);
|
||
|
||
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settingsWithAspect, 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);
|
||
}
|
||
dispCacheVal[vid] = 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 vid = vertexId[i];
|
||
const grey = dispCacheVal[vid];
|
||
|
||
// 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 && excludedPos && excludedPos[vid] === 1;
|
||
const mfTotal = maskedFracTotal[vid];
|
||
const maskedFrac = mfTotal > 0 ? maskedFracMasked[vid] / mfTotal : 0;
|
||
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey;
|
||
const falloffFactor = falloffArr ? falloffArr[vid] : 1.0;
|
||
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : falloffFactor * (1 - maskedFrac) * centeredGrey * settings.amplitude;
|
||
|
||
const newX = tmpPos.x + smoothNrmX[vid] * disp;
|
||
const newY = tmpPos.y + smoothNrmY[vid] * disp;
|
||
let newZ = tmpPos.z + smoothNrmZ[vid] * 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);
|
||
}
|
||
|
||
// 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));
|
||
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;
|
||
// Flip V to match WebGL/Three.js texture convention (flipY=true means
|
||
// v=0 is the bottom of the image, but ImageData row 0 is the top).
|
||
v = 1 - v;
|
||
|
||
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;
|
||
}
|
||
|
||
/** Apply scale/offset/rotation to raw UV for cubic projection.
|
||
* Mirrors the private applyTransform helper in mapping.js. */
|
||
function _cubicUV(rawU, rawV, settings, rotRad, aspectU, aspectV) {
|
||
let u = (rawU * aspectU) / settings.scaleU + settings.offsetU;
|
||
let v = (rawV * aspectV) / settings.scaleV + settings.offsetV;
|
||
if (rotRad !== 0) {
|
||
const c = Math.cos(rotRad), s = Math.sin(rotRad);
|
||
u -= 0.5; v -= 0.5;
|
||
const ru = c*u - s*v, rv = s*u + c*v;
|
||
u = ru + 0.5; v = rv + 0.5;
|
||
}
|
||
return { u: u - Math.floor(u), v: v - Math.floor(v) };
|
||
}
|