Merge PR #17: Performance 2-3x faster pipeline, spatial brush index, fix VRAM leaks, bug fixes, default resolution to 1/250 diagonal

This commit is contained in:
CNCKitchen
2026-04-07 09:51:28 +02:00
11 changed files with 1631 additions and 399 deletions
+2 -1
View File
@@ -16,6 +16,7 @@
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light');
})();
</script>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<script type="importmap">
{
"imports": {
@@ -61,7 +62,7 @@
<p data-i18n-html="dropHint.text">Drop an <strong>.stl</strong>, <strong>.obj</strong> or <strong>.3mf</strong> file here<br/>or <label for="stl-file-input" class="link-label">click to browse</label></p>
<input type="file" id="stl-file-input" accept=".stl,.obj,.3mf" hidden />
</div>
<canvas id="viewport"></canvas>
<canvas id="viewport" role="img" aria-label="3D model preview"></canvas>
<div id="brush-cursor"></div>
<div id="store-cta-wrapper">
<span id="store-cta">
+94 -54
View File
@@ -28,8 +28,8 @@
* - Struct-of-arrays typed-array heap avoids per-entry object allocation.
* - Numeric edge keys (v_lo * MAX_V + v_hi) replace template strings.
* - Vertex deduplication uses a numeric spatial-grid Map instead of strings.
* - Link-violation check packs sorted face triple into two Numbers to avoid
* string allocation.
* - Link-violation check uses a module-level Set with packed keys for O(1)
* duplicate-face lookup.
* - Progress callback fires at most every 512 collapses.
*
* @param {THREE.BufferGeometry} geometry non-indexed input
@@ -46,8 +46,23 @@ const FLIP_DOT_SQ = FLIP_DOT * FLIP_DOT;
const CREASE_COS = 0.5; // cos 60° — edges sharper than this are treated as creases
const CREASE_WEIGHT = 1e4; // quadric penalty weight for crease edges
// Time-based yield: only yield every ~100ms of wall time instead of every N iterations.
// In foreground tabs setTimeout(0) costs ~4ms; in background tabs it's throttled to ~1s.
// By yielding based on elapsed time we get ~10 yields per second in foreground (smooth progress)
// and minimal extra delay in background (~10 yields × 1s = ~10s overhead instead of ~200s).
let _lastYieldTime = 0;
function _shouldYield() {
const now = performance.now();
if (now - _lastYieldTime < 100) return false;
_lastYieldTime = now;
return true;
}
function _yieldFrame() {
return new Promise(r => setTimeout(r, 0));
}
// Module-level Set for hasLinkViolation — avoids per-call heap allocation.
// Module-level scratch arrays for hasLinkViolation — avoids new Map() per call.
// Size 128 exceeds the maximum practical vertex valence in any STL mesh.
const _hlvHi = new Float64Array(512);
const _hlvLo = new Int32Array(512);
@@ -78,17 +93,17 @@ export async function decimate(geometry, targetTriangles, onProgress) {
let activeFaces = faceCount;
// Seed min-heap with one entry per unique edge.
// Use BigInt keys to handle any vertex count without integer overflow.
// Use Number keys when vertCount < 94M (safe integer range), BigInt otherwise.
const heap = new SoAHeap(Math.min(faceCount * 3, 1 << 24));
const seedSeen = new Set();
const _vc = BigInt(vertCount);
const _useNumericSeed = vertCount < 94_000_000;
for (let f = 0; f < faceCount; f++) {
if (faces[f * 3] < 0) continue;
for (let e = 0; e < 3; e++) {
const va = faces[f * 3 + e];
const vb = faces[f * 3 + ((e + 1) % 3)];
const lo = va < vb ? va : vb, hi = va < vb ? vb : va;
const ek = BigInt(lo) * _vc + BigInt(hi);
const ek = _useNumericSeed ? lo * vertCount + hi : BigInt(lo) * BigInt(vertCount) + BigInt(hi);
if (!seedSeen.has(ek)) { seedSeen.add(ek); pushEdge(heap, quadrics, positions, version, va, vb); }
}
}
@@ -103,11 +118,11 @@ export async function decimate(geometry, targetTriangles, onProgress) {
const idx = heap.pop();
if (idx < 0) break;
// Yield periodically based on total iterations (including rejections)
// to keep the UI responsive. Critical for flat / low-displacement
// surfaces where most collapses are rejected by the safety guards.
if ((++iterations & 4095) === 0) {
await new Promise(r => setTimeout(r, 0));
// Yield based on elapsed wall time (~every 100ms) instead of fixed iteration count.
// Drastically reduces overhead in background tabs where setTimeout is throttled to 1s.
++iterations;
if (_shouldYield()) {
await _yieldFrame();
if (onProgress) {
const p = Math.min(1, (initFaces - activeFaces) / toRemove);
if (p - lastProg > 0.005) { onProgress(p); lastProg = p; }
@@ -128,7 +143,7 @@ export async function decimate(geometry, targetTriangles, onProgress) {
if (nsh < 2) continue;
// ── Three safety guards ───────────────────────────────────────────────────
if (hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2)) continue; // Guard 2
if (hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, vertCount)) continue; // Guard 2
if (checkFlipped(positions, vfHead, slotFace, slotNext, faces, v1, v2, px, py, pz)) continue; // Guard 3a
if (checkFlipped(positions, vfHead, slotFace, slotNext, faces, v2, v1, px, py, pz)) continue; // Guard 3b
@@ -258,10 +273,11 @@ function sharedFaceCount(faces, vfHead, slotFace, slotNext, v1, v2) {
}
// ── Guard 2: Duplicate-face / pinch prevention ───────────────────────────────
// Uses module-level scratch arrays (_hlvHi, _hlvLo) instead of new Map()
// to avoid per-call heap allocation.
// Uses module-level scratch arrays (_hlvHi, _hlvLo) — zero allocation per call.
// Linear scan is faster than Set for typical STL vertex valence (5-8).
function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2) {
function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, vc) {
const MUL = vc < 0x200000 ? 0x200000 : vc + 1;
let n = 0;
for (let s = vfHead[v1]; s >= 0; s = slotNext[s]) {
const f = slotFace[s];
@@ -272,7 +288,7 @@ function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2) {
if (fa > fb) { t = fa; fa = fb; fb = t; }
if (fb > fc) { t = fb; fb = fc; fc = t; }
if (fa > fb) { t = fa; fa = fb; fb = t; }
_hlvHi[n] = fa * 0x200000 + fb;
_hlvHi[n] = fa * MUL + fb;
_hlvLo[n] = fc;
n++;
}
@@ -286,7 +302,7 @@ function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2) {
if (fa > fb) { t = fa; fa = fb; fb = t; }
if (fb > fc) { t = fb; fb = fc; fc = t; }
if (fa > fb) { t = fa; fa = fb; fb = t; }
const hi = fa * 0x200000 + fb;
const hi = fa * MUL + fb;
for (let i = 0; i < n; i++) {
if (_hlvHi[i] === hi && _hlvLo[i] === fc) return true;
}
@@ -316,9 +332,10 @@ function checkFlipped(positions, vfHead, slotFace, slotNext, faces, vc, vo, npx,
const ony = ouz*ovx - oux*ovz;
const onz = oux*ovy - ouy*ovx;
// New positions (vc replaced by np)
const nax = fa===vc ? npx : oax, nay = fa===vc ? npy : oay, naz = fa===vc ? npz : oaz;
const nbx = fb===vc ? npx : obx, nby = fb===vc ? npy : oby, nbz = fb===vc ? npz : obz;
const ncx = fc===vc ? npx : ocx, ncy = fc===vc ? npy : ocy, ncz = fc===vc ? npz : ocz;
let nax, nay, naz, nbx, nby, nbz, ncx, ncy, ncz;
if (fa === vc) { nax = npx; nay = npy; naz = npz; nbx = obx; nby = oby; nbz = obz; ncx = ocx; ncy = ocy; ncz = ocz; }
else if (fb === vc) { nax = oax; nay = oay; naz = oaz; nbx = npx; nby = npy; nbz = npz; ncx = ocx; ncy = ocy; ncz = ocz; }
else { nax = oax; nay = oay; naz = oaz; nbx = obx; nby = oby; nbz = obz; ncx = npx; ncy = npy; ncz = npz; }
// Unnormalized new normal
const nux = nbx-nax, nuy = nby-nay, nuz = nbz-naz;
const nvx = ncx-nax, nvy = ncy-nay, nvz = ncz-naz;
@@ -368,18 +385,24 @@ function addCreaseQuadrics(quadrics, positions, faces, faceCount) {
const va = faces[f * 3 + e];
const vb = faces[f * 3 + ((e + 1) % 3)];
const key = va < vb ? va * N + vb : vb * N + va;
let arr = edgeToFaces.get(key);
if (!arr) { arr = []; edgeToFaces.set(key, arr); }
arr.push(f);
const existing = edgeToFaces.get(key);
if (existing === undefined) {
edgeToFaces.set(key, f);
} else if (existing >= 0) {
edgeToFaces.set(key, -(existing * faceCount + f + 1));
} else {
edgeToFaces.set(key, 0);
}
}
}
const sqrtW = Math.sqrt(CREASE_WEIGHT);
for (const [key, flist] of edgeToFaces) {
if (flist.length !== 2) continue; // open boundary or non-manifold — skip
const f0 = flist[0], f1 = flist[1];
for (const [key, val] of edgeToFaces) {
if (val >= 0 || val === 0) continue; // nur 1 Face oder >2 Faces -> skip
const encoded = -(val + 1);
const f0 = Math.floor(encoded / faceCount);
const f1 = encoded - f0 * faceCount;
const v0a = faces[f0*3], v0b = faces[f0*3+1], v0c = faces[f0*3+2];
const v1a = faces[f1*3], v1b = faces[f1*3+1], v1c = faces[f1*3+2];
@@ -482,7 +505,9 @@ function solveQ(q, v1, v2) {
const b2 = -(q[o1+8] + q[o2+8]);
const det = a00*(a11*a22 - a12*a12) - a01*(a01*a22 - a12*a02) + a02*(a01*a12 - a11*a02);
if (Math.abs(det) < 1e-10) return false;
const maxEl = Math.max(Math.abs(a00), Math.abs(a01), Math.abs(a02), Math.abs(a11), Math.abs(a12), Math.abs(a22));
const threshold = maxEl * maxEl * maxEl * 1e-10;
if (Math.abs(det) < Math.max(threshold, 1e-30)) return false;
const inv = 1 / det;
_s[0] = inv * (b0*(a11*a22 - a12*a12) - a01*(b1*a22 - a12*b2) + a02*(b1*a12 - a11*b2));
@@ -665,38 +690,53 @@ class SoAHeap {
this._px[dst] = this._px[src]; this._py[dst] = this._py[src]; this._pz[dst] = this._pz[src];
}
_swap(a, b) {
const tc = this._cost[a], tv1 = this._v1[a], tv2 = this._v2[a];
const te1 = this._ver1[a], te2 = this._ver2[a];
const tpx = this._px[a], tpy = this._py[a], tpz = this._pz[a];
this._cost[a] = this._cost[b]; this._v1[a] = this._v1[b]; this._v2[a] = this._v2[b];
this._ver1[a] = this._ver1[b]; this._ver2[a] = this._ver2[b];
this._px[a] = this._px[b]; this._py[a] = this._py[b]; this._pz[a] = this._pz[b];
this._cost[b] = tc; this._v1[b] = tv1; this._v2[b] = tv2;
this._ver1[b] = te1; this._ver2[b] = te2;
this._px[b] = tpx; this._py[b] = tpy; this._pz[b] = tpz;
}
_bubbleUp(idx) {
const cost = this._cost[idx];
const v1 = this._v1[idx], v2 = this._v2[idx];
const ver1 = this._ver1[idx], ver2 = this._ver2[idx];
const px = this._px[idx], py = this._py[idx], pz = this._pz[idx];
_bubbleUp(i) {
const cost = this._cost;
while (i > 1) {
const p = i >> 1;
if (cost[p] <= cost[i]) break;
this._swap(p, i); i = p;
while (idx > 1) {
const parent = idx >> 1;
if (this._cost[parent] <= cost) break;
this._cost[idx] = this._cost[parent];
this._v1[idx] = this._v1[parent]; this._v2[idx] = this._v2[parent];
this._ver1[idx] = this._ver1[parent]; this._ver2[idx] = this._ver2[parent];
this._px[idx] = this._px[parent]; this._py[idx] = this._py[parent]; this._pz[idx] = this._pz[parent];
idx = parent;
}
this._cost[idx] = cost;
this._v1[idx] = v1; this._v2[idx] = v2;
this._ver1[idx] = ver1; this._ver2[idx] = ver2;
this._px[idx] = px; this._py[idx] = py; this._pz[idx] = pz;
}
_sinkDown(i) {
const cost = this._cost;
_sinkDown(idx) {
const n = this._len;
for (;;) {
let s = i;
const l = i << 1, r = l | 1;
if (l <= n && cost[l] < cost[s]) s = l;
if (r <= n && cost[r] < cost[s]) s = r;
if (s === i) break;
this._swap(s, i); i = s;
const cost = this._cost[idx];
const v1 = this._v1[idx], v2 = this._v2[idx];
const ver1 = this._ver1[idx], ver2 = this._ver2[idx];
const px = this._px[idx], py = this._py[idx], pz = this._pz[idx];
while (true) {
const l = idx << 1, r = l | 1;
let child = -1;
// Find smallest child that is cheaper than saved element
if (l <= n && this._cost[l] < cost) child = l;
if (r <= n && this._cost[r] < (child >= 0 ? this._cost[child] : cost)) child = r;
if (child < 0) break;
// Move child up into hole
this._cost[idx] = this._cost[child];
this._v1[idx] = this._v1[child]; this._v2[idx] = this._v2[child];
this._ver1[idx] = this._ver1[child]; this._ver2[idx] = this._ver2[child];
this._px[idx] = this._px[child]; this._py[idx] = this._py[child]; this._pz[idx] = this._pz[child];
idx = child;
}
// Place saved element in final hole
this._cost[idx] = cost;
this._v1[idx] = v1; this._v2[idx] = v2;
this._ver1[idx] = ver1; this._ver2[idx] = ver2;
this._px[idx] = px; this._py[idx] = py; this._pz[idx] = pz;
}
_grow() {
+108 -100
View File
@@ -45,8 +45,6 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV };
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
@@ -64,33 +62,47 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// 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 ───
// Map: posKey → [nx, ny, nz] (unnormalised sum)
const smoothNrmMap = new Map();
// zoneAreaMap: posKey → [xArea, yArea, zArea] (cubic mapping only)
// Tracks the total adjacent face area in each cubic projection zone (X/Y/Z dominant).
// Seam-edge vertices that border two zones get a blend proportional to face area,
// eliminating the mixed-projection artefact on seam-crossing triangles.
const zoneAreaMap = 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();
// 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 maskedFracMap so that
// 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 (maskedFracMap is only for angle-based blending).
// 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. 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;
// 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);
@@ -141,9 +153,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
}
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 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
@@ -151,62 +162,60 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// This eliminates visible faceting steps on round surfaces while still
// preserving hard edges.
tmpNrm.fromBufferAttribute(nrmAttr, t + v);
const existing = smoothNrmMap.get(k);
if (existing) {
existing[0] += tmpNrm.x * faceArea;
existing[1] += tmpNrm.y * faceArea;
existing[2] += tmpNrm.z * faceArea;
} else {
smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]);
}
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) {
const za = zoneAreaMap.get(k);
if (za) { za[0] += czX; za[1] += czY; za[2] += czZ; }
else { zoneAreaMap.set(k, [czX, czY, czZ]); }
}
const mf = maskedFracMap.get(k);
if (mf) {
if (faceMasked) mf[0] += faceArea;
mf[1] += faceArea;
} else {
maskedFracMap.set(k, [faceMasked ? faceArea : 0, faceArea]);
zoneAreaX[vid] += czX;
zoneAreaY[vid] += czY;
zoneAreaZ[vid] += czZ;
}
if (faceMasked) maskedFracMasked[vid] += faceArea;
maskedFracTotal[vid] += 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;
});
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 a falloffMap: posKey → [0, 1] where 0 means
// "at the boundary" and 1 means "at or beyond the falloff distance".
// 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 falloffMap = null;
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.
const posFromKey = new Map(); // posKey → [x, y, z]
for (let i = 0; i < count; i++) {
tmpPos.fromBufferAttribute(posAttr, i);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
if (!posFromKey.has(k)) posFromKey.set(k, [tmpPos.x, tmpPos.y, tmpPos.z]);
}
for (const [k, pos] of posFromKey) {
const mf = maskedFracMap.get(k);
const maskedFrac = mf && mf[1] > 0 ? mf[0] / mf[1] : 0;
const isOnExclBoundary = excludedPosSet && excludedPosSet.has(k);
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(pos);
boundaryPositions.push([idPosX[id], idPosY[id], idPosZ[id]]);
}
}
@@ -244,15 +253,16 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const searchY = Math.ceil(boundaryFalloff / gDy);
const searchZ = Math.ceil(boundaryFalloff / gDz);
falloffMap = new Map();
for (const [k, pos] of posFromKey) {
const mf = maskedFracMap.get(k);
const maskedFrac = mf && mf[1] > 0 ? mf[0] / mf[1] : 0;
const isOnExclBoundary = excludedPosSet && excludedPosSet.has(k);
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 = pos[0], py = pos[1], pz = pos[2];
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)));
@@ -279,20 +289,19 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
}
const dist = Math.sqrt(minDist2);
const factor = Math.min(1, dist / boundaryFalloff);
if (factor < 1) falloffMap.set(k, factor);
falloffArr[id] = factor;
}
}
}
// ── 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 vid = vertexId[i];
if (dispCacheSet[vid]) continue;
dispCacheSet[vid] = 1;
const sn = smoothNrmMap.get(k);
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
@@ -305,39 +314,39 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// 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 zoneAreaMap is immune to this because it classifies faces by
// their geometric cross-product normal, not the averaged vertex normal.
// 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 za = zoneAreaMap.get(k);
const total = za ? za[0] + za[1] + za[2] : 0;
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 (za[0] > 0) { // X-dominant zone → YZ projection
if (zaX > 0) { // X-dominant zone → YZ projection
let rawU = (tmpPos.y-bounds.min.y)/md;
if (sn[0] < 0) rawU = -rawU; // flip U for -X faces
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) * (za[0]/total);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaX/total);
}
if (za[1] > 0) { // Y-dominant zone → XZ projection
if (zaY > 0) { // Y-dominant zone → XZ projection
let rawU = (tmpPos.x-bounds.min.x)/md;
if (sn[1] > 0) rawU = -rawU; // flip U for +Y faces
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) * (za[1]/total);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaY/total);
}
if (za[2] > 0) { // Z-dominant zone → XY projection
if (zaZ > 0) { // Z-dominant zone → XY projection
let rawU = (tmpPos.x-bounds.min.x)/md;
if (sn[2] < 0) rawU = -rawU; // flip U for -Z faces
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) * (za[2]/total);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaZ/total);
}
dispCache.set(k, grey);
dispCacheVal[vid] = grey;
continue;
}
}
tmpNrm.set(sn[0], sn[1], sn[2]);
tmpNrm.set(smoothNrmX[vid], smoothNrmY[vid], smoothNrmZ[vid]);
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settingsWithAspect, bounds);
let grey;
@@ -349,7 +358,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
} else {
grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v);
}
dispCache.set(k, grey);
dispCacheVal[vid] = grey;
}
// ── Pass 3: displace every vertex copy by the same vector ─────────────────
@@ -362,9 +371,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
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);
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.
@@ -372,16 +380,16 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// 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 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 = (falloffMap && falloffMap.has(k)) ? falloffMap.get(k) : 1.0;
const falloffFactor = falloffArr ? falloffArr[vid] : 1.0;
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : falloffFactor * (1 - maskedFrac) * centeredGrey * settings.amplitude;
const newX = tmpPos.x + sn[0] * disp;
const newY = tmpPos.y + sn[1] * disp;
let newZ = tmpPos.z + sn[2] * disp;
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
+58 -33
View File
@@ -23,7 +23,7 @@ const quantKey = (x, y, z) =>
*
* @param {THREE.BufferGeometry} geometry non-indexed
* @returns {{
* adjacency: Map<number, Array<{neighbor:number, angle:number}>>,
* adjacency: Array<Array<{neighbor:number, angle:number}>>,
* centroids: Float32Array (triCount × 3, world-space centroid per triangle)
* }}
*/
@@ -71,24 +71,27 @@ export function buildAdjacency(geometry) {
// Build edge → triangle list (two triangles share an edge iff they share two
// vertex positions after quantization-based deduplication).
// Vertex-dedup pass: assign a numeric ID to each unique quantised position.
const posToId = new Map();
let nextId = 0;
const vertId = new Uint32Array(triCount * 3);
for (let i = 0; i < triCount * 3; 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 = posToId.get(key);
if (id === undefined) { id = nextId++; posToId.set(key, id); }
vertId[i] = id;
}
// nextId^2 < MAX_SAFE_INTEGER → safe up to ~94M unique vertices
const numEdgeKey = (a, b) => a < b ? a * nextId + b : b * nextId + a;
const edgeMap = new Map();
const makeEdgeKey = (ax, ay, az, bx, by, bz) => {
const ka = quantKey(ax, ay, az);
const kb = quantKey(bx, by, bz);
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
};
const edgePairs = [0, 1, 0, 2, 1, 2]; // vertex-index pairs within triangle
for (let t = 0; t < triCount; t++) {
const i = t * 3;
vA.fromBufferAttribute(posAttr, i);
vB.fromBufferAttribute(posAttr, i + 1);
vC.fromBufferAttribute(posAttr, i + 2);
const ekAB = makeEdgeKey(vA.x, vA.y, vA.z, vB.x, vB.y, vB.z);
const ekBC = makeEdgeKey(vB.x, vB.y, vB.z, vC.x, vC.y, vC.z);
const ekCA = makeEdgeKey(vC.x, vC.y, vC.z, vA.x, vA.y, vA.z);
for (const ek of [ekAB, ekBC, ekCA]) {
const base = t * 3;
for (let e = 0; e < 6; e += 2) {
const ek = numEdgeKey(vertId[base + edgePairs[e]], vertId[base + edgePairs[e + 1]]);
const entry = edgeMap.get(ek);
if (entry) entry.push(t);
else edgeMap.set(ek, [t]);
@@ -96,8 +99,9 @@ export function buildAdjacency(geometry) {
}
// Convert edge map to adjacency list with per-edge dihedral angle
const adjacency = new Map();
for (let t = 0; t < triCount; t++) adjacency.set(t, []);
// Array from buildAdjacency
const adjacency = new Array(triCount);
for (let t = 0; t < triCount; t++) adjacency[t] = [];
for (const [, tris] of edgeMap) {
if (tris.length !== 2) continue;
@@ -106,8 +110,8 @@ export function buildAdjacency(geometry) {
const nBx = faceNormals[b * 3], nBy = faceNormals[b * 3 + 1], nBz = faceNormals[b * 3 + 2];
const dot = Math.max(-1, Math.min(1, nAx * nBx + nAy * nBy + nAz * nBz));
const angleDeg = Math.acos(dot) * (180 / Math.PI);
adjacency.get(a).push({ neighbor: b, angle: angleDeg });
adjacency.get(b).push({ neighbor: a, angle: angleDeg });
adjacency[a].push({ neighbor: b, angle: angleDeg });
adjacency[b].push({ neighbor: a, angle: angleDeg });
}
return { adjacency, centroids, boundRadii };
@@ -120,16 +124,17 @@ export function buildAdjacency(geometry) {
* Spreads across edges whose dihedral angle ≤ thresholdDeg.
*
* @param {number} seedTriIdx
* @param {Map<number, Array<{neighbor:number, angle:number}>>} adjacency
* @param {Array<Array<{neighbor:number, angle:number}>>} adjacency
* @param {number} thresholdDeg
* @returns {Set<number>} set of triangle indices in the filled region
*/
export function bucketFill(seedTriIdx, adjacency, thresholdDeg) {
const visited = new Set([seedTriIdx]);
const queue = [seedTriIdx];
while (queue.length > 0) {
const cur = queue.shift();
const neighbors = adjacency.get(cur);
let head = 0;
while (head < queue.length) {
const cur = queue[head++];
const neighbors = adjacency[cur];
if (!neighbors) continue;
for (const { neighbor, angle } of neighbors) {
if (!visited.has(neighbor) && angle <= thresholdDeg) {
@@ -155,24 +160,44 @@ export function buildExclusionOverlayGeo(geometry, faceSet, invert = false) {
const srcPos = geometry.attributes.position.array;
const srcNrm = geometry.attributes.normal ? geometry.attributes.normal.array : null;
const total = srcPos.length / 9; // total triangle count
const count = invert ? total - faceSet.size : faceSet.size;
const isArr = faceSet instanceof Uint8Array;
// Count included faces
let setSize;
if (isArr) {
setSize = 0;
for (let i = 0; i < faceSet.length; i++) if (faceSet[i]) setSize++;
} else {
setSize = faceSet.size;
}
const count = invert ? total - setSize : setSize;
const outPos = new Float32Array(count * 9);
const outNrm = srcNrm ? new Float32Array(count * 9) : null;
let dst = 0;
if (invert) {
for (let t = 0; t < total; t++) {
if (faceSet.has(t)) continue;
if (isArr ? faceSet[t] : faceSet.has(t)) continue;
const src = t * 9;
for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i];
if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i];
outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9;
}
} else {
for (const t of faceSet) {
const src = t * 9;
for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i];
if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i];
dst += 9;
if (isArr) {
for (let t = 0; t < faceSet.length; t++) {
if (!faceSet[t]) continue;
const src = t * 9;
outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9;
}
} else {
for (const t of faceSet) {
const src = t * 9;
outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9;
}
}
}
const geo = new THREE.BufferGeometry();
+56 -17
View File
@@ -1,32 +1,71 @@
import * as THREE from 'three';
import { STLExporter } from 'three/addons/exporters/STLExporter.js';
const exporter = new STLExporter();
/**
* Export a BufferGeometry as a binary STL file download.
* Fast binary STL exporter — writes directly from BufferGeometry arrays.
*
* @param {THREE.BufferGeometry} geometry
* Eliminates Three.js STLExporter overhead:
* - No Mesh/Material creation
* - No identity matrix multiplication per vertex
* - No redundant normal recomputation
* - Bulk Uint8Array.set() instead of per-float DataView calls
*
* @param {THREE.BufferGeometry} geometry non-indexed with position + normal
* @param {string} [filename]
*/
export function exportSTL(geometry, filename = 'textured.stl') {
// Geometry is already in the original Z-up orientation (the loader never rotates it;
// the viewer uses a Z-up camera instead). Export as-is so slicers receive the correct pose.
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial());
const result = exporter.parse(mesh, { binary: true });
const posArr = geometry.attributes.position.array;
const norArr = geometry.attributes.normal
? geometry.attributes.normal.array
: null;
const triCount = (posArr.length / 9) | 0;
// result is an ArrayBuffer in binary mode
const blob = new Blob([result], { type: 'application/octet-stream' });
// Binary STL: 80-byte header + 4-byte tri count + 50 bytes per triangle
const bufLen = 84 + 50 * triCount;
const buffer = new ArrayBuffer(bufLen);
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
// Header: 80 bytes (already zero-filled)
view.setUint32(80, triCount, true);
// Reinterpret source arrays as raw bytes for bulk copy
const posSrc = new Uint8Array(posArr.buffer, posArr.byteOffset, posArr.byteLength);
const norSrc = norArr
? new Uint8Array(norArr.buffer, norArr.byteOffset, norArr.byteLength)
: null;
for (let i = 0; i < triCount; i++) {
const dst = 84 + i * 50;
const srcOff = i * 36; // 9 floats * 4 bytes
if (norSrc) {
// Normal: copy first vertex normal (12 bytes) — flat shading, all 3 identical
bytes.set(norSrc.subarray(srcOff, srcOff + 12), dst);
} else {
// Compute face normal from cross product
const b = i * 9;
const ux = posArr[b+3]-posArr[b], uy = posArr[b+4]-posArr[b+1], uz = posArr[b+5]-posArr[b+2];
const vx = posArr[b+6]-posArr[b], vy = posArr[b+7]-posArr[b+1], vz = posArr[b+8]-posArr[b+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;
view.setFloat32(dst, nx/len, true);
view.setFloat32(dst + 4, ny/len, true);
view.setFloat32(dst + 8, nz/len, true);
}
// Vertices: 36 bytes (3 vertices * 3 floats * 4 bytes)
bytes.set(posSrc.subarray(srcOff, srcOff + 36), dst + 12);
// Attribute byte count: 0 (already zero-filled)
}
// Download
const blob = new Blob([buffer], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke after a short delay so the download has time to start
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
+779
View File
@@ -193,6 +193,7 @@ export const TRANSLATIONS = {
// Alerts
'alerts.loadFailed': 'Could not load model: {msg}',
'alerts.exportFailed': 'Export failed: {msg}',
'alerts.fileTooLarge': 'File too large ({size} MB). Maximum: {max} MB.',
},
de: {
@@ -386,6 +387,784 @@ export const TRANSLATIONS = {
// Alerts
'alerts.loadFailed': 'Modell konnte nicht geladen werden: {msg}',
'alerts.exportFailed': 'Export fehlgeschlagen: {msg}',
'alerts.fileTooLarge': 'Datei zu gross ({size} MB). Maximum: {max} MB.',
},
it: {
'lang.name': 'Italiano',
// Theme toggle
'theme.dark': 'Tema Scuro',
'theme.light': 'Tema Chiaro',
'theme.toggleTitle': 'Attiva/disattiva modalità chiara/scura',
'theme.toggleAriaLabel': 'Attiva/disattiva modalità chiara/scura',
// Drop zone
'dropHint.text': 'Trascina qui un file <strong>.stl</strong>, <strong>.obj</strong> o <strong>.3mf</strong><br/>o <label for="stl-file-input" class="link-label">clicca per sfogliare</label>',
// Viewport footer
'ui.wireframe': 'Wireframe',
'ui.controlsHint': 'Trascina a sx: orbita \u00a0·\u00a0 Trascina a dx: sposta \u00a0·\u00a0 Scorri: zoom',
'ui.meshInfo': '{n} triangoli · {mb} MB · {sx} × {sy} × {sz} mm',
// Load model button
'ui.loadStl': 'Carica Modello\u2026',
// Displacement map section
'sections.displacementMap': 'Mappa di Deformazione',
'ui.uploadCustomMap': 'Carica mappa personalizzata',
'ui.noMapSelected': 'Nessuna mappa selezionata',
// Projection section
'sections.projection': 'Proiezione',
'labels.mode': 'Modalità',
'projection.triplanar': 'Triplanare',
'projection.cubic': 'Cubica (Box)',
'projection.cylindrical': 'Cilindrica',
'projection.spherical': 'Sferica',
'projection.planarXY': 'Planare XY',
'projection.planarXZ': 'Planare XZ',
'projection.planarYZ': 'Planare YZ',
// Transform section
'sections.transform': 'Trasformazioni',
'labels.scaleU': 'Scala U',
'labels.scaleV': 'Scala V',
'labels.offsetU': 'Offset U',
'labels.offsetV': 'Offset V',
'labels.rotation': 'Rotazione',
'tooltips.proportionalScaling': 'Scala proporzionale (U = V)',
'tooltips.proportionalScalingAria': 'Scala proporzionale (U = V)',
// Displacement section
'sections.displacement': 'Profondità Texture',
'labels.amplitude': 'Ampiezza',
// Seam blend
'labels.seamBlend': 'Unione dei bordi \u24d8',
'tooltips.seamBlend': 'Attenua il bordo netto dove si incontrano le facce della proiezione. Efficace per le modalità Cubica e Cilindrica.',
'labels.transitionSmoothing': 'Smoothing di transizione \u24d8',
'tooltips.transitionSmoothing': 'Larghezza della zona di fusione vicino ai bordi della giuntura. Valori più bassi mantengono le transizioni aderenti alla giuntura; valori più alti sfumano una fascia più ampia.',
'labels.textureSmoothing': 'Smoothing della texture \u24d8',
'tooltips.textureSmoothing': 'Applica una sfocatura gaussiana alla mappa di deformazione. Valori più alti producono dettagli superficiali più morbidi e graduali. 0 = disattivato.',
'labels.capAngle': 'Angolo di copertura \u24d8',
'tooltips.capAngle': 'Angolo (in gradi) rispetto alla verticale al quale entra in gioco la proiezione della copertura superiore/inferiore. Valori più piccoli limitano la proiezione della copertura a facce quasi piatte.',
// Masking parent section
'sections.masking': 'Mascheramento',
// Mask angles section
'sections.maskAngles': 'Per angolo \u24d8',
'tooltips.maskAngles': '0° = nessuna mascheratura. Le superfici comprese in questo angolo rispetto all\'orizzontale non saranno texturizzate.',
'labels.bottomFaces': 'Facce inferiori',
'tooltips.bottomFaces': 'Elimina la texture sulle superfici rivolte verso il basso entro questo angolo rispetto all\'orizzontale',
'labels.topFaces': 'Facce superiori',
'tooltips.topFaces': 'Elimina la texture sulle superfici rivolte verso l\'alto entro questo angolo rispetto all\'orizzontale',
// Surface masking section
'sections.surfaceMasking': 'Per superficie \u24d8',
'sections.surfaceSelection': 'Selezione delle superfici',
'tooltips.surfaceMasking': 'Mascherare le superfici per controllare quali aree subiscono la deformazione.',
'tooltips.surfaceSelection': 'Le superfici selezionate appaiono in verde e saranno le uniche a subire la deformazione durante l\'esportazione.',
'excl.modeExclude': 'Escludi',
'excl.modeExcludeTitle': 'Modalità Escludi: le superfici dipinte non subiranno la deformazione della texture',
'excl.modeIncludeOnly': 'Includi solo',
'excl.modeIncludeOnlyTitle': 'Modalità Includi solo: solo le superfici dipinte subiranno la deformazione della texture',
'excl.toolBrush': 'Pennello',
'excl.toolBrushTitle': 'Pennello: dipingi i triangoli da escludere',
'excl.toolFill': 'Riempimento',
'excl.toolFillTitle': 'Riempimento a secchiello: riempi la superficie fino a un angolo di soglia',
'excl.shiftHint': 'Tieni premuto Shift per cancellare',
'labels.type': 'Tipo',
'brushType.single': 'Singolo',
'brushType.circle': 'Cerchio',
'labels.size': 'Dimensione',
'labels.maxAngle': 'Angolo massimo',
'tooltips.maxAngle': 'Angolo diedro massimo tra triangoli adiacenti che il riempimento può attraversare',
'ui.clearAll': 'Cancella tutto',
'excl.initExcluded': '0 facce mascherate',
'excl.faceExcluded': '{n} facce mascherate',
'excl.facesExcluded': '{n} facce mascherate',
'excl.faceSelected': '{n} faccia selezionata',
'excl.facesSelected': '{n} facce selezionate',
'excl.hintExclude': 'Le superfici mascherate appaiono in arancione e non riceveranno deformazione durante l\'esportazione',
'excl.hintInclude': 'Le superfici selezionate appaiono verdi e saranno le uniche a ricevere la deformazione durante l\'esportazione.',
// Precision masking
'precision.label': 'Precisione (Beta) \u24d8',
'precision.labelTitle': 'Suddividi la mesh in background in modo che il pennello selezioni con una granularità più fine',
'precision.outdated': '\u26a0 Obsoleto',
'precision.refreshTitle': 'Risuddividi la mesh per adattarla alle dimensioni attuali del pennello',
'precision.triCount': '{n} \u25b3',
'precision.refining': 'Raffinamento\u2026',
'precision.warningBody': 'Stima ~{n} triangoli. Ciò potrebbe rallentare il browser. Continuare?',
// Boundary falloff
'labels.boundaryFalloff': 'Maschera liscia \u24d8',
'tooltips.boundaryFalloff': 'Riduce gradualmente la deformazione a zero vicino ai bordi mascherati, impedendo sovrapposizioni di triangoli tra zone con e senza texture.',
// Symmetric displacement
'labels.symmetricDisplacement': 'Deformazione simmetrica \u24d8',
'tooltips.symmetricDisplacement': 'Quando è attivo, il grigio al 50% = nessuna deformazione; il bianco spinge verso l\'esterno, il nero spinge verso l\'interno. Mantiene il volume della parte approssimativamente costante.',
// Displacement preview
'labels.displacementPreview': 'Anteprima 3D \u24d8',
'tooltips.displacementPreview': 'Suddivide la mesh e sposta i vertici in tempo reale in modo da poter valutare la profondità effettiva. Richiede un uso intensivo della GPU su modelli complessi.',
// Place on face
'ui.placeOnFace': 'Posiziona su una faccia',
'ui.placeOnFaceTitle': 'Clicca su una faccia per orientarla verso il basso sul piano di stampa',
'progress.subdividingPreview': 'Preparazione dell\'anteprima...',
// Amplitude overlap warning
'warnings.amplitudeOverlap': '\u26a0 L\'ampiezza supera il 10% della dimensione più piccola del modello \u2014 potrebbero verificarsi sovrapposizioni geometriche nel file STL esportato.',
// Export section
'sections.export': 'Esporta \u24d8',
'tooltips.export': 'Lunghezza del bordo più piccola = dettagli della deformazione più precisi. L\'output viene quindi ridotto al limite di triangoli.',
'labels.resolution': 'Risoluzione',
'tooltips.resolution': 'I bordi più lunghi di questo valore verranno suddivisi durante l\'esportazione',
'labels.outputTriangles': 'Triangoli in uscita',
'tooltips.outputTriangles': 'La mesh viene prima suddivisa completamente, poi decimata fino a questo numero',
'warnings.safetyCapHit': '\u26a0 Limite di sicurezza di 20 milioni di triangoli raggiunto durante la suddivisione \u2014 il risultato potrebbe comunque essere più grossolano della lunghezza del bordo richiesta.',
'ui.exportStl': 'Esporta STL',
// Export progress stages
'progress.subdividing': 'Suddivisione della mesh\u2026',
'progress.refining': 'Raffinamento: {cur} triangoli, spigolo più lungo {edge}',
'progress.applyingDisplacement': 'Applicazione dello spostamento a {n} triangoli\u2026',
'progress.displacingVertices': 'Spostamento dei vertici\u2026',
'progress.decimatingTo': 'Semplificazione da {from} \u2192 {to} triangoli\u2026',
'progress.decimating': 'Semplificazione: {cur} \u2192 {to} triangoli',
'progress.writingStl': 'Scrittura STL\u2026',
'progress.done': 'Fatto!',
'progress.processing': 'Elaborazione\u2026',
// License popup
'license.btn': 'Licenza e condizioni',
'license.title': 'Licenza e condizioni',
'license.item1': 'Utilizzo gratuito per qualsiasi scopo, compresi <strong>lavori commerciali</strong> (ad es. la creazione di texture per file STL destinati a clienti o prodotti).',
'license.item2': 'L\'attribuzione è <strong>gradita</strong> ma <strong>non richiesta</strong> quando si utilizza questo strumento così com\'è.',
'license.item3': 'Vuoi sostenere questo strumento? Acquista su <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener">CNCKitchen.STORE</a> o fai una donazione su <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener">PayPal</a>.',
'license.item4': 'Questo strumento viene fornito <strong>così com\'è</strong> senza <strong>alcuna garanzia</strong> di alcun tipo. L\'utilizzo è a proprio rischio.',
'license.item5': '<strong>Non viene fornita alcuna assistenza</strong>. L\'autore non ha alcun obbligo di correggere bug, rispondere a domande o aggiornare questo strumento. Detto questo, segnalazioni di bug e richieste di funzionalità sono sempre ben accette all\'indirizzo <a href="mailto:texturizer@cnckitchen.com">texturizer@cnckitchen.com</a>.',
'license.item6': 'L\'autore non potrà essere ritenuto <strong>responsabile</strong> per eventuali danni, perdita di dati o problemi derivanti dall\'uso di questo strumento.',
'license.item7': 'Vuoi ottenere una licenza o incorporare questo strumento per la tua attività o il tuo sito web? Contattaci all\'indirizzo <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a>.',
'license.item8': 'Codice sorgente disponibile su <a href="https://github.com/CNCKitchen/stlTexturizer" target="_blank" rel="noopener">GitHub</a>.',
// Imprint & Privacy
'imprint.btn': 'Note legali e privacy',
'imprint.title': 'Note legali e informativa sulla privacy',
'imprint.sectionImprint': 'Note legali (Impressum)',
'imprint.info': 'CNC Kitchen<br>Stefan Hermann<br>Bahnhofstr. 2<br>88145 Hergatz<br>Germania',
'imprint.contact': 'E-mail: <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a><br>Telefono: +49 175 2011824<br><em>Il numero di telefono è riservato esclusivamente a richieste legali/commerciali \u2014 non per l\'assistenza. </em>',
'imprint.odr': 'Piattaforma UE per la risoluzione delle controversie online: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr</a>',
'imprint.sectionPrivacy': 'Informativa sulla privacy (Datenschutzerklärung)',
'imprint.privacyIntro': '<strong>Titolare del trattamento</strong> (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Germania.',
'imprint.privacyHosting': 'Questo sito web è ospitato su <strong>GitHub Pages</strong> (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, USA). Quando visiti questo sito, GitHub potrebbe elaborare il tuo indirizzo IP nei log del server. Base giuridica: Art. 6(1)(f) GDPR (interesse legittimo alla fornitura del sito web). Vedi <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener">Informativa sulla privacy di GitHub</a>.',
'imprint.privacyLocal': 'Questo strumento memorizza le preferenze dell\'utente (lingua, tema) nel <strong>localStorage</strong> del tuo browser. Questi dati non lasciano mai il tuo dispositivo e non vengono trasmessi a nessun server.',
'imprint.privacyNoCookies': 'Questo sito web <strong>non</strong> utilizza cookie, strumenti di analisi o tecnologie di tracciamento.',
'imprint.privacyExternal': 'Questo sito contiene link a siti web esterni (ad es. CNCKitchen.STORE, PayPal). Questi siti hanno le proprie politiche sulla privacy, sulle quali non abbiamo alcun controllo.',
'imprint.privacyRights': 'Ai sensi del GDPR hai il diritto di <strong>accesso, rettifica, cancellazione, limitazione del trattamento, portabilità dei dati</strong> e il diritto di <strong>presentare un reclamo</strong> presso un\'autorità di controllo.',
// Sponsor modal
'sponsor.title': 'Grazie per aver scelto BumpMesh di CNC Kitchen!',
'sponsor.body': 'Questo strumento è offerto <strong>completamente gratis</strong> da CNC Kitchen.<br>Mentre il tuo file STL viene elaborato, perché non dai un\'occhiata al negozio che ci aiuta a continuare a creare cose fantastiche per te?',
'sponsor.visitStore': '\uD83D\uDED2 Visita CNCKitchen.STORE',
'sponsor.donate': '\uD83D\uDC99 Dona su PayPal',
'sponsor.dontShow': 'Non mostrare più questo messaggio',
'sponsor.closeAndContinue': 'Chiudi e continua',
// Store CTA
'cta.store': 'Vuoi sostenere questo strumento? Acquista su <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a> o dona su <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a>',
'cta.storeDismiss': 'Chiudi',
// Alerts
'alerts.loadFailed': 'Caricamento del modello fallito: {msg}',
'alerts.exportFailed': 'Esportazione fallita: {msg}',
'alerts.fileTooLarge': 'File troppo grande ({size} MB). Massimo: {max} MB.',
},
es: {
'lang.name': 'Español (beta)',
// Theme toggle
'theme.dark': 'Tema Oscuro',
'theme.light': 'Tema Claro',
'theme.toggleTitle': 'Alternar modo claro / oscuro',
'theme.toggleAriaLabel': 'Alternar modo claro/oscuro',
// Drop zone
'dropHint.text': 'Arrastra aquí un archivo <strong>.stl</strong>, <strong>.obj</strong> o <strong>.3mf</strong><br/>o <label for="stl-file-input" class="link-label">haz clic para explorar</label>',
// Viewport footer
'ui.wireframe': 'Malla de alambre',
'ui.controlsHint': 'Arrastrar izq.: orbitar \u00a0·\u00a0 Arrastrar der.: desplazar \u00a0·\u00a0 Rueda: zoom',
'ui.meshInfo': '{n} triángulos · {mb} MB · {sx} × {sy} × {sz} mm',
// Load model button
'ui.loadStl': 'Cargar modelo\u2026',
// Displacement map section
'sections.displacementMap': 'Mapa de desplazamiento',
'ui.uploadCustomMap': 'Subir mapa personalizado',
'ui.noMapSelected': 'Ningún mapa seleccionado',
// Projection section
'sections.projection': 'Proyección',
'labels.mode': 'Modo',
'projection.triplanar': 'Triplanar',
'projection.cubic': 'Cúbica (Caja)',
'projection.cylindrical': 'Cilíndrica',
'projection.spherical': 'Esférica',
'projection.planarXY': 'Planar XY',
'projection.planarXZ': 'Planar XZ',
'projection.planarYZ': 'Planar YZ',
// Transform section
'sections.transform': 'Transformación',
'labels.scaleU': 'Escala U',
'labels.scaleV': 'Escala V',
'labels.offsetU': 'Desplazamiento U',
'labels.offsetV': 'Desplazamiento V',
'labels.rotation': 'Rotación',
'tooltips.proportionalScaling': 'Escalado proporcional (U = V)',
'tooltips.proportionalScalingAria': 'Escalado proporcional (U = V)',
// Displacement section
'sections.displacement': 'Profundidad de textura',
'labels.amplitude': 'Amplitud',
// Seam blend
'labels.seamBlend': 'Fusión de costuras \u24d8',
'tooltips.seamBlend': 'Suaviza la costura donde se unen las caras de proyección. Efectivo para los modos Cúbico y Cilíndrico.',
'labels.transitionSmoothing': 'Suavizado de transición \u24d8',
'tooltips.transitionSmoothing': 'Ancho de la zona de fusión cerca de los bordes de la costura. Valores bajos mantienen las transiciones pegadas a la costura; valores altos difuminan una banda más amplia.',
'labels.textureSmoothing': 'Suavizado de textura \u24d8',
'tooltips.textureSmoothing': 'Aplica un desenfoque gaussiano al mapa de desplazamiento. Valores más altos producen detalles de superficie más suaves y graduales. 0 = desactivado.',
'labels.capAngle': 'Ángulo de tapa \u24d8',
'tooltips.capAngle': 'Ángulo (en grados) desde la vertical en el que se activa la proyección de tapa superior/inferior. Valores más pequeños limitan la proyección a caras casi planas.',
// Masking parent section
'sections.masking': 'Enmascaramiento',
// Mask angles section
'sections.maskAngles': 'Por ángulo \u24d8',
'tooltips.maskAngles': '0° = sin enmascaramiento. Las superficies dentro de este ángulo respecto a la horizontal no serán texturizadas.',
'labels.bottomFaces': 'Caras inferiores',
'tooltips.bottomFaces': 'Eliminar textura en superficies orientadas hacia abajo dentro de este ángulo respecto a la horizontal',
'labels.topFaces': 'Caras superiores',
'tooltips.topFaces': 'Eliminar textura en superficies orientadas hacia arriba dentro de este ángulo respecto a la horizontal',
// Surface masking section
'sections.surfaceMasking': 'Por superficie \u24d8',
'sections.surfaceSelection': 'Selección de superficies',
'tooltips.surfaceMasking': 'Enmascarar superficies para controlar qué áreas reciben desplazamiento.',
'tooltips.surfaceSelection': 'Las superficies seleccionadas aparecen en verde y serán las únicas en recibir desplazamiento durante la exportación.',
'excl.modeExclude': 'Excluir',
'excl.modeExcludeTitle': 'Modo Excluir: las superficies pintadas no recibirán desplazamiento de textura',
'excl.modeIncludeOnly': 'Solo incluir',
'excl.modeIncludeOnlyTitle': 'Modo Solo incluir: solo las superficies pintadas recibirán desplazamiento de textura',
'excl.toolBrush': 'Pincel',
'excl.toolBrushTitle': 'Pincel: pintar triángulos para excluir',
'excl.toolFill': 'Relleno',
'excl.toolFillTitle': 'Relleno: rellenar superficie hasta un ángulo umbral',
'excl.shiftHint': 'Mantén Shift para borrar',
'labels.type': 'Tipo',
'brushType.single': 'Individual',
'brushType.circle': 'Círculo',
'labels.size': 'Tamaño',
'labels.maxAngle': 'Ángulo máx.',
'tooltips.maxAngle': 'Ángulo diedro máximo entre triángulos adyacentes que el relleno puede cruzar',
'ui.clearAll': 'Borrar todo',
'excl.initExcluded': '0 caras enmascaradas',
'excl.faceExcluded': '{n} cara enmascarada',
'excl.facesExcluded': '{n} caras enmascaradas',
'excl.faceSelected': '{n} cara seleccionada',
'excl.facesSelected': '{n} caras seleccionadas',
'excl.hintExclude': 'Las superficies enmascaradas aparecen en naranja y no recibirán desplazamiento durante la exportación.',
'excl.hintInclude': 'Las superficies seleccionadas aparecen en verde y serán las únicas en recibir desplazamiento durante la exportación.',
// Precision masking
'precision.label': 'Precisión (Beta) \u24d8',
'precision.labelTitle': 'Subdivide la malla en segundo plano para que el pincel seleccione con mayor granularidad',
'precision.outdated': '\u26a0 Desactualizado',
'precision.refreshTitle': 'Resubdividir la malla para ajustarla al tamaño actual del pincel',
'precision.triCount': '{n} \u25b3',
'precision.refining': 'Refinando\u2026',
'precision.warningBody': 'Estimados ~{n} triángulos. Esto puede ralentizar el navegador. ¿Continuar?',
// Boundary falloff
'labels.boundaryFalloff': 'Suavizar máscara \u24d8',
'tooltips.boundaryFalloff': 'Reduce gradualmente el desplazamiento a cero cerca de los bordes enmascarados, evitando superposiciones de triángulos entre zonas texturizadas y no texturizadas.',
// Symmetric displacement
'labels.symmetricDisplacement': 'Desplazamiento simétrico \u24d8',
'tooltips.symmetricDisplacement': 'Cuando está activado, el gris al 50% = sin desplazamiento; el blanco empuja hacia fuera, el negro empuja hacia dentro. Mantiene el volumen de la pieza aproximadamente constante.',
// Displacement preview
'labels.displacementPreview': 'Vista previa 3D \u24d8',
'tooltips.displacementPreview': 'Subdivide la malla y desplaza los vértices en tiempo real para evaluar la profundidad real. Uso intensivo de GPU en modelos complejos.',
// Place on face
'ui.placeOnFace': 'Colocar en cara',
'ui.placeOnFaceTitle': 'Haz clic en una cara para orientarla hacia abajo sobre la cama de impresión',
'progress.subdividingPreview': 'Preparando vista previa\u2026',
// Amplitude overlap warning
'warnings.amplitudeOverlap': '\u26a0 La amplitud supera el 10% de la dimensión más pequeña del modelo \u2014 pueden ocurrir superposiciones de geometría en el STL exportado.',
// Export section
'sections.export': 'Exportar \u24d8',
'tooltips.export': 'Menor longitud de arista = mayor detalle de desplazamiento. La salida se reduce al límite de triángulos.',
'labels.resolution': 'Resolución',
'tooltips.resolution': 'Las aristas más largas que este valor se subdividirán durante la exportación',
'labels.outputTriangles': 'Triángulos de salida',
'tooltips.outputTriangles': 'La malla se subdivide completamente primero y luego se reduce a esta cantidad',
'warnings.safetyCapHit': '\u26a0 Se alcanzó el límite de seguridad de 20M de triángulos durante la subdivisión \u2014 el resultado puede ser más grueso que la longitud de arista solicitada.',
'ui.exportStl': 'Exportar STL',
// Export progress stages
'progress.subdividing': 'Subdividiendo malla\u2026',
'progress.refining': 'Refinando: {cur} triángulos, arista más larga {edge}',
'progress.applyingDisplacement': 'Aplicando desplazamiento a {n} triángulos\u2026',
'progress.displacingVertices': 'Desplazando vértices\u2026',
'progress.decimatingTo': 'Simplificando {from} \u2192 {to} triángulos\u2026',
'progress.decimating': 'Simplificando: {cur} \u2192 {to} triángulos',
'progress.writingStl': 'Escribiendo STL\u2026',
'progress.done': '¡Listo!',
'progress.processing': 'Procesando\u2026',
// License popup
'license.btn': 'Licencia y condiciones',
'license.title': 'Licencia y condiciones',
'license.item1': 'Uso gratuito para cualquier propósito, incluyendo <strong>trabajo comercial</strong> (p. ej., texturizado de STLs para clientes o productos).',
'license.item2': 'La atribución es <strong>apreciada</strong> pero <strong>no obligatoria</strong> al usar esta herramienta tal cual.',
'license.item3': '¿Quieres apoyar esta herramienta? Compra en <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener">CNCKitchen.STORE</a> o dona en <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener">PayPal</a>.',
'license.item4': 'Esta herramienta se proporciona <strong>tal cual</strong> sin <strong>ninguna garantía</strong> de ningún tipo. Úsala bajo tu propio riesgo.',
'license.item5': '<strong>No se proporciona soporte</strong>. El autor no tiene obligación de corregir errores, responder preguntas ni actualizar esta herramienta. Dicho esto, los informes de errores y solicitudes de funciones son siempre bienvenidos en <a href="mailto:texturizer@cnckitchen.com">texturizer@cnckitchen.com</a>.',
'license.item6': 'El autor no será responsable de <strong>daños</strong>, pérdida de datos o problemas derivados del uso de esta herramienta.',
'license.item7': '¿Quieres licenciar o integrar esta herramienta para tu negocio o sitio web? Contáctanos en <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a>.',
'license.item8': 'Código fuente disponible en <a href="https://github.com/CNCKitchen/stlTexturizer" target="_blank" rel="noopener">GitHub</a>.',
// Imprint & Privacy
'imprint.btn': 'Aviso legal y privacidad',
'imprint.title': 'Aviso legal y política de privacidad',
'imprint.sectionImprint': 'Aviso legal (Impressum)',
'imprint.info': 'CNC Kitchen<br>Stefan Hermann<br>Bahnhofstr. 2<br>88145 Hergatz<br>Alemania',
'imprint.contact': 'Correo: <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a><br>Teléfono: +49 175 2011824<br><em>El número de teléfono es exclusivamente para consultas legales/comerciales \u2014 no para soporte.</em>',
'imprint.odr': 'Plataforma de resolución de litigios en línea de la UE: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr</a>',
'imprint.sectionPrivacy': 'Política de privacidad (Datenschutzerklärung)',
'imprint.privacyIntro': '<strong>Responsable</strong> (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Alemania.',
'imprint.privacyHosting': 'Este sitio web está alojado en <strong>GitHub Pages</strong> (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, EE.UU.). Al visitar este sitio, GitHub puede procesar tu dirección IP en los registros del servidor. Base legal: Art. 6(1)(f) RGPD (interés legítimo en proporcionar el sitio web). Ver <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener">Declaración de privacidad de GitHub</a>.',
'imprint.privacyLocal': 'Esta herramienta almacena las preferencias del usuario (idioma, tema) en el <strong>localStorage</strong> de tu navegador. Estos datos nunca salen de tu dispositivo ni se transmiten a ningún servidor.',
'imprint.privacyNoCookies': 'Este sitio web <strong>no</strong> utiliza cookies, herramientas de análisis ni tecnologías de rastreo.',
'imprint.privacyExternal': 'Este sitio contiene enlaces a sitios web externos (p. ej., CNCKitchen.STORE, PayPal). Estos sitios tienen sus propias políticas de privacidad, sobre las cuales no tenemos control.',
'imprint.privacyRights': 'Según el RGPD, tienes derecho a <strong>acceso, rectificación, supresión, limitación del tratamiento, portabilidad de datos</strong> y derecho a <strong>presentar una reclamación</strong> ante una autoridad de control.',
// Sponsor modal
'sponsor.title': '¡Gracias por usar BumpMesh de CNC Kitchen!',
'sponsor.body': 'Esta herramienta es proporcionada <strong>completamente gratis</strong> por CNC Kitchen.<br>Mientras se procesa tu STL, ¿por qué no echas un vistazo a la tienda que nos ayuda a seguir creando cosas geniales para ti?',
'sponsor.visitStore': '\uD83D\uDED2 Visitar CNCKitchen.STORE',
'sponsor.donate': '\uD83D\uDC99 Donar en PayPal',
'sponsor.dontShow': 'No mostrar de nuevo',
'sponsor.closeAndContinue': 'Cerrar y continuar',
// Store CTA
'cta.store': '¿Quieres apoyar esta herramienta? Compra en <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a> o dona en <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a>',
'cta.storeDismiss': 'Cerrar',
// Alerts
'alerts.loadFailed': 'No se pudo cargar el modelo: {msg}',
'alerts.exportFailed': 'Error en la exportación: {msg}',
'alerts.fileTooLarge': 'Archivo demasiado grande ({size} MB). Máximo: {max} MB.',
},
pt: {
'lang.name': 'Português (beta)',
// Theme toggle
'theme.dark': 'Tema Escuro',
'theme.light': 'Tema Claro',
'theme.toggleTitle': 'Alternar modo claro / escuro',
'theme.toggleAriaLabel': 'Alternar modo claro/escuro',
// Drop zone
'dropHint.text': 'Arraste um arquivo <strong>.stl</strong>, <strong>.obj</strong> ou <strong>.3mf</strong> aqui<br/>ou <label for="stl-file-input" class="link-label">clique para procurar</label>',
// Viewport footer
'ui.wireframe': 'Wireframe',
'ui.controlsHint': 'Arrastar esq.: orbitar \u00a0·\u00a0 Arrastar dir.: deslocar \u00a0·\u00a0 Roda: zoom',
'ui.meshInfo': '{n} triângulos · {mb} MB · {sx} × {sy} × {sz} mm',
// Load model button
'ui.loadStl': 'Carregar modelo\u2026',
// Displacement map section
'sections.displacementMap': 'Mapa de deslocamento',
'ui.uploadCustomMap': 'Enviar mapa personalizado',
'ui.noMapSelected': 'Nenhum mapa selecionado',
// Projection section
'sections.projection': 'Projeção',
'labels.mode': 'Modo',
'projection.triplanar': 'Triplanar',
'projection.cubic': 'Cúbica (Caixa)',
'projection.cylindrical': 'Cilíndrica',
'projection.spherical': 'Esférica',
'projection.planarXY': 'Planar XY',
'projection.planarXZ': 'Planar XZ',
'projection.planarYZ': 'Planar YZ',
// Transform section
'sections.transform': 'Transformação',
'labels.scaleU': 'Escala U',
'labels.scaleV': 'Escala V',
'labels.offsetU': 'Deslocamento U',
'labels.offsetV': 'Deslocamento V',
'labels.rotation': 'Rotação',
'tooltips.proportionalScaling': 'Escala proporcional (U = V)',
'tooltips.proportionalScalingAria': 'Escala proporcional (U = V)',
// Displacement section
'sections.displacement': 'Profundidade da textura',
'labels.amplitude': 'Amplitude',
// Seam blend
'labels.seamBlend': 'Fusão de costuras \u24d8',
'tooltips.seamBlend': 'Suaviza a costura onde as faces de projeção se encontram. Eficaz para os modos Cúbico e Cilíndrico.',
'labels.transitionSmoothing': 'Suavização de transição \u24d8',
'tooltips.transitionSmoothing': 'Largura da zona de fusão perto das bordas da costura. Valores baixos mantêm as transições próximas à costura; valores altos suavizam uma faixa mais larga.',
'labels.textureSmoothing': 'Suavização de textura \u24d8',
'tooltips.textureSmoothing': 'Aplica um desfoque gaussiano ao mapa de deslocamento. Valores mais altos produzem detalhes de superfície mais suaves e graduais. 0 = desativado.',
'labels.capAngle': 'Ângulo de cobertura \u24d8',
'tooltips.capAngle': 'Ângulo (em graus) a partir da vertical no qual a projeção de cobertura superior/inferior é ativada. Valores menores limitam a projeção a faces quase planas.',
// Masking parent section
'sections.masking': 'Mascaramento',
// Mask angles section
'sections.maskAngles': 'Por ângulo \u24d8',
'tooltips.maskAngles': '0° = sem mascaramento. Superfícies dentro deste ângulo em relação à horizontal não serão texturizadas.',
'labels.bottomFaces': 'Faces inferiores',
'tooltips.bottomFaces': 'Suprimir textura em superfícies voltadas para baixo dentro deste ângulo em relação à horizontal',
'labels.topFaces': 'Faces superiores',
'tooltips.topFaces': 'Suprimir textura em superfícies voltadas para cima dentro deste ângulo em relação à horizontal',
// Surface masking section
'sections.surfaceMasking': 'Por superfície \u24d8',
'sections.surfaceSelection': 'Seleção de superfícies',
'tooltips.surfaceMasking': 'Mascarar superfícies para controlar quais áreas recebem deslocamento.',
'tooltips.surfaceSelection': 'As superfícies selecionadas aparecem em verde e serão as únicas a receber deslocamento durante a exportação.',
'excl.modeExclude': 'Excluir',
'excl.modeExcludeTitle': 'Modo Excluir: superfícies pintadas não receberão deslocamento de textura',
'excl.modeIncludeOnly': 'Incluir apenas',
'excl.modeIncludeOnlyTitle': 'Modo Incluir apenas: somente superfícies pintadas receberão deslocamento de textura',
'excl.toolBrush': 'Pincel',
'excl.toolBrushTitle': 'Pincel: pintar triângulos para excluir',
'excl.toolFill': 'Preenchimento',
'excl.toolFillTitle': 'Preenchimento: preencher superfície até um ângulo limite',
'excl.shiftHint': 'Segure Shift para apagar',
'labels.type': 'Tipo',
'brushType.single': 'Individual',
'brushType.circle': 'Círculo',
'labels.size': 'Tamanho',
'labels.maxAngle': 'Ângulo máx.',
'tooltips.maxAngle': 'Ângulo diedral máximo entre triângulos adjacentes que o preenchimento pode cruzar',
'ui.clearAll': 'Limpar tudo',
'excl.initExcluded': '0 faces mascaradas',
'excl.faceExcluded': '{n} face mascarada',
'excl.facesExcluded': '{n} faces mascaradas',
'excl.faceSelected': '{n} face selecionada',
'excl.facesSelected': '{n} faces selecionadas',
'excl.hintExclude': 'Superfícies mascaradas aparecem em laranja e não receberão deslocamento durante a exportação.',
'excl.hintInclude': 'Superfícies selecionadas aparecem em verde e serão as únicas a receber deslocamento durante a exportação.',
// Precision masking
'precision.label': 'Precisão (Beta) \u24d8',
'precision.labelTitle': 'Subdivide a malha em segundo plano para que o pincel selecione com maior granularidade',
'precision.outdated': '\u26a0 Desatualizado',
'precision.refreshTitle': 'Resubdividir a malha para ajustar ao tamanho atual do pincel',
'precision.triCount': '{n} \u25b3',
'precision.refining': 'Refinando\u2026',
'precision.warningBody': 'Estimados ~{n} triângulos. Isso pode deixar o navegador lento. Continuar?',
// Boundary falloff
'labels.boundaryFalloff': 'Suavizar máscara \u24d8',
'tooltips.boundaryFalloff': 'Reduz gradualmente o deslocamento a zero perto das bordas mascaradas, evitando sobreposição de triângulos entre zonas texturizadas e não texturizadas.',
// Symmetric displacement
'labels.symmetricDisplacement': 'Deslocamento simétrico \u24d8',
'tooltips.symmetricDisplacement': 'Quando ativado, cinza a 50% = sem deslocamento; branco empurra para fora, preto empurra para dentro. Mantém o volume da peça aproximadamente constante.',
// Displacement preview
'labels.displacementPreview': 'Pré-visualização 3D \u24d8',
'tooltips.displacementPreview': 'Subdivide a malha e desloca os vértices em tempo real para avaliar a profundidade real. Uso intensivo de GPU em modelos complexos.',
// Place on face
'ui.placeOnFace': 'Posicionar na face',
'ui.placeOnFaceTitle': 'Clique numa face para orientá-la para baixo sobre a mesa de impressão',
'progress.subdividingPreview': 'Preparando pré-visualização\u2026',
// Amplitude overlap warning
'warnings.amplitudeOverlap': '\u26a0 A amplitude excede 10% da menor dimensão do modelo \u2014 podem ocorrer sobreposições de geometria no STL exportado.',
// Export section
'sections.export': 'Exportar \u24d8',
'tooltips.export': 'Menor comprimento de aresta = maior detalhe de deslocamento. A saída é então reduzida ao limite de triângulos.',
'labels.resolution': 'Resolução',
'tooltips.resolution': 'Arestas maiores que este valor serão subdivididas durante a exportação',
'labels.outputTriangles': 'Triângulos de saída',
'tooltips.outputTriangles': 'A malha é totalmente subdividida primeiro e depois reduzida a esta quantidade',
'warnings.safetyCapHit': '\u26a0 Limite de segurança de 20M de triângulos atingido durante a subdivisão \u2014 o resultado pode ser mais grosseiro que o comprimento de aresta solicitado.',
'ui.exportStl': 'Exportar STL',
// Export progress stages
'progress.subdividing': 'Subdividindo malha\u2026',
'progress.refining': 'Refinando: {cur} triângulos, aresta mais longa {edge}',
'progress.applyingDisplacement': 'Aplicando deslocamento em {n} triângulos\u2026',
'progress.displacingVertices': 'Deslocando vértices\u2026',
'progress.decimatingTo': 'Simplificando {from} \u2192 {to} triângulos\u2026',
'progress.decimating': 'Simplificando: {cur} \u2192 {to} triângulos',
'progress.writingStl': 'Escrevendo STL\u2026',
'progress.done': 'Pronto!',
'progress.processing': 'Processando\u2026',
// License popup
'license.btn': 'Licença e termos',
'license.title': 'Licença e termos',
'license.item1': 'Uso gratuito para qualquer finalidade, incluindo <strong>trabalho comercial</strong> (p. ex., texturização de STLs para clientes ou produtos).',
'license.item2': 'A atribuição é <strong>apreciada</strong> mas <strong>não obrigatória</strong> ao usar esta ferramenta como está.',
'license.item3': 'Quer apoiar esta ferramenta? Compre na <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener">CNCKitchen.STORE</a> ou doe via <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener">PayPal</a>.',
'license.item4': 'Esta ferramenta é fornecida <strong>como está</strong> sem <strong>nenhuma garantia</strong> de qualquer tipo. Use por sua conta e risco.',
'license.item5': '<strong>Nenhum suporte</strong> é fornecido. O autor não tem obrigação de corrigir bugs, responder perguntas ou atualizar esta ferramenta. Dito isso, relatórios de bugs e pedidos de funcionalidades são sempre bem-vindos em <a href="mailto:texturizer@cnckitchen.com">texturizer@cnckitchen.com</a>.',
'license.item6': 'O autor não será responsável por <strong>danos</strong>, perda de dados ou problemas decorrentes do uso desta ferramenta.',
'license.item7': 'Quer licenciar ou incorporar esta ferramenta para o seu negócio ou site? Entre em contato em <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a>.',
'license.item8': 'Código-fonte disponível no <a href="https://github.com/CNCKitchen/stlTexturizer" target="_blank" rel="noopener">GitHub</a>.',
// Imprint & Privacy
'imprint.btn': 'Aviso legal e privacidade',
'imprint.title': 'Aviso legal e política de privacidade',
'imprint.sectionImprint': 'Aviso legal (Impressum)',
'imprint.info': 'CNC Kitchen<br>Stefan Hermann<br>Bahnhofstr. 2<br>88145 Hergatz<br>Alemanha',
'imprint.contact': 'E-mail: <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a><br>Telefone: +49 175 2011824<br><em>O número de telefone é exclusivamente para consultas legais/comerciais \u2014 não para suporte.</em>',
'imprint.odr': 'Plataforma de resolução de litígios online da UE: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr</a>',
'imprint.sectionPrivacy': 'Política de privacidade (Datenschutzerklärung)',
'imprint.privacyIntro': '<strong>Responsável</strong> (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Alemanha.',
'imprint.privacyHosting': 'Este site é hospedado no <strong>GitHub Pages</strong> (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, EUA). Ao visitar este site, o GitHub pode processar seu endereço IP nos logs do servidor. Base legal: Art. 6(1)(f) RGPD (interesse legítimo em fornecer o site). Veja a <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener">Declaração de privacidade do GitHub</a>.',
'imprint.privacyLocal': 'Esta ferramenta armazena as preferências do usuário (idioma, tema) no <strong>localStorage</strong> do seu navegador. Esses dados nunca saem do seu dispositivo e não são transmitidos a nenhum servidor.',
'imprint.privacyNoCookies': 'Este site <strong>não</strong> utiliza cookies, ferramentas de análise ou tecnologias de rastreamento.',
'imprint.privacyExternal': 'Este site contém links para sites externos (p. ex., CNCKitchen.STORE, PayPal). Esses sites têm suas próprias políticas de privacidade, sobre as quais não temos controle.',
'imprint.privacyRights': 'Nos termos do RGPD, você tem direito a <strong>acesso, retificação, eliminação, limitação do tratamento, portabilidade de dados</strong> e direito de <strong>apresentar uma reclamação</strong> junto a uma autoridade de supervisão.',
// Sponsor modal
'sponsor.title': 'Obrigado por usar o BumpMesh da CNC Kitchen!',
'sponsor.body': 'Esta ferramenta é fornecida <strong>totalmente grátis</strong> pela CNC Kitchen.<br>Enquanto seu STL está sendo processado, que tal dar uma olhada na loja que nos ajuda a continuar criando coisas legais para você?',
'sponsor.visitStore': '\uD83D\uDED2 Visitar CNCKitchen.STORE',
'sponsor.donate': '\uD83D\uDC99 Doar via PayPal',
'sponsor.dontShow': 'Não mostrar novamente',
'sponsor.closeAndContinue': 'Fechar e continuar',
// Store CTA
'cta.store': 'Quer apoiar esta ferramenta? Compre na <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a> ou doe via <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a>',
'cta.storeDismiss': 'Fechar',
// Alerts
'alerts.loadFailed': 'Não foi possível carregar o modelo: {msg}',
'alerts.exportFailed': 'Falha na exportação: {msg}',
'alerts.fileTooLarge': 'Arquivo muito grande ({size} MB). Máximo: {max} MB.',
},
ja: {
'lang.name': '日本語 (beta)',
// Theme toggle
'theme.dark': 'ダークテーマ',
'theme.light': 'ライトテーマ',
'theme.toggleTitle': 'ライト/ダークモードを切り替え',
'theme.toggleAriaLabel': 'ライト/ダークモードを切り替え',
// Drop zone
'dropHint.text': '<strong>.stl</strong>、<strong>.obj</strong>、<strong>.3mf</strong> ファイルをここにドロップ<br/>または <label for="stl-file-input" class="link-label">クリックして参照</label>',
// Viewport footer
'ui.wireframe': 'ワイヤーフレーム',
'ui.controlsHint': '左ドラッグ: 回転 \u00a0·\u00a0 右ドラッグ: パン \u00a0·\u00a0 スクロール: ズーム',
'ui.meshInfo': '{n} 三角形 · {mb} MB · {sx} × {sy} × {sz} mm',
// Load model button
'ui.loadStl': 'モデルを読み込む\u2026',
// Displacement map section
'sections.displacementMap': 'ディスプレイスメントマップ',
'ui.uploadCustomMap': 'カスタムマップをアップロード',
'ui.noMapSelected': 'マップが選択されていません',
// Projection section
'sections.projection': '投影',
'labels.mode': 'モード',
'projection.triplanar': 'トライプラナー',
'projection.cubic': 'キュービック (ボックス)',
'projection.cylindrical': '円筒',
'projection.spherical': '球面',
'projection.planarXY': '平面 XY',
'projection.planarXZ': '平面 XZ',
'projection.planarYZ': '平面 YZ',
// Transform section
'sections.transform': '変換',
'labels.scaleU': 'スケール U',
'labels.scaleV': 'スケール V',
'labels.offsetU': 'オフセット U',
'labels.offsetV': 'オフセット V',
'labels.rotation': '回転',
'tooltips.proportionalScaling': '比例スケーリング (U = V)',
'tooltips.proportionalScalingAria': '比例スケーリング (U = V)',
// Displacement section
'sections.displacement': 'テクスチャの深さ',
'labels.amplitude': '振幅',
// Seam blend
'labels.seamBlend': 'シームブレンド \u24d8',
'tooltips.seamBlend': '投影面が接する境界の硬い継ぎ目を滑らかにします。キュービックおよび円筒モードで効果的です。',
'labels.transitionSmoothing': 'トランジションスムージング \u24d8',
'tooltips.transitionSmoothing': '継ぎ目の端付近のブレンドゾーンの幅。低い値はトランジションを継ぎ目に近づけ、高い値はより広い帯域をブレンドします。',
'labels.textureSmoothing': 'テクスチャスムージング \u24d8',
'tooltips.textureSmoothing': 'ディスプレイスメントマップにガウシアンブラーを適用します。値が高いほど、より滑らかで緩やかな表面ディテールになります。0 = オフ。',
'labels.capAngle': 'キャップ角度 \u24d8',
'tooltips.capAngle': '上面/下面のキャップ投影が作動する垂直からの角度(度数)。小さい値はキャップ投影をほぼ平らな面に制限します。',
// Masking parent section
'sections.masking': 'マスキング',
// Mask angles section
'sections.maskAngles': '角度別 \u24d8',
'tooltips.maskAngles': '0° = マスクなし。水平からこの角度内の面はテクスチャが適用されません。',
'labels.bottomFaces': '底面',
'tooltips.bottomFaces': '水平からこの角度内の下向きの面のテクスチャを抑制',
'labels.topFaces': '上面',
'tooltips.topFaces': '水平からこの角度内の上向きの面のテクスチャを抑制',
// Surface masking section
'sections.surfaceMasking': 'サーフェス別 \u24d8',
'sections.surfaceSelection': 'サーフェス選択',
'tooltips.surfaceMasking': 'サーフェスをマスクして、どの領域にディスプレイスメントを適用するかを制御します。',
'tooltips.surfaceSelection': '選択されたサーフェスは緑色で表示され、エクスポート時にディスプレイスメントを受ける唯一の面になります。',
'excl.modeExclude': '除外',
'excl.modeExcludeTitle': '除外モード: 塗られたサーフェスはテクスチャディスプレイスメントを受けません',
'excl.modeIncludeOnly': '選択のみ',
'excl.modeIncludeOnlyTitle': '選択のみモード: 塗られたサーフェスのみがテクスチャディスプレイスメントを受けます',
'excl.toolBrush': 'ブラシ',
'excl.toolBrushTitle': 'ブラシ: 三角形を塗って除外',
'excl.toolFill': '塗りつぶし',
'excl.toolFillTitle': '塗りつぶし: 閾値角度までサーフェスをフラッドフィル',
'excl.shiftHint': 'Shiftキーを押しながら消去',
'labels.type': 'タイプ',
'brushType.single': '単一',
'brushType.circle': '円',
'labels.size': 'サイズ',
'labels.maxAngle': '最大角度',
'tooltips.maxAngle': '塗りつぶしが越えることができる隣接三角形間の最大二面角',
'ui.clearAll': 'すべてクリア',
'excl.initExcluded': '0 面マスク済み',
'excl.faceExcluded': '{n} 面マスク済み',
'excl.facesExcluded': '{n} 面マスク済み',
'excl.faceSelected': '{n} 面選択済み',
'excl.facesSelected': '{n} 面選択済み',
'excl.hintExclude': 'マスクされたサーフェスはオレンジ色で表示され、エクスポート時にディスプレイスメントを受けません。',
'excl.hintInclude': '選択されたサーフェスは緑色で表示され、エクスポート時にディスプレイスメントを受ける唯一の面になります。',
// Precision masking
'precision.label': '精度 (ベータ) \u24d8',
'precision.labelTitle': 'バックグラウンドでメッシュを細分化し、ブラシの選択精度を向上させます',
'precision.outdated': '\u26a0 古い情報',
'precision.refreshTitle': '現在のブラシサイズに合わせてメッシュを再細分化',
'precision.triCount': '{n} \u25b3',
'precision.refining': '精密化中\u2026',
'precision.warningBody': '推定 ~{n} 三角形。ブラウザが遅くなる可能性があります。続行しますか?',
// Boundary falloff
'labels.boundaryFalloff': 'マスクを滑らかに \u24d8',
'tooltips.boundaryFalloff': 'マスク境界付近でディスプレイスメントを徐々にゼロに減少させ、テクスチャ適用面と非適用面の間の三角形の重なりを防ぎます。',
// Symmetric displacement
'labels.symmetricDisplacement': '対称ディスプレイスメント \u24d8',
'tooltips.symmetricDisplacement': 'オンの場合、50%グレー = 変位なし、白は外側に押し出し、黒は内側に押し込みます。部品の体積をほぼ一定に保ちます。',
// Displacement preview
'labels.displacementPreview': '3Dプレビュー \u24d8',
'tooltips.displacementPreview': 'メッシュを細分化し、リアルタイムで頂点を変位させて実際の深さを確認できます。複雑なモデルではGPU負荷が高くなります。',
// Place on face
'ui.placeOnFace': '面に配置',
'ui.placeOnFaceTitle': '面をクリックして印刷ベッドに向けて配置します',
'progress.subdividingPreview': 'プレビューを準備中\u2026',
// Amplitude overlap warning
'warnings.amplitudeOverlap': '\u26a0 振幅がモデルの最小寸法の10%を超えています \u2014 エクスポートされたSTLでジオメトリの重なりが発生する可能性があります。',
// Export section
'sections.export': 'エクスポート \u24d8',
'tooltips.export': 'エッジ長が短いほど = ディスプレイスメントの詳細度が高くなります。出力はその後三角形の上限まで削減されます。',
'labels.resolution': '解像度',
'tooltips.resolution': 'この値より長いエッジはエクスポート時に分割されます',
'labels.outputTriangles': '出力三角形数',
'tooltips.outputTriangles': 'メッシュはまず完全に細分化され、その後この数まで削減されます',
'warnings.safetyCapHit': '\u26a0 細分化中に2000万三角形の安全制限に達しました \u2014 結果は要求されたエッジ長よりも粗くなる可能性があります。',
'ui.exportStl': 'STLをエクスポート',
// Export progress stages
'progress.subdividing': 'メッシュを細分化中\u2026',
'progress.refining': '精密化中: {cur} 三角形、最長エッジ {edge}',
'progress.applyingDisplacement': '{n} 三角形にディスプレイスメントを適用中\u2026',
'progress.displacingVertices': '頂点を変位中\u2026',
'progress.decimatingTo': '{from} → {to} 三角形に簡略化中\u2026',
'progress.decimating': '簡略化中: {cur} → {to} 三角形',
'progress.writingStl': 'STLを書き出し中\u2026',
'progress.done': '完了!',
'progress.processing': '処理中\u2026',
// License popup
'license.btn': 'ライセンスと利用規約',
'license.title': 'ライセンスと利用規約',
'license.item1': '<strong>商用利用</strong>を含む、あらゆる目的で無料で使用できます(例:クライアントや製品向けのSTLテクスチャリング)。',
'license.item2': 'このツールをそのまま使用する場合、クレジット表記は<strong>歓迎</strong>されますが<strong>必須ではありません</strong>。',
'license.item3': 'このツールを支援しませんか? <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener">CNCKitchen.STORE</a>でお買い物、または<a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener">PayPal</a>で寄付できます。',
'license.item4': 'このツールは<strong>現状のまま</strong>提供され、いかなる種類の<strong>保証もありません</strong>。自己責任でご利用ください。',
'license.item5': '<strong>サポートは提供されません</strong>。作者にはバグの修正、質問への回答、ツールの更新の義務はありません。ただし、バグ報告や機能リクエストは <a href="mailto:texturizer@cnckitchen.com">texturizer@cnckitchen.com</a> までいつでも歓迎します。',
'license.item6': '作者は、このツールの使用に起因する<strong>損害</strong>、データ損失、またはその他の問題について責任を負いません。',
'license.item7': 'このツールをビジネスやウェブサイトにライセンスまたは組み込みたい場合は、<a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a> までお問い合わせください。',
'license.item8': 'ソースコードは <a href="https://github.com/CNCKitchen/stlTexturizer" target="_blank" rel="noopener">GitHub</a> で公開されています。',
// Imprint & Privacy
'imprint.btn': '特定商取引法に基づく表記とプライバシー',
'imprint.title': '特定商取引法に基づく表記とプライバシーポリシー',
'imprint.sectionImprint': '運営者情報 (Impressum)',
'imprint.info': 'CNC Kitchen<br>Stefan Hermann<br>Bahnhofstr. 2<br>88145 Hergatz<br>ドイツ',
'imprint.contact': 'メール: <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a><br>電話: +49 175 2011824<br><em>電話番号は法律/ビジネスに関するお問い合わせ専用です \u2014 サポートには対応しておりません。</em>',
'imprint.odr': 'EU オンライン紛争解決プラットフォーム: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr</a>',
'imprint.sectionPrivacy': 'プライバシーポリシー (Datenschutzerklärung)',
'imprint.privacyIntro': '<strong>責任者</strong> (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, ドイツ。',
'imprint.privacyHosting': 'このウェブサイトは <strong>GitHub Pages</strong>GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, USA)でホストされています。このサイトにアクセスすると、GitHubがサーバーログでIPアドレスを処理する場合があります。法的根拠: GDPR第6条(1)(f)(ウェブサイト提供の正当な利益)。<a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener">GitHubのプライバシーステートメント</a>を参照してください。',
'imprint.privacyLocal': 'このツールはユーザーの設定(言語、テーマ)をブラウザの<strong>localStorage</strong>に保存します。このデータはデバイスの外に出ることはなく、サーバーに送信されることもありません。',
'imprint.privacyNoCookies': 'このウェブサイトはCookie、分析ツール、トラッキング技術を<strong>一切使用しません</strong>。',
'imprint.privacyExternal': 'このサイトには外部ウェブサイト(CNCKitchen.STORE、PayPalなど)へのリンクが含まれています。これらのサイトには独自のプライバシーポリシーがあり、当方では管理できません。',
'imprint.privacyRights': 'GDPRに基づき、<strong>アクセス、訂正、削除、処理の制限、データポータビリティ</strong>の権利、および監督機関に<strong>苦情を申し立てる</strong>権利があります。',
// Sponsor modal
'sponsor.title': 'CNC Kitchen の BumpMesh をご利用いただきありがとうございます!',
'sponsor.body': 'このツールは CNC Kitchen が<strong>完全無料</strong>で提供しています。<br>STLの処理中に、私たちがクールなものを作り続けるのを支えてくれるストアを覗いてみませんか?',
'sponsor.visitStore': '\uD83D\uDED2 CNCKitchen.STORE を訪問',
'sponsor.donate': '\uD83D\uDC99 PayPal で寄付',
'sponsor.dontShow': '今後表示しない',
'sponsor.closeAndContinue': '閉じて続行',
// Store CTA
'cta.store': 'このツールを支援しませんか? <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a>でお買い物、または<a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a>で寄付できます',
'cta.storeDismiss': '閉じる',
// Alerts
'alerts.loadFailed': 'モデルを読み込めませんでした: {msg}',
'alerts.exportFailed': 'エクスポートに失敗しました: {msg}',
'alerts.fileTooLarge': 'ファイルが大きすぎます ({size} MB)。最大: {max} MB。',
},
it: {
+382 -116
View File
@@ -1,7 +1,8 @@
import * as THREE from 'three';
import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe,
getControls, getCamera, getCurrentMesh,
setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js';
setExclusionOverlay, setHoverPreview, setViewerTheme,
requestRender } from './viewer.js';
import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js';
import { loadPresets, loadCustomTexture } from './presetTextures.js';
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
@@ -31,7 +32,7 @@ let _falloffGeometry = null; // geometry the falloff was last computed for
// ── Exclusion state ───────────────────────────────────────────────────────────
let excludedFaces = new Set(); // triangle indices in currentGeometry
let triangleAdjacency = null; // Map from buildAdjacency
let triangleAdjacency = null; // Array from buildAdjacency
let triangleCentroids = null; // Float32Array from buildAdjacency
let triangleBoundRadii = null; // Float32Array — max vertex-to-centroid dist per tri
let exclusionTool = null; // 'brush' | 'bucket' | null
@@ -44,6 +45,11 @@ let selectionMode = false; // false = exclude painted faces; true = i
let _lastHoverTriIdx = -1; // last triangle index used for hover preview
let placeOnFaceActive = false; // true while "Place on Face" mode is active
const _raycaster = new THREE.Raycaster();
let _lastPaintHitPoint = null; // THREE.Vector3 — last brush paint position for shift-line
let _shiftLineMesh = null; // THREE.Line — preview line from last paint to cursor
let _lastEffectiveTexture = null;
let _effectiveMapCache = null;
let _effectiveMapCacheKey = null;
const settings = {
mappingMode: 5, // Triplanar default
@@ -146,7 +152,7 @@ let precisionEdgeLength = null; // edge length used for current refinement
let precisionBusy = false; // true while async subdivision is running
let precisionCentroids = null; // Float32Array from buildAdjacency on refined mesh
let precisionBoundRadii = null; // Float32Array — max vertex-to-centroid per refined tri
let precisionAdjacency = null; // Map from buildAdjacency on refined mesh
let precisionAdjacency = null; // Array from buildAdjacency on refined mesh
let precisionExcludedFaces = new Set(); // precision face indices excluded while precision is active
// ── Displacement preview state ────────────────────────────────────────────────
@@ -349,6 +355,8 @@ function buildPresetGrid() {
PRESETS.forEach((preset, idx) => {
const swatch = document.createElement('div');
swatch.className = 'preset-swatch';
swatch.setAttribute('role', 'button');
swatch.setAttribute('tabindex', '0');
swatch.title = preset.name;
// Use the small thumbnail canvas
@@ -360,6 +368,12 @@ function buildPresetGrid() {
swatch.appendChild(label);
swatch.addEventListener('click', () => selectPreset(idx, swatch));
swatch.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectPreset(idx, swatch);
}
});
presetGrid.appendChild(swatch);
});
}
@@ -380,6 +394,32 @@ function selectPreset(idx, swatchEl) {
updatePreview();
}
// ── Accessibility: Modal focus trap ───────────────────────────────────────────
function trapFocus(overlay) {
const focusable = overlay.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
first.focus();
function handler(e) {
if (e.key === 'Escape') {
overlay.classList.add('hidden');
overlay.removeEventListener('keydown', handler);
return;
}
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
overlay.addEventListener('keydown', handler);
}
// ── Event wiring ──────────────────────────────────────────────────────────────
function wireEvents() {
@@ -490,14 +530,14 @@ function wireEvents() {
});
// ── License ──
licenseLink.addEventListener('click', () => licenseOverlay.classList.remove('hidden'));
licenseLink.addEventListener('click', () => { licenseOverlay.classList.remove('hidden'); trapFocus(licenseOverlay); });
licenseClose.addEventListener('click', () => licenseOverlay.classList.add('hidden'));
licenseOverlay.addEventListener('click', (e) => {
if (e.target === licenseOverlay) licenseOverlay.classList.add('hidden');
});
// ── Imprint & Privacy ──
imprintLink.addEventListener('click', () => imprintOverlay.classList.remove('hidden'));
imprintLink.addEventListener('click', () => { imprintOverlay.classList.remove('hidden'); trapFocus(imprintOverlay); });
imprintClose.addEventListener('click', () => imprintOverlay.classList.add('hidden'));
imprintOverlay.addEventListener('click', (e) => {
if (e.target === imprintOverlay) imprintOverlay.classList.add('hidden');
@@ -518,6 +558,7 @@ function wireEvents() {
const closeBtn = document.getElementById('sponsor-close');
const storeLink = overlay.querySelector('.sponsor-link');
overlay.classList.remove('hidden');
trapFocus(overlay);
const dismiss = () => {
if (document.getElementById('sponsor-dont-show').checked) {
@@ -554,7 +595,6 @@ function wireEvents() {
exclBrushRadiusBtn.classList.remove('active');
exclRadiusRow.classList.add('hidden');
precisionMaskingRow.classList.add('hidden');
// Deactivate precision when switching away from circle mode
if (precisionMaskingEnabled) deactivatePrecisionMasking();
canvas.style.cursor = exclusionTool ? 'crosshair' : '';
brushCursorEl.style.display = 'none';
@@ -676,23 +716,42 @@ function wireEvents() {
}
});
// RAF-Batching: paint events fire immediately, hover/cursor batched per frame
let _pendingHoverEvent = null;
let _hoverRafId = 0;
canvas.addEventListener('mousemove', (e) => {
if (placeOnFaceActive && currentGeometry) {
updatePlaceOnFaceHover(e);
return;
}
if (exclusionTool === 'brush' && brushIsRadius) {
updateBrushCursor(e);
}
// Paint-Events sofort verarbeiten (jeder Event zaehlt fuer lueckenloses Malen)
if (isPainting && exclusionTool === 'brush') {
paintAt(e);
// Cursor-Update kann warten
_pendingHoverEvent = e;
if (!_hoverRafId) {
_hoverRafId = requestAnimationFrame(() => {
_hoverRafId = 0;
if (_pendingHoverEvent) updateBrushCursor(_pendingHoverEvent);
_pendingHoverEvent = null;
});
}
return;
}
if (!isPainting && exclusionTool === 'brush' && currentGeometry) {
updateBrushHover(e);
}
if (!isPainting && exclusionTool === 'bucket' && currentGeometry) {
updateBucketHover(e);
// Alle anderen Hover-Pfade: RAF-Batching OK
_pendingHoverEvent = e;
if (!_hoverRafId) {
_hoverRafId = requestAnimationFrame(() => {
_hoverRafId = 0;
const ev = _pendingHoverEvent;
if (!ev) return;
_pendingHoverEvent = null;
if (placeOnFaceActive && currentGeometry) { updatePlaceOnFaceHover(ev); return; }
if (exclusionTool === 'brush') {
updateBrushCursor(ev);
if (!isPainting && currentGeometry) updateBrushHover(ev);
_updateShiftLinePreview(ev);
} else if (exclusionTool === 'bucket' && !isPainting && currentGeometry) {
updateBucketHover(ev);
}
});
}
});
@@ -714,8 +773,13 @@ function wireEvents() {
if (exclusionTool) setExclusionTool(null);
licenseOverlay.classList.add('hidden');
imprintOverlay.classList.add('hidden');
_clearShiftLinePreview();
}
});
document.addEventListener('keyup', (e) => {
if (e.key === 'Control') _clearShiftLinePreview();
});
}
// ── Exclusion helpers ─────────────────────────────────────────────────────────
@@ -775,19 +839,23 @@ function setExclusionTool(tool) {
getControls().enabled = true;
// Recompute boundary falloff now that masking is done
if (_falloffDirty && currentGeometry) {
const activeGeo = (settings.useDisplacement && dispPreviewGeometry)
? dispPreviewGeometry : currentGeometry;
const activeGeo = (precisionMaskingEnabled && precisionGeometry)
? precisionGeometry
: (settings.useDisplacement && dispPreviewGeometry)
? dispPreviewGeometry : currentGeometry;
updateFaceMask(activeGeo);
}
}
}
const _ndcResult = new THREE.Vector2();
function _canvasNDC(e) {
const rect = canvas.getBoundingClientRect();
return new THREE.Vector2(
_ndcResult.set(
((e.clientX - rect.left) / rect.width) * 2 - 1,
((e.clientY - rect.top) / rect.height) * -2 + 1,
);
return _ndcResult;
}
// The preview material uses THREE.DoubleSide, so the raycaster can return
@@ -877,6 +945,31 @@ function distSqPointToTri(px, py, pz, ax, ay, az, bx, by, bz, cx, cy, cz) {
return qx*qx + qy*qy + qz*qz;
}
// ── Spatial grid for fast sphere queries ──────────────────────────────────
let _spatialGrid = null;
let _spatialCellSize = 0;
let _spatialMinX = 0, _spatialMinY = 0, _spatialMinZ = 0;
function buildSpatialGrid(centroids, triCount, bounds) {
const vol = bounds.size.x * bounds.size.y * bounds.size.z;
const cellSize = Math.max(Math.cbrt(vol / Math.max(triCount, 1)) * 2, 1e-6);
_spatialCellSize = cellSize;
_spatialMinX = bounds.min.x;
_spatialMinY = bounds.min.y;
_spatialMinZ = bounds.min.z;
const grid = new Map();
for (let t = 0; t < triCount; t++) {
const gx = Math.floor((centroids[t*3] - _spatialMinX) / cellSize);
const gy = Math.floor((centroids[t*3+1] - _spatialMinY) / cellSize);
const gz = Math.floor((centroids[t*3+2] - _spatialMinZ) / cellSize);
const key = (gx * 73856093) ^ (gy * 19349663) ^ (gz * 83492791);
let list = grid.get(key);
if (!list) { list = []; grid.set(key, list); }
list.push(t);
}
_spatialGrid = grid;
}
/** Test all triangles against a sphere and invoke cb(triIdx) for each hit. */
function forEachTriInSphere(hitPt, r2, cb) {
const usePrecision = precisionMaskingEnabled && precisionGeometry;
@@ -884,50 +977,69 @@ function forEachTriInSphere(hitPt, r2, cb) {
const centroids = usePrecision ? precisionCentroids : triangleCentroids;
const boundRadii = usePrecision ? precisionBoundRadii : triangleBoundRadii;
const pos = geo.attributes.position;
const triCount = centroids.length / 3;
const r = Math.sqrt(r2);
for (let t = 0; t < triCount; t++) {
// Quick reject: centroid distance > brush radius + triangle bounding radius
const dx = centroids[t*3] - hitPt.x;
const dy = centroids[t*3+1] - hitPt.y;
const dz = centroids[t*3+2] - hitPt.z;
const bound = r + boundRadii[t];
if (dx*dx + dy*dy + dz*dz > bound*bound) continue;
// Precise sphere-triangle test
const i = t * 3;
const d2 = distSqPointToTri(
hitPt.x, hitPt.y, hitPt.z,
pos.getX(i), pos.getY(i), pos.getZ(i),
pos.getX(i+1), pos.getY(i+1), pos.getZ(i+1),
pos.getX(i+2), pos.getY(i+2), pos.getZ(i+2),
);
if (d2 <= r2) cb(t);
if (!_spatialGrid) {
// Fallback: linear scan (grid not built yet)
const triCount = centroids.length / 3;
for (let t = 0; t < triCount; t++) {
const dx = centroids[t*3] - hitPt.x, dy = centroids[t*3+1] - hitPt.y, dz = centroids[t*3+2] - hitPt.z;
const bound = r + boundRadii[t];
if (dx*dx + dy*dy + dz*dz > bound*bound) continue;
const i = t * 3;
const d2 = distSqPointToTri(hitPt.x, hitPt.y, hitPt.z,
pos.getX(i), pos.getY(i), pos.getZ(i),
pos.getX(i+1), pos.getY(i+1), pos.getZ(i+1),
pos.getX(i+2), pos.getY(i+2), pos.getZ(i+2));
if (d2 <= r2) cb(t);
}
return;
}
const cs = _spatialCellSize;
const xMin = Math.floor((hitPt.x - r - _spatialMinX) / cs);
const xMax = Math.floor((hitPt.x + r - _spatialMinX) / cs);
const yMin = Math.floor((hitPt.y - r - _spatialMinY) / cs);
const yMax = Math.floor((hitPt.y + r - _spatialMinY) / cs);
const zMin = Math.floor((hitPt.z - r - _spatialMinZ) / cs);
const zMax = Math.floor((hitPt.z + r - _spatialMinZ) / cs);
for (let gx = xMin; gx <= xMax; gx++) {
for (let gy = yMin; gy <= yMax; gy++) {
for (let gz = zMin; gz <= zMax; gz++) {
const key = (gx * 73856093) ^ (gy * 19349663) ^ (gz * 83492791);
const list = _spatialGrid.get(key);
if (!list) continue;
for (let li = 0; li < list.length; li++) {
const t = list[li];
const dx = centroids[t*3] - hitPt.x, dy = centroids[t*3+1] - hitPt.y, dz = centroids[t*3+2] - hitPt.z;
const bound = r + boundRadii[t];
if (dx*dx + dy*dy + dz*dz > bound*bound) continue;
const i = t * 3;
const d2 = distSqPointToTri(hitPt.x, hitPt.y, hitPt.z,
pos.getX(i), pos.getY(i), pos.getZ(i),
pos.getX(i+1), pos.getY(i+1), pos.getZ(i+1),
pos.getX(i+2), pos.getY(i+2), pos.getZ(i+2));
if (d2 <= r2) cb(t);
}
}
}
}
}
function paintAt(e) {
const mesh = getCurrentMesh();
if (!mesh) return;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
const hit = getFrontFaceHit(hits, mesh);
if (!hit) return;
function _paintSingleHit(hit, mesh) {
const usePrecision = precisionMaskingEnabled && precisionGeometry && precisionParentMap;
if (usePrecision) {
// Precision mode: store precision face indices for fine-grained selection
if (brushIsRadius) {
const r2 = brushRadius * brushRadius;
forEachTriInSphere(hit.point, r2, t => {
if (eraseMode) precisionExcludedFaces.delete(t); else precisionExcludedFaces.add(t);
});
} else {
const precIdx = hit.faceIndex; // precision face index (mesh is precision geometry)
const precIdx = hit.faceIndex;
if (eraseMode) precisionExcludedFaces.delete(precIdx); else precisionExcludedFaces.add(precIdx);
}
} else {
// Normal mode: store original face indices
let triIdx = hit.faceIndex;
if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) {
triIdx = dispPreviewParentMap[triIdx];
@@ -941,12 +1053,90 @@ function paintAt(e) {
if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx);
}
}
}
function _paintLineBetween(from, to, mesh) {
// Sample points along the line and paint at each
const dist = from.distanceTo(to);
const step = brushIsRadius ? Math.max(brushRadius * 0.5, 0.1) : 0.5;
const steps = Math.max(Math.ceil(dist / step), 1);
const dir = new THREE.Vector3().subVectors(to, from);
const cam = getCamera();
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const pt = new THREE.Vector3().lerpVectors(from, to, t);
// Project 3D point to screen, then raycast back to find mesh hit
const ndc = pt.clone().project(cam);
_raycaster.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), cam);
const hits = _raycaster.intersectObject(mesh);
const hit = getFrontFaceHit(hits, mesh);
if (hit) _paintSingleHit(hit, mesh);
}
}
function paintAt(e) {
const mesh = getCurrentMesh();
if (!mesh) return;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
const hit = getFrontFaceHit(hits, mesh);
if (!hit) return;
// Shift+click: draw line from last paint point to current
if (e.ctrlKey && _lastPaintHitPoint) {
_paintLineBetween(_lastPaintHitPoint, hit.point, mesh);
_clearShiftLinePreview();
} else {
_paintSingleHit(hit, mesh);
}
_lastPaintHitPoint = hit.point.clone();
refreshExclusionOverlay();
}
// ── Place on Face ─────────────────────────────────────────────────────────────
// ── Shift-line preview for brush painting ─────────────────────────────────
function _updateShiftLinePreview(e) {
if (!e.ctrlKey || !_lastPaintHitPoint || !exclusionTool || exclusionTool !== 'brush') {
_clearShiftLinePreview();
return;
}
const mesh = getCurrentMesh();
if (!mesh) return;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
const hit = getFrontFaceHit(hits, mesh);
if (!hit) { _clearShiftLinePreview(); return; }
const points = [_lastPaintHitPoint, hit.point];
if (_shiftLineMesh) {
_shiftLineMesh.geometry.setFromPoints(points);
_shiftLineMesh.geometry.attributes.position.needsUpdate = true;
} else {
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: 0x00ffaa, linewidth: 2, depthTest: false });
_shiftLineMesh = new THREE.Line(geo, mat);
_shiftLineMesh.renderOrder = 999;
const scene = mesh.parent.parent; // meshGroup → scene
if (scene) scene.add(_shiftLineMesh);
}
requestRender();
}
function _clearShiftLinePreview() {
if (_shiftLineMesh) {
if (_shiftLineMesh.parent) _shiftLineMesh.parent.remove(_shiftLineMesh);
_shiftLineMesh.geometry.dispose();
_shiftLineMesh.material.dispose();
_shiftLineMesh = null;
requestRender();
}
}
// ── Place on Face ─────────────────────────────────────────────────────────────
function togglePlaceOnFace(active) {
placeOnFaceActive = active;
placeOnFaceBtn.classList.toggle('active', active);
@@ -1065,10 +1255,11 @@ function handlePlaceOnFaceClick(e) {
const adjData = buildAdjacency(currentGeometry);
triangleAdjacency = adjData.adjacency;
triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
buildSpatialGrid(triangleCentroids, currentGeometry.attributes.position.count / 3, currentBounds);
// Update edge length for new bounds
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
const diag = Math.sqrt(currentBounds.size.x ** 2 + currentBounds.size.y ** 2 + currentBounds.size.z ** 2);
const defaultEdge = Math.max(0.05, Math.min(5.0, +(diag / 300).toFixed(2)));
settings.refineLength = defaultEdge;
refineLenSlider.value = defaultEdge;
refineLenVal.value = defaultEdge;
@@ -1318,6 +1509,7 @@ function loadDefaultCube() {
const adjData = buildAdjacency(geo);
triangleAdjacency = adjData.adjacency;
triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
buildSpatialGrid(triangleCentroids, geo.attributes.position.count / 3, currentBounds);
settings.scaleU = 0.5; scaleUSlider.value = scaleToPos(0.5); scaleUVal.value = 0.5;
settings.scaleV = 0.5; scaleVSlider.value = scaleToPos(0.5); scaleVVal.value = 0.5;
@@ -1325,8 +1517,8 @@ function loadDefaultCube() {
settings.offsetV = 0; offsetVSlider.value = 0; offsetVVal.value = 0;
triLimitWarning.classList.add('hidden');
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
const diag = Math.sqrt(currentBounds.size.x ** 2 + currentBounds.size.y ** 2 + currentBounds.size.z ** 2);
const defaultEdge = Math.max(0.05, Math.min(5.0, +(diag / 250).toFixed(2)));
settings.refineLength = defaultEdge;
refineLenSlider.value = defaultEdge;
refineLenVal.value = defaultEdge;
@@ -1412,6 +1604,7 @@ async function handleModelFile(file) {
const adjData = buildAdjacency(geometry);
triangleAdjacency = adjData.adjacency;
triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
buildSpatialGrid(triangleCentroids, geometry.attributes.position.count / 3, bounds);
// Reset scale & offset sliders so scale=1 = one tile covers the full bounding box
const resetVal = (slider, valEl, value) => {
@@ -1424,9 +1617,9 @@ async function handleModelFile(file) {
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0);
triLimitWarning.classList.add('hidden');
// Default edge length = 1/200 of the largest bounding box dimension
const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z);
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
// Default edge length = 1/250 of the bounding box diagonal
const diag = Math.sqrt(bounds.size.x ** 2 + bounds.size.y ** 2 + bounds.size.z ** 2);
const defaultEdge = Math.max(0.05, Math.min(5.0, +(diag / 250).toFixed(2)));
settings.refineLength = defaultEdge;
refineLenSlider.value = defaultEdge;
refineLenVal.value = defaultEdge;
@@ -1470,7 +1663,11 @@ function updateFaceMask(geometry) {
if (!geometry) return;
const posCount = geometry.attributes.position.count;
const triCount = posCount / 3;
const maskArr = new Float32Array(posCount);
// Reuse existing buffer if length matches exactly, otherwise allocate new
const existing = geometry.getAttribute('faceMask');
const reuseBuffer = existing && existing.array.length === posCount;
const maskArr = reuseBuffer ? existing.array : new Float32Array(posCount);
// Determine which face set to check
const isPrecision = (geometry === precisionGeometry && precisionMaskingEnabled);
@@ -1494,7 +1691,11 @@ function updateFaceMask(geometry) {
}
}
geometry.setAttribute('faceMask', new THREE.Float32BufferAttribute(maskArr, 1));
if (reuseBuffer) {
existing.needsUpdate = true;
} else {
geometry.setAttribute('faceMask', new THREE.Float32BufferAttribute(maskArr, 1));
}
// Ensure faceNormal attribute exists (needed by shader for angle masking).
// For the original geometry normal == faceNormal; for subdivided geometry
@@ -1504,8 +1705,25 @@ function updateFaceMask(geometry) {
addFaceNormals(geometry);
}
// Skip expensive falloff recomputation while actively masking;
// it will be recalculated when the masking tool is deactivated.
// Ensure falloff attributes exist so the shader doesn't read 0.0 for missing
// attributes (which would make totalMask = 0 → entire model appears masked).
// This matters when a fresh geometry is displayed while the masking tool is
// active (e.g. entering precision mode) because the expensive recomputation
// below is intentionally skipped during active masking.
if (!geometry.attributes.boundaryFalloffAttr) {
const arr = new Float32Array(posCount);
arr.fill(1.0);
geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(arr, 1));
}
if (!geometry.attributes.boundaryMaskTypeAttr) {
const arr = new Float32Array(posCount);
arr.fill(1.0);
geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(arr, 1));
}
// Skip expensive per-vertex falloff and boundary edge recomputation while
// actively masking; both will be recalculated when the masking tool is
// deactivated (in setExclusionTool → updateFaceMask with exclusionTool=null).
if (!exclusionTool && (_falloffDirty || geometry !== _falloffGeometry)) {
computeBoundaryFalloffAttr(geometry, maskArr);
computeBoundaryEdges(geometry, maskArr);
@@ -1513,6 +1731,7 @@ function updateFaceMask(geometry) {
_falloffGeometry = geometry;
}
syncBoundaryEdgeUniforms();
requestRender();
}
/**
@@ -1529,14 +1748,25 @@ function computeBoundaryFalloffAttr(geometry, userMaskArr) {
const posCount = posAttr.count;
const triCount = posCount / 3;
const falloff = settings.boundaryFalloff ?? 0;
const falloffArr = new Float32Array(posCount);
// Reuse existing attribute buffers when sizes match to avoid Three.js
// WebGL binding state cache issues when replacing attribute objects on
// a geometry that is already attached to a rendered mesh.
const existingFalloff = geometry.getAttribute('boundaryFalloffAttr');
const reuseFalloff = existingFalloff && existingFalloff.array.length === posCount;
const falloffArr = reuseFalloff ? existingFalloff.array : new Float32Array(posCount);
falloffArr.fill(1.0);
const existingType = geometry.getAttribute('boundaryMaskTypeAttr');
const reuseType = existingType && existingType.array.length === posCount;
const maskTypeArr = reuseType ? existingType.array : new Float32Array(posCount);
maskTypeArr.fill(1.0);
if (falloff <= 0) {
geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
const defaultType = new Float32Array(posCount);
defaultType.fill(1.0);
geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(defaultType, 1));
if (reuseFalloff) existingFalloff.needsUpdate = true;
else geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
if (reuseType) existingType.needsUpdate = true;
else geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(maskTypeArr, 1));
return;
}
@@ -1620,10 +1850,10 @@ function computeBoundaryFalloffAttr(geometry, userMaskArr) {
}
if (boundaryPositions.length === 0) {
geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
const defaultType = new Float32Array(posCount);
defaultType.fill(1.0);
geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(defaultType, 1));
if (reuseFalloff) existingFalloff.needsUpdate = true;
else geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
if (reuseType) existingType.needsUpdate = true;
else geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(maskTypeArr, 1));
return;
}
@@ -1710,8 +1940,6 @@ function computeBoundaryFalloffAttr(geometry, userMaskArr) {
}
// Write per-vertex attributes
const maskTypeArr = new Float32Array(posCount);
maskTypeArr.fill(1.0); // default: angle mask (grey)
for (let i = 0; i < posCount; i++) {
tmpV.fromBufferAttribute(posAttr, i);
const k = posKey(tmpV.x, tmpV.y, tmpV.z);
@@ -1719,8 +1947,10 @@ function computeBoundaryFalloffAttr(geometry, userMaskArr) {
if (maskTypeCache.has(k)) maskTypeArr[i] = maskTypeCache.get(k);
}
geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(maskTypeArr, 1));
if (reuseFalloff) existingFalloff.needsUpdate = true;
else geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
if (reuseType) existingType.needsUpdate = true;
else geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(maskTypeArr, 1));
}
/**
@@ -1958,8 +2188,16 @@ function buildParentFaceMap(subdivGeo) {
}
function getEffectiveMapEntry() {
if (!activeMapEntry || settings.textureSmoothing === 0) return activeMapEntry;
const { fullCanvas, width, height } = activeMapEntry;
if (!activeMapEntry || settings.textureSmoothing === 0) {
_effectiveMapCache = null;
_effectiveMapCacheKey = null;
return activeMapEntry;
}
const { fullCanvas, width, height, name } = activeMapEntry;
const cacheKey = `${name}_${width}_${height}_${settings.textureSmoothing}`;
if (_effectiveMapCacheKey === cacheKey && _effectiveMapCache) {
return _effectiveMapCache;
}
// Tile the source 3×3 before blurring so edge pixels have correct
// neighbours and the blurred centre tile is seamlessly tileable.
const tiled = document.createElement('canvas');
@@ -1984,7 +2222,11 @@ function getEffectiveMapEntry() {
const imageData = offscreen.getContext('2d').getImageData(0, 0, width, height);
const texture = new THREE.CanvasTexture(offscreen);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
return { ...activeMapEntry, imageData, texture };
if (_lastEffectiveTexture) _lastEffectiveTexture.dispose();
_lastEffectiveTexture = texture;
_effectiveMapCache = { ...activeMapEntry, imageData, texture };
_effectiveMapCacheKey = cacheKey;
return _effectiveMapCache;
}
function updatePreview() {
@@ -2074,19 +2316,28 @@ function addFaceNormals(geometry) {
function addSmoothNormals(geometry) {
const pos = geometry.attributes.position.array;
const count = geometry.attributes.position.count;
const nrm = geometry.attributes.normal.array;
// Vertex-dedup pass: assign a numeric ID to each unique quantised position.
const QUANT = 1e4;
const key = (x, y, z) =>
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
const dedupMap = new Map();
let nextId = 0;
const vertId = new Uint32Array(count);
for (let i = 0; i < count; i++) {
const key = `${Math.round(pos[i*3]*QUANT)}_${Math.round(pos[i*3+1]*QUANT)}_${Math.round(pos[i*3+2]*QUANT)}`;
let id = dedupMap.get(key);
if (id === undefined) { id = nextId++; dedupMap.set(key, id); }
vertId[i] = id;
}
// Accumulate area-weighted buffer normals per unique position.
// Accumulate area-weighted buffer normals per unique position into flat arrays.
// The subdivision pipeline splits indexed vertices at sharp dihedral edges
// (>30°) so the interpolated buffer normals are smooth across soft edges
// (>30 deg) 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 nrm = geometry.attributes.normal.array;
const uc = nextId;
const snx = new Float64Array(uc), sny = new Float64Array(uc), snz = new Float64Array(uc);
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();
@@ -2101,32 +2352,24 @@ function addSmoothNormals(geometry) {
if (area < 1e-12) continue;
for (let v = 0; v < 3; v++) {
const vi = i + v;
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);
if (prev) {
prev[0] += nx * area;
prev[1] += ny * area;
prev[2] += nz * area;
} else {
nrmMap.set(k, [nx * area, ny * area, nz * area]);
}
const id = vertId[vi];
snx[id] += nrm[vi * 3] * area;
sny[id] += nrm[vi * 3 + 1] * area;
snz[id] += nrm[vi * 3 + 2] * area;
}
}
// Normalize accumulated normals
for (const n of nrmMap.values()) {
const len = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
if (len > 1e-12) { n[0] /= len; n[1] /= len; n[2] /= len; }
for (let id = 0; id < uc; id++) {
const len = Math.sqrt(snx[id] * snx[id] + sny[id] * sny[id] + snz[id] * snz[id]) || 1;
snx[id] /= len; sny[id] /= len; snz[id] /= len;
}
// Write smoothNormal attribute
// Write smoothNormal attribute via vertId lookup
const sn = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const k = key(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]);
const n = nrmMap.get(k);
if (n) { sn[i * 3] = n[0]; sn[i * 3 + 1] = n[1]; sn[i * 3 + 2] = n[2]; }
else { sn[i * 3] = 0; sn[i * 3 + 1] = 0; sn[i * 3 + 2] = 1; }
const id = vertId[i];
sn[i * 3] = snx[id]; sn[i * 3 + 1] = sny[id]; sn[i * 3 + 2] = snz[id];
}
geometry.setAttribute('smoothNormal', new THREE.Float32BufferAttribute(sn, 3));
}
@@ -2182,11 +2425,14 @@ function deactivatePrecisionMasking() {
triangleCentroids = precisionCentroids;
triangleBoundRadii = precisionBoundRadii;
// Rebuild spatial grid for the promoted base mesh
const triCount = currentGeometry.attributes.position.count / 3;
buildSpatialGrid(triangleCentroids, triCount, currentBounds);
// Promote precision excluded faces to the base set
excludedFaces = precisionExcludedFaces;
// Update mesh info display
const triCount = getTriangleCount(currentGeometry);
const mb = ((currentGeometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
const sx = currentBounds.size.x.toFixed(2);
const sy = currentBounds.size.y.toFixed(2);
@@ -2262,6 +2508,10 @@ async function refreshPrecisionMesh() {
precisionCentroids = adjData.centroids;
precisionBoundRadii = adjData.boundRadii;
// Rebuild spatial grid for the precision mesh so brush queries are fast
const precTriCount = precisionGeometry.attributes.position.count / 3;
buildSpatialGrid(precisionCentroids, precTriCount, currentBounds);
// Seed precisionExcludedFaces from existing excludedFaces
precisionExcludedFaces = new Set();
if (excludedFaces.size > 0) {
@@ -2274,6 +2524,18 @@ async function refreshPrecisionMesh() {
// Swap display mesh to refined geometry
setMeshGeometry(precisionGeometry);
updateFaceMask(precisionGeometry);
// Force per-vertex falloff computation on the fresh geometry even though
// the masking tool is still active updateFaceMask only computes boundary
// edges during painting; the full vertex-level falloff is deferred until
// the tool is deactivated, but we need it now for the initial state.
{
const maskAttr = precisionGeometry.getAttribute('faceMask');
if (maskAttr) {
computeBoundaryFalloffAttr(precisionGeometry, maskAttr.array);
_falloffDirty = false;
_falloffGeometry = precisionGeometry;
}
}
if (precisionExcludedFaces.size > 0) refreshExclusionOverlay();
else setExclusionOverlay(null);
@@ -2550,26 +2812,30 @@ async function handleExport() {
// Flat-bottom clamp: when bottom faces are masked (bottomAngleLimit > 0),
// any vertex that ended up below the original model's bottom layer gets
// snapped back up to that Z. Only the Z-value is changed.
// snapped back up to that Z. Single pass with selective normal recomputation.
if (settings.bottomAngleLimit > 0) {
const bottomZ = currentBounds.min.z;
const posArr = finalGeometry.attributes.position.array;
for (let i = 2; i < posArr.length; i += 3) {
if (posArr[i] < bottomZ) posArr[i] = bottomZ;
}
finalGeometry.attributes.position.needsUpdate = true;
// 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;
let dirty = false;
if (pa[i+2] < bottomZ) { pa[i+2] = bottomZ; dirty = true; }
if (pa[i+5] < bottomZ) { pa[i+5] = bottomZ; dirty = true; }
if (pa[i+8] < bottomZ) { pa[i+8] = bottomZ; dirty = true; }
if (dirty) {
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;
}
}
finalGeometry.attributes.position.needsUpdate = true;
if (!finalGeometry.attributes.normal) finalGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(na, 3));
else finalGeometry.attributes.normal.needsUpdate = true;
}
+23 -22
View File
@@ -112,6 +112,8 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const scaleV = (settings.scaleV) / aV;
const { offsetU, offsetV } = settings;
const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
const cosR = Math.cos(rotRad);
const sinR = Math.sin(rotRad);
const maxDim = Math.max(size.x, size.y, size.z);
const md = Math.max(maxDim, 1e-6);
@@ -161,14 +163,14 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const d = uRaw < 0.5 ? uRaw : uRaw - 1.0;
const tRaw = (d + seamBand) / (2.0 * seamBand);
const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep
const tLeft = applyTransform(1.0 + d, vSide, scaleU, scaleV, offsetU, offsetV, rotRad);
const tRight = applyTransform(d, vSide, scaleU, scaleV, offsetU, offsetV, rotRad);
const tLeft = applyTransform(1.0 + d, vSide, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
const tRight = applyTransform(d, vSide, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
sideSamples = [
{ u: tRight.u, v: tRight.v, w: t },
{ u: tLeft.u, v: tLeft.v, w: 1 - t },
];
} else {
const tSide = applyTransform(uRaw, vSide, scaleU, scaleV, offsetU, offsetV, rotRad);
const tSide = applyTransform(uRaw, vSide, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
sideSamples = [{ u: tSide.u, v: tSide.v, w: 1 }];
}
@@ -189,7 +191,7 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const uCap = rx / C + 0.5;
const vCap = ry / C + 0.5;
const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad);
const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
if (capW >= 1) {
return tCap;
@@ -218,8 +220,8 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const d = uRaw < 0.5 ? uRaw : uRaw - 1.0;
const tRaw = (d + seamBand) / (2.0 * seamBand);
const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep
const tLeft = applyTransform(1.0 + d, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad);
const tRight = applyTransform(d, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad);
const tLeft = applyTransform(1.0 + d, vRaw, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
const tRight = applyTransform(d, vRaw, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
return {
triplanar: true,
samples: [
@@ -244,9 +246,9 @@ export function computeUV(pos, normal, mode, settings, bounds) {
if (normal.y > 0) xzU = -xzU;
let xyU = (pos.x - min.x) / md;
if (normal.z < 0) xyU = -xyU;
const tYZ = applyTransform(yzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
const tXZ = applyTransform(xzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
const tXY = applyTransform(xyU, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
const tYZ = applyTransform(yzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
const tXZ = applyTransform(xzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
const tXY = applyTransform(xyU, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
if (weights.x > 0.999) return tYZ;
if (weights.y > 0.999) return tXZ;
@@ -268,10 +270,10 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const ax = Math.abs(normal.x);
const ay = Math.abs(normal.y);
const az = Math.abs(normal.z);
const pw = 4.0;
const bx = Math.pow(ax, pw);
const by = Math.pow(ay, pw);
const bz = Math.pow(az, pw);
const ax2 = ax * ax, ay2 = ay * ay, az2 = az * az;
const bx = ax2 * ax2;
const by = ay2 * ay2;
const bz = az2 * az2;
const sum = bx + by + bz + 1e-6;
const wx = bx / sum;
const wy = by / sum;
@@ -304,25 +306,24 @@ export function computeUV(pos, normal, mode, settings, bounds) {
return {
triplanar: true,
samples: [
{ ...applyTransform(uvXY.u, uvXY.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXY.w },
{ ...applyTransform(uvXZ.u, uvXZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXZ.w },
{ ...applyTransform(uvYZ.u, uvYZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvYZ.w },
{ ...applyTransform(uvXY.u, uvXY.v, scaleU, scaleV, offsetU, offsetV, cosR, sinR), w: uvXY.w },
{ ...applyTransform(uvXZ.u, uvXZ.v, scaleU, scaleV, offsetU, offsetV, cosR, sinR), w: uvXZ.w },
{ ...applyTransform(uvYZ.u, uvYZ.v, scaleU, scaleV, offsetU, offsetV, cosR, sinR), w: uvYZ.w },
],
};
}
}
return applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, rotRad);
return applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
}
function applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, rotRad) {
function applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, cosR, sinR) {
let uu = u / scaleU + offsetU;
let vv = v / scaleV + offsetV;
if (rotRad !== 0) {
const c = Math.cos(rotRad), s = Math.sin(rotRad);
if (cosR !== 1 || sinR !== 0) {
uu -= 0.5; vv -= 0.5;
const ru = c * uu - s * vv;
const rv = s * uu + c * vv;
const ru = cosR * uu - sinR * vv;
const rv = sinR * uu + cosR * vv;
uu = ru + 0.5; vv = rv + 0.5;
}
return { triplanar: false, u: fract(uu), v: fract(vv) };
+24
View File
@@ -3,6 +3,8 @@ import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { unzipSync } from 'fflate';
import * as THREE from 'three';
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
const stlLoader = new STLLoader();
const objLoader = new OBJLoader();
@@ -12,6 +14,11 @@ const objLoader = new OBJLoader();
* The geometry is translated so its bounding-box centre is at the world origin.
*/
export function loadSTLFile(file) {
if (file.size > MAX_FILE_SIZE) {
return Promise.reject(new Error(
'File too large (' + Math.round(file.size / 1024 / 1024) + ' MB). Maximum supported: ' + (MAX_FILE_SIZE / 1024 / 1024) + ' MB.'
));
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
@@ -73,6 +80,11 @@ export function getTriangleCount(geometry) {
* Returns { geometry, bounds }.
*/
export function loadOBJFile(file) {
if (file.size > MAX_FILE_SIZE) {
return Promise.reject(new Error(
'File too large (' + Math.round(file.size / 1024 / 1024) + ' MB). Maximum supported: ' + (MAX_FILE_SIZE / 1024 / 1024) + ' MB.'
));
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
@@ -99,6 +111,11 @@ export function loadOBJFile(file) {
* Returns { geometry, bounds }.
*/
export function load3MFFile(file) {
if (file.size > MAX_FILE_SIZE) {
return Promise.reject(new Error(
'File too large (' + Math.round(file.size / 1024 / 1024) + ' MB). Maximum supported: ' + (MAX_FILE_SIZE / 1024 / 1024) + ' MB.'
));
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
@@ -170,6 +187,13 @@ function parse3MF(data) {
triangles[i * 3 + 2] = parseInt(triEls[i].getAttribute('v3'), 10);
}
const vertCount = vertEls.length;
for (let i = 0; i < triangles.length; i++) {
if (triangles[i] < 0 || triangles[i] >= vertCount || isNaN(triangles[i])) {
throw new Error('Invalid triangle index in 3MF file');
}
}
// Normalise path for lookup (strip leading slash, use forward slashes)
const normPath = path.replace(/^\//, '').replace(/\\/g, '/');
objectMap.set(normPath + '#' + id, { vertices, triangles });
+11 -8
View File
@@ -84,6 +84,7 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights
const newTriCount = newIndices.length / 3;
if (onProgress) onProgress(Math.min(0.95, (iter + 1) / maxIterations), newTriCount, longestEdge);
// Yield once per subdivision pass (not per iteration) — keeps background tabs fast
await new Promise(r => setTimeout(r, 0));
if (!changed || safetyCapHit) break;
}
@@ -119,13 +120,17 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights
function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap, faceExcluded = null, canonIdx = null, posCanonMap = null, faceParentId = null) {
const maxSq = maxEdgeLength * maxEdgeLength;
const midCache = new Map();
midCache._maxV = positions.length / 3; // set before any getMidpoint calls
// When canonIdx is available (accurate/export mode), use position-canonical
// edge keys so split-vertex faces on both sides of a sharp edge see the same
// split decision. Otherwise (fast/preview mode) use simple index-based keys.
// NOTE: _maxV is computed once at pass start. During the pass, positions grows
// via getMidpoint, but splitEdges and midCache only use indices < _maxV.
const _maxV = positions.length / 3;
const _edgeKey = canonIdx
? (a, b) => { const ca = canonIdx[a], cb = canonIdx[b]; return ca < cb ? `${ca}:${cb}` : `${cb}:${ca}`; }
: (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`;
? (a, b) => { const ca = canonIdx[a], cb = canonIdx[b]; return ca < cb ? ca * _maxV + cb : cb * _maxV + ca; }
: (a, b) => a < b ? a * _maxV + b : b * _maxV + a;
// ── Step 1: globally mark edges that need splitting ─────────────────────
// Excluded triangles do NOT proactively mark their own edges their
@@ -264,11 +269,6 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Canonical order key for an undirected edge matches the getMidpoint cache key. */
function edgeKey(a, b) {
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
function edgeLenSq(pos, a, b) {
const dx = pos[a*3] - pos[b*3];
const dy = pos[a*3+1] - pos[b*3+1];
@@ -277,7 +277,10 @@ function edgeLenSq(pos, a, b) {
}
function getMidpoint(positions, normals, weights, cache, a, b, canonIdx, posCanonMap) {
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
// Numeric edge key: uses _maxV set on the cache by subdividePass at pass start.
// All lookups use indices < _maxV, so this is safe even as positions grows.
const _mv = cache._maxV;
const key = a < b ? a * _mv + b : b * _mv + a;
if (cache.has(key)) return cache.get(key);
// Midpoint position
+94 -48
View File
@@ -4,6 +4,14 @@ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
// Pre-allocated temp objects for hot-path event handlers (avoid GC pressure)
const _tmpQ1 = new THREE.Quaternion();
const _tmpQ2 = new THREE.Quaternion();
const _tmpV1 = new THREE.Vector3();
const _tmpV2 = new THREE.Vector3();
const _tmpV3 = new THREE.Vector3();
const _tmpV4 = new THREE.Vector3();
let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid;
let currentMesh = null;
let axesGroup = null;
@@ -12,6 +20,9 @@ let wireframeLines = null; // LineSegments overlay, or null when hidden
let wireframeVisible = false;
let exclusionMesh = null; // flat orange overlay for user-excluded faces
let hoverMesh = null; // semi-transparent yellow bucket-fill preview
let _exclMaterial = null;
let _hoverMaterial = null;
let _needsRender = true;
// Build a labelled coordinate axes indicator scaled to `size`.
// X = red, Y = green, Z = blue (up).
@@ -141,8 +152,7 @@ export function initViewer(canvas) {
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.shadowMap.enabled = false;
// Scene
scene = new THREE.Scene();
@@ -166,8 +176,7 @@ export function initViewer(canvas) {
dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight1.position.set(80, 120, 60);
dirLight1.castShadow = true;
dirLight1.shadow.mapSize.set(1024, 1024);
dirLight1.castShadow = false;
scene.add(dirLight1);
dirLight2 = new THREE.DirectionalLight(0x8899ff, 0.4);
@@ -229,6 +238,7 @@ export function initViewer(canvas) {
const markerScale = (camera.top / camera.zoom) * 0.015;
_pivotMarker.scale.setScalar(markerScale);
_pivotMarker.visible = true;
_needsRender = true;
});
document.addEventListener('pointermove', (e) => {
@@ -240,24 +250,24 @@ export function initViewer(canvas) {
const rotSpeed = 0.005;
// Horizontal: rotate around world Z (up)
const qH = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 0, 1), -dx * rotSpeed);
_tmpQ1.setFromAxisAngle(_tmpV1.set(0, 0, 1), -dx * rotSpeed);
// Vertical: rotate around camera's local X (right vector)
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0).normalize();
const qV = new THREE.Quaternion().setFromAxisAngle(right, -dy * rotSpeed);
const qTotal = new THREE.Quaternion().multiplyQuaternions(qV, qH);
_tmpV2.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
_tmpQ2.setFromAxisAngle(_tmpV2, -dy * rotSpeed);
_tmpQ1.premultiply(_tmpQ2); // _tmpQ1 = qV * qH (total rotation)
// Rotate camera position around the pivot
const camOff = camera.position.clone().sub(_customPivot);
camOff.applyQuaternion(qTotal);
camera.position.copy(_customPivot).add(camOff);
_tmpV3.copy(camera.position).sub(_customPivot);
_tmpV3.applyQuaternion(_tmpQ1);
camera.position.copy(_customPivot).add(_tmpV3);
// Rotate orbit target around the same pivot so OrbitControls stays in sync
const tgtOff = controls.target.clone().sub(_customPivot);
tgtOff.applyQuaternion(qTotal);
controls.target.copy(_customPivot).add(tgtOff);
_tmpV4.copy(controls.target).sub(_customPivot);
_tmpV4.applyQuaternion(_tmpQ1);
controls.target.copy(_customPivot).add(_tmpV4);
camera.lookAt(controls.target);
_needsRender = true;
});
document.addEventListener('pointerup', () => {
@@ -266,6 +276,7 @@ export function initViewer(canvas) {
_lastPointer = null;
controls.enableRotate = true;
_pivotMarker.visible = false;
_needsRender = true;
}
});
@@ -300,26 +311,27 @@ export function initViewer(canvas) {
const curNdcX = ((midX - rect.left) / rect.width) * 2 - 1;
const curNdcY = -((midY - rect.top) / rect.height) * 2 + 1;
const prevWorld = new THREE.Vector3(prevNdcX, prevNdcY, 0).unproject(camera);
const curWorld = new THREE.Vector3(curNdcX, curNdcY, 0).unproject(camera);
const panDelta = prevWorld.sub(curWorld);
camera.position.add(panDelta);
controls.target.add(panDelta);
_tmpV1.set(prevNdcX, prevNdcY, 0).unproject(camera);
_tmpV2.set(curNdcX, curNdcY, 0).unproject(camera);
_tmpV1.sub(_tmpV2); // panDelta
camera.position.add(_tmpV1);
controls.target.add(_tmpV1);
// ── Zoom: zoom toward the current midpoint ────────────────────────
const factor = newDist / _pinchDist;
const before = new THREE.Vector3(curNdcX, curNdcY, 0).unproject(camera);
_tmpV3.set(curNdcX, curNdcY, 0).unproject(camera);
camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor));
camera.updateProjectionMatrix();
const after = new THREE.Vector3(curNdcX, curNdcY, 0).unproject(camera);
_tmpV4.set(curNdcX, curNdcY, 0).unproject(camera);
const zoomDelta = before.clone().sub(after);
camera.position.add(zoomDelta);
controls.target.add(zoomDelta);
_tmpV3.sub(_tmpV4); // zoomDelta
camera.position.add(_tmpV3);
controls.target.add(_tmpV3);
_pinchDist = newDist;
_pinchMid = { x: midX, y: midY };
controls.update();
_needsRender = true;
}, { passive: false });
renderer.domElement.addEventListener('touchend', (e) => {
@@ -338,7 +350,7 @@ export function initViewer(canvas) {
const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
// World position under cursor before zoom
const before = new THREE.Vector3(ndcX, ndcY, 0).unproject(camera);
_tmpV1.set(ndcX, ndcY, 0).unproject(camera);
// Apply zoom
const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1;
@@ -346,12 +358,12 @@ export function initViewer(canvas) {
camera.updateProjectionMatrix();
// World position under cursor after zoom
const after = new THREE.Vector3(ndcX, ndcY, 0).unproject(camera);
_tmpV2.set(ndcX, ndcY, 0).unproject(camera);
// Shift camera + target so the world point stays under the cursor
const delta = before.clone().sub(after);
camera.position.add(delta);
controls.target.add(delta);
_tmpV1.sub(_tmpV2); // delta = before - after
camera.position.add(_tmpV1);
controls.target.add(_tmpV1);
controls.update();
}, { passive: false });
@@ -360,12 +372,17 @@ export function initViewer(canvas) {
resizeObserver.observe(canvas.parentElement);
onResize();
// Damping needs controls.update() every frame; re-render only when needed
controls.addEventListener('change', () => { _needsRender = true; });
// Render loop
(function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
if (_needsRender) {
_needsRender = false;
renderer.render(scene, camera);
}
})();
}
@@ -387,6 +404,21 @@ function onResize() {
h * renderer.getPixelRatio(),
);
}
requestRender();
}
function disposeGroup(group) {
group.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => { if (m.map) m.map.dispose(); m.dispose(); });
} else {
if (obj.material.map) obj.material.map.dispose();
obj.material.dispose();
}
}
});
}
/**
@@ -435,7 +467,7 @@ export function loadGeometry(geometry, material) {
fitCamera(sphere);
// Place coordinate axes away from the part corner
if (axesGroup) scene.remove(axesGroup);
if (axesGroup) { disposeGroup(axesGroup); scene.remove(axesGroup); }
const axisSize = sphere.radius * 0.30;
axesGroup = buildAxesIndicator(axisSize);
// Offset from the bounding box corner by ~1 axis-length so it doesn't overlap the mesh
@@ -444,9 +476,10 @@ export function loadGeometry(geometry, material) {
scene.add(axesGroup);
// Bounding-box dimension annotations on the ground plane
if (dimensionGroup) scene.remove(dimensionGroup);
if (dimensionGroup) { disposeGroup(dimensionGroup); scene.remove(dimensionGroup); }
dimensionGroup = buildDimensions(box, groundZ, sphere.radius);
scene.add(dimensionGroup);
requestRender();
}
/**
@@ -464,6 +497,7 @@ export function setMeshMaterial(material) {
metalness: 0.1,
side: THREE.DoubleSide,
});
requestRender();
}
/**
@@ -484,6 +518,7 @@ export function setMeshGeometry(geometry) {
wireframeLines = null;
}
if (wireframeVisible) _buildWireframe(geometry);
requestRender();
}
/**
@@ -514,6 +549,8 @@ function fitCamera(sphere) {
controls.update();
}
export function requestRender() { _needsRender = true; }
export function getRenderer() { return renderer; }
export function getCamera() { return camera; }
export function getScene() { return scene; }
@@ -522,6 +559,7 @@ export function getCurrentMesh() { return currentMesh; }
export function setSceneBackground(hexColor) {
if (scene) scene.background = new THREE.Color(hexColor);
requestRender();
}
export function setViewerTheme(isLight) {
@@ -541,6 +579,7 @@ export function setViewerTheme(isLight) {
grid.rotation.x = Math.PI / 2;
grid.position.z = savedZ;
scene.add(grid);
requestRender();
}
/**
@@ -556,13 +595,11 @@ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0)
if (exclusionMesh) {
scene.remove(exclusionMesh);
exclusionMesh.geometry.dispose();
exclusionMesh.material.dispose();
exclusionMesh = null;
}
if (!overlayGeo || overlayGeo.attributes.position.count === 0) return;
exclusionMesh = new THREE.Mesh(
overlayGeo,
new THREE.MeshLambertMaterial({
if (!overlayGeo || overlayGeo.attributes.position.count === 0) { requestRender(); return; }
if (!_exclMaterial) {
_exclMaterial = new THREE.MeshLambertMaterial({
color,
side: THREE.DoubleSide,
transparent: opacity < 1.0,
@@ -570,10 +607,16 @@ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0)
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
}),
);
});
} else {
_exclMaterial.color.set(color);
_exclMaterial.opacity = opacity;
_exclMaterial.transparent = opacity < 1.0;
}
exclusionMesh = new THREE.Mesh(overlayGeo, _exclMaterial);
exclusionMesh.renderOrder = 1;
scene.add(exclusionMesh);
requestRender();
}
/**
@@ -586,13 +629,11 @@ export function setHoverPreview(overlayGeo, color = 0xffee00) {
if (hoverMesh) {
scene.remove(hoverMesh);
hoverMesh.geometry.dispose();
hoverMesh.material.dispose();
hoverMesh = null;
}
if (!overlayGeo || overlayGeo.attributes.position.count === 0) return;
hoverMesh = new THREE.Mesh(
overlayGeo,
new THREE.MeshBasicMaterial({
if (!overlayGeo || overlayGeo.attributes.position.count === 0) { requestRender(); return; }
if (!_hoverMaterial) {
_hoverMaterial = new THREE.MeshBasicMaterial({
color,
side: THREE.DoubleSide,
transparent: true,
@@ -600,10 +641,14 @@ export function setHoverPreview(overlayGeo, color = 0xffee00) {
polygonOffset: true,
polygonOffsetFactor: -2,
polygonOffsetUnits: -2,
}),
);
});
} else {
_hoverMaterial.color.set(color);
}
hoverMesh = new THREE.Mesh(overlayGeo, _hoverMaterial);
hoverMesh.renderOrder = 2;
scene.add(hoverMesh);
requestRender();
}
/**
@@ -618,6 +663,7 @@ export function setWireframe(enabled) {
} else {
if (wireframeLines) wireframeLines.visible = false;
}
requestRender();
}
function _buildWireframe(geometry) {