diff --git a/README.md b/README.md index a4f2185..d237c1c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,18 @@ Load an STL, OBJ, 3MF, or STEP file, pick a texture, tune the parameters, and ex - Downloads a **binary STL** with displacement baked in - Progress reporting through subdivision → displacement → decimation → writing stages - Configurable edge-length threshold and output triangle limit +- **Multi-colour 3MF round-trip** — a 3MF whose bodies carry per-part colours and + extruder assignments (Bambu Studio / OrcaSlicer / PrusaSlicer) keeps them through + texturing: each part is re-exported as its own object with its original name, + `displaycolor` and extruder, so no repainting is needed after re-import. STL has + no notion of colour, so use 3MF export to preserve it. +- **Part Colours view** — a viewport toggle (bottom bar, shown only for a + multi-colour import) shades each body by the **extruder** it is assigned to, + with a legend mapping colour → part name → tool. Colouring by tool rather than + by the file's `displaycolor` matches how a slicer presents the plate, and keeps + parts distinct even when the file gives several of them the same swatch. Masked + and include-only regions keep their part hue under the mask tint rather than + going flat orange. ### Other - **Light / Dark theme** — respects OS preference, persisted per browser diff --git a/index.html b/index.html index aa0a151..c6e5ba0 100644 --- a/index.html +++ b/index.html @@ -147,6 +147,8 @@ + +
Support this tool? Shop at CNCKitchen.STORE or send a tip via PayPal / Ko-fi @@ -163,6 +165,13 @@ Perspective View + +
Left drag: orbit  Â·  Right drag: pan  Â·  Scroll: zoom
diff --git a/js/decimation.js b/js/decimation.js index 930fa99..f0ca5a4 100644 --- a/js/decimation.js +++ b/js/decimation.js @@ -49,6 +49,10 @@ * touch are left completely untouched. If locked faces alone reach the * triangle target the run degrades to harvest-only and the returned * geometry carries userData.lockedOverBudget = true. + * @param {Uint16Array|null} [faceMaterial] per-face palette slot in the + * input's face order (3MF colour preservation). Vertices shared by two + * materials are pinned so part boundaries stay crisp, and the surviving + * faces' slots are returned on the output's userData.faceMaterial. * @returns {THREE.BufferGeometry} */ @@ -113,12 +117,14 @@ function _yieldFrame() { // ── Public API ─────────────────────────────────────────────────────────────── -export async function decimate(geometry, targetTriangles, onProgress, harvestFlat = true, harvestTol = DEFAULT_HARVEST_TOL, lockedFaces = null) { +export async function decimate(geometry, targetTriangles, onProgress, harvestFlat = true, harvestTol = DEFAULT_HARVEST_TOL, lockedFaces = null, faceMaterial = null) { const { positions, faces, vertCount, faceCount } = buildIndexed(geometry); // Already at/under the target: nothing to decimate. But if harvesting is on we // still run — there may be flat faces collapsible for free even below the limit. - if (faceCount <= targetTriangles && !harvestFlat) return buildOutput(positions, faces, faceCount); + if (faceCount <= targetTriangles && !harvestFlat) { + return buildOutput(positions, faces, faceCount, faceMaterial); + } // Preserve-untextured (beta): a vertex touching any locked (untextured) face // may neither move nor be removed, so edges with a locked endpoint are never @@ -137,6 +143,24 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla lockedVert[faces[f * 3 + 2]] = 1; } } + + // Multi-material (3MF colour preservation): buildIndexed welds coincident + // vertices globally, so two touching parts share the vertices along their + // contact surface. Collapsing there would drag one filament's geometry into + // the other's and visibly ragged the colour boundary, so every vertex seen by + // more than one material is pinned — the same treatment locked faces get. + if (faceMaterial) { + const seenMat = new Int32Array(vertCount).fill(-1); + if (!lockedVert) lockedVert = new Uint8Array(vertCount); + for (let f = 0; f < faceCount; f++) { + const m = faceMaterial[f]; + for (let k = 0; k < 3; k++) { + const v = faces[f * 3 + k]; + if (seenMat[v] === -1) seenMat[v] = m; + else if (seenMat[v] !== m) lockedVert[v] = 1; + } + } + } // When the locked faces alone meet or exceed the triangle target, the target // is unreachable without touching untextured geometry. Chasing it anyway // would grind the textured region down to its guard limit, so instead drop @@ -146,7 +170,7 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla && lockedFaceCount >= targetTriangles; if (lockedOverBudget && !harvestFlat) { if (onProgress) onProgress(1); - const out = buildOutput(positions, faces, faceCount); + const out = buildOutput(positions, faces, faceCount, faceMaterial); out.userData.lockedOverBudget = true; return out; } @@ -303,7 +327,7 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla } if (onProgress) onProgress(1); - const out = buildOutput(positions, faces, faceCount); + const out = buildOutput(positions, faces, faceCount, faceMaterial); if (lockedOverBudget) out.userData.lockedOverBudget = true; return out; } @@ -706,14 +730,18 @@ function buildIndexed(geometry) { // (adjacency helpers replaced by buildLinkedAdj and _unlinkSlot/_moveSlot above) -function buildOutput(positions, faces, faceCount) { +function buildOutput(positions, faces, faceCount, faceMaterial = null) { let activeFaces = 0; for (let f = 0; f < faceCount; f++) { if (faces[f * 3] >= 0) activeFaces++; } const posArray = new Float32Array(activeFaces * 9); + // Surviving faces keep their own material slot; collapses only delete faces + // and move vertices, they never merge two faces into one. + const outMaterial = faceMaterial ? new Uint16Array(activeFaces) : null; let out = 0; + let outFace = 0; for (let f = 0; f < faceCount; f++) { if (faces[f * 3] < 0) continue; for (let v = 0; v < 3; v++) { @@ -722,6 +750,8 @@ function buildOutput(positions, faces, faceCount) { posArray[out++] = positions[vi * 3 + 1]; posArray[out++] = positions[vi * 3 + 2]; } + if (outMaterial) outMaterial[outFace] = faceMaterial[f]; + outFace++; } // Compute exact per-face normals from the final positions so winding order @@ -744,6 +774,7 @@ function buildOutput(positions, faces, faceCount) { const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); geo.setAttribute('normal', new THREE.BufferAttribute(nrmArray, 3)); + if (outMaterial) geo.userData.faceMaterial = outMaterial; return geo; } diff --git a/js/exportPipeline.js b/js/exportPipeline.js index dc5b059..083ae63 100644 --- a/js/exportPipeline.js +++ b/js/exportPipeline.js @@ -201,6 +201,12 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(input.positions, 3)); + // Multi-material 3MF colour preservation: one palette slot per source + // triangle. When present it is carried all the way to the exporter so each + // body keeps its filament assignment. Absent for STL/OBJ and single-colour + // 3MFs, in which case every stage below behaves exactly as before. + const srcMaterial = input.triMaterial || null; + // Hoist intermediates so the finally block can always dispose them. let subdivided = null; let displaced = null; @@ -226,7 +232,10 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = if (settings.regularizeEnabled) { onEvent('regularize', 0); await yieldFrame(); - const regParents = mode === 'bake' + // Export mode normally has no use for the parent map, but material + // tracking needs it too — thread the real one whenever either wants it. + const needParents = mode === 'bake' || srcMaterial !== null; + const regParents = needParents ? faceParentId : new Int32Array(subdivided.attributes.position.count / 3); const reg = regularizeMesh(subdivided, regParents, settings.refineLength, regularizeOpts); @@ -239,7 +248,7 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = secondPassWeights, { fast: false } ); reg.geometry.dispose(); - if (mode === 'bake') { + if (needParents) { const composed = new Int32Array(resubParents.length); for (let i = 0; i < resubParents.length; i++) { composed[i] = reg.faceParentId[resubParents[i]]; @@ -250,6 +259,17 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = } if (shouldAbort()) return null; + // Project the source palette slots onto the refined mesh. faceParentId maps + // each refined face back to the source triangle it descends from, so this + // is exact — no spatial guessing at part boundaries. + let faceMaterial = null; + if (srcMaterial) { + faceMaterial = new Uint16Array(faceParentId.length); + for (let i = 0; i < faceParentId.length; i++) { + faceMaterial[i] = srcMaterial[faceParentId[i]]; + } + } + const subTriCount = subdivided.attributes.position.count / 3; onEvent('displace', 0, { triCount: subTriCount }); await yieldFrame(); @@ -302,10 +322,12 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = (p) => onEvent('decimate', p, { from: dispTriCount, needsDecimation }), settings.harvestFlatFaces, settings.harvestTol, - lockedFaces + lockedFaces, + faceMaterial ); // Capture before repair replaces the geometry (userData isn't carried over). lockedOverBudget = !!finalGeometry.userData.lockedOverBudget; + if (faceMaterial) faceMaterial = finalGeometry.userData.faceMaterial; // Free pre-decimation geometry — decimate created a separate copy. displaced.dispose(); displaced = null; @@ -327,9 +349,10 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = onEvent('repair', 0); await yieldFrame(); const beforeSlivers = countAreaSlivers(finalGeometry); - const repaired = resolveTJunctions(finalGeometry); + const repaired = resolveTJunctions(finalGeometry, { faceMaterial }); finalGeometry.dispose(); finalGeometry = repaired; + if (faceMaterial) faceMaterial = repaired.userData.faceMaterial; const after = countEdgeDefects(finalGeometry); repairStats = { beforeSlivers, @@ -350,6 +373,7 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = runDecimation, needsDecimation, faceParentId: mode === 'bake' ? faceParentId : null, + faceMaterial, repairStats, }; } finally { diff --git a/js/exportWorker.js b/js/exportWorker.js index 34cbe53..b7c10e0 100644 --- a/js/exportWorker.js +++ b/js/exportWorker.js @@ -35,6 +35,7 @@ self.onmessage = async (e) => { const transfers = [result.positions.buffer]; if (result.normals) transfers.push(result.normals.buffer); if (result.faceParentId) transfers.push(result.faceParentId.buffer); + if (result.faceMaterial) transfers.push(result.faceMaterial.buffer); self.postMessage({ type: 'done', result }, transfers); } catch (err) { self.postMessage({ type: 'error', message: (err && err.message) || String(err) }); diff --git a/js/exporter.js b/js/exporter.js index 80bee5c..0a2e0b7 100644 --- a/js/exporter.js +++ b/js/exporter.js @@ -87,6 +87,30 @@ export function exportSTL(geometry, filename = 'textured.stl') { triggerDownload(buffer, filename); } +/** + * Escape a string for use inside a double-quoted XML attribute. Part names come + * from the imported file, so they can legitimately contain & < > and quotes. + */ +function xmlAttr(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + // Strip control characters that are simply illegal in XML 1.0. + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); +} + +/** + * Render a palette colour as a 3MF displaycolor ("#RRGGBBAA"). Parts imported + * without a colour still need a valid value here, so fall back to opaque white. + */ +function toDisplayColor(color) { + const m = /^#([0-9a-fA-F]{6})$/.exec(color || ''); + return '#' + (m ? m[1].toUpperCase() : 'FFFFFF') + 'FF'; +} + /** * 3MF exporter — builds a ZIP-packaged XML mesh in the Microsoft 3D * Manufacturing core format (2015/02). @@ -95,37 +119,37 @@ export function exportSTL(geometry, filename = 'textured.stl') { * tolerance) so the output is both smaller than binary STL and round-trippable * by this project's own 3MF loader. * + * When `materials` is supplied (multi-colour / multi-tool 3MF import), each + * part is written as its own carrying its original colour and + * extruder assignment, wrapped in a components assembly — the layout Bambu + * Studio, OrcaSlicer and PrusaSlicer all read back as separate painted bodies. + * Without it the mesh is written as a single object exactly as before. + * * @param {THREE.BufferGeometry} geometry – non-indexed with position attribute * @param {string} [filename] + * @param {{palette: Array<{name,color,extruder}>, faceMaterial: Uint16Array}|null} [materials] */ -export function export3MF(geometry, filename = 'textured.3mf') { +export function export3MF(geometry, filename = 'textured.3mf', materials = null) { const posArr = geometry.attributes.position.array; const triCount = (posArr.length / 9) | 0; - // ── Deduplicate vertices ───────────────────────────────────────────────── - // Weld on the 1e4 grid (0.0001 mm cells), matching the 4-decimal precision - // the coordinates are written with below — safely below the resolution of - // any FDM/SLA printer and far tighter than float32 rounding noise from the - // displacement pipeline. The pipeline snaps coordinates onto this exact - // grid in resolveTJunctions before export, so welding here only merges - // bit-identical (or grid-identical) points. - const indexMap = new QuantizedPointMap(1e4, Math.min(triCount * 3, 1 << 22)); - const uniqueXYZ = []; // flat [x,y,z,x,y,z,...] - const triIdx = new Uint32Array(triCount * 3); - - for (let i = 0; i < triCount; i++) { - for (let j = 0; j < 3; j++) { - const b = i * 9 + j * 3; - const x = posArr[b]; - const y = posArr[b + 1]; - const z = posArr[b + 2]; - const idx = indexMap.getOrSet(x, y, z, uniqueXYZ.length / 3); - if (indexMap.inserted) uniqueXYZ.push(x, y, z); - triIdx[i * 3 + j] = idx; + // ── Decide single-object vs multi-part layout ──────────────────────────── + const palette = materials && materials.palette; + const faceMat = materials && materials.faceMaterial; + // Slots that survived the pipeline, in palette order. A part decimated out + // of existence is simply dropped rather than emitted as an empty object. + let partSlots = []; + if (palette && faceMat && faceMat.length === triCount) { + const counts = new Uint32Array(palette.length); + for (let i = 0; i < triCount; i++) { + const s = faceMat[i]; + if (s < palette.length) counts[s]++; } + for (let s = 0; s < palette.length; s++) if (counts[s] > 0) partSlots.push(s); } - - const vertCount = uniqueXYZ.length / 3; + // One surviving part carries no more information than a plain single object. + const multi = partSlots.length > 1; + if (!multi) partSlots = []; // ── Build 3dmodel.model XML as Uint8Array chunks ───────────────────────── // A single concatenated string would exceed V8's max-string-length limit @@ -151,16 +175,6 @@ export function export3MF(geometry, filename = 'textured.3mf') { if (pending.length >= FLUSH_THRESHOLD) flush(); } - emit( - '\n' + - '\n' + - '\n' + - '\n' + - '\n' + - '\n' - ); - // Vertices: trim trailing zeros to keep the file compact. const fmt = (n) => { // 4 decimals matches the dedup precision; strip trailing zeros and ".". @@ -168,34 +182,107 @@ export function export3MF(geometry, filename = 'textured.3mf') { if (s.indexOf('.') !== -1) s = s.replace(/0+$/, '').replace(/\.$/, ''); return s; }; - for (let i = 0; i < vertCount; i++) { - const b = i * 3; - emit( - '\n' - ); + + /** + * Emit one covering the triangles whose palette slot is `slot` + * (or every triangle when `slot` is null). + * + * Deduplication is per-mesh: 3MF vertex indices are object-local, and + * keeping each part's vertex list separate is also what makes the parts + * independent bodies rather than one welded shell. Welds on the 1e4 grid + * (0.0001 mm cells), matching the 4-decimal precision the coordinates are + * written with — safely below any FDM/SLA printer's resolution and far + * tighter than float32 rounding noise from the displacement pipeline. The + * pipeline snaps coordinates onto this exact grid in resolveTJunctions + * before export, so welding here only merges grid-identical points. + */ + function emitMesh(slot) { + const indexMap = new QuantizedPointMap(1e4, Math.min(triCount * 3, 1 << 22)); + const uniqueXYZ = []; // flat [x,y,z,x,y,z,...] + const triIdx = []; // flat [i0,i1,i2,...] for the selected triangles + + for (let i = 0; i < triCount; i++) { + if (slot !== null && faceMat[i] !== slot) continue; + for (let j = 0; j < 3; j++) { + const b = i * 9 + j * 3; + const x = posArr[b]; + const y = posArr[b + 1]; + const z = posArr[b + 2]; + const idx = indexMap.getOrSet(x, y, z, uniqueXYZ.length / 3); + if (indexMap.inserted) uniqueXYZ.push(x, y, z); + triIdx.push(idx); + } + } + + emit('\n\n'); + for (let i = 0; i < uniqueXYZ.length; i += 3) { + emit( + '\n' + ); + } + emit('\n\n'); + for (let i = 0; i < triIdx.length; i += 3) { + emit( + '\n' + ); + } + emit('\n\n'); } - emit('\n\n'); + // Resource ids: basematerials 1, part objects 2..N+1, assembly N+2. + const MAT_ID = 1; + const partObjId = (k) => 2 + k; + const assemblyId = 2 + partSlots.length; - for (let i = 0; i < triCount; i++) { - const b = i * 3; - emit( - '\n' - ); + emit( + '\n' + + '\n' + + // Signals slicers to read Metadata/model_settings.config, where the + // per-part extruder assignments live. + (multi ? '1\n' : '') + + '\n' + ); + + if (multi) { + emit('\n'); + for (const s of partSlots) { + const p = palette[s]; + emit( + '\n' + ); + } + emit('\n'); + + partSlots.forEach((s, k) => { + emit('\n'); + emitMesh(s); + emit('\n'); + }); + + // Assembly: one printable item made of all the parts, so the slicer shows + // a single object with sub-parts rather than N unrelated plate items. + emit('\n\n'); + partSlots.forEach((_, k) => emit('\n')); + emit('\n\n'); + } else { + emit('\n'); + emitMesh(null); + emit('\n'); } emit( - '\n' + - '\n' + - '\n' + '\n' + - '\n\n\n' + + '\n\n\n' + '\n' ); flush(); @@ -221,12 +308,37 @@ export function export3MF(geometry, filename = 'textured.3mf') { 'Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>\n' + '\n'; - // ── Zip and download ───────────────────────────────────────────────────── - const zipped = zipSync({ + // Slicer part metadata. The core spec's basematerials above already carries + // the colours; this is what restores the *extruder* assignment, so a + // re-imported file needs no repainting. Mirrors the layout Bambu Studio / + // OrcaSlicer write (and, deliberately, their omission of a content-type + // declaration for it — matching a known-good file is safer here than being + // strictly OPC-correct). + const files = { '[Content_Types].xml': strToU8(contentTypesXml), '_rels/.rels': strToU8(relsXml), '3D/3dmodel.model': modelBytes, - }, { level: 6 }); + }; + + if (multi) { + let cfg = '\n\n' + + '\n'; + partSlots.forEach((s, k) => { + const p = palette[s]; + cfg += '\n' + + '\n' + + '\n'; + if (p.extruder !== null && p.extruder !== undefined) { + cfg += '\n'; + } + cfg += '\n'; + }); + cfg += '\n\n'; + files['Metadata/model_settings.config'] = strToU8(cfg); + } + + // ── Zip and download ───────────────────────────────────────────────────── + const zipped = zipSync(files, { level: 6 }); triggerDownload( zipped, diff --git a/js/i18n/en.js b/js/i18n/en.js index ff0f86e..6753ddc 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -23,6 +23,10 @@ export default { "progress.stepParse": "Reading STEP file…", "progress.stepTessellate": "Tessellating STEP model…", "ui.wireframe": "Wireframe", + "ui.partColors": "Part Colours", + "ui.partColorsTitle": "Shade each part by the extruder it is assigned to in the imported 3MF", + "ui.partColorsLegend": "Parts / Tools", + "ui.partColorsTool": "T{n}", "ui.perspective": "Perspective View", "ui.controlsHint": "Left drag: orbit  ·  Right drag: pan  ·  Scroll: zoom", "ui.meshInfo": "{n} triangles · {mb} MB · {sx} × {sy} × {sz} mm", diff --git a/js/i18n/es.js b/js/i18n/es.js index 60369ca..a4b593d 100644 --- a/js/i18n/es.js +++ b/js/i18n/es.js @@ -23,6 +23,10 @@ export default { "progress.stepParse": "Leyendo archivo STEP…", "progress.stepTessellate": "Teselando modelo STEP…", "ui.wireframe": "Malla de alambre", + "ui.partColors": "Colores de piezas", + "ui.partColorsTitle": "Colorear cada pieza según el extrusor asignado en el 3MF importado", + "ui.partColorsLegend": "Piezas / Extrusores", + "ui.partColorsTool": "E{n}", "ui.perspective": "Vista en perspectiva", "ui.controlsHint": "Arrastrar izq.: orbitar  ·  Arrastrar der.: desplazar  ·  Rueda: zoom", "ui.meshInfo": "{n} triángulos · {mb} MB · {sx} × {sy} × {sz} mm", diff --git a/js/main.js b/js/main.js index 8820b33..501f3a5 100644 --- a/js/main.js +++ b/js/main.js @@ -118,6 +118,10 @@ const settings = { // without the key fall back to 'linear' — the only ramp they had. boundaryFalloffCurve: 'ease', symmetricDisplacement: false, + // Shade the viewport with the per-part colours of a multi-colour 3MF instead + // of the flat teal. Starts off and is switched on by syncPartColorsUI() only + // when the loaded model actually carries part colours. + showPartColors: false, noDownwardZ: false, smoothBottom: true, harvestFlatFaces: true, @@ -301,6 +305,9 @@ const advancedSection = document.getElementById('advanced-section'); const advancedToggle = document.getElementById('advanced-toggle'); const wireframeToggle = document.getElementById('wireframe-toggle'); const projectionToggle = document.getElementById('projection-toggle'); +const partColorsToggle = document.getElementById('part-colors-toggle'); +const partColorsRow = document.getElementById('part-colors-row'); +const partLegend = document.getElementById('part-legend'); const placeOnFaceBtn = document.getElementById('place-on-face-btn'); const rotateBtn = document.getElementById('rotate-btn'); const rotateControls = document.getElementById('rotate-controls'); @@ -1032,6 +1039,9 @@ function populateLanguageSelector() { refreshExclusionOverlay(); if (lastFastDiag) renderFastDiag(lastFastDiag); if (lastAdvancedDiag) renderAdvancedDiag(lastAdvancedDiag); + // Built imperatively from the part palette, so applyTranslations() can't + // reach its labels — rebuild it in the new locale. + renderPartLegend(); } // The cylinder panel paints its placeholder text via Canvas2D, which // applyTranslations() doesn't reach — re-render so the new locale lands. @@ -1686,6 +1696,14 @@ function wireEvents() { // ── Projection toggle ── projectionToggle.addEventListener('change', () => setProjection(projectionToggle.checked)); + // ── Part colours (multi-colour 3MF) ── + partColorsToggle.addEventListener('change', () => { + settings.showPartColors = partColorsToggle.checked; + renderPartLegend(); + updatePreview(); + requestRender(); + }); + // ── Exclusion tool wiring ───────────────────────────────────────────────── exclBrushBtn.addEventListener('click', () => setExclusionTool('brush')); @@ -2961,6 +2979,7 @@ function loadDefaultCube() { currentStlName = 'cube_50x50x50'; currentStlExt = '.stl'; checkAmplitudeWarning(); + syncPartColorsUI(); // demo cube has no parts → hides the toggle loadGeometry(geo); dropHint.classList.add('hidden'); @@ -3128,6 +3147,8 @@ async function handleModelFile(file, stepSettings = null) { const _extMatch = file.name.match(/\.(stl|obj|3mf|step|stp)$/i); currentStlExt = _extMatch ? _extMatch[0].toLowerCase() : ''; checkAmplitudeWarning(); + // Reveal (and default on) the part-colour view when the import carried one. + syncPartColorsUI(); // Surface the STEP conversion verdict without blocking the user. if (step && step.diagnostics && !step.diagnostics.ok) { @@ -3515,6 +3536,161 @@ function updateSmartResBtnState() { if (smartResBtn) smartResBtn.addEventListener('click', applySmartResolution); +/** + * Viewport colours by extruder / tool number, mirroring how a slicer presents a + * multi-tool plate: one distinct colour per tool, not per stored swatch. + * + * A 3MF's own `displaycolor` is a poor basis for the viewport — files routinely + * give several parts the same swatch (white is the common default) even though + * they print on different tools, which would render them indistinguishable here + * while the slicer shows them apart. Keying on the extruder instead means what + * you see maps 1:1 to what actually prints. + * + * Saturated orange is deliberately absent: it is the exclusion-mask colour, and + * a part sharing that hue would be ambiguous while painting. + */ +const EXTRUDER_COLORS = [ + '#3b82f6', // 1 blue + '#ef4444', // 2 red + '#22c55e', // 3 green + '#a855f7', // 4 purple + '#eab308', // 5 yellow + '#06b6d4', // 6 cyan + '#ec4899', // 7 pink + '#84cc16', // 8 lime +]; + +/** Colour a palette entry is drawn with: by tool, else its own file swatch. */ +function partSwatch(p) { + if (p.extruder !== null && p.extruder !== undefined) { + return EXTRUDER_COLORS[(p.extruder - 1) % EXTRUDER_COLORS.length]; + } + return p.color || '#b8b8bf'; // no tool and no swatch → neutral grey +} + +/** "#rrggbb" → linear-space [r,g,b]; the renderer is linear, so sRGB values fed + * in raw come out visibly washed out. Returns null for anything unparseable. */ +function hexToLinear(hex) { + const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || ''); + if (!m) return null; + return [1, 2, 3].map((k) => { + const c = parseInt(m[k], 16) / 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); +} + +/** + * Set (or update) the `partColor` vertex attribute so the preview shader can + * paint each body of a multi-colour 3MF in its own tool colour. + * + * The palette lives on currentGeometry (put there by the 3MF importer). The + * preview and precision geometries are refined copies, so their faces are + * mapped back to original faces through the same parent maps the masking code + * uses. Without a palette the attribute is removed and the shader falls back to + * the flat teal. + */ +function updatePartColors(geometry) { + if (!geometry) return; + const mats = currentGeometry && currentGeometry.userData.materials; + // Skip the buffer entirely when unused — on a heavily subdivided preview mesh + // this attribute is three floats per vertex and would cost real memory. + if (!mats || !settings.showPartColors) { + if (geometry.attributes.partColor) geometry.deleteAttribute('partColor'); + return; + } + + const posCount = geometry.attributes.position.count; + const triCount = posCount / 3; + + // Refined geometries index their own faces; map back to original faces. + const parentMap = (geometry === dispPreviewGeometry) ? dispPreviewParentMap + : (geometry === precisionGeometry) ? precisionParentMap + : null; + + // Decode once — parsing "#rrggbb" per vertex would be wasteful. An entry that + // resolves to nothing falls back to neutral grey rather than black, which + // would read as a shading bug rather than "this part has no colour". + const rgb = mats.palette.map((p) => hexToLinear(partSwatch(p)) || [0.72, 0.72, 0.75]); + + const existing = geometry.getAttribute('partColor'); + const reuse = existing && existing.array.length === posCount * 3; + const arr = reuse ? existing.array : new Float32Array(posCount * 3); + + const fallback = [0.72, 0.72, 0.75]; + for (let t = 0; t < triCount; t++) { + const faceIdx = parentMap ? parentMap[t] : t; + const slot = mats.triMaterial[faceIdx]; + const c = rgb[slot] || fallback; + for (let v = 0; v < 3; v++) { + const o = (t * 3 + v) * 3; + arr[o] = c[0]; arr[o + 1] = c[1]; arr[o + 2] = c[2]; + } + } + + if (reuse) existing.needsUpdate = true; + else geometry.setAttribute('partColor', new THREE.Float32BufferAttribute(arr, 3)); +} + +/** + * Show or hide the part-colour toggle to match the loaded model, and default it + * on whenever a model that has colours is imported. + */ +function syncPartColorsUI() { + const has = !!(currentGeometry && currentGeometry.userData.materials); + partColorsRow.classList.toggle('hidden', !has); + // Must be cleared for a colourless model, not just hidden: the shader would + // otherwise keep shading from an attribute that no longer exists, which WebGL + // supplies as (0,0,0) — a solid black mesh. + settings.showPartColors = has; + partColorsToggle.checked = has; + renderPartLegend(); +} + +/** + * Draw the viewport legend mapping each swatch to its part name and tool. + * Colours are assigned per extruder rather than taken from the file, so without + * this the mapping would be unguessable. + */ +function renderPartLegend() { + const mats = currentGeometry && currentGeometry.userData.materials; + if (!mats || !settings.showPartColors) { + partLegend.classList.add('hidden'); + partLegend.replaceChildren(); + return; + } + + const title = document.createElement('div'); + title.className = 'part-legend-title'; + title.textContent = t('ui.partColorsLegend'); + const rows = [title]; + + mats.palette.forEach((p) => { + const row = document.createElement('div'); + row.className = 'part-legend-row'; + + const sw = document.createElement('span'); + sw.className = 'part-legend-swatch'; + sw.style.background = partSwatch(p); + + // textContent, not innerHTML — part names come from the imported file. + const name = document.createElement('span'); + name.className = 'part-legend-name'; + name.textContent = p.name || '—'; + + const tool = document.createElement('span'); + tool.className = 'part-legend-tool'; + tool.textContent = (p.extruder !== null && p.extruder !== undefined) + ? t('ui.partColorsTool', { n: p.extruder }) + : '—'; + + row.append(sw, name, tool); + rows.push(row); + }); + + partLegend.replaceChildren(...rows); + partLegend.classList.remove('hidden'); +} + /** * Set (or update) the `faceMask` vertex attribute on a geometry. * 1.0 = textured, 0.0 = user-excluded. Angle masking stays in the shader. @@ -3562,6 +3738,12 @@ function updateFaceMask(geometry) { geometry.setAttribute('faceMask', new THREE.Float32BufferAttribute(maskArr, 1)); } + // partColor is a sibling per-face shader attribute with the same lifetime and + // the same parent-map handling, and several call sites swap geometry straight + // into the viewer without going through updatePreview(). Refreshing it here + // keeps the two from drifting apart (a missing partColor renders black). + updatePartColors(geometry); + // Ensure faceNormal attribute exists (needed by shader for angle masking). // For the original geometry normal == faceNormal; for subdivided geometry // addFaceNormals() is called after subdivision, but guard here in case the @@ -4781,9 +4963,13 @@ async function handleExport(format = 'stl') { // running inline if the worker can't initialise. See exportPipeline.js. const exportEntry = getEffectiveMapEntry(); const isStale = () => exportToken !== myToken; + // Multi-colour 3MF: per-triangle palette slots ride along so each body + // keeps its filament assignment in the exported file (no repainting). + const srcMaterials = currentGeometry.userData.materials || null; const result = await runPipeline({ positions: currentGeometry.attributes.position.array, faceWeights, + triMaterial: srcMaterials ? srcMaterials.triMaterial : null, imageData: exportEntry.imageData, imgWidth: exportEntry.width, imgHeight: exportEntry.height, @@ -4835,7 +5021,10 @@ async function handleExport(format = 'stl') { setProgress(0.97, t('progress.writing3mf')); await yieldFrame(); if (exportToken !== myToken) return; - export3MF(finalGeometry, `${baseName}.3mf`); + export3MF(finalGeometry, `${baseName}.3mf`, + srcMaterials && result.faceMaterial + ? { palette: srcMaterials.palette, faceMaterial: result.faceMaterial } + : null); } else { setProgress(0.97, t('progress.writingStl')); await yieldFrame(); @@ -5082,9 +5271,11 @@ async function bakeTextures() { // to remap user exclusions onto the baked output). Worker-first with // inline fallback, same as handleExport. const exportEntry = getEffectiveMapEntry(); + const srcMaterials = currentGeometry.userData.materials || null; const result = await runPipeline({ positions: currentGeometry.attributes.position.array, faceWeights, + triMaterial: srcMaterials ? srcMaterials.triMaterial : null, imageData: exportEntry.imageData, imgWidth: exportEntry.width, imgHeight: exportEntry.height, @@ -5099,6 +5290,14 @@ async function bakeTextures() { displaced = new THREE.BufferGeometry(); displaced.setAttribute('position', new THREE.BufferAttribute(result.positions, 3)); if (result.normals) displaced.setAttribute('normal', new THREE.BufferAttribute(result.normals, 3)); + // Carry the part/colour assignment onto the baked mesh so a second texture + // pass — and the eventual export — still knows which body is which. + if (srcMaterials && result.faceMaterial) { + displaced.userData.materials = { + palette: srcMaterials.palette, + triMaterial: result.faceMaterial, + }; + } setBakeProgress(0.90, t('progress.finalizing')); await yieldFrame(); @@ -5176,6 +5375,7 @@ function adoptBakedGeometry(geometry, bounds, opts = {}) { currentBounds = bounds; currentStlName = `${currentStlName}_baked`; checkAmplitudeWarning(); + syncPartColorsUI(); // bake carries materials forward, so the toggle stays geometry = currentGeometry; diff --git a/js/meshRepair.js b/js/meshRepair.js index 3dcf3d7..1cd96fb 100644 --- a/js/meshRepair.js +++ b/js/meshRepair.js @@ -122,6 +122,11 @@ export function resolveTJunctions(geometry, opts = {}) { // the two — and since we output the snapped coords, the importer sees the // identical geometry and finds nothing left to remove. const DEGEN_AREA2 = 1e-18; + // Multi-material (3MF colour preservation): carry each face's palette slot + // alongside `faces` so drops and fan-splits below keep it in step. + const inMaterial = opts.faceMaterial || null; + let faceMat = inMaterial ? [] : null; + let faces = [], droppedNeedles = 0; for (let t = 0; t < nTri; t++) { const a = vid[t*3], b = vid[t*3+1], c = vid[t*3+2]; @@ -131,6 +136,7 @@ export function resolveTJunctions(geometry, opts = {}) { const cx = uy*wz - uz*wy, cy = uz*wx - ux*wz, cz = ux*wy - uy*wx; if (cx*cx + cy*cy + cz*cz < DEGEN_AREA2) { droppedNeedles++; continue; } faces.push([a, b, c]); + if (faceMat) faceMat.push(inMaterial[t]); } const ekey = (a, b) => (a < b ? a * 4294967296 + b : b * 4294967296 + a); @@ -185,18 +191,28 @@ export function resolveTJunctions(geometry, opts = {}) { // Apply: replace each split face with a fan from its apex over the split edge. const next = []; + const nextMat = faceMat ? [] : null; for (let fi = 0; fi < faces.length; fi++) { const sp = splits.get(fi); - if (!sp) { next.push(faces[fi]); continue; } + if (!sp) { + next.push(faces[fi]); + if (nextMat) nextMat.push(faceMat[fi]); + continue; + } const f = faces[fi], { a, b, mids } = sp; const apex = f[0] !== a && f[0] !== b ? f[0] : f[1] !== a && f[1] !== b ? f[1] : f[2]; // Preserve winding: walk the base in the direction the face traverses it. let dirAB = false; for (let e = 0; e < 3; e++) if (f[e] === a && f[(e+1)%3] === b) { dirAB = true; break; } const seq = dirAB ? [a, ...mids, b] : [b, ...mids.slice().reverse(), a]; - for (let s = 0; s < seq.length - 1; s++) next.push([seq[s], seq[s+1], apex]); + // Every shard of a split face belongs to the same part as its parent. + for (let s = 0; s < seq.length - 1; s++) { + next.push([seq[s], seq[s+1], apex]); + if (nextMat) nextMat.push(faceMat[fi]); + } } faces = next; + if (nextMat) faceMat = nextMat; } // ── Rebuild non-indexed soup with flat normals ────────────────────────────── @@ -219,5 +235,6 @@ export function resolveTJunctions(geometry, opts = {}) { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(out, 3)); g.setAttribute('normal', new THREE.BufferAttribute(nrm, 3)); + if (faceMat) g.userData.faceMaterial = Uint16Array.from(faceMat); return g; } diff --git a/js/previewMaterial.js b/js/previewMaterial.js index e6bb572..ba5e19e 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -220,6 +220,9 @@ const vertexShader = /* glsl */` attribute float faceMask; attribute float boundaryFalloffAttr; attribute float boundaryMaskTypeAttr; + // Per-part display colour from a multi-colour 3MF import. Absent on plain + // meshes, where WebGL supplies (0,0,0) — the usePartColors uniform gates it. + attribute vec3 partColor; varying vec3 vModelPos; // ORIGINAL model-space position → UV computation in fragment varying vec3 vModelNormal; // model-space face normal → stable UV blending @@ -229,8 +232,10 @@ const vertexShader = /* glsl */` varying float vFaceMask; // combined mask (angle + user exclusion + boundary falloff) varying float vUserMask; // raw user-exclusion mask (0 = user-excluded, 1 = included) varying float vMaskType; // boundary mask type (0 = user mask, 1 = angle mask) + varying vec3 vPartColor; // per-part colour from a multi-colour 3MF void main() { + vPartColor = partColor; vec3 safeN = length(normal) > 1e-6 ? normalize(normal) : vec3(0.0, 0.0, 1.0); // Use the true geometric face normal for angle masking so that // smooth/interpolated normals from subdivision don't cause mask bleeding. @@ -283,7 +288,9 @@ const fragmentShader = /* glsl */` uniform float boundaryEdgeTexWidth; uniform float boundaryFalloffDist; uniform int boundaryFalloffCurve; // 0 = linear, 1 = s-curve, 2 = ease-in + uniform int usePartColors; // 1 = shade with the imported 3MF part colours + varying vec3 vPartColor; varying vec3 vModelPos; varying vec3 vModelNormal; varying vec3 vViewPos; @@ -379,7 +386,12 @@ const fragmentShader = /* glsl */` // that specular highlights, diffuse response, and view-dependent shading // are perfectly consistent everywhere. Mask tinting is applied AFTER // lighting as a colour blend so masked areas keep the same glossy look. - vec3 tealBase = vec3(0.22, 0.68, 0.68); + // Base surface colour: the stock teal, or — for a multi-colour 3MF with + // "part colours" enabled — the body's own filament colour, so the viewport + // reads like the slicer will after export. + vec3 tealBase = usePartColors == 1 + ? vPartColor + : vec3(0.22, 0.68, 0.68); vec3 userMaskColor = vec3(0.85, 0.40, 0.15); vec3 angleMaskColor = vec3(0.45, 0.48, 0.50); @@ -403,6 +415,11 @@ const fragmentShader = /* glsl */` float maskEffect = 1.0 - maskBlend; // 0 = fully textured, 1 = fully masked float effectiveMaskType = mix(vMaskType, 0.0, step(0.5, 1.0 - vUserMask)); vec3 maskBase = mix(userMaskColor, angleMaskColor, effectiveMaskType); + // With part colours on, a flat orange mask would hide exactly what the user + // turned the mode on to see. Keep the part's own hue dominant and let the + // mask read as a darkened tint over it, so include/exclude painting stays + // legible without erasing the colour layout. + if (usePartColors == 1) maskBase = mix(vPartColor * 0.5, maskBase, 0.35); vec3 litMask = maskBase * 0.55 + maskBase * diff1 * vec3(1.00, 0.96, 0.88) * 0.55 + maskBase * diff2 * vec3(0.80, 0.60, 0.50) * 0.15 @@ -473,6 +490,7 @@ export function updateMaterial(material, displacementTexture, settings) { u.textureAspect.value.set(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1); u.boundaryFalloffDist.value = settings.boundaryFalloff ?? 0.0; u.boundaryFalloffCurve.value = FALLOFF_CURVE_INDEX[settings.boundaryFalloffCurve] ?? 0; + u.usePartColors.value = settings.showPartColors ? 1 : 0; } // ── Internal ────────────────────────────────────────────────────────────────── @@ -513,6 +531,7 @@ function buildUniforms(tex, settings) { boundaryEdgeTexWidth: { value: 1.0 }, boundaryFalloffDist: { value: settings.boundaryFalloff ?? 0.0 }, boundaryFalloffCurve: { value: FALLOFF_CURVE_INDEX[settings.boundaryFalloffCurve] ?? 0 }, + usePartColors: { value: settings.showPartColors ? 1 : 0 }, }; } diff --git a/js/stlLoader.js b/js/stlLoader.js index 27b852c..85aa99b 100644 --- a/js/stlLoader.js +++ b/js/stlLoader.js @@ -50,6 +50,10 @@ export function loadSTLFile(file) { * BufferAttribute. Any existing normal attribute is deleted so that * setupGeometry will recompute it on the clean data. * + * geometry.userData.materials.triMaterial (one entry per triangle, set by the + * 3MF importer) is compacted in lockstep — dropping triangles here renumbers + * every face, and the whole colour-preservation chain is keyed on face index. + * * Returns { nanCount, degenerateCount } so callers can warn the user. */ function validateAndCleanGeometry(geometry) { @@ -57,6 +61,11 @@ function validateAndCleanGeometry(geometry) { const src = pos.array; // Float32Array, 9 floats per triangle const triCount = src.length / 9; + const triMaterial = geometry.userData.materials + ? geometry.userData.materials.triMaterial + : null; + let matWrite = 0; + let writeIdx = 0; let nanCount = 0; let degenerateCount = 0; @@ -89,12 +98,16 @@ function validateAndCleanGeometry(geometry) { src[writeIdx+6] = cx; src[writeIdx+7] = cy; src[writeIdx+8] = cz; } writeIdx += 9; + if (triMaterial) triMaterial[matWrite++] = triMaterial[t]; } const removed = nanCount + degenerateCount; if (removed > 0) { geometry.setAttribute('position', new THREE.BufferAttribute(src.slice(0, writeIdx), 3)); geometry.deleteAttribute('normal'); // stale — recomputed below + if (triMaterial) { + geometry.userData.materials.triMaterial = triMaterial.slice(0, matWrite); + } } if (writeIdx === 0) { @@ -253,6 +266,53 @@ export function load3MFFile(file) { const MAX_3MF_TRIANGLES = 10_000_000; const MAX_3MF_DEPTH = 32; +/** + * Normalise a 3MF displaycolor ("#RRGGBB" or "#RRGGBBAA", case-insensitive) + * to lowercase "#rrggbb". Alpha is dropped — it has no meaning for filament + * assignment and slicers ignore it. Returns null for anything unparseable. + */ +function normaliseColor(raw) { + if (!raw) return null; + const m = /^#?([0-9a-fA-F]{6})(?:[0-9a-fA-F]{2})?$/.exec(raw.trim()); + return m ? '#' + m[1].toLowerCase() : null; +} + +/** + * Parse Bambu Studio / OrcaSlicer's Metadata/model_settings.config. + * + * Shape: + * + * + * + * ... + * + * + * Each refers to an in 3dmodel.model, so the result is + * keyed by object id directly. Returns Map(objectId → { name, extruder }). + * An absent or malformed file yields an empty map — colour handling then falls + * back to the core-spec basematerials alone. + */ +function parseModelSettings(doc) { + const out = new Map(); + if (!doc || doc.getElementsByTagName('parsererror').length > 0) return out; + for (const part of doc.getElementsByTagName('part')) { + const id = part.getAttribute('id'); + if (id === null) continue; + let name = '', extruder = null; + for (const md of part.getElementsByTagName('metadata')) { + const key = md.getAttribute('key'); + const val = md.getAttribute('value'); + if (key === 'name') name = val || ''; + else if (key === 'extruder') { + const n = Number(val); + if (Number.isInteger(n) && n >= 1 && n <= 64) extruder = n; + } + } + out.set(String(id), { name, extruder }); + } + return out; +} + // ── Custom 3MF parser ──────────────────────────────────────────────────────── function parse3MF(data) { @@ -293,6 +353,35 @@ function parse3MF(data) { // Find all .model files in the zip const modelPaths = Object.keys(files).filter(f => f.endsWith('.model')); + // ── Material / colour resources ──────────────────────────────────────────── + // baseMaterials: "path#groupId" → [{ name, color }] indexed by pindex. + // Slicers (Bambu/Orca/Prusa) declare one per body and point each + // at it via pid (group id) + pindex (slot). + const baseMaterials = new Map(); + for (const path of modelPaths) { + const doc = readXML(path); + if (!doc) continue; + const normPath = path.replace(/^\//, '').replace(/\\/g, '/'); + const groups = doc.getElementsByTagNameNS(NS_CORE, 'basematerials'); + for (const g of groups) { + const gid = g.getAttribute('id'); + if (gid === null) continue; + const bases = []; + for (const b of g.getElementsByTagNameNS(NS_CORE, 'base')) { + bases.push({ + name: b.getAttribute('name') || '', + color: normaliseColor(b.getAttribute('displaycolor')), + }); + } + baseMaterials.set(normPath + '#' + gid, bases); + } + } + + // Slicer part metadata (Bambu Studio / OrcaSlicer): per-part display name and + // — the bit that actually matters for a multi-tool print — the extruder the + // part is assigned to. Lives outside the 3MF core spec, so it is optional. + const partSettings = parseModelSettings(readXML('Metadata/model_settings.config')); + for (const path of modelPaths) { const doc = readXML(path); if (!doc) continue; @@ -327,7 +416,25 @@ function parse3MF(data) { // Normalise path for lookup (strip leading slash, use forward slashes) const normPath = path.replace(/^\//, '').replace(/\\/g, '/'); - objectMap.set(normPath + '#' + id, { vertices, triangles }); + // Resolve this object's colour slot (pid → basematerials group, pindex → + // slot within it) and its slicer-assigned extruder, keyed by object id. + const pid = obj.getAttribute('pid'); + const pindex = obj.getAttribute('pindex'); + let baseName = '', baseColor = null; + if (pid !== null && pindex !== null) { + const group = baseMaterials.get(normPath + '#' + pid); + const slot = group && group[Number(pindex)]; + if (slot) { baseName = slot.name; baseColor = slot.color; } + } + const slicer = partSettings.get(String(id)) || null; + objectMap.set(normPath + '#' + id, { + vertices, triangles, + material: { + name: (slicer && slicer.name) || baseName || ('Part ' + id), + color: baseColor, + extruder: slicer ? slicer.extruder : null, + }, + }); } } @@ -446,12 +553,26 @@ function parse3MF(data) { } const positions = new Float32Array(totalTris * 9); + // Per-triangle palette slot. Built alongside positions so it stays in exact + // face order — every later stage (cleanup, subdivision, decimation, export) + // maps faces back to this array. + const triMaterial = new Uint16Array(totalTris); + const palette = []; + const slotOfMesh = new Map(); // meshKey → palette index let writeOffset = 0; + let triWrite = 0; const tmpV = new THREE.Vector3(); for (const inst of instances) { const mesh = objectMap.get(inst.meshKey); if (!mesh) continue; + // Instances of the same object (e.g. a duplicated body) share one slot. + let slot = slotOfMesh.get(inst.meshKey); + if (slot === undefined) { + slot = palette.length; + slotOfMesh.set(inst.meshKey, slot); + palette.push(mesh.material); + } const { vertices, triangles } = mesh; for (let t = 0; t < triangles.length; t += 3) { for (let v = 0; v < 3; v++) { @@ -462,11 +583,21 @@ function parse3MF(data) { positions[writeOffset++] = tmpV.y; positions[writeOffset++] = tmpV.z; } + triMaterial[triWrite++] = slot; } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + + // Only carry material data when it actually distinguishes the parts. A plain + // single-body 3MF, or a multi-body one with no colour/extruder information, + // keeps the previous behaviour of exporting as one merged object. + const distinctColors = new Set(palette.map(p => p.color).filter(Boolean)); + const hasExtruder = palette.some(p => p.extruder !== null); + if (palette.length > 1 && (hasExtruder || distinctColors.size > 1)) { + geometry.userData.materials = { palette, triMaterial }; + } return geometry; } diff --git a/style.css b/style.css index 74fc6ce..7039dbd 100644 --- a/style.css +++ b/style.css @@ -343,6 +343,61 @@ main { user-select: none; } +/* Part-colour toggle is only meaningful for a multi-colour 3MF import. */ +.wireframe-toggle.hidden { display: none; } + +/* ── Tool/extruder legend (multi-colour 3MF) ─────────────────────────────── + Bottom-left of the viewport: the top-left corner is taken by the cylinder + projection panel, and the bottom-right by the controls hint. */ +.part-legend { + position: absolute; + bottom: 14px; + left: 14px; + z-index: 21; + padding: 8px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + font-size: 11px; + color: var(--text-muted); + user-select: none; + pointer-events: none; + max-width: 240px; +} +.part-legend.hidden { display: none; } +.part-legend-title { + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0.7; + margin-bottom: 5px; +} +.part-legend-row { + display: flex; + align-items: center; + gap: 6px; + line-height: 1.6; +} +.part-legend-swatch { + width: 11px; + height: 11px; + border-radius: 2px; + flex: 0 0 auto; + border: 1px solid rgba(0, 0, 0, 0.35); +} +.part-legend-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.part-legend-tool { + margin-left: auto; + padding-left: 8px; + opacity: 0.65; + font-variant-numeric: tabular-nums; +} + .place-on-face-btn { display: flex; align-items: center;