mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
initial commit
This commit is contained in:
+166
@@ -0,0 +1,166 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>STL Texturizer</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="#7c6aff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 17L12 22L22 17" stroke="#7c6aff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 12L12 17L22 12" stroke="#a08cff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>STL Texturizer</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-note">Units assumed to be <strong>mm</strong></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- ─── 3-D Viewport ─────────────────────────────────────────── -->
|
||||||
|
<section id="viewport-section">
|
||||||
|
<div id="drop-zone" class="drop-zone">
|
||||||
|
<div id="drop-hint" class="drop-hint">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="#555" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 17L12 22L22 17" stroke="#555" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 12L12 17L22 12" stroke="#555" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<p>Drop an <strong>.stl</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" hidden />
|
||||||
|
</div>
|
||||||
|
<canvas id="viewport"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="viewport-footer">
|
||||||
|
<span id="mesh-info" class="mesh-info"></span>
|
||||||
|
<div class="viewport-controls-hint">Left drag: orbit · Right drag: pan · Scroll: zoom</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── Settings Panel ───────────────────────────────────────── -->
|
||||||
|
<aside id="settings-panel">
|
||||||
|
|
||||||
|
<!-- Displacement Map -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<h2>Displacement Map</h2>
|
||||||
|
|
||||||
|
<!-- Preset grid -->
|
||||||
|
<div id="preset-grid" class="preset-grid">
|
||||||
|
<!-- filled by js/presetTextures.js -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom upload -->
|
||||||
|
<label class="upload-btn" for="texture-file-input">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
Upload custom map
|
||||||
|
</label>
|
||||||
|
<input type="file" id="texture-file-input" accept="image/*" hidden />
|
||||||
|
<div id="active-map-name" class="active-map-name">No map selected</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Projection -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<h2>Projection</h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="mapping-mode">Mode</label>
|
||||||
|
<select id="mapping-mode">
|
||||||
|
<option value="0">Planar XY</option>
|
||||||
|
<option value="1">Planar XZ</option>
|
||||||
|
<option value="2">Planar YZ</option>
|
||||||
|
<option value="3">Cylindrical</option>
|
||||||
|
<option value="4">Spherical</option>
|
||||||
|
<option value="5" selected>Triplanar</option>
|
||||||
|
<option value="6">Cubic (Box)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Transform -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<h2>Transform</h2>
|
||||||
|
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="scale-u">Scale U</label>
|
||||||
|
<input type="range" id="scale-u" min="0.1" max="10" step="0.05" value="1" />
|
||||||
|
<span class="val" id="scale-u-val">1.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="lock-row">
|
||||||
|
<div class="lock-line"></div>
|
||||||
|
<button id="lock-scale" class="lock-btn active" title="Proportional scaling (U = V)" aria-pressed="true">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="lock-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="scale-v">Scale V</label>
|
||||||
|
<input type="range" id="scale-v" min="0.1" max="10" step="0.05" value="1" />
|
||||||
|
<span class="val" id="scale-v-val">1.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="offset-u">Offset U</label>
|
||||||
|
<input type="range" id="offset-u" min="-1" max="1" step="0.01" value="0" />
|
||||||
|
<span class="val" id="offset-u-val">0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="offset-v">Offset V</label>
|
||||||
|
<input type="range" id="offset-v" min="-1" max="1" step="0.01" value="0" />
|
||||||
|
<span class="val" id="offset-v-val">0.00</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Displacement -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<h2>Displacement</h2>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="amplitude">Amplitude</label>
|
||||||
|
<input type="range" id="amplitude" min="-1" max="1" step="0.01" value="0.5" />
|
||||||
|
<span class="val" id="amplitude-val">0.5 mm</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Export -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<h2>Export</h2>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="refine-length" title="Edges longer than this value will be split during export">Max Edge Length</label>
|
||||||
|
<input type="range" id="refine-length" min="0.1" max="5" step="0.1" value="1" />
|
||||||
|
<span class="val" id="refine-length-val">1.0 mm</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="max-triangles" title="Subdivision stops when this triangle count is reached">Max Triangles</label>
|
||||||
|
<input type="range" id="max-triangles" min="100000" max="5000000" step="100000" value="1000000" />
|
||||||
|
<span class="val" id="max-triangles-val">1.0 M</span>
|
||||||
|
</div>
|
||||||
|
<div id="tri-limit-warning" class="tri-limit-warning hidden">
|
||||||
|
⚠ Triangle limit reached — some edges were not subdivided further.
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Smaller edge length = finer geometry. Does not affect preview.
|
||||||
|
</p>
|
||||||
|
<div id="export-progress" class="export-progress hidden">
|
||||||
|
<div id="export-progress-bar" class="export-progress-bar"></div>
|
||||||
|
<span id="export-progress-label">Processing…</span>
|
||||||
|
</div>
|
||||||
|
<button id="export-btn" class="export-btn" disabled>Export STL</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { computeUV } from './mapping.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply displacement to every vertex of a non-indexed BufferGeometry.
|
||||||
|
*
|
||||||
|
* For each vertex:
|
||||||
|
* 1. Compute UV with the same math used in the GLSL preview shader (mapping.js).
|
||||||
|
* 2. Bilinear-sample the greyscale ImageData at that UV.
|
||||||
|
* 3. Move the vertex along its normal by: grey * amplitude
|
||||||
|
*
|
||||||
|
* @param {THREE.BufferGeometry} geometry – non-indexed (from subdivide())
|
||||||
|
* @param {ImageData} imageData – raw pixel data from Canvas2D
|
||||||
|
* @param {number} imgWidth
|
||||||
|
* @param {number} imgHeight
|
||||||
|
* @param {object} settings – { mappingMode, scaleU, scaleV, amplitude, offsetU, offsetV }
|
||||||
|
* @param {object} bounds – { min, max, center, size } (THREE.Vector3)
|
||||||
|
* @param {function} [onProgress]
|
||||||
|
* @returns {THREE.BufferGeometry} new non-indexed geometry with displaced positions
|
||||||
|
*/
|
||||||
|
export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, settings, bounds, onProgress) {
|
||||||
|
const posAttr = geometry.attributes.position;
|
||||||
|
const nrmAttr = geometry.attributes.normal;
|
||||||
|
const count = posAttr.count;
|
||||||
|
|
||||||
|
const newPos = new Float32Array(count * 3);
|
||||||
|
const newNrm = new Float32Array(count * 3);
|
||||||
|
|
||||||
|
const tmpPos = new THREE.Vector3();
|
||||||
|
const tmpNrm = new THREE.Vector3();
|
||||||
|
|
||||||
|
const REPORT_EVERY = 5000;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
tmpPos.fromBufferAttribute(posAttr, i);
|
||||||
|
tmpNrm.fromBufferAttribute(nrmAttr, i);
|
||||||
|
|
||||||
|
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds);
|
||||||
|
|
||||||
|
let grey;
|
||||||
|
if (uvResult.triplanar) {
|
||||||
|
// Weighted blend of three samples
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disp = grey * settings.amplitude;
|
||||||
|
|
||||||
|
newPos[i*3] = tmpPos.x + tmpNrm.x * disp;
|
||||||
|
newPos[i*3+1] = tmpPos.y + tmpNrm.y * disp;
|
||||||
|
newPos[i*3+2] = tmpPos.z + tmpNrm.z * disp;
|
||||||
|
|
||||||
|
newNrm[i*3] = tmpNrm.x;
|
||||||
|
newNrm[i*3+1] = tmpNrm.y;
|
||||||
|
newNrm[i*3+2] = tmpNrm.z;
|
||||||
|
|
||||||
|
if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new THREE.BufferGeometry();
|
||||||
|
out.setAttribute('position', new THREE.BufferAttribute(newPos, 3));
|
||||||
|
out.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3));
|
||||||
|
// Recompute face normals for correct lighting in exported STL
|
||||||
|
out.computeVertexNormals();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bilinear sampler ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample a greyscale value (0–1) from raw RGBA ImageData using
|
||||||
|
* bilinear interpolation. UV is tiled via mod 1.
|
||||||
|
*/
|
||||||
|
function sampleBilinear(data, w, h, u, v) {
|
||||||
|
// Ensure [0,1) — guard against floating-point edge cases
|
||||||
|
u = ((u % 1) + 1) % 1;
|
||||||
|
v = ((v % 1) + 1) % 1;
|
||||||
|
|
||||||
|
const fx = u * (w - 1);
|
||||||
|
const fy = v * (h - 1);
|
||||||
|
const x0 = Math.floor(fx);
|
||||||
|
const y0 = Math.floor(fy);
|
||||||
|
const x1 = Math.min(x0 + 1, w - 1);
|
||||||
|
const y1 = Math.min(y0 + 1, h - 1);
|
||||||
|
const tx = fx - x0;
|
||||||
|
const ty = fy - y0;
|
||||||
|
|
||||||
|
// Red channel — image is greyscale so R == G == B
|
||||||
|
const v00 = data[(y0 * w + x0) * 4] / 255;
|
||||||
|
const v10 = data[(y0 * w + x1) * 4] / 255;
|
||||||
|
const v01 = data[(y1 * w + x0) * 4] / 255;
|
||||||
|
const v11 = data[(y1 * w + x1) * 4] / 255;
|
||||||
|
|
||||||
|
return v00 * (1-tx) * (1-ty)
|
||||||
|
+ v10 * tx * (1-ty)
|
||||||
|
+ v01 * (1-tx) * ty
|
||||||
|
+ v11 * tx * ty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* @param {THREE.BufferGeometry} geometry
|
||||||
|
* @param {string} [filename]
|
||||||
|
*/
|
||||||
|
export function exportSTL(geometry, filename = 'textured.stl') {
|
||||||
|
// The geometry was rotated -90° around X on load to convert Z-up → Y-up for the viewer.
|
||||||
|
// Undo that rotation before export so the STL lands back in the original Z-up orientation
|
||||||
|
// that 3D-print slicers expect.
|
||||||
|
const exportGeom = geometry.clone();
|
||||||
|
exportGeom.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(exportGeom, new THREE.MeshBasicMaterial());
|
||||||
|
const result = exporter.parse(mesh, { binary: true });
|
||||||
|
exportGeom.dispose();
|
||||||
|
|
||||||
|
// result is an ArrayBuffer in binary mode
|
||||||
|
const blob = new Blob([result], { type: 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
+366
@@ -0,0 +1,366 @@
|
|||||||
|
import { initViewer, loadGeometry, setMeshMaterial } from './viewer.js';
|
||||||
|
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
|
||||||
|
import { PRESETS, loadCustomTexture } from './presetTextures.js';
|
||||||
|
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
|
||||||
|
import { subdivide } from './subdivision.js';
|
||||||
|
import { applyDisplacement } from './displacement.js';
|
||||||
|
import { exportSTL } from './exporter.js';
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let currentGeometry = null; // original loaded geometry
|
||||||
|
let currentBounds = null; // bounds of the original geometry
|
||||||
|
let activeMapEntry = null; // { name, texture, imageData, width, height }
|
||||||
|
let previewMaterial = null;
|
||||||
|
let isExporting = false;
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
mappingMode: 5, // Triplanar default — covers all faces of any shape
|
||||||
|
scaleU: 1.0,
|
||||||
|
scaleV: 1.0,
|
||||||
|
amplitude: 0.5,
|
||||||
|
offsetU: 0.0,
|
||||||
|
offsetV: 0.0,
|
||||||
|
refineLength: 1.0,
|
||||||
|
maxTriangles: 1_000_000,
|
||||||
|
lockScale: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canvas = document.getElementById('viewport');
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const dropHint = document.getElementById('drop-hint');
|
||||||
|
const stlFileInput = document.getElementById('stl-file-input');
|
||||||
|
const textureInput = document.getElementById('texture-file-input');
|
||||||
|
const presetGrid = document.getElementById('preset-grid');
|
||||||
|
const activeMapName = document.getElementById('active-map-name');
|
||||||
|
const meshInfo = document.getElementById('mesh-info');
|
||||||
|
const exportBtn = document.getElementById('export-btn');
|
||||||
|
const exportProgress = document.getElementById('export-progress');
|
||||||
|
const exportProgBar = document.getElementById('export-progress-bar');
|
||||||
|
const exportProgLbl = document.getElementById('export-progress-label');
|
||||||
|
const triLimitWarning = document.getElementById('tri-limit-warning');
|
||||||
|
|
||||||
|
const mappingSelect = document.getElementById('mapping-mode');
|
||||||
|
const scaleUSlider = document.getElementById('scale-u');
|
||||||
|
const scaleVSlider = document.getElementById('scale-v');
|
||||||
|
const lockScaleBtn = document.getElementById('lock-scale');
|
||||||
|
const offsetUSlider = document.getElementById('offset-u');
|
||||||
|
const offsetVSlider = document.getElementById('offset-v');
|
||||||
|
const amplitudeSlider = document.getElementById('amplitude');
|
||||||
|
const refineLenSlider = document.getElementById('refine-length');
|
||||||
|
const maxTriSlider = document.getElementById('max-triangles');
|
||||||
|
|
||||||
|
const scaleUVal = document.getElementById('scale-u-val');
|
||||||
|
const scaleVVal = document.getElementById('scale-v-val');
|
||||||
|
const offsetUVal = document.getElementById('offset-u-val');
|
||||||
|
const offsetVVal = document.getElementById('offset-v-val');
|
||||||
|
const amplitudeVal = document.getElementById('amplitude-val');
|
||||||
|
const refineLenVal = document.getElementById('refine-length-val');
|
||||||
|
const maxTriVal = document.getElementById('max-triangles-val');
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
initViewer(canvas);
|
||||||
|
buildPresetGrid();
|
||||||
|
wireEvents();
|
||||||
|
|
||||||
|
// ── Preset grid ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildPresetGrid() {
|
||||||
|
PRESETS.forEach((preset, idx) => {
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.className = 'preset-swatch';
|
||||||
|
swatch.title = preset.name;
|
||||||
|
|
||||||
|
// Use the small thumbnail canvas
|
||||||
|
swatch.appendChild(preset.thumbCanvas);
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'preset-label';
|
||||||
|
label.textContent = preset.name;
|
||||||
|
swatch.appendChild(label);
|
||||||
|
|
||||||
|
swatch.addEventListener('click', () => selectPreset(idx, swatch));
|
||||||
|
presetGrid.appendChild(swatch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreset(idx, swatchEl) {
|
||||||
|
document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active'));
|
||||||
|
swatchEl.classList.add('active');
|
||||||
|
activeMapEntry = PRESETS[idx];
|
||||||
|
activeMapName.textContent = PRESETS[idx].name;
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function wireEvents() {
|
||||||
|
// ── STL loading ──
|
||||||
|
stlFileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files[0]) handleSTL(e.target.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag & drop on the viewport section
|
||||||
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('drag-over');
|
||||||
|
const file = [...e.dataTransfer.files].find(f => f.name.toLowerCase().endsWith('.stl'));
|
||||||
|
if (file) handleSTL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow clicking the drop zone to open the file picker (except on canvas)
|
||||||
|
dropZone.addEventListener('click', (e) => {
|
||||||
|
if (e.target === dropZone) stlFileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Custom texture upload ──
|
||||||
|
textureInput.addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
activeMapEntry = await loadCustomTexture(file);
|
||||||
|
activeMapName.textContent = file.name;
|
||||||
|
document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active'));
|
||||||
|
updatePreview();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load texture:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Settings ──
|
||||||
|
mappingSelect.addEventListener('change', () => {
|
||||||
|
settings.mappingMode = parseInt(mappingSelect.value, 10);
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scale U — when lock is on, mirror to V
|
||||||
|
scaleUSlider.addEventListener('input', () => {
|
||||||
|
const v = parseFloat(scaleUSlider.value);
|
||||||
|
settings.scaleU = v;
|
||||||
|
scaleUVal.textContent = v.toFixed(2);
|
||||||
|
if (settings.lockScale) {
|
||||||
|
settings.scaleV = v;
|
||||||
|
scaleVSlider.value = v;
|
||||||
|
scaleVVal.textContent = v.toFixed(2);
|
||||||
|
}
|
||||||
|
clearTimeout(previewDebounce);
|
||||||
|
previewDebounce = setTimeout(updatePreview, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scale V — when lock is on, mirror to U
|
||||||
|
scaleVSlider.addEventListener('input', () => {
|
||||||
|
const v = parseFloat(scaleVSlider.value);
|
||||||
|
settings.scaleV = v;
|
||||||
|
scaleVVal.textContent = v.toFixed(2);
|
||||||
|
if (settings.lockScale) {
|
||||||
|
settings.scaleU = v;
|
||||||
|
scaleUSlider.value = v;
|
||||||
|
scaleUVal.textContent = v.toFixed(2);
|
||||||
|
}
|
||||||
|
clearTimeout(previewDebounce);
|
||||||
|
previewDebounce = setTimeout(updatePreview, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lock toggle
|
||||||
|
lockScaleBtn.addEventListener('click', () => {
|
||||||
|
settings.lockScale = !settings.lockScale;
|
||||||
|
lockScaleBtn.classList.toggle('active', settings.lockScale);
|
||||||
|
lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale));
|
||||||
|
// When locking, snap V to current U
|
||||||
|
if (settings.lockScale) {
|
||||||
|
settings.scaleV = settings.scaleU;
|
||||||
|
scaleVSlider.value = settings.scaleU;
|
||||||
|
scaleVVal.textContent = settings.scaleU.toFixed(2);
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); });
|
||||||
|
linkSlider(offsetVSlider, offsetVVal, v => { settings.offsetV = v; return v.toFixed(2); });
|
||||||
|
linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; return `${v.toFixed(2)} mm`; });
|
||||||
|
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return `${v.toFixed(1)} mm`; }, false);
|
||||||
|
linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false);
|
||||||
|
|
||||||
|
// ── Export ──
|
||||||
|
exportBtn.addEventListener('click', handleExport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slider helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let previewDebounce = null;
|
||||||
|
|
||||||
|
function linkSlider(slider, valEl, onChangeFn, livePreview = true) {
|
||||||
|
slider.addEventListener('input', () => {
|
||||||
|
const v = parseFloat(slider.value);
|
||||||
|
valEl.textContent = onChangeFn(v);
|
||||||
|
if (livePreview) {
|
||||||
|
clearTimeout(previewDebounce);
|
||||||
|
previewDebounce = setTimeout(updatePreview, 80);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatM(n) {
|
||||||
|
return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} M`
|
||||||
|
: n >= 1_000 ? `${(n / 1_000).toFixed(0)} k`
|
||||||
|
: String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── STL loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleSTL(file) {
|
||||||
|
try {
|
||||||
|
const { geometry, bounds } = await loadSTLFile(file);
|
||||||
|
currentGeometry = geometry;
|
||||||
|
currentBounds = bounds;
|
||||||
|
|
||||||
|
// Dispose old preview material and reset state for the new mesh
|
||||||
|
if (previewMaterial) {
|
||||||
|
previewMaterial.dispose();
|
||||||
|
previewMaterial = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show mesh with a default material until a map is selected
|
||||||
|
loadGeometry(geometry);
|
||||||
|
dropHint.classList.add('hidden');
|
||||||
|
|
||||||
|
// Reset scale & offset sliders so scale=1 = one tile covers the full bounding box
|
||||||
|
const resetVal = (slider, valEl, value, fmt) => {
|
||||||
|
slider.value = value;
|
||||||
|
valEl.textContent = fmt(value);
|
||||||
|
};
|
||||||
|
settings.scaleU = 1; resetVal(scaleUSlider, scaleUVal, 1, v => v.toFixed(2));
|
||||||
|
settings.scaleV = 1; resetVal(scaleVSlider, scaleVVal, 1, v => v.toFixed(2));
|
||||||
|
settings.offsetU = 0; resetVal(offsetUSlider, offsetUVal, 0, v => v.toFixed(2));
|
||||||
|
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0, v => v.toFixed(2));
|
||||||
|
triLimitWarning.classList.add('hidden');
|
||||||
|
|
||||||
|
// 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 defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 100).toFixed(2)));
|
||||||
|
settings.refineLength = defaultEdge;
|
||||||
|
refineLenSlider.value = defaultEdge;
|
||||||
|
refineLenVal.textContent = `${defaultEdge.toFixed(2)} mm`;
|
||||||
|
|
||||||
|
const triCount = getTriangleCount(geometry);
|
||||||
|
const mb = ((geometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
|
||||||
|
meshInfo.textContent = `${triCount.toLocaleString()} triangles · ${mb} MB`;
|
||||||
|
|
||||||
|
exportBtn.disabled = (activeMapEntry === null);
|
||||||
|
updatePreview();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load STL:', err);
|
||||||
|
alert(`Could not load STL: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live preview ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
if (!currentGeometry || !currentBounds) return;
|
||||||
|
|
||||||
|
const fullSettings = { ...settings, bounds: currentBounds };
|
||||||
|
|
||||||
|
if (!activeMapEntry) {
|
||||||
|
// No map yet — plain material
|
||||||
|
if (previewMaterial) {
|
||||||
|
setMeshMaterial(null);
|
||||||
|
previewMaterial.dispose();
|
||||||
|
previewMaterial = null;
|
||||||
|
}
|
||||||
|
exportBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewMaterial) {
|
||||||
|
previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings);
|
||||||
|
loadGeometry(currentGeometry, previewMaterial);
|
||||||
|
} else {
|
||||||
|
updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export pipeline ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
if (!currentGeometry || !activeMapEntry || isExporting) return;
|
||||||
|
isExporting = true;
|
||||||
|
exportBtn.classList.add('busy');
|
||||||
|
exportProgress.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProgress(0.02, 'Subdividing mesh…');
|
||||||
|
|
||||||
|
// Run subdivision synchronously (may take a few seconds on large meshes)
|
||||||
|
// Wrap in a small yielding loop to allow the browser to repaint the progress bar.
|
||||||
|
const { geometry: subdivided, limitReached } = await runAsync(() =>
|
||||||
|
subdivide(currentGeometry, settings.refineLength, settings.maxTriangles,
|
||||||
|
(p) => setProgress(p * 0.6, 'Subdividing mesh…'))
|
||||||
|
);
|
||||||
|
|
||||||
|
triLimitWarning.classList.toggle('hidden', !limitReached);
|
||||||
|
|
||||||
|
const subTriCount = subdivided.attributes.position.count / 3;
|
||||||
|
setProgress(0.62, `Applying displacement to ${subTriCount.toLocaleString()} triangles…`);
|
||||||
|
|
||||||
|
const displaced = await runAsync(() =>
|
||||||
|
applyDisplacement(
|
||||||
|
subdivided,
|
||||||
|
activeMapEntry.imageData,
|
||||||
|
activeMapEntry.width,
|
||||||
|
activeMapEntry.height,
|
||||||
|
settings,
|
||||||
|
currentBounds,
|
||||||
|
(p) => setProgress(0.62 + p * 0.35, `Displacing vertices…`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setProgress(0.98, 'Writing STL…');
|
||||||
|
await yieldFrame();
|
||||||
|
|
||||||
|
const baseName = 'textured';
|
||||||
|
exportSTL(displaced, `${baseName}.stl`);
|
||||||
|
|
||||||
|
setProgress(1.0, 'Done!');
|
||||||
|
setTimeout(() => {
|
||||||
|
exportProgress.classList.add('hidden');
|
||||||
|
setProgress(0, '');
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err);
|
||||||
|
alert(`Export failed: ${err.message}`);
|
||||||
|
exportProgress.classList.add('hidden');
|
||||||
|
} finally {
|
||||||
|
isExporting = false;
|
||||||
|
exportBtn.classList.remove('busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(fraction, label) {
|
||||||
|
exportProgBar.style.width = `${Math.round(fraction * 100)}%`;
|
||||||
|
exportProgLbl.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yield to the browser event loop for one frame, then run fn. */
|
||||||
|
function runAsync(fn) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try { resolve(fn()); }
|
||||||
|
catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function yieldFrame() {
|
||||||
|
return new Promise(r => requestAnimationFrame(r));
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* CPU-side UV mapping — exact JavaScript mirror of the GLSL in previewMaterial.js.
|
||||||
|
* All functions take Three.js Vector3 objects for position/normal and
|
||||||
|
* a bounds object { min, max, center, size } (all THREE.Vector3).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MODE_PLANAR_XY = 0;
|
||||||
|
export const MODE_PLANAR_XZ = 1;
|
||||||
|
export const MODE_PLANAR_YZ = 2;
|
||||||
|
export const MODE_CYLINDRICAL = 3;
|
||||||
|
export const MODE_SPHERICAL = 4;
|
||||||
|
export const MODE_TRIPLANAR = 5;
|
||||||
|
export const MODE_CUBIC = 6;
|
||||||
|
|
||||||
|
const TWO_PI = Math.PI * 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute normalised UV coordinates [0, 1) (tiling) for a vertex.
|
||||||
|
*
|
||||||
|
* @param {{ x:number, y:number, z:number }} pos vertex position
|
||||||
|
* @param {{ x:number, y:number, z:number }} normal vertex normal (unit)
|
||||||
|
* @param {number} mode one of the MODE_* constants
|
||||||
|
* @param {{ scaleU:number, scaleV:number, offsetU:number, offsetV:number }} settings
|
||||||
|
* @param {{ min, max, center, size }} bounds THREE.Vector3 fields
|
||||||
|
* @returns {{ u:number, v:number }} tiled UV after scale+offset
|
||||||
|
*/
|
||||||
|
export function computeUV(pos, normal, mode, settings, bounds) {
|
||||||
|
const { min, size, center } = bounds;
|
||||||
|
const { scaleU, scaleV, offsetU, offsetV } = settings;
|
||||||
|
|
||||||
|
let u = 0, v = 0;
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
|
||||||
|
case MODE_PLANAR_XY: {
|
||||||
|
u = (pos.x - min.x) / Math.max(size.x, 1e-6);
|
||||||
|
v = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MODE_PLANAR_XZ: {
|
||||||
|
u = (pos.x - min.x) / Math.max(size.x, 1e-6);
|
||||||
|
v = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MODE_PLANAR_YZ: {
|
||||||
|
u = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
||||||
|
v = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MODE_CYLINDRICAL: {
|
||||||
|
// Wrap around Y axis (vertical axis after Z-up → Y-up rotation)
|
||||||
|
const rx = pos.x - center.x;
|
||||||
|
const rz = pos.z - center.z;
|
||||||
|
const theta = Math.atan2(rz, rx); // [-PI, PI]
|
||||||
|
u = (theta / TWO_PI) + 0.5; // [0, 1]
|
||||||
|
v = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MODE_SPHERICAL: {
|
||||||
|
const rx = pos.x - center.x;
|
||||||
|
const ry = pos.y - center.y;
|
||||||
|
const rz = pos.z - center.z;
|
||||||
|
const r = Math.sqrt(rx*rx + ry*ry + rz*rz);
|
||||||
|
const phi = Math.acos(Math.max(-1, Math.min(1, ry / Math.max(r, 1e-6)))); // [0, PI], Y is up
|
||||||
|
const theta = Math.atan2(rz, rx); // [-PI, PI]
|
||||||
|
u = (theta / TWO_PI) + 0.5;
|
||||||
|
v = phi / Math.PI;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MODE_CUBIC: {
|
||||||
|
const ax = Math.abs(normal.x);
|
||||||
|
const ay = Math.abs(normal.y);
|
||||||
|
const az = Math.abs(normal.z);
|
||||||
|
let uRaw, vRaw;
|
||||||
|
if (ax >= ay && ax >= az) {
|
||||||
|
// ±X dominant → project onto YZ
|
||||||
|
uRaw = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
||||||
|
vRaw = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
||||||
|
} else if (ay >= ax && ay >= az) {
|
||||||
|
// ±Y dominant → project onto XZ
|
||||||
|
uRaw = (pos.x - min.x) / Math.max(size.x, 1e-6);
|
||||||
|
vRaw = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
||||||
|
} else {
|
||||||
|
// ±Z dominant → project onto XY
|
||||||
|
uRaw = (pos.x - min.x) / Math.max(size.x, 1e-6);
|
||||||
|
vRaw = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
triplanar: false,
|
||||||
|
u: fract(uRaw * scaleU + offsetU),
|
||||||
|
v: fract(vRaw * scaleV + offsetV),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case MODE_TRIPLANAR:
|
||||||
|
default: {
|
||||||
|
// World-space normal blending
|
||||||
|
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 sum = bx + by + bz + 1e-6;
|
||||||
|
const wx = bx / sum;
|
||||||
|
const wy = by / sum;
|
||||||
|
const wz = bz / sum;
|
||||||
|
|
||||||
|
const uvXY = {
|
||||||
|
u: (pos.x - min.x) / Math.max(size.x, 1e-6),
|
||||||
|
v: (pos.y - min.y) / Math.max(size.y, 1e-6),
|
||||||
|
w: wz,
|
||||||
|
};
|
||||||
|
const uvXZ = {
|
||||||
|
u: (pos.x - min.x) / Math.max(size.x, 1e-6),
|
||||||
|
v: (pos.z - min.z) / Math.max(size.z, 1e-6),
|
||||||
|
w: wy,
|
||||||
|
};
|
||||||
|
const uvYZ = {
|
||||||
|
u: (pos.y - min.y) / Math.max(size.y, 1e-6),
|
||||||
|
v: (pos.z - min.z) / Math.max(size.z, 1e-6),
|
||||||
|
w: wx,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply scale+offset and tile each independently
|
||||||
|
// We return a special { triplanar: true, samples } object.
|
||||||
|
// The caller (displacement.js) handles the 3-sample blend itself.
|
||||||
|
return {
|
||||||
|
triplanar: true,
|
||||||
|
samples: [
|
||||||
|
{ u: fract(uvXY.u * scaleU + offsetU), v: fract(uvXY.v * scaleV + offsetV), w: uvXY.w },
|
||||||
|
{ u: fract(uvXZ.u * scaleU + offsetU), v: fract(uvXZ.v * scaleV + offsetV), w: uvXZ.w },
|
||||||
|
{ u: fract(uvYZ.u * scaleU + offsetU), v: fract(uvYZ.v * scaleV + offsetV), w: uvYZ.w },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
triplanar: false,
|
||||||
|
u: fract(u * scaleU + offsetU),
|
||||||
|
v: fract(v * scaleV + offsetV),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fractional part, always positive (mirrors GLSL fract) */
|
||||||
|
function fract(x) { return x - Math.floor(x); }
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const SIZE = 512; // texture resolution for both preview and sampling
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeCanvas(size = SIZE) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = size;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grayPixel(value255) {
|
||||||
|
return `rgb(${value255},${value255},${value255})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple seeded LCG pseudo-random number generator (deterministic)
|
||||||
|
function lcg(seed) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (Math.imul(1664525, s) + 1013904223) >>> 0;
|
||||||
|
return s / 0xFFFFFFFF;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generators ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 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 }) => {
|
||||||
|
const fullCanvas = gen(SIZE);
|
||||||
|
const thumbCanvas = gen(80); // small canvas for swatch UI
|
||||||
|
const texture = new THREE.CanvasTexture(fullCanvas);
|
||||||
|
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
||||||
|
texture.name = name;
|
||||||
|
|
||||||
|
// Extract ImageData for CPU sampling
|
||||||
|
const ctx = fullCanvas.getContext('2d');
|
||||||
|
const imageData = ctx.getImageData(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
return { name, thumbCanvas, fullCanvas, texture, imageData, width: SIZE, height: SIZE };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a THREE.CanvasTexture + ImageData from a user-uploaded image File.
|
||||||
|
*/
|
||||||
|
export function loadCustomTexture(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const canvas = makeCanvas(SIZE);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, SIZE, SIZE);
|
||||||
|
const imageData = ctx.getImageData(0, 0, SIZE, SIZE);
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
||||||
|
texture.name = file.name;
|
||||||
|
resolve({ name: file.name, fullCanvas: canvas, texture, imageData, width: SIZE, height: SIZE });
|
||||||
|
};
|
||||||
|
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load image')); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// Mapping mode constants (must match index.html <option value="…">)
|
||||||
|
export const MODE_PLANAR_XY = 0;
|
||||||
|
export const MODE_PLANAR_XZ = 1;
|
||||||
|
export const MODE_PLANAR_YZ = 2;
|
||||||
|
export const MODE_CYLINDRICAL = 3;
|
||||||
|
export const MODE_SPHERICAL = 4;
|
||||||
|
export const MODE_TRIPLANAR = 5;
|
||||||
|
export const MODE_CUBIC = 6;
|
||||||
|
|
||||||
|
// ── GLSL source ──────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Preview strategy: NO vertex displacement.
|
||||||
|
// All UV projection is done in the fragment shader so the underlying mesh
|
||||||
|
// geometry is never modified. The displacement map is visualised via
|
||||||
|
// per-fragment bump mapping (perturbing the shading normal from screen-space
|
||||||
|
// height derivatives). `amplitude` scales the bump intensity only.
|
||||||
|
|
||||||
|
const vertexShader = /* glsl */`
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
varying vec3 vModelPos; // model-space position → UV computation in fragment
|
||||||
|
varying vec3 vModelNormal; // model-space normal → stable UV blending (triplanar/cubic)
|
||||||
|
varying vec3 vViewPos; // view-space position → TBN & specular
|
||||||
|
varying vec3 vNormal; // view-space normal → lighting
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vModelPos = position;
|
||||||
|
vModelNormal = normalize(normal);
|
||||||
|
vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
vViewPos = mvPos.xyz;
|
||||||
|
vNormal = normalize(normalMatrix * normal);
|
||||||
|
gl_Position = projectionMatrix * mvPos;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fragmentShader = /* glsl */`
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform sampler2D displacementMap;
|
||||||
|
uniform int mappingMode;
|
||||||
|
uniform vec2 scaleUV;
|
||||||
|
uniform float amplitude;
|
||||||
|
uniform vec2 offsetUV;
|
||||||
|
uniform vec3 boundsMin;
|
||||||
|
uniform vec3 boundsSize;
|
||||||
|
uniform vec3 boundsCenter;
|
||||||
|
|
||||||
|
varying vec3 vModelPos;
|
||||||
|
varying vec3 vModelNormal;
|
||||||
|
varying vec3 vViewPos;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
|
||||||
|
const float PI = 3.14159265358979;
|
||||||
|
const float TWO_PI = 6.28318530717959;
|
||||||
|
|
||||||
|
// Sample after applying scale + tiling
|
||||||
|
float sampleMap(vec2 rawUV) {
|
||||||
|
return texture2D(displacementMap, fract(rawUV * scaleUV + offsetUV)).r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height at this fragment for all projection modes.
|
||||||
|
// Uses vModelPos / vModelNormal (model-space) so UV is stable as the camera orbits.
|
||||||
|
float getHeight() {
|
||||||
|
vec3 pos = vModelPos;
|
||||||
|
vec3 MN = vModelNormal; // model-space normal
|
||||||
|
vec3 rel = pos - boundsCenter;
|
||||||
|
|
||||||
|
if (mappingMode == 0) {
|
||||||
|
return sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4)));
|
||||||
|
|
||||||
|
} else if (mappingMode == 1) {
|
||||||
|
return sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4)));
|
||||||
|
|
||||||
|
} else if (mappingMode == 2) {
|
||||||
|
return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
||||||
|
|
||||||
|
} else if (mappingMode == 3) {
|
||||||
|
// Cylindrical around Y (vertical axis after Z-up → Y-up rotation)
|
||||||
|
float u = atan(rel.z, rel.x) / TWO_PI + 0.5;
|
||||||
|
float v = (pos.y - boundsMin.y) / max(boundsSize.y, 1e-4);
|
||||||
|
return sampleMap(vec2(u, v));
|
||||||
|
|
||||||
|
} else if (mappingMode == 4) {
|
||||||
|
// Spherical
|
||||||
|
float r = length(rel);
|
||||||
|
float phi = acos(clamp(rel.y / max(r, 1e-4), -1.0, 1.0));
|
||||||
|
float theta = atan(rel.z, rel.x);
|
||||||
|
return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI));
|
||||||
|
|
||||||
|
} else if (mappingMode == 5) {
|
||||||
|
// Triplanar – smooth blend using model-space normal (stable regardless of camera)
|
||||||
|
vec3 blend = abs(MN);
|
||||||
|
blend = pow(blend, vec3(4.0));
|
||||||
|
blend /= dot(blend, vec3(1.0)) + 1e-4;
|
||||||
|
|
||||||
|
float hXY = sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4)));
|
||||||
|
float hXZ = sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4)));
|
||||||
|
float hYZ = sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
||||||
|
|
||||||
|
return hXY * blend.z + hXZ * blend.y + hYZ * blend.x;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Cubic (box) – hard-edge face selection using model-space normal
|
||||||
|
// Picks the single planar projection whose axis is most aligned with the face normal.
|
||||||
|
vec3 absN = abs(MN);
|
||||||
|
if (absN.x >= absN.y && absN.x >= absN.z) {
|
||||||
|
// ±X dominant → project onto YZ plane
|
||||||
|
return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
||||||
|
} else if (absN.y >= absN.x && absN.y >= absN.z) {
|
||||||
|
// ±Y dominant → project onto XZ plane
|
||||||
|
return sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4)));
|
||||||
|
} else {
|
||||||
|
// ±Z dominant → project onto XY plane
|
||||||
|
return sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 N = normalize(vNormal);
|
||||||
|
float h = getHeight();
|
||||||
|
|
||||||
|
// ── Bump mapping via screen-space height derivatives ──────────────────
|
||||||
|
// dFdx/dFdy give the height change per screen pixel → height gradient
|
||||||
|
float dhx = dFdx(h);
|
||||||
|
float dhy = dFdy(h);
|
||||||
|
|
||||||
|
// Screen-space surface tangent / bitangent, projected onto the surface plane
|
||||||
|
vec3 dp1 = dFdx(vViewPos);
|
||||||
|
vec3 dp2 = dFdy(vViewPos);
|
||||||
|
|
||||||
|
vec3 T = dp1 - dot(dp1, N) * N;
|
||||||
|
vec3 B = dp2 - dot(dp2, N) * N;
|
||||||
|
float lenT = length(T);
|
||||||
|
float lenB = length(B);
|
||||||
|
T = lenT > 1e-5 ? T / lenT : vec3(1.0, 0.0, 0.0);
|
||||||
|
B = lenB > 1e-5 ? B / lenB : vec3(0.0, 1.0, 0.0);
|
||||||
|
|
||||||
|
// Normalise bump strength by position derivative so the effect is
|
||||||
|
// independent of zoom level / mesh scale.
|
||||||
|
float posScale = max(length(dp1) + length(dp2), 1e-6);
|
||||||
|
float bumpStr = amplitude * 1.2 / posScale;
|
||||||
|
|
||||||
|
vec3 bumpN = normalize(N - bumpStr * (dhx * T + dhy * B));
|
||||||
|
|
||||||
|
// ── Shading ───────────────────────────────────────────────────────────
|
||||||
|
// Base colour: cool-to-warm tint driven by the displacement height value
|
||||||
|
// so the texture pattern is clearly visible even without bump lighting.
|
||||||
|
vec3 lo = vec3(0.18, 0.20, 0.35);
|
||||||
|
vec3 hi = vec3(0.90, 0.84, 0.68);
|
||||||
|
vec3 baseColor = mix(lo, hi, h);
|
||||||
|
|
||||||
|
vec3 L1 = normalize(vec3( 0.5, 0.8, 1.0));
|
||||||
|
vec3 L2 = normalize(vec3(-0.5, -0.2, -0.6));
|
||||||
|
vec3 V = normalize(-vViewPos);
|
||||||
|
|
||||||
|
float diff1 = max(dot(bumpN, L1), 0.0);
|
||||||
|
float diff2 = max(dot(bumpN, L2), 0.0) * 0.35;
|
||||||
|
|
||||||
|
vec3 H1 = normalize(L1 + V);
|
||||||
|
float spec = pow(max(dot(bumpN, H1), 0.0), 48.0) * 0.55;
|
||||||
|
|
||||||
|
vec3 color = baseColor * 0.60 // strong ambient — texture always visible
|
||||||
|
+ baseColor * diff1 * vec3(1.00, 0.97, 0.90) * 0.45 // key light
|
||||||
|
+ baseColor * diff2 * vec3(0.40, 0.50, 0.80) * 0.20 // fill light
|
||||||
|
+ vec3(spec); // specular
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ShaderMaterial for the displacement preview.
|
||||||
|
* @param {THREE.Texture|null} displacementTexture
|
||||||
|
* @param {object} settings – { mappingMode, scaleU, scaleV, amplitude, offsetU, offsetV, bounds }
|
||||||
|
*/
|
||||||
|
export function createPreviewMaterial(displacementTexture, settings) {
|
||||||
|
const mat = new THREE.ShaderMaterial({
|
||||||
|
vertexShader,
|
||||||
|
fragmentShader,
|
||||||
|
uniforms: buildUniforms(displacementTexture, settings),
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing ShaderMaterial uniforms in-place (no recreate).
|
||||||
|
*/
|
||||||
|
export function updateMaterial(material, displacementTexture, settings) {
|
||||||
|
const u = material.uniforms;
|
||||||
|
if (displacementTexture && u.displacementMap.value !== displacementTexture) {
|
||||||
|
u.displacementMap.value = displacementTexture;
|
||||||
|
}
|
||||||
|
u.mappingMode.value = settings.mappingMode;
|
||||||
|
u.scaleUV.value.set(settings.scaleU, settings.scaleV);
|
||||||
|
u.amplitude.value = settings.amplitude;
|
||||||
|
u.offsetUV.value.set(settings.offsetU, settings.offsetV);
|
||||||
|
if (settings.bounds) {
|
||||||
|
u.boundsMin.value.copy(settings.bounds.min);
|
||||||
|
u.boundsSize.value.copy(settings.bounds.size);
|
||||||
|
u.boundsCenter.value.copy(settings.bounds.center);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildUniforms(tex, settings) {
|
||||||
|
const b = settings.bounds || {
|
||||||
|
min: new THREE.Vector3(),
|
||||||
|
size: new THREE.Vector3(1, 1, 1),
|
||||||
|
center: new THREE.Vector3(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
displacementMap: { value: tex || createFallbackTexture() },
|
||||||
|
mappingMode: { value: settings.mappingMode ?? MODE_TRIPLANAR },
|
||||||
|
scaleUV: { value: new THREE.Vector2(settings.scaleU ?? 1, settings.scaleV ?? 1) },
|
||||||
|
amplitude: { value: settings.amplitude ?? 1.0 },
|
||||||
|
offsetUV: { value: new THREE.Vector2(settings.offsetU ?? 0, settings.offsetV ?? 0) },
|
||||||
|
boundsMin: { value: b.min.clone() },
|
||||||
|
boundsSize: { value: b.size.clone() },
|
||||||
|
boundsCenter: { value: b.center.clone() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackTexture() {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = 4;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = '#808080';
|
||||||
|
ctx.fillRect(0, 0, 4, 4);
|
||||||
|
const t = new THREE.CanvasTexture(canvas);
|
||||||
|
t.wrapS = t.wrapT = THREE.RepeatWrapping;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const loader = new STLLoader();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an STL from a File object.
|
||||||
|
* Returns { geometry, bounds } where bounds = { min, max, center, size } (THREE.Vector3).
|
||||||
|
* The geometry is translated so its bounding-box centre is at the world origin.
|
||||||
|
*/
|
||||||
|
export function loadSTLFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const geometry = loader.parse(e.target.result);
|
||||||
|
setupGeometry(geometry);
|
||||||
|
const bounds = computeBounds(geometry);
|
||||||
|
resolve({ geometry, bounds });
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Could not read file'));
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure vertex normals exist, then centre the geometry on its bounding-box centroid.
|
||||||
|
*/
|
||||||
|
function setupGeometry(geometry) {
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
const box = geometry.boundingBox;
|
||||||
|
const centre = new THREE.Vector3();
|
||||||
|
box.getCenter(centre);
|
||||||
|
geometry.translate(-centre.x, -centre.y, -centre.z);
|
||||||
|
// Convert Z-up (3D-print convention) to Y-up (Three.js convention)
|
||||||
|
geometry.rotateX(-Math.PI / 2);
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the bounds object that all UV mapping functions depend on.
|
||||||
|
* Must be called after the geometry has been centred.
|
||||||
|
*/
|
||||||
|
export function computeBounds(geometry) {
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
const box = geometry.boundingBox;
|
||||||
|
const min = box.min.clone();
|
||||||
|
const max = box.max.clone();
|
||||||
|
const size = new THREE.Vector3();
|
||||||
|
box.getSize(size);
|
||||||
|
const center = new THREE.Vector3();
|
||||||
|
box.getCenter(center);
|
||||||
|
return { min, max, center, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triangle count helper.
|
||||||
|
*/
|
||||||
|
export function getTriangleCount(geometry) {
|
||||||
|
const pos = geometry.attributes.position;
|
||||||
|
return geometry.index
|
||||||
|
? geometry.index.count / 3
|
||||||
|
: pos.count / 3;
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Edge-based adaptive mesh subdivision.
|
||||||
|
*
|
||||||
|
* @param {THREE.BufferGeometry} geometry – non-indexed input from STLLoader
|
||||||
|
* @param {number} maxEdgeLength – maximum allowed edge length (same unit as STL)
|
||||||
|
* @param {number} maxTriangles – hard cap on output triangle count
|
||||||
|
* @param {function} [onProgress] – optional callback(fraction 0–1)
|
||||||
|
* @returns {{ geometry: THREE.BufferGeometry, limitReached: boolean }}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const QUANTISE = 1e4;
|
||||||
|
|
||||||
|
// ── Public entry point ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function subdivide(geometry, maxEdgeLength, maxTriangles, onProgress) {
|
||||||
|
const { positions, normals, indices } = toIndexed(geometry);
|
||||||
|
|
||||||
|
const maxIterations = 12;
|
||||||
|
let currentIndices = indices;
|
||||||
|
let limitReached = false;
|
||||||
|
|
||||||
|
for (let iter = 0; iter < maxIterations; iter++) {
|
||||||
|
const triCount = currentIndices.length / 3;
|
||||||
|
if (triCount >= maxTriangles) {
|
||||||
|
limitReached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { newIndices, changed } = subdividePass(
|
||||||
|
positions, normals, currentIndices, maxEdgeLength, maxTriangles
|
||||||
|
);
|
||||||
|
currentIndices = newIndices;
|
||||||
|
|
||||||
|
// Check if the pass itself hit the limit
|
||||||
|
if (newIndices.length / 3 >= maxTriangles) {
|
||||||
|
limitReached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) onProgress(Math.min(0.95, (iter + 1) / maxIterations));
|
||||||
|
if (!changed || limitReached) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { geometry: toNonIndexed(positions, normals, currentIndices), limitReached };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One subdivision pass ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function subdividePass(positions, normals, indices, maxEdgeLength, maxTriangles) {
|
||||||
|
const maxSq = maxEdgeLength * maxEdgeLength;
|
||||||
|
const midCache = new Map();
|
||||||
|
const nextIndices = [];
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (let t = 0; t < indices.length; t += 3) {
|
||||||
|
// Hard stop: don't add more triangles once the cap is reached
|
||||||
|
if (nextIndices.length / 3 >= maxTriangles) {
|
||||||
|
// Push remaining unsplit triangles as-is
|
||||||
|
for (let r = t; r < indices.length; r++) nextIndices.push(indices[r]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = indices[t];
|
||||||
|
const b = indices[t + 1];
|
||||||
|
const c = indices[t + 2];
|
||||||
|
|
||||||
|
const ab = edgeLenSq(positions, a, b);
|
||||||
|
const bc = edgeLenSq(positions, b, c);
|
||||||
|
const ca = edgeLenSq(positions, c, a);
|
||||||
|
|
||||||
|
const longest = Math.max(ab, bc, ca);
|
||||||
|
if (longest <= maxSq) {
|
||||||
|
// Triangle is fine – keep as is
|
||||||
|
nextIndices.push(a, b, c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
// Split the longest edge
|
||||||
|
if (longest === ab) {
|
||||||
|
const m = getMidpoint(positions, normals, midCache, a, b);
|
||||||
|
nextIndices.push(a, m, c, m, b, c);
|
||||||
|
} else if (longest === bc) {
|
||||||
|
const m = getMidpoint(positions, normals, midCache, b, c);
|
||||||
|
nextIndices.push(a, b, m, a, m, c);
|
||||||
|
} else {
|
||||||
|
const m = getMidpoint(positions, normals, midCache, c, a);
|
||||||
|
nextIndices.push(a, b, m, m, b, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { newIndices: nextIndices, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function edgeLenSq(pos, a, b) {
|
||||||
|
const dx = pos[a*3] - pos[b*3];
|
||||||
|
const dy = pos[a*3+1] - pos[b*3+1];
|
||||||
|
const dz = pos[a*3+2] - pos[b*3+2];
|
||||||
|
return dx*dx + dy*dy + dz*dz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMidpoint(positions, normals, cache, a, b) {
|
||||||
|
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
|
||||||
|
if (cache.has(key)) return cache.get(key);
|
||||||
|
|
||||||
|
// Midpoint position
|
||||||
|
const mx = (positions[a*3] + positions[b*3]) / 2;
|
||||||
|
const my = (positions[a*3+1] + positions[b*3+1]) / 2;
|
||||||
|
const mz = (positions[a*3+2] + positions[b*3+2]) / 2;
|
||||||
|
|
||||||
|
// Midpoint normal (average + normalise)
|
||||||
|
const nx = normals[a*3] + normals[b*3];
|
||||||
|
const ny = normals[a*3+1] + normals[b*3+1];
|
||||||
|
const nz = normals[a*3+2] + normals[b*3+2];
|
||||||
|
const nl = Math.sqrt(nx*nx + ny*ny + nz*nz) || 1;
|
||||||
|
|
||||||
|
const idx = (positions.length / 3) | 0;
|
||||||
|
positions.push(mx, my, mz);
|
||||||
|
normals.push(nx / nl, ny / nl, nz / nl);
|
||||||
|
cache.set(key, idx);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Non-indexed → indexed conversion ────────────────────────────────────────
|
||||||
|
|
||||||
|
function toIndexed(geometry) {
|
||||||
|
const posAttr = geometry.attributes.position;
|
||||||
|
const nrmAttr = geometry.attributes.normal;
|
||||||
|
|
||||||
|
const positions = [];
|
||||||
|
const normals = [];
|
||||||
|
const indices = [];
|
||||||
|
const vertMap = new Map();
|
||||||
|
|
||||||
|
const n = posAttr.count;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const px = posAttr.getX(i);
|
||||||
|
const py = posAttr.getY(i);
|
||||||
|
const pz = posAttr.getZ(i);
|
||||||
|
const nx_ = nrmAttr ? nrmAttr.getX(i) : 0;
|
||||||
|
const ny_ = nrmAttr ? nrmAttr.getY(i) : 0;
|
||||||
|
const nz_ = nrmAttr ? nrmAttr.getZ(i) : 1;
|
||||||
|
|
||||||
|
const key = `${Math.round(px * QUANTISE)}_${Math.round(py * QUANTISE)}_${Math.round(pz * QUANTISE)}`;
|
||||||
|
let idx = vertMap.get(key);
|
||||||
|
if (idx === undefined) {
|
||||||
|
idx = positions.length / 3;
|
||||||
|
positions.push(px, py, pz);
|
||||||
|
normals.push(nx_, ny_, nz_);
|
||||||
|
vertMap.set(key, idx);
|
||||||
|
}
|
||||||
|
indices.push(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positions, normals, indices };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Indexed → non-indexed ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toNonIndexed(positions, normals, indices) {
|
||||||
|
const triCount = indices.length / 3;
|
||||||
|
const posArray = new Float32Array(triCount * 9);
|
||||||
|
const nrmArray = new Float32Array(triCount * 9);
|
||||||
|
|
||||||
|
for (let t = 0; t < triCount; t++) {
|
||||||
|
for (let v = 0; v < 3; v++) {
|
||||||
|
const vidx = indices[t * 3 + v];
|
||||||
|
posArray[t * 9 + v * 3] = positions[vidx * 3];
|
||||||
|
posArray[t * 9 + v * 3 + 1] = positions[vidx * 3 + 1];
|
||||||
|
posArray[t * 9 + v * 3 + 2] = positions[vidx * 3 + 2];
|
||||||
|
|
||||||
|
nrmArray[t * 9 + v * 3] = normals[vidx * 3];
|
||||||
|
nrmArray[t * 9 + v * 3 + 1] = normals[vidx * 3 + 1];
|
||||||
|
nrmArray[t * 9 + v * 3 + 2] = normals[vidx * 3 + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
|
||||||
|
geo.setAttribute('normal', new THREE.BufferAttribute(nrmArray, 3));
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
|
let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid;
|
||||||
|
let currentMesh = null;
|
||||||
|
|
||||||
|
export function initViewer(canvas) {
|
||||||
|
// Renderer
|
||||||
|
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.1;
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x111114);
|
||||||
|
|
||||||
|
// Grid helper (subtle)
|
||||||
|
grid = new THREE.GridHelper(200, 40, 0x222228, 0x1e1e24);
|
||||||
|
grid.position.y = 0;
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
camera = new THREE.PerspectiveCamera(45, 1, 0.01, 5000);
|
||||||
|
camera.position.set(0, 80, 120);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||||
|
dirLight1.position.set(80, 120, 60);
|
||||||
|
dirLight1.castShadow = true;
|
||||||
|
dirLight1.shadow.mapSize.set(1024, 1024);
|
||||||
|
scene.add(dirLight1);
|
||||||
|
|
||||||
|
dirLight2 = new THREE.DirectionalLight(0x8899ff, 0.4);
|
||||||
|
dirLight2.position.set(-60, -20, -80);
|
||||||
|
scene.add(dirLight2);
|
||||||
|
|
||||||
|
// Group to hold the mesh
|
||||||
|
meshGroup = new THREE.Group();
|
||||||
|
scene.add(meshGroup);
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
controls.minDistance = 1;
|
||||||
|
controls.maxDistance = 3000;
|
||||||
|
controls.screenSpacePanning = true;
|
||||||
|
|
||||||
|
// Resize observer
|
||||||
|
const resizeObserver = new ResizeObserver(() => onResize());
|
||||||
|
resizeObserver.observe(canvas.parentElement);
|
||||||
|
onResize();
|
||||||
|
|
||||||
|
// Render loop
|
||||||
|
(function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
const el = renderer.domElement.parentElement;
|
||||||
|
const w = el.clientWidth;
|
||||||
|
const h = el.clientHeight;
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the mesh in the scene with new geometry.
|
||||||
|
* @param {THREE.BufferGeometry} geometry
|
||||||
|
* @param {THREE.Material} [material] – if omitted, a default material is used
|
||||||
|
*/
|
||||||
|
export function loadGeometry(geometry, material) {
|
||||||
|
// Clear previous mesh
|
||||||
|
while (meshGroup.children.length) {
|
||||||
|
const old = meshGroup.children[0];
|
||||||
|
old.geometry.dispose();
|
||||||
|
if (old.material && old.material.dispose) old.material.dispose();
|
||||||
|
meshGroup.remove(old);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mat = material || new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xaaaacc,
|
||||||
|
roughness: 0.6,
|
||||||
|
metalness: 0.1,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
||||||
|
|
||||||
|
currentMesh = new THREE.Mesh(geometry, mat);
|
||||||
|
currentMesh.castShadow = true;
|
||||||
|
currentMesh.receiveShadow = true;
|
||||||
|
meshGroup.add(currentMesh);
|
||||||
|
|
||||||
|
// Position grid at mesh bottom
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
const box = geometry.boundingBox;
|
||||||
|
const centerY = (box.min.y + box.max.y) / 2;
|
||||||
|
grid.position.y = box.min.y - 0.01;
|
||||||
|
|
||||||
|
// Fit camera
|
||||||
|
const sphere = new THREE.Sphere();
|
||||||
|
geometry.computeBoundingSphere();
|
||||||
|
sphere.copy(geometry.boundingSphere);
|
||||||
|
fitCamera(sphere);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update only the material on the current mesh.
|
||||||
|
* @param {THREE.Material} material
|
||||||
|
*/
|
||||||
|
export function setMeshMaterial(material) {
|
||||||
|
if (!currentMesh) return;
|
||||||
|
if (currentMesh.material && currentMesh.material.dispose) {
|
||||||
|
currentMesh.material.dispose();
|
||||||
|
}
|
||||||
|
currentMesh.material = material || new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xaaaacc,
|
||||||
|
roughness: 0.6,
|
||||||
|
metalness: 0.1,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the grid object so callers can adjust position.
|
||||||
|
*/
|
||||||
|
export function getGrid() { return grid; }
|
||||||
|
|
||||||
|
function fitCamera(sphere) {
|
||||||
|
const fov = THREE.MathUtils.degToRad(camera.fov);
|
||||||
|
const dist = (sphere.radius * 2.2) / Math.tan(fov / 2);
|
||||||
|
const dir = camera.position.clone().sub(controls.target).normalize();
|
||||||
|
controls.target.copy(sphere.center);
|
||||||
|
camera.position.copy(sphere.center).addScaledVector(dir, dist);
|
||||||
|
controls.update();
|
||||||
|
camera.near = dist * 0.001;
|
||||||
|
camera.far = dist * 10;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRenderer() { return renderer; }
|
||||||
|
export function getCamera() { return camera; }
|
||||||
|
export function getScene() { return scene; }
|
||||||
|
export function getCurrentMesh() { return currentMesh; }
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
/* ── Reset & tokens ──────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #111114;
|
||||||
|
--surface: #1a1a1f;
|
||||||
|
--surface2: #222228;
|
||||||
|
--border: #2e2e38;
|
||||||
|
--accent: #7c6aff;
|
||||||
|
--accent-hover: #9b8dff;
|
||||||
|
--text: #e0e0e8;
|
||||||
|
--text-muted: #888899;
|
||||||
|
--danger: #ff5f5f;
|
||||||
|
--success: #4ade80;
|
||||||
|
--radius: 8px;
|
||||||
|
--sidebar-w: 310px;
|
||||||
|
--header-h: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||||
|
header {
|
||||||
|
height: var(--header-h);
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 12px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-note {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main layout ─────────────────────────────────────────────────────── */
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - var(--header-h));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 3-D Viewport section ────────────────────────────────────────────── */
|
||||||
|
#viewport-section {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
flex: 1 1 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.drag-over::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px dashed var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgba(124, 106, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-hint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-hint.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.link-label {
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: all;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#viewport {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#viewport-footer {
|
||||||
|
height: 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-info {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-controls-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555566;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Settings panel ──────────────────────────────────────────────────── */
|
||||||
|
#settings-panel {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-panel::-webkit-scrollbar { width: 5px; }
|
||||||
|
#settings-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
|
||||||
|
.panel-section {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-section h2 {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preset grid ─────────────────────────────────────────────────────── */
|
||||||
|
.preset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.15s, transform 0.1s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch:hover { border-color: var(--text-muted); transform: scale(1.04); }
|
||||||
|
.preset-swatch.active { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.preset-swatch canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch .preset-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch:hover .preset-label { opacity: 1; }
|
||||||
|
|
||||||
|
/* ── Custom upload button ─────────────────────────────────────────────── */
|
||||||
|
.upload-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-map-name {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form rows ───────────────────────────────────────────────────────── */
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.form-row label {
|
||||||
|
flex: 0 0 80px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row select {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sliders ─────────────────────────────────────────────────────────── */
|
||||||
|
.slider-row { align-items: center; }
|
||||||
|
|
||||||
|
.slider-row input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-row input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: grab;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-row input[type="range"]::-webkit-slider-thumb:active { cursor: grabbing; background: var(--accent-hover); }
|
||||||
|
.slider-row input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
background: var(--accent); border: none; cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.val {
|
||||||
|
flex: 0 0 56px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hint text ───────────────────────────────────────────────────────── */
|
||||||
|
.hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Export progress ─────────────────────────────────────────────────── */
|
||||||
|
.export-progress {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-progress.hidden { display: none; }
|
||||||
|
|
||||||
|
.export-progress-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.1s;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Export button ───────────────────────────────────────────────────── */
|
||||||
|
.export-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
|
.export-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.export-btn.busy { opacity: 0.7; pointer-events: none; }
|
||||||
|
/* ── Lock / proportional-scale button ───────────────────────────────────── */
|
||||||
|
.lock-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-btn.active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, var(--surface2));
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-btn:hover { border-color: var(--accent-hover); }
|
||||||
|
|
||||||
|
/* ── Lock row connector ───────────────────────────────────────────────────────── */
|
||||||
|
.lock-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Triangle limit warning ──────────────────────────────────────────────── */
|
||||||
|
.tri-limit-warning {
|
||||||
|
background: color-mix(in srgb, #f59e0b 15%, var(--surface2));
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
color: #f59e0b;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tri-limit-warning.hidden { display: none; }
|
||||||
Reference in New Issue
Block a user