Refactor code structure for improved readability and maintainability
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 76 KiB |
@@ -88,13 +88,13 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="mapping-mode">Mode</label>
|
<label for="mapping-mode">Mode</label>
|
||||||
<select id="mapping-mode">
|
<select id="mapping-mode">
|
||||||
|
<option value="5" selected>Triplanar</option>
|
||||||
|
<option value="6">Cubic (Box)</option>
|
||||||
|
<option value="3">Cylindrical</option>
|
||||||
|
<option value="4">Spherical</option>
|
||||||
<option value="0">Planar XY</option>
|
<option value="0">Planar XY</option>
|
||||||
<option value="1">Planar XZ</option>
|
<option value="1">Planar XZ</option>
|
||||||
<option value="2">Planar YZ</option>
|
<option value="2">Planar YZ</option>
|
||||||
<option value="3">Cylindrical</option>
|
|
||||||
<option value="4">Spherical</option>
|
|
||||||
<option value="5">Triplanar</option>
|
|
||||||
<option value="6" selected>Cubic (Box)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -134,6 +134,11 @@
|
|||||||
<input type="range" id="offset-v" min="-1" max="1" step="0.01" value="0" />
|
<input type="range" id="offset-v" min="-1" max="1" step="0.01" value="0" />
|
||||||
<input type="number" class="val" id="offset-v-val" value="0" min="-1" max="1" step="0.01" />
|
<input type="number" class="val" id="offset-v-val" value="0" min="-1" max="1" step="0.01" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="rotation">Rotation</label>
|
||||||
|
<input type="range" id="rotation" min="0" max="360" step="1" value="0" />
|
||||||
|
<input type="number" class="val" id="rotation-val" value="0" min="0" max="360" step="1" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Displacement -->
|
<!-- Displacement -->
|
||||||
@@ -141,8 +146,8 @@
|
|||||||
<h2>Displacement</h2>
|
<h2>Displacement</h2>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="amplitude">Amplitude</label>
|
<label for="amplitude">Amplitude</label>
|
||||||
<input type="range" id="amplitude" min="-1" max="1" step="0.01" value="0.5" />
|
<input type="range" id="amplitude" min="-2" max="2" step="0.01" value="0.5" />
|
||||||
<input type="number" class="val" id="amplitude-val" value="0.5" min="-1" max="1" step="0.01" />
|
<input type="number" class="val" id="amplitude-val" value="0.5" min="-100" max="100" step="0.01" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -253,7 +258,10 @@
|
|||||||
Smaller edge length = finer displacement detail. Output is then decimated to the triangle limit.
|
Smaller edge length = finer displacement detail. Output is then decimated to the triangle limit.
|
||||||
</p>
|
</p>
|
||||||
<div id="export-progress" class="export-progress hidden">
|
<div id="export-progress" class="export-progress hidden">
|
||||||
|
<div class="export-progress-track">
|
||||||
<div id="export-progress-bar" class="export-progress-bar"></div>
|
<div id="export-progress-bar" class="export-progress-bar"></div>
|
||||||
|
<span id="export-progress-pct" class="export-progress-pct"></span>
|
||||||
|
</div>
|
||||||
<span id="export-progress-label">Processing…</span>
|
<span id="export-progress-label">Processing…</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="export-btn" class="export-btn" disabled>Export STL</button>
|
<button id="export-btn" class="export-btn" disabled>Export STL</button>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const _hlvLo = new Int32Array(128);
|
|||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────────────────────────
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function decimate(geometry, targetTriangles, onProgress) {
|
export async function decimate(geometry, targetTriangles, onProgress) {
|
||||||
const { positions, faces, vertCount, faceCount } = buildIndexed(geometry);
|
const { positions, faces, vertCount, faceCount } = buildIndexed(geometry);
|
||||||
|
|
||||||
if (faceCount <= targetTriangles) return buildOutput(positions, faces, faceCount);
|
if (faceCount <= targetTriangles) return buildOutput(positions, faces, faceCount);
|
||||||
@@ -172,7 +172,10 @@ export function decimate(geometry, targetTriangles, onProgress) {
|
|||||||
|
|
||||||
if (onProgress && (++collapses & 511) === 0) {
|
if (onProgress && (++collapses & 511) === 0) {
|
||||||
const p = Math.min(1, (initFaces - activeFaces) / toRemove);
|
const p = Math.min(1, (initFaces - activeFaces) / toRemove);
|
||||||
if (p - lastProg > 0.015) { onProgress(p); lastProg = p; }
|
if (p - lastProg > 0.015) {
|
||||||
|
onProgress(p); lastProg = p;
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { initViewer, loadGeometry, setMeshMaterial, setWireframe,
|
|||||||
getControls, getCamera, getCurrentMesh,
|
getControls, getCamera, getCurrentMesh,
|
||||||
setExclusionOverlay, setHoverPreview } from './viewer.js';
|
setExclusionOverlay, setHoverPreview } from './viewer.js';
|
||||||
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
|
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
|
||||||
import { PRESETS, loadCustomTexture } from './presetTextures.js';
|
import { loadPresets, loadCustomTexture } from './presetTextures.js';
|
||||||
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
|
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
|
||||||
import { subdivide } from './subdivision.js';
|
import { subdivide } from './subdivision.js';
|
||||||
import { applyDisplacement } from './displacement.js';
|
import { applyDisplacement } from './displacement.js';
|
||||||
@@ -36,12 +36,13 @@ let _lastHoverTriIdx = -1; // last triangle index used for hover prev
|
|||||||
const _raycaster = new THREE.Raycaster();
|
const _raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
mappingMode: 6, // Cubic default
|
mappingMode: 5, // Triplanar default
|
||||||
scaleU: 1.0,
|
scaleU: 1.0,
|
||||||
scaleV: 1.0,
|
scaleV: 1.0,
|
||||||
amplitude: 0.5,
|
amplitude: 0.5,
|
||||||
offsetU: 0.0,
|
offsetU: 0.0,
|
||||||
offsetV: 0.0,
|
offsetV: 0.0,
|
||||||
|
rotation: 0,
|
||||||
refineLength: 1.0,
|
refineLength: 1.0,
|
||||||
maxTriangles: 1_000_000,
|
maxTriangles: 1_000_000,
|
||||||
lockScale: true,
|
lockScale: true,
|
||||||
@@ -63,6 +64,7 @@ const meshInfo = document.getElementById('mesh-info');
|
|||||||
const exportBtn = document.getElementById('export-btn');
|
const exportBtn = document.getElementById('export-btn');
|
||||||
const exportProgress = document.getElementById('export-progress');
|
const exportProgress = document.getElementById('export-progress');
|
||||||
const exportProgBar = document.getElementById('export-progress-bar');
|
const exportProgBar = document.getElementById('export-progress-bar');
|
||||||
|
const exportProgPct = document.getElementById('export-progress-pct');
|
||||||
const exportProgLbl = document.getElementById('export-progress-label');
|
const exportProgLbl = document.getElementById('export-progress-label');
|
||||||
const triLimitWarning = document.getElementById('tri-limit-warning');
|
const triLimitWarning = document.getElementById('tri-limit-warning');
|
||||||
const wireframeToggle = document.getElementById('wireframe-toggle');
|
const wireframeToggle = document.getElementById('wireframe-toggle');
|
||||||
@@ -81,6 +83,8 @@ const scaleUVal = document.getElementById('scale-u-val');
|
|||||||
const scaleVVal = document.getElementById('scale-v-val');
|
const scaleVVal = document.getElementById('scale-v-val');
|
||||||
const offsetUVal = document.getElementById('offset-u-val');
|
const offsetUVal = document.getElementById('offset-u-val');
|
||||||
const offsetVVal = document.getElementById('offset-v-val');
|
const offsetVVal = document.getElementById('offset-v-val');
|
||||||
|
const rotationSlider = document.getElementById('rotation');
|
||||||
|
const rotationVal = document.getElementById('rotation-val');
|
||||||
const amplitudeVal = document.getElementById('amplitude-val');
|
const amplitudeVal = document.getElementById('amplitude-val');
|
||||||
const refineLenVal = document.getElementById('refine-length-val');
|
const refineLenVal = document.getElementById('refine-length-val');
|
||||||
const maxTriVal = document.getElementById('max-triangles-val');
|
const maxTriVal = document.getElementById('max-triangles-val');
|
||||||
@@ -120,13 +124,19 @@ const posToScale = p => parseFloat(Math.exp(_LOG_MIN + (p / 1000) * (_LOG_MAX -
|
|||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let PRESETS = [];
|
||||||
|
|
||||||
initViewer(canvas);
|
initViewer(canvas);
|
||||||
buildPresetGrid();
|
|
||||||
wireEvents();
|
wireEvents();
|
||||||
// Sync scale number inputs with the slider's initial position
|
// Sync scale number inputs with the slider's initial position
|
||||||
scaleUVal.value = posToScale(parseFloat(scaleUSlider.value));
|
scaleUVal.value = posToScale(parseFloat(scaleUSlider.value));
|
||||||
scaleVVal.value = posToScale(parseFloat(scaleVSlider.value));
|
scaleVVal.value = posToScale(parseFloat(scaleVSlider.value));
|
||||||
|
|
||||||
|
loadPresets().then(presets => {
|
||||||
|
PRESETS = presets;
|
||||||
|
buildPresetGrid();
|
||||||
|
}).catch(err => console.error('Failed to load preset textures:', err));
|
||||||
|
|
||||||
// ── Preset grid ───────────────────────────────────────────────────────────────
|
// ── Preset grid ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildPresetGrid() {
|
function buildPresetGrid() {
|
||||||
@@ -241,6 +251,7 @@ function wireEvents() {
|
|||||||
|
|
||||||
linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); });
|
linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); });
|
||||||
linkSlider(offsetVSlider, offsetVVal, v => { settings.offsetV = v; return v.toFixed(2); });
|
linkSlider(offsetVSlider, offsetVVal, v => { settings.offsetV = v; return v.toFixed(2); });
|
||||||
|
linkSlider(rotationSlider, rotationVal, v => { settings.rotation = v; return Math.round(v); });
|
||||||
linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; return v.toFixed(2); });
|
linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; return v.toFixed(2); });
|
||||||
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(1); }, false);
|
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(1); }, false);
|
||||||
linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false);
|
linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false);
|
||||||
@@ -542,8 +553,14 @@ function linkSlider(slider, valInput, onChangeFn, livePreview = true) {
|
|||||||
valInput.addEventListener('change', () => {
|
valInput.addEventListener('change', () => {
|
||||||
const raw = parseFloat(valInput.value);
|
const raw = parseFloat(valInput.value);
|
||||||
if (isNaN(raw)) { valInput.value = slider.value; return; }
|
if (isNaN(raw)) { valInput.value = slider.value; return; }
|
||||||
const clamped = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), raw));
|
// Clamp to the input's own min/max (may be wider than the slider range)
|
||||||
slider.value = clamped;
|
const inMin = parseFloat(valInput.min);
|
||||||
|
const inMax = parseFloat(valInput.max);
|
||||||
|
const clamped = (!isNaN(inMin) && !isNaN(inMax))
|
||||||
|
? Math.max(inMin, Math.min(inMax, raw))
|
||||||
|
: raw;
|
||||||
|
// Move slider thumb to nearest valid position (saturates at slider edges)
|
||||||
|
slider.value = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), clamped));
|
||||||
valInput.value = onChangeFn(clamped);
|
valInput.value = onChangeFn(clamped);
|
||||||
if (livePreview) {
|
if (livePreview) {
|
||||||
clearTimeout(previewDebounce);
|
clearTimeout(previewDebounce);
|
||||||
@@ -573,13 +590,12 @@ async function handleSTL(file) {
|
|||||||
previewMaterial = null;
|
previewMaterial = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select Brick preset (index 5) on first load
|
// Auto-select first preset on first load
|
||||||
const brickIdx = PRESETS.findIndex(p => p.name === 'Brick');
|
if (!activeMapEntry && PRESETS.length > 0) {
|
||||||
if (brickIdx >= 0 && !activeMapEntry) {
|
activeMapEntry = PRESETS[0];
|
||||||
activeMapEntry = PRESETS[brickIdx];
|
activeMapName.textContent = PRESETS[0].name;
|
||||||
activeMapName.textContent = PRESETS[brickIdx].name;
|
|
||||||
const swatches = document.querySelectorAll('.preset-swatch');
|
const swatches = document.querySelectorAll('.preset-swatch');
|
||||||
swatches.forEach((s, i) => s.classList.toggle('active', i === brickIdx));
|
if (swatches.length > 0) swatches[0].classList.add('active');
|
||||||
}
|
}
|
||||||
mappingSelect.value = String(settings.mappingMode);
|
mappingSelect.value = String(settings.mappingMode);
|
||||||
|
|
||||||
@@ -622,7 +638,7 @@ async function handleSTL(file) {
|
|||||||
|
|
||||||
// Default edge length = 1/100 of the largest bounding box dimension
|
// Default edge length = 1/100 of the largest bounding box dimension
|
||||||
const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z);
|
const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z);
|
||||||
const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 100).toFixed(2)));
|
||||||
settings.refineLength = defaultEdge;
|
settings.refineLength = defaultEdge;
|
||||||
refineLenSlider.value = defaultEdge;
|
refineLenSlider.value = defaultEdge;
|
||||||
refineLenVal.value = defaultEdge;
|
refineLenVal.value = defaultEdge;
|
||||||
@@ -719,6 +735,7 @@ async function handleExport() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setProgress(0.02, 'Subdividing mesh…');
|
setProgress(0.02, 'Subdividing mesh…');
|
||||||
|
await yieldFrame();
|
||||||
|
|
||||||
// Build per-vertex exclusion weights combining user-painted exclusion + angle masking.
|
// Build per-vertex exclusion weights combining user-painted exclusion + angle masking.
|
||||||
// Faces masked by top/bottom angle limits are treated the same as user-excluded faces
|
// Faces masked by top/bottom angle limits are treated the same as user-excluded faces
|
||||||
@@ -729,10 +746,10 @@ async function handleExport() {
|
|||||||
? buildCombinedFaceWeights(currentGeometry, excludedFaces, selectionMode, settings)
|
? buildCombinedFaceWeights(currentGeometry, excludedFaces, selectionMode, settings)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { geometry: subdivided, safetyCapHit } = await runAsync(() =>
|
const { geometry: subdivided, safetyCapHit } = await subdivide(
|
||||||
subdivide(currentGeometry, settings.refineLength,
|
currentGeometry, settings.refineLength,
|
||||||
(p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'),
|
(p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'),
|
||||||
faceWeights)
|
faceWeights
|
||||||
);
|
);
|
||||||
|
|
||||||
const subTriCount = subdivided.attributes.position.count / 3;
|
const subTriCount = subdivided.attributes.position.count / 3;
|
||||||
@@ -761,7 +778,13 @@ async function handleExport() {
|
|||||||
decimate(
|
decimate(
|
||||||
displaced,
|
displaced,
|
||||||
settings.maxTriangles,
|
settings.maxTriangles,
|
||||||
(p) => setProgress(0.71 + p * 0.25, `Decimating mesh…`)
|
(p) => {
|
||||||
|
const cur = Math.round(dispTriCount - (dispTriCount - settings.maxTriangles) * p);
|
||||||
|
setProgress(
|
||||||
|
0.71 + p * 0.25,
|
||||||
|
`Decimating: ${cur.toLocaleString()} → ${settings.maxTriangles.toLocaleString()} triangles`
|
||||||
|
);
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -800,7 +823,9 @@ async function handleExport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setProgress(fraction, label) {
|
function setProgress(fraction, label) {
|
||||||
exportProgBar.style.width = `${Math.round(fraction * 100)}%`;
|
const pct = Math.round(fraction * 100);
|
||||||
|
exportProgBar.style.width = `${pct}%`;
|
||||||
|
exportProgPct.textContent = `${pct}%`;
|
||||||
exportProgLbl.textContent = label;
|
exportProgLbl.textContent = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,26 +27,29 @@ const TWO_PI = Math.PI * 2;
|
|||||||
export function computeUV(pos, normal, mode, settings, bounds) {
|
export function computeUV(pos, normal, mode, settings, bounds) {
|
||||||
const { min, size, center } = bounds;
|
const { min, size, center } = bounds;
|
||||||
const { scaleU, scaleV, offsetU, offsetV } = settings;
|
const { scaleU, scaleV, offsetU, offsetV } = settings;
|
||||||
|
const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const md = Math.max(maxDim, 1e-6);
|
||||||
|
|
||||||
let u = 0, v = 0;
|
let u = 0, v = 0;
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
|
|
||||||
case MODE_PLANAR_XY: {
|
case MODE_PLANAR_XY: {
|
||||||
u = (pos.x - min.x) / Math.max(size.x, 1e-6);
|
u = (pos.x - min.x) / md;
|
||||||
v = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
v = (pos.y - min.y) / md;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case MODE_PLANAR_XZ: {
|
case MODE_PLANAR_XZ: {
|
||||||
u = (pos.x - min.x) / Math.max(size.x, 1e-6);
|
u = (pos.x - min.x) / md;
|
||||||
v = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
v = (pos.z - min.z) / md;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case MODE_PLANAR_YZ: {
|
case MODE_PLANAR_YZ: {
|
||||||
u = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
u = (pos.y - min.y) / md;
|
||||||
v = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
v = (pos.z - min.z) / md;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,23 +96,16 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
const az = Math.abs(normal.z);
|
const az = Math.abs(normal.z);
|
||||||
let uRaw, vRaw;
|
let uRaw, vRaw;
|
||||||
if (ax >= ay && ax >= az) {
|
if (ax >= ay && ax >= az) {
|
||||||
// ±X dominant → project onto ZY (U=Z, V=Y keeps texture upright on side faces)
|
uRaw = (pos.z - min.z) / md;
|
||||||
uRaw = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
vRaw = (pos.y - min.y) / md;
|
||||||
vRaw = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
|
||||||
} else if (ay >= ax && ay >= az) {
|
} else if (ay >= ax && ay >= az) {
|
||||||
// ±Y dominant → project onto XZ
|
uRaw = (pos.x - min.x) / md;
|
||||||
uRaw = (pos.x - min.x) / Math.max(size.x, 1e-6);
|
vRaw = (pos.z - min.z) / md;
|
||||||
vRaw = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
|
||||||
} else {
|
} else {
|
||||||
// ±Z dominant → project onto XY
|
uRaw = (pos.x - min.x) / md;
|
||||||
uRaw = (pos.x - min.x) / Math.max(size.x, 1e-6);
|
vRaw = (pos.y - min.y) / md;
|
||||||
vRaw = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
|
||||||
}
|
}
|
||||||
return {
|
return applyTransform(uRaw, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad);
|
||||||
triplanar: false,
|
|
||||||
u: fract(uRaw / scaleU + offsetU),
|
|
||||||
v: fract(vRaw / scaleV + offsetV),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case MODE_TRIPLANAR:
|
case MODE_TRIPLANAR:
|
||||||
@@ -128,40 +124,47 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
const wz = bz / sum;
|
const wz = bz / sum;
|
||||||
|
|
||||||
const uvXY = {
|
const uvXY = {
|
||||||
u: (pos.x - min.x) / Math.max(size.x, 1e-6),
|
u: (pos.x - min.x) / md,
|
||||||
v: (pos.y - min.y) / Math.max(size.y, 1e-6),
|
v: (pos.y - min.y) / md,
|
||||||
w: wz,
|
w: wz,
|
||||||
};
|
};
|
||||||
const uvXZ = {
|
const uvXZ = {
|
||||||
u: (pos.x - min.x) / Math.max(size.x, 1e-6),
|
u: (pos.x - min.x) / md,
|
||||||
v: (pos.z - min.z) / Math.max(size.z, 1e-6),
|
v: (pos.z - min.z) / md,
|
||||||
w: wy,
|
w: wy,
|
||||||
};
|
};
|
||||||
const uvYZ = {
|
const uvYZ = {
|
||||||
u: (pos.y - min.y) / Math.max(size.y, 1e-6),
|
u: (pos.y - min.y) / md,
|
||||||
v: (pos.z - min.z) / Math.max(size.z, 1e-6),
|
v: (pos.z - min.z) / md,
|
||||||
w: wx,
|
w: wx,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply scale+offset and tile each independently
|
// Apply scale+offset+rotation and tile each independently
|
||||||
// We return a special { triplanar: true, samples } object.
|
|
||||||
// The caller (displacement.js) handles the 3-sample blend itself.
|
|
||||||
return {
|
return {
|
||||||
triplanar: true,
|
triplanar: true,
|
||||||
samples: [
|
samples: [
|
||||||
{ u: fract(uvXY.u / scaleU + offsetU), v: fract(uvXY.v / scaleV + offsetV), w: uvXY.w },
|
{ ...applyTransform(uvXY.u, uvXY.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXY.w },
|
||||||
{ u: fract(uvXZ.u / scaleU + offsetU), v: fract(uvXZ.v / scaleV + offsetV), w: uvXZ.w },
|
{ ...applyTransform(uvXZ.u, uvXZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXZ.w },
|
||||||
{ u: fract(uvYZ.u / scaleU + offsetU), v: fract(uvYZ.v / scaleV + offsetV), w: uvYZ.w },
|
{ ...applyTransform(uvYZ.u, uvYZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvYZ.w },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, rotRad);
|
||||||
triplanar: false,
|
}
|
||||||
u: fract(u / scaleU + offsetU),
|
|
||||||
v: fract(v / scaleV + offsetV),
|
function applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, rotRad) {
|
||||||
};
|
let uu = u / scaleU + offsetU;
|
||||||
|
let vv = v / scaleV + offsetV;
|
||||||
|
if (rotRad !== 0) {
|
||||||
|
const c = Math.cos(rotRad), s = Math.sin(rotRad);
|
||||||
|
uu -= 0.5; vv -= 0.5;
|
||||||
|
const ru = c * uu - s * vv;
|
||||||
|
const rv = s * uu + c * vv;
|
||||||
|
uu = ru + 0.5; vv = rv + 0.5;
|
||||||
|
}
|
||||||
|
return { triplanar: false, u: fract(uu), v: fract(vv) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fractional part, always positive (mirrors GLSL fract) */
|
/** Fractional part, always positive (mirrors GLSL fract) */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
const SIZE = 512; // texture resolution for both preview and sampling
|
const SIZE = 512; // texture resolution for both preview and sampling
|
||||||
|
const THUMB = 80;
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -10,227 +11,47 @@ function makeCanvas(size = SIZE) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
function grayPixel(value255) {
|
// ── Image-based presets ───────────────────────────────────────────────────────
|
||||||
return `rgb(${value255},${value255},${value255})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple seeded LCG pseudo-random number generator (deterministic)
|
const IMAGE_PRESETS = [
|
||||||
function lcg(seed) {
|
{ name: 'Basket', url: 'basket.jpg' },
|
||||||
let s = seed >>> 0;
|
{ name: 'Brick', url: 'brick.jpg' },
|
||||||
return () => {
|
{ name: 'Bubble', url: 'bubble.jpg' },
|
||||||
s = (Math.imul(1664525, s) + 1013904223) >>> 0;
|
{ name: 'Crystal', url: 'crystal.jpg' },
|
||||||
return s / 0xFFFFFFFF;
|
{ name: 'Knitting', url: 'knitting.jpg' },
|
||||||
};
|
{ name: 'Knurling', url: 'knurling.jpg' },
|
||||||
}
|
{ name: 'Leather', url: 'leather.jpg' },
|
||||||
|
{ name: 'Leather 2', url: 'leather2.jpg' },
|
||||||
// ── Generators ───────────────────────────────────────────────────────────────
|
{ name: 'Weave', url: 'weave.jpg' },
|
||||||
|
{ name: 'Wood', url: 'wood.jpg' },
|
||||||
/** Horizontal sine waves */
|
|
||||||
function generateWaves(size = SIZE) {
|
|
||||||
const canvas = makeCanvas(size);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const id = ctx.createImageData(size, size);
|
|
||||||
const d = id.data;
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
const v = Math.sin((y / size) * Math.PI * 10) * 0.5 + 0.5;
|
|
||||||
const g = Math.round(v * 255);
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
const i = (y * size + x) * 4;
|
|
||||||
d[i] = d[i+1] = d[i+2] = g;
|
|
||||||
d[i+3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.putImageData(id, 0, 0);
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fish-scale / overlapping circles */
|
|
||||||
function generateScales(size = SIZE) {
|
|
||||||
const canvas = makeCanvas(size);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.fillStyle = '#000';
|
|
||||||
ctx.fillRect(0, 0, size, size);
|
|
||||||
|
|
||||||
const r = size / 8;
|
|
||||||
const rStroke = r * 0.08;
|
|
||||||
ctx.strokeStyle = '#fff';
|
|
||||||
ctx.lineWidth = rStroke;
|
|
||||||
ctx.fillStyle = '#333';
|
|
||||||
|
|
||||||
const rows = Math.ceil(size / r) + 2;
|
|
||||||
const cols = Math.ceil(size / r) + 2;
|
|
||||||
for (let row = -1; row < rows; row++) {
|
|
||||||
for (let col = -1; col < cols; col++) {
|
|
||||||
const ox = col * r * 1.0 + (row % 2 === 0 ? 0 : r * 0.5);
|
|
||||||
const oy = row * r * 0.75;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(ox, oy, r * 0.92, Math.PI, 0);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hexagonal grid */
|
|
||||||
function generateHex(size = SIZE) {
|
|
||||||
const canvas = makeCanvas(size);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.fillStyle = '#222';
|
|
||||||
ctx.fillRect(0, 0, size, size);
|
|
||||||
|
|
||||||
const r = size / 8;
|
|
||||||
const w = Math.sqrt(3) * r;
|
|
||||||
const h = 2 * r;
|
|
||||||
ctx.strokeStyle = '#fff';
|
|
||||||
ctx.lineWidth = r * 0.12;
|
|
||||||
|
|
||||||
function hexPath(cx, cy) {
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const angle = (Math.PI / 3) * i - Math.PI / 6;
|
|
||||||
const px = cx + r * 0.88 * Math.cos(angle);
|
|
||||||
const py = cy + r * 0.88 * Math.sin(angle);
|
|
||||||
if (i === 0) ctx.moveTo(px, py);
|
|
||||||
else ctx.lineTo(px, py);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
const cols = Math.ceil(size / w) + 2;
|
|
||||||
const rows = Math.ceil(size / (h * 0.75)) + 2;
|
|
||||||
for (let row = -1; row < rows; row++) {
|
|
||||||
for (let col = -1; col < cols; col++) {
|
|
||||||
const cx = col * w + (row % 2 === 0 ? 0 : w / 2);
|
|
||||||
const cy = row * h * 0.75;
|
|
||||||
hexPath(cx, cy);
|
|
||||||
ctx.fillStyle = `hsl(0,0%,${20 + Math.random() * 10}%)`;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Diamond / crosshatch */
|
|
||||||
function generateDiamonds(size = SIZE) {
|
|
||||||
const canvas = makeCanvas(size);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const id = ctx.createImageData(size, size);
|
|
||||||
const d = id.data;
|
|
||||||
const freq = 8;
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
const u = x / size;
|
|
||||||
const v = y / size;
|
|
||||||
const val = (Math.abs(Math.sin(u * Math.PI * freq)) +
|
|
||||||
Math.abs(Math.sin(v * Math.PI * freq))) / 2;
|
|
||||||
const g = Math.round(val * 255);
|
|
||||||
const i = (y * size + x) * 4;
|
|
||||||
d[i] = d[i+1] = d[i+2] = g;
|
|
||||||
d[i+3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.putImageData(id, 0, 0);
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Smooth noise (value noise via bilinear interpolation of random grid) */
|
|
||||||
function generateNoise(size = SIZE) {
|
|
||||||
const canvas = makeCanvas(size);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const id = ctx.createImageData(size, size);
|
|
||||||
const d = id.data;
|
|
||||||
const rand = lcg(0xdeadbeef);
|
|
||||||
|
|
||||||
// Generate random value grid at coarser resolution
|
|
||||||
const GRID = 16;
|
|
||||||
const grid = new Float32Array((GRID + 1) * (GRID + 1));
|
|
||||||
for (let i = 0; i < grid.length; i++) grid[i] = rand();
|
|
||||||
|
|
||||||
function bilerp(gx, gy) {
|
|
||||||
const x0 = Math.floor(gx) % GRID;
|
|
||||||
const y0 = Math.floor(gy) % GRID;
|
|
||||||
const x1 = (x0 + 1) % GRID;
|
|
||||||
const y1 = (y0 + 1) % GRID;
|
|
||||||
const fx = gx - Math.floor(gx);
|
|
||||||
const fy = gy - Math.floor(gy);
|
|
||||||
// Smoothstep
|
|
||||||
const sx = fx * fx * (3 - 2 * fx);
|
|
||||||
const sy = fy * fy * (3 - 2 * fy);
|
|
||||||
const v00 = grid[y0 * (GRID+1) + x0];
|
|
||||||
const v10 = grid[y0 * (GRID+1) + x1];
|
|
||||||
const v01 = grid[y1 * (GRID+1) + x0];
|
|
||||||
const v11 = grid[y1 * (GRID+1) + x1];
|
|
||||||
return v00 + sx * (v10 - v00) + sy * (v01 - v00) + sx * sy * (v00 - v10 - v01 + v11);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
const gx = (x / size) * GRID;
|
|
||||||
const gy = (y / size) * GRID;
|
|
||||||
// Octave 1 + octave 2
|
|
||||||
let v = bilerp(gx, gy) * 0.65 + bilerp(gx * 2, gy * 2) * 0.25 + bilerp(gx * 4, gy * 4) * 0.10;
|
|
||||||
const g = Math.round(Math.max(0, Math.min(1, v)) * 255);
|
|
||||||
const i4 = (y * size + x) * 4;
|
|
||||||
d[i4] = d[i4+1] = d[i4+2] = g;
|
|
||||||
d[i4+3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.putImageData(id, 0, 0);
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Brick pattern */
|
|
||||||
function generateBrick(size = SIZE) {
|
|
||||||
const canvas = makeCanvas(size);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.fillStyle = '#555';
|
|
||||||
ctx.fillRect(0, 0, size, size);
|
|
||||||
|
|
||||||
const bw = size / 5; // brick width
|
|
||||||
const bh = size / 10; // brick height
|
|
||||||
const mortar = bw * 0.07;
|
|
||||||
|
|
||||||
ctx.fillStyle = '#ddd';
|
|
||||||
const rows = Math.ceil(size / bh) + 1;
|
|
||||||
const cols = Math.ceil(size / bw) + 2;
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const offset = (row % 2 === 0 ? 0 : bw * 0.5);
|
|
||||||
for (let col = -1; col < cols; col++) {
|
|
||||||
const x = col * bw + offset + mortar / 2;
|
|
||||||
const y = row * bh + mortar / 2;
|
|
||||||
ctx.fillRect(x, y, bw - mortar, bh - mortar);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Build PRESETS array ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const GENERATORS = [
|
|
||||||
{ name: 'Waves', gen: generateWaves },
|
|
||||||
{ name: 'Scales', gen: generateScales },
|
|
||||||
{ name: 'Hexagonal', gen: generateHex },
|
|
||||||
{ name: 'Diamonds', gen: generateDiamonds },
|
|
||||||
{ name: 'Noise', gen: generateNoise },
|
|
||||||
{ name: 'Brick', gen: generateBrick },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PRESETS = GENERATORS.map(({ name, gen }) => {
|
function loadImagePreset({ name, url }) {
|
||||||
const fullCanvas = gen(SIZE);
|
return new Promise((resolve, reject) => {
|
||||||
const thumbCanvas = gen(80); // small canvas for swatch UI
|
const img = new Image();
|
||||||
const texture = new THREE.CanvasTexture(fullCanvas);
|
img.onload = () => {
|
||||||
|
const full = makeCanvas(SIZE);
|
||||||
|
full.getContext('2d').drawImage(img, 0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
const thumb = makeCanvas(THUMB);
|
||||||
|
thumb.getContext('2d').drawImage(img, 0, 0, THUMB, THUMB);
|
||||||
|
|
||||||
|
const imageData = full.getContext('2d').getImageData(0, 0, SIZE, SIZE);
|
||||||
|
const texture = new THREE.CanvasTexture(full);
|
||||||
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
||||||
texture.name = name;
|
texture.name = name;
|
||||||
|
|
||||||
// Extract ImageData for CPU sampling
|
resolve({ name, thumbCanvas: thumb, fullCanvas: full, texture, imageData, width: SIZE, height: SIZE });
|
||||||
const ctx = fullCanvas.getContext('2d');
|
};
|
||||||
const imageData = ctx.getImageData(0, 0, SIZE, SIZE);
|
img.onerror = () => reject(new Error(`Failed to load preset image: ${url}`));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPresets() {
|
||||||
|
return Promise.all(IMAGE_PRESETS.map(loadImagePreset));
|
||||||
|
}
|
||||||
|
|
||||||
return { name, thumbCanvas, fullCanvas, texture, imageData, width: SIZE, height: SIZE };
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a THREE.CanvasTexture + ImageData from a user-uploaded image File.
|
* Build a THREE.CanvasTexture + ImageData from a user-uploaded image File.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const fragmentShader = /* glsl */`
|
|||||||
uniform vec2 scaleUV;
|
uniform vec2 scaleUV;
|
||||||
uniform float amplitude;
|
uniform float amplitude;
|
||||||
uniform vec2 offsetUV;
|
uniform vec2 offsetUV;
|
||||||
|
uniform float rotation;
|
||||||
uniform vec3 boundsMin;
|
uniform vec3 boundsMin;
|
||||||
uniform vec3 boundsSize;
|
uniform vec3 boundsSize;
|
||||||
uniform vec3 boundsCenter;
|
uniform vec3 boundsCenter;
|
||||||
@@ -59,7 +60,13 @@ const fragmentShader = /* glsl */`
|
|||||||
|
|
||||||
// Sample after applying scale + tiling
|
// Sample after applying scale + tiling
|
||||||
float sampleMap(vec2 rawUV) {
|
float sampleMap(vec2 rawUV) {
|
||||||
return texture2D(displacementMap, fract(rawUV / scaleUV + offsetUV)).r;
|
vec2 uv = rawUV / scaleUV + offsetUV;
|
||||||
|
// rotate around tile centre
|
||||||
|
float c = cos(rotation); float s = sin(rotation);
|
||||||
|
uv -= 0.5;
|
||||||
|
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
|
||||||
|
uv += 0.5;
|
||||||
|
return texture2D(displacementMap, fract(uv)).r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Height at this fragment for all projection modes.
|
// Height at this fragment for all projection modes.
|
||||||
@@ -68,15 +75,17 @@ const fragmentShader = /* glsl */`
|
|||||||
vec3 pos = vModelPos;
|
vec3 pos = vModelPos;
|
||||||
vec3 MN = vModelNormal; // model-space normal
|
vec3 MN = vModelNormal; // model-space normal
|
||||||
vec3 rel = pos - boundsCenter;
|
vec3 rel = pos - boundsCenter;
|
||||||
|
float maxDim = max(boundsSize.x, max(boundsSize.y, boundsSize.z));
|
||||||
|
float md = max(maxDim, 1e-4);
|
||||||
|
|
||||||
if (mappingMode == 0) {
|
if (mappingMode == 0) {
|
||||||
return sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4)));
|
return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md));
|
||||||
|
|
||||||
} else if (mappingMode == 1) {
|
} else if (mappingMode == 1) {
|
||||||
return sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4)));
|
return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md));
|
||||||
|
|
||||||
} else if (mappingMode == 2) {
|
} else if (mappingMode == 2) {
|
||||||
return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
return sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md));
|
||||||
|
|
||||||
} else if (mappingMode == 3) {
|
} else if (mappingMode == 3) {
|
||||||
// Cylindrical around Z axis (Z is up) with automatic caps.
|
// Cylindrical around Z axis (Z is up) with automatic caps.
|
||||||
@@ -114,9 +123,9 @@ const fragmentShader = /* glsl */`
|
|||||||
blend = pow(blend, vec3(4.0));
|
blend = pow(blend, vec3(4.0));
|
||||||
blend /= dot(blend, vec3(1.0)) + 1e-4;
|
blend /= dot(blend, vec3(1.0)) + 1e-4;
|
||||||
|
|
||||||
float hXY = sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4)));
|
float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md));
|
||||||
float hXZ = sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4)));
|
float hXZ = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md));
|
||||||
float hYZ = sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
float hYZ = sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md));
|
||||||
|
|
||||||
return hXY * blend.z + hXZ * blend.y + hYZ * blend.x;
|
return hXY * blend.z + hXZ * blend.y + hYZ * blend.x;
|
||||||
|
|
||||||
@@ -125,14 +134,11 @@ const fragmentShader = /* glsl */`
|
|||||||
// Picks the single planar projection whose axis is most aligned with the face normal.
|
// Picks the single planar projection whose axis is most aligned with the face normal.
|
||||||
vec3 absN = abs(MN);
|
vec3 absN = abs(MN);
|
||||||
if (absN.x >= absN.y && absN.x >= absN.z) {
|
if (absN.x >= absN.y && absN.x >= absN.z) {
|
||||||
// ±X dominant → project onto ZY plane (U=Z, V=Y keeps texture upright on side faces)
|
return sampleMap(vec2((pos.z - boundsMin.z) / md, (pos.y - boundsMin.y) / md));
|
||||||
return sampleMap((pos.zy - boundsMin.zy) / max(boundsSize.zy, vec2(1e-4)));
|
|
||||||
} else if (absN.y >= absN.x && absN.y >= absN.z) {
|
} else if (absN.y >= absN.x && absN.y >= absN.z) {
|
||||||
// ±Y dominant → project onto XZ plane
|
return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md));
|
||||||
return sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4)));
|
|
||||||
} else {
|
} else {
|
||||||
// ±Z dominant → project onto XY plane
|
return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md));
|
||||||
return sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +235,7 @@ export function updateMaterial(material, displacementTexture, settings) {
|
|||||||
u.scaleUV.value.set(settings.scaleU, settings.scaleV);
|
u.scaleUV.value.set(settings.scaleU, settings.scaleV);
|
||||||
u.amplitude.value = settings.amplitude;
|
u.amplitude.value = settings.amplitude;
|
||||||
u.offsetUV.value.set(settings.offsetU, settings.offsetV);
|
u.offsetUV.value.set(settings.offsetU, settings.offsetV);
|
||||||
|
u.rotation.value = (settings.rotation ?? 0) * Math.PI / 180;
|
||||||
if (settings.bounds) {
|
if (settings.bounds) {
|
||||||
u.boundsMin.value.copy(settings.bounds.min);
|
u.boundsMin.value.copy(settings.bounds.min);
|
||||||
u.boundsSize.value.copy(settings.bounds.size);
|
u.boundsSize.value.copy(settings.bounds.size);
|
||||||
@@ -252,6 +259,7 @@ function buildUniforms(tex, settings) {
|
|||||||
scaleUV: { value: new THREE.Vector2(settings.scaleU ?? 1, settings.scaleV ?? 1) },
|
scaleUV: { value: new THREE.Vector2(settings.scaleU ?? 1, settings.scaleV ?? 1) },
|
||||||
amplitude: { value: settings.amplitude ?? 1.0 },
|
amplitude: { value: settings.amplitude ?? 1.0 },
|
||||||
offsetUV: { value: new THREE.Vector2(settings.offsetU ?? 0, settings.offsetV ?? 0) },
|
offsetUV: { value: new THREE.Vector2(settings.offsetU ?? 0, settings.offsetV ?? 0) },
|
||||||
|
rotation: { value: ((settings.rotation ?? 0) * Math.PI / 180) },
|
||||||
boundsMin: { value: b.min.clone() },
|
boundsMin: { value: b.min.clone() },
|
||||||
boundsSize: { value: b.size.clone() },
|
boundsSize: { value: b.size.clone() },
|
||||||
boundsCenter: { value: b.center.clone() },
|
boundsCenter: { value: b.center.clone() },
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const SAFETY_CAP = 5_000_000; // absolute OOM guard
|
|||||||
|
|
||||||
// ── Public entry point ───────────────────────────────────────────────────────
|
// ── Public entry point ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) {
|
export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) {
|
||||||
// Derive per-face exclusion BEFORE toIndexed so we use the untouched
|
// Derive per-face exclusion BEFORE toIndexed so we use the untouched
|
||||||
// non-indexed weights (toIndexed uses MAX-merge which can push boundary
|
// non-indexed weights (toIndexed uses MAX-merge which can push boundary
|
||||||
// vertices to weight 1.0 even on included triangles).
|
// vertices to weight 1.0 even on included triangles).
|
||||||
@@ -56,6 +56,7 @@ export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = nul
|
|||||||
if (newIndices.length / 3 >= SAFETY_CAP) safetyCapHit = true;
|
if (newIndices.length / 3 >= SAFETY_CAP) safetyCapHit = true;
|
||||||
|
|
||||||
if (onProgress) onProgress(Math.min(0.95, (iter + 1) / maxIterations));
|
if (onProgress) onProgress(Math.min(0.95, (iter + 1) / maxIterations));
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
if (!changed || safetyCapHit) break;
|
if (!changed || safetyCapHit) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -198,14 +198,14 @@ main {
|
|||||||
/* ── Preset grid ─────────────────────────────────────────────────────── */
|
/* ── Preset grid ─────────────────────────────────────────────────────── */
|
||||||
.preset-grid {
|
.preset-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-swatch {
|
.preset-swatch {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 5px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
@@ -230,9 +230,9 @@ main {
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: rgba(0,0,0,0.6);
|
background: rgba(0,0,0,0.6);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 9px;
|
font-size: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2px 0;
|
padding: 1px 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
@@ -377,13 +377,34 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); }
|
|||||||
|
|
||||||
.export-progress.hidden { display: none; }
|
.export-progress.hidden { display: none; }
|
||||||
|
|
||||||
|
.export-progress-track {
|
||||||
|
position: relative;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.export-progress-bar {
|
.export-progress-bar {
|
||||||
height: 3px;
|
height: 100%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
width: 0%;
|
width: 0%;
|
||||||
transition: width 0.1s;
|
}
|
||||||
margin-bottom: 6px;
|
|
||||||
|
.export-progress-pct {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
mix-blend-mode: difference;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Export button ───────────────────────────────────────────────────── */
|
/* ── Export button ───────────────────────────────────────────────────── */
|
||||||
|
|||||||