Files
archived-stlTexturizer/js/displacement.js
T
CNCKitchen 498581d8cf feat: enhance language selection and boundary falloff features
- Added a language dropdown selector to replace the previous button-based language toggle.
- Integrated boundary falloff settings into the preview material, allowing for smoother transitions between masked and unmasked areas.
- Updated shaders to utilize boundary falloff attributes for improved visual fidelity in bump-only previews.
- Refactored related CSS styles for the new dropdown and adjusted layout for better usability.
- Introduced new functions for computing boundary attributes and managing edge data textures.
2026-04-07 09:48:45 +02:00

489 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { computeUV, getDominantCubicAxis, getCubicBlendWeights } from './mapping.js';
/**
* Apply displacement to every vertex of a non-indexed BufferGeometry.
*
* For each vertex:
* 1. Compute UV with the same math used in the GLSL preview shader (mapping.js).
* 2. Bilinear-sample the greyscale ImageData at that UV.
* 3. Move the vertex along its normal by: (grey 0.5) × 2 × amplitude
* so 50% grey = no displacement, white = outward, black = inward.
*
* @param {THREE.BufferGeometry} geometry non-indexed (from subdivide())
* @param {ImageData} imageData raw pixel data from Canvas2D
* @param {number} imgWidth
* @param {number} imgHeight
* @param {object} settings { mappingMode, scaleU, scaleV, amplitude, offsetU, offsetV }
* @param {object} bounds { min, max, center, size } (THREE.Vector3)
* @param {function} [onProgress]
* @returns {THREE.BufferGeometry} new non-indexed geometry with displaced positions
*/
export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, settings, bounds, onProgress) {
const posAttr = geometry.attributes.position;
const nrmAttr = geometry.attributes.normal;
const count = posAttr.count;
const newPos = new Float32Array(count * 3);
const newNrm = new Float32Array(count * 3);
const tmpPos = new THREE.Vector3();
const tmpNrm = new THREE.Vector3();
const vA = new THREE.Vector3();
const vB = new THREE.Vector3();
const vC = new THREE.Vector3();
const edge1 = new THREE.Vector3();
const edge2 = new THREE.Vector3();
const faceNrm = new THREE.Vector3();
// Texture aspect correction so non-square textures keep their proportions.
// The shorter axis gets aspect > 1 so it tiles faster, making each tile
// proportionally shorter in world-space to match the texture's content.
const tmax = Math.max(imgWidth, imgHeight, 1);
const aspectU = tmax / Math.max(imgWidth, 1);
const aspectV = tmax / Math.max(imgHeight, 1);
const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV };
const QUANT = 1e4;
// ── WHY GAPS HAPPEN ───────────────────────────────────────────────────────
// The mesh is non-indexed (unrolled): every triangle has its own copy of
// each vertex. At a shared edge two triangles have the same position but
// different face normals. Displacing each copy along its own face normal
// moves them to DIFFERENT final positions → crack / gap.
//
// THE FIX: every copy of the same position must arrive at the exact same
// displaced point. We achieve this by computing a single *smooth* (area-
// weighted average) normal per unique position and using that both for the
// texture UV lookup and for the displacement direction. All copies of the
// same position then move by the same vector → watertight result.
//
// The tradeoff is that displaced normals are smooth at hard edges, but the
// underlying geometry is still faceted (the subdivision didn't change it),
// so printed edges remain sharp.
// ── Vertex dedup pass: position → numeric ID via one-time string-map pass ─
const _dedupMap = new Map();
let _nextId = 0;
const vertexId = new Uint32Array(count);
for (let i = 0; i < count; i++) {
const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i);
const key = `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
let id = _dedupMap.get(key);
if (id === undefined) { id = _nextId++; _dedupMap.set(key, id); }
vertexId[i] = id;
}
const uniqueCount = _nextId;
// ── Pass 1: accumulate area-weighted smooth normals per unique position ───
// Flat arrays indexed by vertex dedup ID (replaces Map<string, ...>)
const smoothNrmX = new Float64Array(uniqueCount);
const smoothNrmY = new Float64Array(uniqueCount);
const smoothNrmZ = new Float64Array(uniqueCount);
// zoneArea: per-axis face area for cubic mapping (replaces zoneAreaMap)
const zoneAreaX = new Float64Array(uniqueCount);
const zoneAreaY = new Float64Array(uniqueCount);
const zoneAreaZ = new Float64Array(uniqueCount);
// maskedFrac: [maskedArea, totalArea] per unique vertex (replaces maskedFracMap)
const maskedFracMasked = new Float64Array(uniqueCount);
const maskedFracTotal = new Float64Array(uniqueCount);
// Optional per-vertex exclusion weights threaded through by subdivision.js.
// A face's user-exclusion flag = average of its 3 vertex weights > 0.99.
const ewAttr = geometry.attributes.excludeWeight || null;
// Per-face user-exclusion flag: stored separately from maskedFrac so that
// user-excluded faces do NOT bleed reduced displacement into adjacent faces
// via shared vertices (maskedFrac is only for angle-based blending).
const userExcludedFaces = ewAttr ? new Uint8Array(count / 3) : null;
// Positions that belong to at least one user-excluded face (replaces excludedPosSet).
const excludedPos = ewAttr ? new Uint8Array(uniqueCount) : null;
// Displacement cache: one sample per unique vertex (replaces dispCache Map)
const dispCacheVal = new Float64Array(uniqueCount);
const dispCacheSet = new Uint8Array(uniqueCount);
for (let t = 0; t < count; t += 3) {
vA.fromBufferAttribute(posAttr, t);
vB.fromBufferAttribute(posAttr, t + 1);
vC.fromBufferAttribute(posAttr, t + 2);
edge1.subVectors(vB, vA);
edge2.subVectors(vC, vA);
faceNrm.crossVectors(edge1, edge2); // length = 2× triangle area → natural area weighting
// Determine if this face is masked (used to build the per-vertex blend weight).
// Combines angle-based masking with optional user-painted exclusion.
const faceArea = faceNrm.length(); // ∝ 2× triangle area
const faceNzNorm = faceArea > 1e-12 ? faceNrm.z / faceArea : 0; // unit-normal Z component
const faceAngle = Math.acos(Math.abs(faceNzNorm)) * (180 / Math.PI);
const angleMasked = faceNzNorm < 0
? (settings.bottomAngleLimit > 0 && faceAngle <= settings.bottomAngleLimit)
: (settings.topAngleLimit > 0 && faceAngle <= settings.topAngleLimit);
// Threshold >0.99 (not 0.5) prevents shared-vertex MAX-propagation from
// accidentally marking adjacent faces as excluded on closed meshes (e.g. a
// cube): adjacent faces have 2/3 vertices at weight 1.0 → avg ≈ 0.67 which
// would wrongly trigger the old 0.5 threshold.
const userExcluded = ewAttr
? (ewAttr.getX(t) + ewAttr.getX(t + 1) + ewAttr.getX(t + 2)) / 3 > 0.99
: false;
// maskedFracMap is ONLY used for angle-based blending at surface boundaries.
// User exclusion is tracked per-face in userExcludedFaces and applied
// directly in Pass 3, so excluded faces don't reduce displacement on their
// neighbours through shared boundary vertices.
const faceMasked = angleMasked;
if (userExcluded && userExcludedFaces) userExcludedFaces[t / 3] = 1;
// For cubic mapping: distribute this face's area across projection zones
// proportionally to its blend weights. When blend=0, getCubicBlendWeights
// returns a one-hot vector (same as the old argmax), preserving sharp seams.
// When blend>0, faces near a zone boundary contribute partial area to
// adjacent zones, creating a smooth multi-vertex-wide gradient that matches
// the preview shader. The old single-zone approach only blended at the
// one-vertex-wide boundary, leaving an abrupt seam in the export.
let czX = 0, czY = 0, czZ = 0;
if (settings.mappingMode === 6 && faceArea > 1e-12) {
const cubicBlend = settings.mappingBlend ?? 0;
const cubicBandWidth = settings.seamBandWidth ?? 0.35;
const unitFaceNrm = { x: faceNrm.x / faceArea, y: faceNrm.y / faceArea, z: faceNrm.z / faceArea };
const w = getCubicBlendWeights(unitFaceNrm, cubicBlend, cubicBandWidth);
czX = w.x * faceArea;
czY = w.y * faceArea;
czZ = w.z * faceArea;
}
for (let v = 0; v < 3; v++) {
const vid = vertexId[t + v];
if (userExcluded && excludedPos) excludedPos[vid] = 1;
// Use the buffer normal (from subdivision) weighted by face area.
// The subdivision pipeline splits indexed vertices at sharp dihedral
// edges (>30°), so the interpolated buffer normals are smooth across
// soft edges (cylinder, sphere) but sharp across hard edges (cube).
// This eliminates visible faceting steps on round surfaces while still
// preserving hard edges.
tmpNrm.fromBufferAttribute(nrmAttr, t + v);
smoothNrmX[vid] += tmpNrm.x * faceArea;
smoothNrmY[vid] += tmpNrm.y * faceArea;
smoothNrmZ[vid] += tmpNrm.z * faceArea;
if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) {
zoneAreaX[vid] += czX;
zoneAreaY[vid] += czY;
zoneAreaZ[vid] += czZ;
}
if (faceMasked) maskedFracMasked[vid] += faceArea;
maskedFracTotal[vid] += faceArea;
}
}
// Normalise each accumulated normal
for (let id = 0; id < uniqueCount; id++) {
const len = Math.sqrt(smoothNrmX[id]*smoothNrmX[id] + smoothNrmY[id]*smoothNrmY[id] + smoothNrmZ[id]*smoothNrmZ[id]) || 1;
smoothNrmX[id] /= len; smoothNrmY[id] /= len; smoothNrmZ[id] /= len;
}
// ── Boundary falloff distance field ──────────────────────────────────────────
// When boundaryFalloff > 0, identify boundary positions (vertices adjacent to
// both masked and unmasked faces, or on the user-exclusion seam) and compute
// the Euclidean distance from every fully-textured vertex to its nearest
// boundary position. The result is falloffArr: Float64Array[uniqueCount]
// where 0 means "at the boundary" and 1 means "at or beyond the falloff distance".
const boundaryFalloff = settings.boundaryFalloff ?? 0;
let falloffArr = null;
if (boundaryFalloff > 0) {
// Build position lookup per unique vertex ID (first occurrence)
const idPosX = new Float64Array(uniqueCount);
const idPosY = new Float64Array(uniqueCount);
const idPosZ = new Float64Array(uniqueCount);
const idPosSeen = new Uint8Array(uniqueCount);
for (let i = 0; i < count; i++) {
const vid = vertexId[i];
if (!idPosSeen[vid]) {
idPosSeen[vid] = 1;
idPosX[vid] = posAttr.getX(i);
idPosY[vid] = posAttr.getY(i);
idPosZ[vid] = posAttr.getZ(i);
}
}
const boundaryPositions = []; // [[x, y, z], ...]
// Collect boundary positions: vertices where maskedFrac is between 0 and 1,
// or that sit on the user-exclusion seam.
for (let id = 0; id < uniqueCount; id++) {
const mfTotal = maskedFracTotal[id];
const maskedFrac = mfTotal > 0 ? maskedFracMasked[id] / mfTotal : 0;
const isOnExclBoundary = excludedPos && excludedPos[id] === 1;
if (isOnExclBoundary || (maskedFrac > 0 && maskedFrac < 1)) {
boundaryPositions.push([idPosX[id], idPosY[id], idPosZ[id]]);
}
}
if (boundaryPositions.length > 0) {
// Build a spatial grid of boundary positions for fast nearest-neighbor lookup
let gMinX = Infinity, gMinY = Infinity, gMinZ = Infinity;
let gMaxX = -Infinity, gMaxY = -Infinity, gMaxZ = -Infinity;
for (const bp of boundaryPositions) {
if (bp[0] < gMinX) gMinX = bp[0]; if (bp[0] > gMaxX) gMaxX = bp[0];
if (bp[1] < gMinY) gMinY = bp[1]; if (bp[1] > gMaxY) gMaxY = bp[1];
if (bp[2] < gMinZ) gMinZ = bp[2]; if (bp[2] > gMaxZ) gMaxZ = bp[2];
}
const gPad = boundaryFalloff + 1e-3;
gMinX -= gPad; gMinY -= gPad; gMinZ -= gPad;
gMaxX += gPad; gMaxY += gPad; gMaxZ += gPad;
const gRes = Math.max(4, Math.min(128, Math.ceil(Math.cbrt(boundaryPositions.length) * 2)));
const gDx = (gMaxX - gMinX) / gRes || 1;
const gDy = (gMaxY - gMinY) / gRes || 1;
const gDz = (gMaxZ - gMinZ) / gRes || 1;
const bGrid = new Map();
const bCellKey = (ix, iy, iz) => (ix * gRes + iy) * gRes + iz;
for (const bp of boundaryPositions) {
const ix = Math.max(0, Math.min(gRes - 1, Math.floor((bp[0] - gMinX) / gDx)));
const iy = Math.max(0, Math.min(gRes - 1, Math.floor((bp[1] - gMinY) / gDy)));
const iz = Math.max(0, Math.min(gRes - 1, Math.floor((bp[2] - gMinZ) / gDz)));
const ck = bCellKey(ix, iy, iz);
const cell = bGrid.get(ck);
if (cell) cell.push(bp); else bGrid.set(ck, [bp]);
}
// How many grid cells to search in each direction to cover boundaryFalloff distance
const searchX = Math.ceil(boundaryFalloff / gDx);
const searchY = Math.ceil(boundaryFalloff / gDy);
const searchZ = Math.ceil(boundaryFalloff / gDz);
falloffArr = new Float64Array(uniqueCount);
falloffArr.fill(1); // default: full displacement
for (let id = 0; id < uniqueCount; id++) {
const mfTotal = maskedFracTotal[id];
const maskedFrac = mfTotal > 0 ? maskedFracMasked[id] / mfTotal : 0;
const isOnExclBoundary = excludedPos && excludedPos[id] === 1;
// Only compute falloff for fully-textured, non-boundary positions
if (maskedFrac > 0 || isOnExclBoundary) continue;
const px = idPosX[id], py = idPosY[id], pz = idPosZ[id];
const cix = Math.max(0, Math.min(gRes - 1, Math.floor((px - gMinX) / gDx)));
const ciy = Math.max(0, Math.min(gRes - 1, Math.floor((py - gMinY) / gDy)));
const ciz = Math.max(0, Math.min(gRes - 1, Math.floor((pz - gMinZ) / gDz)));
let minDist2 = boundaryFalloff * boundaryFalloff;
for (let dix = -searchX; dix <= searchX; dix++) {
const nix = cix + dix;
if (nix < 0 || nix >= gRes) continue;
for (let diy = -searchY; diy <= searchY; diy++) {
const niy = ciy + diy;
if (niy < 0 || niy >= gRes) continue;
for (let diz = -searchZ; diz <= searchZ; diz++) {
const niz = ciz + diz;
if (niz < 0 || niz >= gRes) continue;
const cell = bGrid.get(bCellKey(nix, niy, niz));
if (!cell) continue;
for (const bp of cell) {
const dx = px - bp[0], dy = py - bp[1], dz = pz - bp[2];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < minDist2) minDist2 = d2;
}
}
}
}
const dist = Math.sqrt(minDist2);
const factor = Math.min(1, dist / boundaryFalloff);
falloffArr[id] = factor;
}
}
}
// ── Pass 2: sample displacement texture once per unique position ──────────
for (let i = 0; i < count; i++) {
const vid = vertexId[i];
if (dispCacheSet[vid]) continue;
dispCacheSet[vid] = 1;
tmpPos.fromBufferAttribute(posAttr, i);
// Cubic: zone-area-weighted sampling with a stable per-face dominant axis.
// Non-seam vertices use their single zone purely; seam-edge vertices that
// adjoin two zones get a face-area-proportional blend. This guarantees all
// three vertices of every triangle receive consistent displacement, making
// the mesh watertight with no mixed-projection artefact rows at the seam.
//
// Always use this path regardless of mappingBlend. The smooth normals from
// subdivision can be wrong on thin structures (e.g. a flat base plate) where
// top (0,0,1) and bottom (0,0,-1) face normals cancel at shared edge vertices,
// leaving a horizontal smooth normal. computeUV would then pick the wrong
// cubic projection axis, making those faces appear untextured. The face-
// normal-based zoneArea arrays are immune to this because they classify faces
// by their geometric cross-product normal, not the averaged vertex normal.
if (settings.mappingMode === 6 /* MODE_CUBIC */) {
const zaX = zoneAreaX[vid], zaY = zoneAreaY[vid], zaZ = zoneAreaZ[vid];
const total = zaX + zaY + zaZ;
if (total > 0) {
const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6);
const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
let grey = 0;
if (zaX > 0) { // X-dominant zone → YZ projection
let rawU = (tmpPos.y-bounds.min.y)/md;
if (smoothNrmX[vid] < 0) rawU = -rawU; // flip U for -X faces
const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaX/total);
}
if (zaY > 0) { // Y-dominant zone → XZ projection
let rawU = (tmpPos.x-bounds.min.x)/md;
if (smoothNrmY[vid] > 0) rawU = -rawU; // flip U for +Y faces
const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaY/total);
}
if (zaZ > 0) { // Z-dominant zone → XY projection
let rawU = (tmpPos.x-bounds.min.x)/md;
if (smoothNrmZ[vid] < 0) rawU = -rawU; // flip U for -Z faces
const uv = _cubicUV(rawU, (tmpPos.y-bounds.min.y)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaZ/total);
}
dispCacheVal[vid] = grey;
continue;
}
}
tmpNrm.set(smoothNrmX[vid], smoothNrmY[vid], smoothNrmZ[vid]);
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settingsWithAspect, bounds);
let grey;
if (uvResult.triplanar) {
grey = 0;
for (const s of uvResult.samples) {
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, s.u, s.v) * s.w;
}
} else {
grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v);
}
dispCacheVal[vid] = grey;
}
// ── Pass 3: displace every vertex copy by the same vector ─────────────────
// Using the smooth normal for the displacement direction ensures all copies
// of the same position land at exactly the same 3-D point.
const REPORT_EVERY = 5000;
for (let i = 0; i < count; i++) {
tmpPos.fromBufferAttribute(posAttr, i);
tmpNrm.fromBufferAttribute(nrmAttr, i);
const vid = vertexId[i];
const grey = dispCacheVal[vid];
// User-excluded faces get zero displacement; only angle-based masking uses
// the smooth per-vertex blend so neighbours are never unintentionally dimmed.
const isFaceExcluded = userExcludedFaces && userExcludedFaces[Math.floor(i / 3)];
// Pin included-face vertices that share a position with an excluded face.
// This seals the open crack at the mask boundary so the mesh stays watertight
// and the decimator cannot collapse the excluded patch to zero faces.
const isSealedBoundary = !isFaceExcluded && excludedPos && excludedPos[vid] === 1;
const mfTotal = maskedFracTotal[vid];
const maskedFrac = mfTotal > 0 ? maskedFracMasked[vid] / mfTotal : 0;
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey;
const falloffFactor = falloffArr ? falloffArr[vid] : 1.0;
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : falloffFactor * (1 - maskedFrac) * centeredGrey * settings.amplitude;
const newX = tmpPos.x + smoothNrmX[vid] * disp;
const newY = tmpPos.y + smoothNrmY[vid] * disp;
let newZ = tmpPos.z + smoothNrmZ[vid] * disp;
// Prevent boundary vertices from poking through the masked surface in Z.
// Only triggers for vertices that are partly masked (maskedFrac > 0) and
// whose displacement would push them toward the masked surface direction.
if (maskedFrac > 0) {
if (settings.bottomAngleLimit > 0 && newZ < tmpPos.z) newZ = tmpPos.z;
if (settings.topAngleLimit > 0 && newZ > tmpPos.z) newZ = tmpPos.z;
}
newPos[i*3] = newX;
newPos[i*3+1] = newY;
newPos[i*3+2] = newZ;
// Keep per-face normal for shading (recomputed below anyway)
newNrm[i*3] = tmpNrm.x;
newNrm[i*3+1] = tmpNrm.y;
newNrm[i*3+2] = tmpNrm.z;
if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count);
}
// Compute exact per-face normals from the displaced positions.
// Using computeVertexNormals() would average across shared positions, which
// can flip normals on excluded faces whose neighbours were displaced outward.
// A direct cross-product per triangle is unambiguous and matches winding order.
const eA = new THREE.Vector3();
const eB = new THREE.Vector3();
const fn = new THREE.Vector3();
for (let t = 0; t < count; t += 3) {
const ax = newPos[t*3], ay = newPos[t*3+1], az = newPos[t*3+2];
const bx = newPos[t*3+3], by = newPos[t*3+4], bz = newPos[t*3+5];
const cx = newPos[t*3+6], cy = newPos[t*3+7], cz = newPos[t*3+8];
eA.set(bx - ax, by - ay, bz - az);
eB.set(cx - ax, cy - ay, cz - az);
fn.crossVectors(eA, eB).normalize();
for (let v = 0; v < 3; v++) {
newNrm[(t + v) * 3] = fn.x;
newNrm[(t + v) * 3 + 1] = fn.y;
newNrm[(t + v) * 3 + 2] = fn.z;
}
}
const out = new THREE.BufferGeometry();
out.setAttribute('position', new THREE.BufferAttribute(newPos, 3));
out.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3));
return out;
}
// ── Bilinear sampler ─────────────────────────────────────────────────────────
/**
* Sample a greyscale value (01) from raw RGBA ImageData using
* bilinear interpolation. UV is tiled via mod 1.
*/
function sampleBilinear(data, w, h, u, v) {
// Ensure [0,1) — guard against floating-point edge cases
u = ((u % 1) + 1) % 1;
v = ((v % 1) + 1) % 1;
// Flip V to match WebGL/Three.js texture convention (flipY=true means
// v=0 is the bottom of the image, but ImageData row 0 is the top).
v = 1 - v;
const fx = u * (w - 1);
const fy = v * (h - 1);
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const x1 = Math.min(x0 + 1, w - 1);
const y1 = Math.min(y0 + 1, h - 1);
const tx = fx - x0;
const ty = fy - y0;
// Red channel — image is greyscale so R == G == B
const v00 = data[(y0 * w + x0) * 4] / 255;
const v10 = data[(y0 * w + x1) * 4] / 255;
const v01 = data[(y1 * w + x0) * 4] / 255;
const v11 = data[(y1 * w + x1) * 4] / 255;
return v00 * (1-tx) * (1-ty)
+ v10 * tx * (1-ty)
+ v01 * (1-tx) * ty
+ v11 * tx * ty;
}
/** Apply scale/offset/rotation to raw UV for cubic projection.
* Mirrors the private applyTransform helper in mapping.js. */
function _cubicUV(rawU, rawV, settings, rotRad, aspectU, aspectV) {
let u = (rawU * aspectU) / settings.scaleU + settings.offsetU;
let v = (rawV * aspectV) / settings.scaleV + settings.offsetV;
if (rotRad !== 0) {
const c = Math.cos(rotRad), s = Math.sin(rotRad);
u -= 0.5; v -= 0.5;
const ru = c*u - s*v, rv = s*u + c*v;
u = ru + 0.5; v = rv + 0.5;
}
return { u: u - Math.floor(u), v: v - Math.floor(v) };
}