diff --git a/CNAME b/CNAME deleted file mode 100644 index 6acbe29..0000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -bumpmesh.com \ No newline at end of file diff --git a/HANDOFF_TO_REVIEWER.md b/HANDOFF_TO_REVIEWER.md new file mode 100644 index 0000000..23ba231 --- /dev/null +++ b/HANDOFF_TO_REVIEWER.md @@ -0,0 +1,345 @@ +# Handoff: BumpMesh Color Export Feature + +**Reviewer:** GPT-5.5 Codex +**Author:** Claude Opus 4.7 (with parallel general-purpose agents for the four work units) +**Status:** Functional end-to-end. Stress-tested. Cache-bust query strings stripped. Codex review found and fixed an additional quantizer edge case plus color-image import/UI cleanup. +**Repo:** `/Users/eric/Documents/GitHub/stlTexturizer` +**Upstream:** `CNCKitchen/stlTexturizer` (the user's intent is to upstream this). +**Branch:** `feat/color-export-3mf`. + +--- + +## 1. What this feature does + +Adds full per-triangle color export to the 3MF output path of BumpMesh / stlTexturizer. STL output is unchanged (geometry only). + +Three composable color sources, layered in this precedence: + +1. **Excluded faces** (angle mask + exclusion paint) → `settings.colorBaseColor` (default `#ffffff`). +2. **Manual color paint override** → user-painted RGB per original face. +3. **Auto color source** (one of): + - **Gradient mapping**: sample the *displacement texture* (greyscale) at each vertex's UV using the same UV pipeline as displacement, then look up that grey value in a user-defined N-stop gradient. This is the killer feature — wood grain auto-darkens valleys, brick auto-grouts, etc. + - **Color image**: sample a user-uploaded RGB image at each vertex's UV (same UV pipeline). + - **None**: only base + manual paint. + +Per-triangle colors are quantized to ≤32 entries via median cut and emitted as a standard 3MF Materials Extension `` with `pid="3" p1=""` per triangle. This is what OrcaSlicer / Bambu Studio / PrusaSlicer all reliably consume; modern slicer "filament blending" makes the quantization visually smooth at print time. + +Backward compat: when the master toggle is OFF, the 3MF XML is byte-identical to today's geometry-only output. + +## 2. Scope locked + +**In scope:** +- N-stop gradient editor widget (custom, no deps) +- Color image upload (separate from displacement texture) +- Manual color paint (extends the existing exclusion-paint UX with a strategy callback) +- Per-triangle quantized colorgroup 3MF emission +- Persistence in `.bumpmesh` (settings + paint map + color.png) +- Undo/redo for all color settings + paint strokes +- i18n keys (English authored; other locales fall back to English — flag for native translation) + +**Explicitly deferred:** +- **Live preview tinting.** The plan deliberately does not tint the live displacement preview with the chosen colors. Adding it is a small follow-up (~30 lines on top of the per-vertex `color` attribute the bake already writes — enable `vertexColors: true` on the preview material and add a `mix(baseColor, vColor, mask)` line in the fragment shader). Documented in the plan file's "Open risks" section. + +## 3. Architecture overview + +The export pipeline (in order): + +``` +currentGeometry (loaded mesh) + │ + ├── buildCombinedFaceWeights(geo, excludedFaces, selectionMode, settings) + │ Per-vertex weights (1.0 = excluded by user paint OR angle mask) + │ Existing function in main.js, unchanged. + │ + ├── subdivide(geo, refineLength, onProgress, faceWeights) + │ Returns { geometry, safetyCapHit, faceParentId } + │ faceParentId: subdivided face index → original face index + │ Sets per-vertex `excludeWeight` BufferAttribute on output. + │ Existing module, unchanged. + │ + ├── applyDisplacement(subdivided, dispImage, w, h, settings, bounds, onProgress) + │ Returns displaced geometry. + │ FIX during stress test: now also forwards `excludeWeight` to its + │ output (was previously stripped). See bug #1 below. + │ + ├── applyColors(displaced, faceParentId, dispImg, dispW, dispH, colorImg, + │ colorW, colorH, settings, bounds, paintedFaceColors, + │ excludedFaces, selectionMode) + │ Writes per-vertex Float32×3 `color` attribute on `displaced`. + │ New module: js/colorBake.js + │ Mirrors displacement.js's UV pipeline exactly so colors line up + │ with displacement texels. Composition order applied per-face: + │ - faceExcluded[i] (avg of 3 vertex excludeWeights > 0.99) → base color + │ - paintedFaceColors.has(origFace[i]) → packed RGB + │ - autoSource gradient → sampleGradient(grey) + │ - autoSource image → sampleBilinearRGB + │ - else → base color + │ + ├── decimate(displaced, maxTri, onProgress, { preserveColor }) + │ Threads `color` Float32×3 attribute through QEM edge collapses. + │ Modified module: js/decimation.js (+56 LOC, gated on opts.preserveColor). + │ Backward compat: when preserveColor is false, byte-identical to before. + │ + ├── medianCut(triRGB, maxColors=32) + │ Returns { palette: Uint8Array(N*3), indices: Uint16Array(triCount) } + │ New module: js/quantize.js (~160 LOC). + │ FIX during stress test: bucket selection switched from + │ largest-population to range × log(pop+1). See bug #3 below. + │ + └── export3MF(finalGeometry, filename, { palette, triPaletteIndices }) + New options arg. When absent → byte-identical to today's output. + When present → adds `xmlns:m`, `requiredextensions="m"`, + `...`, and `pid="3" p1="..."` + per triangle. + Modified module: js/exporter.js (+~60 LOC, additive only). +``` + +UI state lives in: +- `settings` object in `js/main.js` (5 new keys) +- Top-level let-bindings in `js/main.js` (`paintedFaceColors: Map`, `_lastColorMap`, `colorPaintActive`, etc.) +- `
` in `index.html` + +State plumbing (all in `js/main.js`): +- `PERSISTED_KEYS` — controls sessionStorage + .bumpmesh inclusion +- `getSettingsSnapshot` / `applySettingsSnapshot` — snapshot ⇄ live state +- `_collectCurrentMask` / `_restoreMask` — paint map serialization (extended to include `coloredFaces`) +- `_captureUndoSnapshot` / `_undoSnapshotsEqual` — undo capture (extended to include color state) +- `wireColorPaintUI()` — newly added, runs once during `wireEvents()`. Wires all color UI controls + the colorPaint factory. + +## 4. Files changed (categorized) + +### New files (Unit deliverables) +- `js/colorBake.js` (~470 LOC) — `applyColors(...)`. Mirrors displacement.js Pass 1+2 structure exactly. +- `js/quantize.js` (~165 LOC) — `medianCut(triRGBs, maxColors)`. Range-weighted bucket selection. +- `js/gradientEditor.js` (~385 LOC) — `class GradientEditor` + `wireColorSectionVisibility()`. Self-contained widget. +- `js/colorPaint.js` (~250 LOC) — `setColorPaintHandlers(hooks) → { startPaint, paintAt, endPaint, isPainting }`. + +### Modified existing files +- `js/main.js` (~5340 LOC, +~250 LOC) — settings, persistence, undo, wireColorPaintUI, handleExport integration. **All main.js edits orchestrator-owned to avoid agent collisions.** +- `js/displacement.js` (+1 line, in stress-test fix) — forward `excludeWeight` to output. +- `js/decimation.js` (+~56 LOC) — thread `color` Float32×3 through QEM edge collapses, gated on `opts.preserveColor`. +- `js/exporter.js` (+~60 LOC) — extend `export3MF` with optional `{ palette, triPaletteIndices }` opts. +- `index.html` (+~83 LOC) — `
`. +- `style.css` (+~178 LOC) — gradient bar, stop handles, color section panel. +- `js/i18n/en.js` (+18 keys) — color.heading, color.enable, color.sourceNone/Gradient/Image, etc. +- `js/i18n/{de,fr,it,es,pt,ja}.js` (+18 keys each, English fallback values). + +### Untouched +- `js/subdivision.js` — already produced `excludeWeight` and `faceParentId`; we just consume them. +- `js/exclusion.js` — paint machinery reused via callbacks; no internal changes. +- `js/mapping.js`, `js/previewMaterial.js`, `js/stlLoader.js`, `js/presetTextures.js`, `js/meshValidation.js`, `js/viewer.js` — untouched. + +## 5. Critical design decisions + +### Why median-cut, and why range-weighted bucket selection +Median-cut is implementable in ~80 LOC without iteration loops, gives perceptually balanced palettes for the smooth gradients that dominate the use case, and runs sub-100ms for 250k triangles. + +The standard "split by largest population" variant equalizes bucket sizes, which **dilutes outlier clusters into nearby dominant clusters**. We hit this in stress test: 1022 angle-masked-bottom-face white triangles got assigned to a bucket with ~19k wood-tone neighbors and the bucket mean came out wood, not white. Every palette entry had exactly 656382/32 = 20512 triangles — telltale sign of population equalization. + +Switching to `range × log(pop+1)` bucket selection prioritizes buckets that contain outlier clusters while still keeping smooth gradients smooth. Codex review added a second guard: after sorting a bucket by its widest channel, `js/quantize.js` now splits at a large channel gap when one exists, falling back to a median-ish split for smooth ramps. This prevents small but visually important clusters from being averaged away for many iterations. + +### Why per-face exclusion threshold (not per-vertex) +The first pass of colorBake checked `excludeWeight >= 0.99` per-vertex. This failed at boundary corners on the cube's bottom face: subdivision dedups corner vertex weights to either the side-face copy (w=0) or the bottom-face copy (w=1) depending on iteration order, so partial-weight subdivided vertices fail the per-vertex check. + +Switched to per-face: `(ew[f*3] + ew[f*3+1] + ew[f*3+2]) / 3 > 0.99`. This matches `displacement.js`'s own exclusion semantics (line 140) exactly, so colorBake and displacement agree on which faces are excluded. + +### Why a separate post-displacement color bake (not inline with displacement) +Decouples color settings from displacement work: changing a gradient stop or paint stroke does not invalidate the cached `displaced` geometry in any future preview optimization. The bake runs on `displaced` (which has 1:1 vertex parity with `subdivided`, so `faceParentId` from subdivision still indexes it correctly). + +### Why range-based 3MF colorgroup over per-vertex color +Per-vertex color is a non-standard 3MF extension. Slicers (Bambu/Orca/Prusa) all consume the standard `` + per-triangle `pid`/`p1`. Quantization to ≤32 entries gives a manageable palette size; modern slicer filament-blending makes the quantization visually smooth at print time. + +### Why color-image lives in `_lastColorMap` (top-level let), not `settings` +`.bumpmesh` ships the color image as a separate `color.png` zip entry (mirroring `texture.png` at `js/main.js:4749`). Embedding it as a data URL in `settings` would blow the ~5MB sessionStorage quota for any non-trivial image. Top-level `_lastColorMap` is a runtime cache; persistence flows through `.bumpmesh` zip directly. + +## 6. Bugs found and fixed during stress test + +### Bug #1: `excludeWeight` stripped by displacement +**Symptom:** Angle-masked bottom face on a default cube exported with wood-tone gradient instead of base white. + +**Cause:** `js/displacement.js` constructed its output `BufferGeometry` with only `position` and `normal` attributes. The `excludeWeight` attribute set by subdivision was dropped. `applyColors` running on the displaced geometry never saw the exclusion data. + +**Fix:** `js/displacement.js` end of `applyDisplacement` (after line 458): +```js +if (ewAttr) out.setAttribute('excludeWeight', new THREE.BufferAttribute(ewAttr.array, 1)); +``` +Underlying typed array is shared with the input (input is disposed after applyDisplacement returns; THREE.BufferGeometry.dispose() doesn't free the JS-side typed array, only the GPU-side WebGL buffer, so this is safe). + +### Bug #2: Per-vertex exclusion check missed boundary triangles +**Symptom:** After Bug #1 fix, ewGE099 (count of vertices with weight ≥ 0.99) was 3066 — but the bottom face has 3070 tris × 3 verts = 9210 vertices. Only 33% of bottom-face vertices passed the per-vertex check; 0 triangles came out white. + +**Cause:** Subdivision dedups corner vertices. A cube corner participates in 3 face-corner copies in the non-indexed mesh. After subdivision, the corner has ONE canonical weight — either 0 (taken from a side-face copy) or 1 (from the bottom-face copy). Bottom-face triangles whose corners got the side-face value have at most 1 vertex with weight=1, failing the per-vertex `>= 0.99` check. + +**Fix:** `js/colorBake.js` Pass 3 (around line 280): precompute a per-face flag = `(avg of 3 vertex weights > 0.99)`, then check `faceExcluded[subdivFaceIdx]` instead of per-vertex. This matches `js/displacement.js:140` exactly. + +### Bug #3: Quantization equalized bucket populations and lost outliers +**Symptom:** 1022 white triangles correctly written to the geometry's `color` attribute, but the exported palette had no white entries. Every palette entry had exactly 20512 triangles (656382 / 32). + +**Cause:** `js/quantize.js` median-cut split the bucket with the largest population. With 1022 whites in a population of 656k, the white cluster never landed in its own bucket — it always got grouped with neighbors, and the bucket mean came out as the dominant neighbor color. + +**Fix:** `js/quantize.js` — switch bucket selection to `range × log(pop+1)`, and split at a large sorted-channel gap when one exists. Range prioritizes outlier-containing buckets; log-pop prevents tiny buckets from monopolizing; gap-aware splitting isolates the outlier cluster instead of repeatedly bisecting the dominant population. + +**Verification:** With all three fixes, a default cube exported with wood gradient + one painted face produces a palette with 3 white entries (angle-masked bottom), 8 magenta entries (painted face — duplicated due to decimation dedup smearing, see "Known caveats" below), and 21 wood entries (the gradient). Bottom-face triangle count: 3070 total, 1022 of which are exactly `#FFFFFF`, the remaining 2048 are wood tones at the smooth mask boundary (expected behavior, matches displacement's smooth boundary). + +## 7. Known caveats and limitations + +### Decimation dedup smear at color boundaries +`js/decimation.js` `buildIndexed` dedups vertices by quantized position and averages their color attribute components. At HARD color discontinuities (e.g. painted face adjacent to gradient face), this smears one row of vertices across the seam. Visual effect: a thin transition band of intermediate colors at paint boundaries. + +In stress test, painting one face magenta produced 8 distinct near-magenta palette entries (instead of 1) due to this smearing. Slicer filament-blending interprets these as transitional colors and prints fine, but the palette has slight redundancy. + +**Possible improvement (not done):** carry an exclusion-flag-like attribute through decimation alongside color, and skip color averaging on dedup hits when the flag differs. Out of scope for v1. + +### Paint mode + precision masking are mutually exclusive +`wireColorPaintUI` deactivates precision masking when color paint is activated (`js/main.js:1879-1888`). Reason: `paintedFaceColors` is keyed on **original** face indices (so it survives subdivision), but precision paint stores **subdivided** indices. Without a remap, mixing the two produces wrong results. + +### Live preview deferred +Color preview in the live displacement preview shader is not implemented. Painted faces tint the existing exclusion overlay (orange) for click feedback, but the user's chosen color does not appear in the live preview. Documented in the plan as a planned follow-up. + +### Cache-bust query strings removed +The temporary `?v=10` query strings were stripped from `js/main.js` after stress testing. `rg "\\?v="` now only finds historical mentions in this handoff. + +### i18n: non-English locales fall back to English +All 18 new keys exist in en.js with proper text. The other 6 locale files (de, fr, it, es, pt, ja) have the same keys with English values. CNCKitchen typically lands the feature first then crowdsources translations. + +## 8. How to verify + +Run the local server already started by the user (Python HTTP on :8765) and visit `http://localhost:8765/?fresh=1` (or any new query string to defeat the module cache). + +### Smoke +1. Color section appears in the right sidebar with master toggle, source radio, gradient editor, color image upload, paint controls. +2. Master toggle OFF → 3MF export bytes match today's output (`diff` two unzipped models). +3. Master toggle ON, source = None → 3MF includes a single white colorgroup entry (or whatever base color is); all triangles point at it. +4. Master toggle ON, source = Gradient, edit gradient stops → palette reflects gradient. +5. Master toggle ON, source = Image, upload a 4-quadrant RGB image → palette contains entries representative of all 4 quadrant colors. + +### Stress +6. Default settings + cube STL: bottom face triangles (z near -10) come out as the base color, not gradient values. Verify by inspecting the unzipped 3MF. +7. Paint one face manually with a vivid color, export → that color appears in the palette and on triangles in the painted region. +8. Multiple consecutive exports without reloading the page → no leaks, no errors. +9. Cylindrical mapping mode + gradient → gradient sampling follows the cylindrical UV pipeline. +10. .bumpmesh save → reload page → import → all color settings + painted faces + color image restored bit-exactly. +11. Make 3 settings changes, Ctrl+Z three times → all reverted in order. +12. Reset settings → color state cleared (gradient back to default 2-stop greyscale, paint map empty, image cleared). + +### Adversarial +13. Set gradient to 1 stop only → editor auto-pads to 2 stops (defensive normalize). +14. Paint a face, then drag the gradient over it → painted color wins (manual paint precedence is correct). +15. Toggle source from Gradient to Image with no image uploaded → falls through to base color (no errors). +16. Click "Export STL" with color toggle ON → STL is geometry only (no color metadata bleed). 656k tris × 50 bytes/tri + 84-byte header = exact expected size. + +### Slicer interop (needs human) +17. Open a colored 3MF in OrcaSlicer (or Bambu Studio, or PrusaSlicer 2.8+). Per-triangle colors should display in the filament/painting view; main 3D viewport may render flat-grey by default in some slicers — this is slicer behavior, not a file issue. + +## 9. Suggested review focus + +Priority order for what to examine carefully: + +1. **`js/colorBake.js`** — UV computation must mirror `js/displacement.js` exactly. Compare line-by-line. Any drift produces miscoloration that lines up offset from displacement. +2. **`js/decimation.js`** color threading (lines ~165-173 in collapse loop, ~574-635 in `buildIndexed`, ~640-680 in `buildOutput`). Backward compat with `opts.preserveColor=false` is essential — the existing geometry-only export must not regress. +3. **`js/main.js` `handleExport`** integration glue (around the `subdivide()` call site and the new `applyColors`/`decimate`/`export3MF` calls). The `format === '3mf' && settings.colorExportEnabled` gating must be airtight; STL exports must never trigger color bake. +4. **`js/quantize.js`** range-weighted bucket selection. Verify edge cases: empty input, all-same-color input, fewer than maxColors distinct colors. +5. **`js/exporter.js`** XML emission. The geometry-only path must be byte-for-byte identical to the previous version when no color is provided. +6. **`js/colorPaint.js`** — the paint-state persistence and undo flow. Particularly: after a paint stroke, `_scheduleUndoCapture` runs, and `_collectCurrentMask` must include `coloredFaces`. Verify in `js/main.js:_collectCurrentMask` (around line 4670) and `_undoSnapshotsEqual` (around line 4815). +7. **`js/gradientEditor.js`** — defensive `setStops` normalization (clamps positions, enforces ≥2 stops). Test edge cases: empty array, single stop, unsorted stops, stops with positions outside [0,1], stops with malformed colors. +8. **i18n**: confirm the 18 new keys exist in `js/i18n/en.js` and are referenced by `data-i18n` attributes in `index.html` (search index.html for `data-i18n="color.`). + +## 10. Reproduction recipes + +All run inside a browser tab at `localhost:8765`. The `?fresh=N` query bumps the document URL so module imports re-fetch. + +### Build a synthetic cube STL programmatically +```js +function buildCubeSTL(size=20) { + const s = size / 2; + const tris = [ + [-s,-s,-s,-s,s,-s,-s,s,s,-1,0,0],[-s,-s,-s,-s,s,s,-s,-s,s,-1,0,0], + [s,-s,-s,s,-s,s,s,s,s,1,0,0],[s,-s,-s,s,s,s,s,s,-s,1,0,0], + [-s,-s,-s,s,-s,-s,s,-s,s,0,-1,0],[-s,-s,-s,s,-s,s,-s,-s,s,0,-1,0], + [-s,s,-s,-s,s,s,s,s,s,0,1,0],[-s,s,-s,s,s,s,s,s,-s,0,1,0], + [-s,-s,-s,-s,s,-s,s,s,-s,0,0,-1],[-s,-s,-s,s,s,-s,s,-s,-s,0,0,-1], + [-s,-s,s,s,-s,s,s,s,s,0,0,1],[-s,-s,s,s,s,s,-s,s,s,0,0,1] + ]; + const buf = new ArrayBuffer(84 + 50 * tris.length); + const dv = new DataView(buf); + dv.setUint32(80, tris.length, true); + for (let i = 0; i < tris.length; i++) { + const t = tris[i], off = 84 + i * 50; + dv.setFloat32(off, t[9], true); dv.setFloat32(off+4, t[10], true); dv.setFloat32(off+8, t[11], true); + for (let v = 0; v < 3; v++) { + dv.setFloat32(off + 12 + v*12, t[v*3], true); + dv.setFloat32(off + 12 + v*12 + 4, t[v*3+1], true); + dv.setFloat32(off + 12 + v*12 + 8, t[v*3+2], true); + } + } + return new File([buf], 'cube.stl'); +} +const dt = new DataTransfer(); +dt.items.add(buildCubeSTL()); +const inp = document.getElementById('stl-file-input'); +inp.files = dt.files; +inp.dispatchEvent(new Event('change', { bubbles: true })); +``` + +### Programmatically set gradient and trigger callback +The gradient editor's `setStops()` is "external apply" and intentionally does NOT fire onChange (avoids feedback loops with the orchestrator's settings reflection). To propagate a programmatic change to settings, invoke the registered callback explicitly: +```js +const ge = window._gradientEditor; +ge.setStops([{pos:0,color:'#3a1f0e'},{pos:1,color:'#f4d99e'}]); +ge._onChange(ge.getStops()); // private property; orchestrator's callback +``` + +Real user interactions (pointer events on stops, clicks on the bar) call the editor's internal `_emitChange()`, which DOES fire onChange. + +### Capture an export blob without download dialog +```js +sessionStorage.setItem('stlt-no-sponsor', '1'); // skip the sponsor overlay +window.__capturedBlobs = []; +const _orig = URL.createObjectURL; +URL.createObjectURL = function(b){ window.__capturedBlobs.push(b); return _orig(b); }; +document.getElementById('export-3mf-btn').click(); +// Wait ~6 seconds for subdivide → displace → bake → quantize → write. +// Then: const blob = window.__capturedBlobs[window.__capturedBlobs.length - 1]; +``` + +### Inspect the exported 3MF's colorgroup +```js +const blob = window.__capturedBlobs[window.__capturedBlobs.length - 1]; +const ab = await blob.arrayBuffer(); +const fflate = await import('fflate'); +const unz = fflate.unzipSync(new Uint8Array(ab)); +const xml = new TextDecoder().decode(unz['3D/3dmodel.model']); +const palette = [...xml.matchAll(//g)].map(m => m[1]); +const counts = new Array(palette.length).fill(0); +for (const m of xml.matchAll(/p1="(\d+)"/g)) counts[+m[1]]++; +console.log({ paletteSize: palette.length, palette, counts }); +``` + +## 11. Things to actively try to break + +If you want to find more bugs, attack these: + +- **High-poly mesh + tight maxTriangles** (force aggressive decimation) → does the color attribute survive cleanly? Hint: dedup-time averaging may smear color boundaries. The smearing is acknowledged in `js/decimation.js` Unit B's notes; quantify how bad it gets. +- **Cubic mapping mode (mappingMode=6)** + gradient → the cubic UV pipeline in colorBake.js is the most complex code path; verify it agrees with displacement.js's cubic path (around `js/displacement.js:317-367`). +- **Boundary falloff > 0** + color export → does the falloff smoothing affect color the way it affects displacement? colorBake doesn't currently consult `falloffArr`. Probably fine for v1 (color and displacement diverge only at the soft boundary band, by design — colors are not faded at boundaries), but worth confirming with the user that this matches expectation. +- **Symmetric displacement + gradient** → colorBake doesn't reach into symmetric displacement logic, but the displacement texel value is what drives the gradient lookup. Verify gradient looks right when symmetric is on (50% grey = no displacement). +- **3MF re-import** of a colored 3MF → does BumpMesh's own importer cope, or does it choke on the `xmlns:m`? (Probably fine; the importer ignores unknown attributes. But test it.) +- **Very dense paint** (paint many faces with many different colors) → does median-cut produce a sensible palette, or does it produce 32 buckets that all look like averages? +- **i18n** — switch to German/French and verify the section labels, tooltips, and progress messages render with English fallback (no missing-key crashes). + +## 12. Status of TODOs to close out + +Before merging the upstream PR, do these in order: + +1. **Run a fresh full pipeline end-to-end** in a freshly opened Chrome window (no cache) to confirm the import paths still resolve. +2. **Confirm the README's feature list** doesn't already claim "color export" in a way that conflicts with this PR's claims; update if needed. +3. **Open the upstream PR** with these claims: + - All changes are additive + - Toggle-OFF emits identical bytes to today + - No new dependencies + - Default-OFF on fresh load + - Live preview tinting deferred to a follow-up PR (small, ~30 lines) + +--- + +**Reviewer instructions:** Read this document, read the plan file at `/Users/eric/.claude/plans/1-yes-3mf-export-keen-falcon.md` (the original architectural plan, written before stress testing), then attack the code with adversarial scenarios from §11. Your job is to find what I missed. diff --git a/index.html b/index.html index 47a76f9..970c1db 100644 --- a/index.html +++ b/index.html @@ -512,6 +512,130 @@

Bake Tex

+ +
+

Color export (3MF)

+ + +
+ +
+ + +
+ + +
+ + +

Auto color source

+
+ + + +
+ + +
+
+

Click bar to add a stop · Click handle to select · Drag to move

+ + +
+
Selected stop
+
+ + + +
+
+ + + % +
+
+ +
+
+ +
+
+ + +
+
Will export as:
+
+
+ + +
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+ + +
+
+

Export ⓘ

diff --git a/js/colorBake.js b/js/colorBake.js new file mode 100644 index 0000000..fb5161d --- /dev/null +++ b/js/colorBake.js @@ -0,0 +1,449 @@ +/** + * colorBake.js — Per-vertex color bake for 3MF export. + * + * Writes a Float32×3 `color` BufferAttribute onto the displaced (post-subdivision, + * pre-decimation) geometry. Composition order per vertex: + * excludeWeight ≥ 0.99 → settings.colorBaseColor + * autoSource === 'gradient' → sample displacementImageData (same UV pipeline as + * displacement.js Pass 2), look up grey in + * settings.colorGradientStops + * autoSource === 'image' → sample colorImageData at the same UV + * else → settings.colorBaseColor + * + * Mirrors displacement.js's pipeline exactly: same QUANT vertex dedup, same Pass 1 + * area accumulation for zoneArea (cubic) and smoothNrm, same Pass 2 UV resolution. + * For cubic mode, the per-zone color is computed from the per-zone grey (gradient) + * or per-zone RGB sample (image) and weight-blended across zones, matching the + * displacement-side approach so colors line up with displacement texels. + */ +import * as THREE from 'three'; +import { computeUV, getCubicBlendWeights } from './mapping.js'; + +export function applyColors( + geometry, + faceParentId, + displacementImageData, dispW, dispH, + colorImageData, colorW, colorH, + settings, bounds, +) { + if (!geometry || !geometry.attributes || !geometry.attributes.position) return geometry; + const posAttr = geometry.attributes.position; + const nrmAttr = geometry.attributes.normal; + const ewAttr = geometry.attributes.excludeWeight || null; + const count = posAttr.count; + + // ── Resolve effective color source ──────────────────────────────────────── + const baseRGB = _parseHex(settings.colorBaseColor || '#ffffff'); + const autoSource = settings.colorAutoSource || 'none'; + const haveGradient = autoSource === 'gradient' && displacementImageData && displacementImageData.data; + const haveImage = autoSource === 'image' && colorImageData && colorImageData.data; + + // Pre-sort gradient stops once (defensive — UI may emit unsorted). + let gradientStops = null; + if (haveGradient) { + const stops = Array.isArray(settings.colorGradientStops) ? settings.colorGradientStops : []; + gradientStops = stops + .map(s => ({ pos: +s.pos, rgb: _parseHex(s.color) })) + .filter(s => Number.isFinite(s.pos)) + .sort((a, b) => a.pos - b.pos); + if (gradientStops.length === 0) gradientStops = null; + } + + // Texture-aspect correction. Two distinct sets — displacement and color images + // may have different aspect ratios. Mirror displacement.js exactly. + const dispTmax = Math.max(dispW || 1, dispH || 1, 1); + const dispAspectU = dispTmax / Math.max(dispW || 1, 1); + const dispAspectV = dispTmax / Math.max(dispH || 1, 1); + const dispSettings = { ...settings, textureAspectU: dispAspectU, textureAspectV: dispAspectV }; + + let colSettings = dispSettings; + let colAspectU = dispAspectU, colAspectV = dispAspectV; + if (haveImage) { + const cTmax = Math.max(colorW || 1, colorH || 1, 1); + colAspectU = cTmax / Math.max(colorW || 1, 1); + colAspectV = cTmax / Math.max(colorH || 1, 1); + colSettings = { ...settings, textureAspectU: colAspectU, textureAspectV: colAspectV }; + } + + // ── Vertex dedup pass: position → numeric ID via one-time string-map pass ─ + const QUANT = 1e4; + const _dedupMap = new Map(); + let _nextId = 0; + const vertexId = new Uint32Array(count); + for (let i = 0; i < count; i++) { + const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i); + const key = `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; + let id = _dedupMap.get(key); + if (id === undefined) { + id = _nextId++; + _dedupMap.set(key, id); + } + vertexId[i] = id; + } + const uniqueCount = _nextId; + + // ── Pass 1: area-weighted smooth normals + cubic zoneArea per unique pos ── + const smoothNrmX = new Float64Array(uniqueCount); + const smoothNrmY = new Float64Array(uniqueCount); + const smoothNrmZ = new Float64Array(uniqueCount); + const zoneAreaX = new Float64Array(uniqueCount); + const zoneAreaY = new Float64Array(uniqueCount); + const zoneAreaZ = new Float64Array(uniqueCount); + + const vA = new THREE.Vector3(); + const vB = new THREE.Vector3(); + const vC = new THREE.Vector3(); + const edge1 = new THREE.Vector3(); + const edge2 = new THREE.Vector3(); + const faceNrm = new THREE.Vector3(); + const tmpPos = new THREE.Vector3(); + const tmpNrm = new THREE.Vector3(); + + const isCubic = settings.mappingMode === 6; + const cubicBlend = settings.mappingBlend ?? 0; + const cubicBandWidth = settings.seamBandWidth ?? 0.35; + + for (let t = 0; t < count; t += 3) { + vA.fromBufferAttribute(posAttr, t); + vB.fromBufferAttribute(posAttr, t + 1); + vC.fromBufferAttribute(posAttr, t + 2); + edge1.subVectors(vB, vA); + edge2.subVectors(vC, vA); + faceNrm.crossVectors(edge1, edge2); + const faceArea = faceNrm.length(); + + let czX = 0, czY = 0, czZ = 0; + if (isCubic && faceArea > 1e-12) { + const unitFaceNrm = { x: faceNrm.x / faceArea, y: faceNrm.y / faceArea, z: faceNrm.z / faceArea }; + const w = getCubicBlendWeights(unitFaceNrm, cubicBlend, cubicBandWidth); + czX = w.x * faceArea; + czY = w.y * faceArea; + czZ = w.z * faceArea; + } + + for (let v = 0; v < 3; v++) { + const vid = vertexId[t + v]; + tmpNrm.fromBufferAttribute(nrmAttr, t + v); + smoothNrmX[vid] += tmpNrm.x * faceArea; + smoothNrmY[vid] += tmpNrm.y * faceArea; + smoothNrmZ[vid] += tmpNrm.z * faceArea; + if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) { + zoneAreaX[vid] += czX; + zoneAreaY[vid] += czY; + zoneAreaZ[vid] += czZ; + } + } + } + + // Normalise smooth normals + for (let id = 0; id < uniqueCount; id++) { + const len = Math.sqrt( + smoothNrmX[id] * smoothNrmX[id] + + smoothNrmY[id] * smoothNrmY[id] + + smoothNrmZ[id] * smoothNrmZ[id] + ) || 1; + smoothNrmX[id] /= len; + smoothNrmY[id] /= len; + smoothNrmZ[id] /= len; + } + + // ── Pass 2: per-unique-vertex auto color cache ──────────────────────────── + // We cache the auto-source color (gradient or image sample) per unique + // position so coincident vertices on adjacent triangles agree. Per-face + // overrides (excludeWeight and paint) are applied per-vertex-copy in Pass 3, + // because they depend on the owning original-face index, which is per-copy. + const autoR = new Float32Array(uniqueCount); + const autoG = new Float32Array(uniqueCount); + const autoB = new Float32Array(uniqueCount); + const autoSet = new Uint8Array(uniqueCount); + + const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6); + const rotRad = (settings.rotation ?? 0) * Math.PI / 180; + const useAuto = haveGradient || haveImage; + + if (useAuto) { + for (let i = 0; i < count; i++) { + const vid = vertexId[i]; + if (autoSet[vid]) continue; + autoSet[vid] = 1; + + tmpPos.fromBufferAttribute(posAttr, i); + + // Cubic: zone-area-weighted color blending. Per-zone we sample the + // appropriate UV, then for gradient mode look up grey→RGB per zone (so + // we blend final colors, not greys). For image mode we sample RGB + // directly per zone. Mirrors displacement.js's cubic Pass 2 layout. + if (isCubic) { + const zaX = zoneAreaX[vid], zaY = zoneAreaY[vid], zaZ = zoneAreaZ[vid]; + const total = zaX + zaY + zaZ; + if (total > 0) { + let rOut = 0, gOut = 0, bOut = 0; + + // Each zone contributes to BOTH displacement-UV (for gradient grey lookup) + // AND color-UV (for image RGB lookup). The two UV sets differ only in + // their per-image aspect correction, so we recompute when different. + + if (zaX > 0) { // X-dominant zone → YZ projection + let rawU = (tmpPos.y - bounds.min.y) / md; + if (smoothNrmX[vid] < 0) rawU = -rawU; + const rawV = (tmpPos.z - bounds.min.z) / md; + const w = zaX / total; + const c = _zoneColor(rawU, rawV, settings, rotRad, + dispAspectU, dispAspectV, colAspectU, colAspectV, + displacementImageData, dispW, dispH, + colorImageData, colorW, colorH, + gradientStops, haveGradient, haveImage, baseRGB); + rOut += c[0] * w; gOut += c[1] * w; bOut += c[2] * w; + } + if (zaY > 0) { // Y-dominant zone → XZ projection + let rawU = (tmpPos.x - bounds.min.x) / md; + if (smoothNrmY[vid] > 0) rawU = -rawU; + const rawV = (tmpPos.z - bounds.min.z) / md; + const w = zaY / total; + const c = _zoneColor(rawU, rawV, settings, rotRad, + dispAspectU, dispAspectV, colAspectU, colAspectV, + displacementImageData, dispW, dispH, + colorImageData, colorW, colorH, + gradientStops, haveGradient, haveImage, baseRGB); + rOut += c[0] * w; gOut += c[1] * w; bOut += c[2] * w; + } + if (zaZ > 0) { // Z-dominant zone → XY projection + let rawU = (tmpPos.x - bounds.min.x) / md; + if (smoothNrmZ[vid] < 0) rawU = -rawU; + const rawV = (tmpPos.y - bounds.min.y) / md; + const w = zaZ / total; + const c = _zoneColor(rawU, rawV, settings, rotRad, + dispAspectU, dispAspectV, colAspectU, colAspectV, + displacementImageData, dispW, dispH, + colorImageData, colorW, colorH, + gradientStops, haveGradient, haveImage, baseRGB); + rOut += c[0] * w; gOut += c[1] * w; bOut += c[2] * w; + } + + autoR[vid] = rOut; + autoG[vid] = gOut; + autoB[vid] = bOut; + continue; + } + } + + // Non-cubic: use computeUV with the smooth normal. + tmpNrm.set(smoothNrmX[vid], smoothNrmY[vid], smoothNrmZ[vid]); + + if (haveGradient) { + const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, dispSettings, bounds); + let grey; + if (uvResult.triplanar) { + grey = 0; + for (const s of uvResult.samples) { + grey += _sampleBilinearGrey(displacementImageData.data, dispW, dispH, s.u, s.v) * s.w; + } + } else { + grey = _sampleBilinearGrey(displacementImageData.data, dispW, dispH, uvResult.u, uvResult.v); + } + const rgb = _sampleGradient(gradientStops, grey); + autoR[vid] = rgb[0]; autoG[vid] = rgb[1]; autoB[vid] = rgb[2]; + } else if (haveImage) { + const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, colSettings, bounds); + let r = 0, g = 0, b = 0; + if (uvResult.triplanar) { + for (const s of uvResult.samples) { + const c = _sampleBilinearRGB(colorImageData.data, colorW, colorH, s.u, s.v); + r += c[0] * s.w; g += c[1] * s.w; b += c[2] * s.w; + } + } else { + const c = _sampleBilinearRGB(colorImageData.data, colorW, colorH, uvResult.u, uvResult.v); + r = c[0]; g = c[1]; b = c[2]; + } + autoR[vid] = r; autoG[vid] = g; autoB[vid] = b; + } else { + autoR[vid] = baseRGB[0]; autoG[vid] = baseRGB[1]; autoB[vid] = baseRGB[2]; + } + } + } + + // ── Pass 3: write per-vertex-copy color, applying per-face overrides ────── + // Per-face excluded flag (matches displacement.js semantics): a face is excluded + // iff the AVERAGE of its 3 vertex weights > 0.99. Per-vertex thresholding fails + // at the cube's bottom-face corners because subdivision dedups corner weights to + // either the side-face copy (w=0) or the bottom-face copy (w=1) depending on + // iteration order, leaving partial-weight subdivided vertices that the per-vertex + // check would falsely treat as non-excluded. + const faceCount = (count / 3) | 0; + const faceExcluded = new Uint8Array(faceCount); + if (ewAttr) { + const ewArr = ewAttr.array; + for (let f = 0; f < faceCount; f++) { + const avg = (ewArr[f * 3] + ewArr[f * 3 + 1] + ewArr[f * 3 + 2]) / 3; + if (avg > 0.99) faceExcluded[f] = 1; + } + } + + const out = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + const subdivFaceIdx = (i / 3) | 0; + + // Excluded faces always get base color regardless of auto source. + let r, g, b; + if (faceExcluded[subdivFaceIdx]) { + r = baseRGB[0]; g = baseRGB[1]; b = baseRGB[2]; + } else if (useAuto) { + const vid = vertexId[i]; + r = autoR[vid]; g = autoG[vid]; b = autoB[vid]; + } else { + r = baseRGB[0]; g = baseRGB[1]; b = baseRGB[2]; + } + + out[i * 3] = r; + out[i * 3 + 1] = g; + out[i * 3 + 2] = b; + } + + geometry.setAttribute('color', new THREE.BufferAttribute(out, 3)); + return geometry; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Compute one cubic-zone color contribution. + * Returns [r, g, b] (0..1). Picks gradient or image branch based on flags; + * displacement and color images have separate aspect-corrected UVs. + */ +function _zoneColor(rawU, rawV, settings, rotRad, + dAU, dAV, cAU, cAV, + dispImgData, dW, dH, + colImgData, cW, cH, + gradientStops, haveGradient, haveImage, baseRGB) { + if (haveGradient) { + const uv = _cubicUV(rawU, rawV, settings, rotRad, dAU, dAV); + const grey = _sampleBilinearGrey(dispImgData.data, dW, dH, uv.u, uv.v); + return _sampleGradient(gradientStops, grey); + } + if (haveImage) { + const uv = _cubicUV(rawU, rawV, settings, rotRad, cAU, cAV); + return _sampleBilinearRGB(colImgData.data, cW, cH, uv.u, uv.v); + } + return baseRGB; +} + +/** Apply scale/offset/rotation to raw UV for cubic projection. + * Exact mirror of displacement.js's _cubicUV helper. */ +function _cubicUV(rawU, rawV, settings, rotRad, aspectU, aspectV) { + let u = (rawU * aspectU) / settings.scaleU + settings.offsetU; + let v = (rawV * aspectV) / settings.scaleV + settings.offsetV; + if (rotRad !== 0) { + const c = Math.cos(rotRad), s = Math.sin(rotRad); + u -= 0.5; v -= 0.5; + const ru = c * u - s * v, rv = s * u + c * v; + u = ru + 0.5; v = rv + 0.5; + } + return { u: u - Math.floor(u), v: v - Math.floor(v) }; +} + +/** + * Sample a greyscale value (0–1) from raw RGBA ImageData using bilinear + * interpolation. UV is tiled via mod 1. Mirrors displacement.js exactly. + */ +function _sampleBilinearGrey(data, w, h, u, v) { + u = ((u % 1) + 1) % 1; + v = ((v % 1) + 1) % 1; + v = 1 - v; + + const fx = u * (w - 1); + const fy = v * (h - 1); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const x1 = Math.min(x0 + 1, w - 1); + const y1 = Math.min(y0 + 1, h - 1); + const tx = fx - x0; + const ty = fy - y0; + + 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; +} + +/** + * Sample [r, g, b] (each 0–1) from raw RGBA ImageData using bilinear + * interpolation. UV is tiled via mod 1. + */ +function _sampleBilinearRGB(data, w, h, u, v) { + u = ((u % 1) + 1) % 1; + v = ((v % 1) + 1) % 1; + v = 1 - v; + + const fx = u * (w - 1); + const fy = v * (h - 1); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const x1 = Math.min(x0 + 1, w - 1); + const y1 = Math.min(y0 + 1, h - 1); + const tx = fx - x0; + const ty = fy - y0; + + const i00 = (y0 * w + x0) * 4; + const i10 = (y0 * w + x1) * 4; + const i01 = (y1 * w + x0) * 4; + const i11 = (y1 * w + x1) * 4; + + const w00 = (1 - tx) * (1 - ty); + const w10 = tx * (1 - ty); + const w01 = (1 - tx) * ty; + const w11 = tx * ty; + + const r = (data[i00] * w00 + data[i10] * w10 + data[i01] * w01 + data[i11] * w11) / 255; + const g = (data[i00 + 1] * w00 + data[i10 + 1] * w10 + data[i01 + 1] * w01 + data[i11 + 1] * w11) / 255; + const b = (data[i00 + 2] * w00 + data[i10 + 2] * w10 + data[i01 + 2] * w01 + data[i11 + 2] * w11) / 255; + return [r, g, b]; +} + +/** Parse a CSS hex color (#rgb / #rrggbb) → [r, g, b] in 0..1. */ +function _parseHex(hex) { + if (!hex || typeof hex !== 'string') return [1, 1, 1]; + let s = hex.trim(); + if (s[0] === '#') s = s.slice(1); + if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]; + if (s.length !== 6) return [1, 1, 1]; + const n = parseInt(s, 16); + if (!Number.isFinite(n)) return [1, 1, 1]; + return [ + ((n >> 16) & 0xff) / 255, + ((n >> 8) & 0xff) / 255, + ( n & 0xff) / 255, + ]; +} + +/** + * Sample a sorted-by-pos gradient at parameter t ∈ [0, 1]. + * Stops are pre-sorted in applyColors. Linear interpolation between adjacent + * stops; clamp at edges. Returns [r, g, b] in 0..1. + */ +function _sampleGradient(stops, t) { + if (!stops || stops.length === 0) return [0, 0, 0]; + if (stops.length === 1) return stops[0].rgb.slice(); + if (t <= stops[0].pos) return stops[0].rgb.slice(); + const last = stops[stops.length - 1]; + if (t >= last.pos) return last.rgb.slice(); + + // Linear scan is fine — N stops is small (typically 2–8). + for (let i = 1; i < stops.length; i++) { + const a = stops[i - 1], b = stops[i]; + if (t <= b.pos) { + const span = b.pos - a.pos; + const f = span > 1e-9 ? (t - a.pos) / span : 0; + return [ + a.rgb[0] + (b.rgb[0] - a.rgb[0]) * f, + a.rgb[1] + (b.rgb[1] - a.rgb[1]) * f, + a.rgb[2] + (b.rgb[2] - a.rgb[2]) * f, + ]; + } + } + return last.rgb.slice(); +} diff --git a/js/decimation.js b/js/decimation.js index 2def897..7a70f10 100644 --- a/js/decimation.js +++ b/js/decimation.js @@ -35,6 +35,13 @@ * @param {THREE.BufferGeometry} geometry non-indexed input * @param {number} targetTriangles desired output face count * @param {function} [onProgress] callback(0–1) + * @param {object} [opts] optional flags + * @param {boolean} [opts.preserveColor=false] when true and the + * input has a `color` BufferAttribute (Float32×3), thread it through the + * edge-collapse process and emit it on the output geometry. Vertices + * deduped at index time and merged at collapse time take a uniform + * component-wise average. When false (or color absent) output is + * byte-identical to the colorless path. * @returns {THREE.BufferGeometry} */ @@ -68,10 +75,16 @@ const _hlvLo = new Int32Array(512); // ── Public API ─────────────────────────────────────────────────────────────── -export async function decimate(geometry, targetTriangles, onProgress) { - const { positions, faces, vertCount, faceCount } = buildIndexed(geometry); +export async function decimate(geometry, targetTriangles, onProgress, opts = {}) { + // Color-threading is gated on BOTH the explicit opt-in AND a present color + // attribute on the input. Either condition false → behaves exactly as the + // colorless path, with no extra allocations or per-vertex work. + const colorAttr = (opts && opts.preserveColor) ? geometry.attributes.color : null; + const threadColor = !!(colorAttr && colorAttr.itemSize >= 3); - if (faceCount <= targetTriangles) return buildOutput(positions, faces, faceCount); + const { positions, faces, vertCount, faceCount, colors } = buildIndexed(geometry, threadColor ? colorAttr : null); + + if (faceCount <= targetTriangles) return buildOutput(positions, faces, faceCount, colors); // Per-vertex error quadrics (10 doubles = upper triangle of symmetric 4×4) const quadrics = new Float64Array(vertCount * 10); @@ -152,6 +165,15 @@ export async function decimate(geometry, targetTriangles, onProgress) { positions[v1 * 3 + 1] = py; positions[v1 * 3 + 2] = pz; mergeQuadric(quadrics, v1, v2); + // Uniform average of v1 and v2 colors into v1. Final per-triangle averaging + // happens at quantization; this approximation drifts toward the more + // recently-merged subtree's color but keeps the hot path branch-light. + if (colors) { + const c1 = v1 * 3, c2 = v2 * 3; + colors[c1] = (colors[c1] + colors[c2]) * 0.5; + colors[c1 + 1] = (colors[c1 + 1] + colors[c2 + 1]) * 0.5; + colors[c1 + 2] = (colors[c1 + 2] + colors[c2 + 2]) * 0.5; + } version[v1]++; // v1's quadric and position changed — invalidate old heap entries // Walk v2's face list; read sNext BEFORE modifying the list. @@ -200,7 +222,7 @@ export async function decimate(geometry, targetTriangles, onProgress) { } if (onProgress) onProgress(1); - return buildOutput(positions, faces, faceCount); + return buildOutput(positions, faces, faceCount, colors); } // ── Linked-list vertex-face incidence ──────────────────────────────────────── @@ -554,7 +576,7 @@ function pushEdge(heap, quadrics, positions, version, v1, v2) { // Numeric spatial-hash vertex deduplication. // Avoids template-string allocation by encoding quantised (ix,iy,iz) as a // BigInt key: this is still fast because we only call BigInt() once per vertex. -function buildIndexed(geometry) { +function buildIndexed(geometry, colorAttr = null) { const posAttr = geometry.attributes.position; const n = posAttr.count; @@ -564,6 +586,13 @@ function buildIndexed(geometry) { const vertMap = new Map(); + // Color SoA — only allocated when color-threading was requested AND the + // input provides a color attribute. Sums are accumulated here, then divided + // by per-vertex hit counts at the end to produce an exact uniform mean + // across all input vertices that quantised to the same grid cell. + const colorSums = colorAttr ? new Float64Array(n * 3) : null; // double precision sum, trimmed later + const colorCount = colorAttr ? new Uint32Array(n) : null; + for (let i = 0; i < n; i++) { const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i); // Encode three 21-bit quantised integers into one BigInt key. @@ -581,32 +610,57 @@ function buildIndexed(geometry) { vertMap.set(key, idx); } indexRemap[i] = idx; + if (colorSums) { + colorSums[idx * 3] += colorAttr.getX(i); + colorSums[idx * 3 + 1] += colorAttr.getY(i); + colorSums[idx * 3 + 2] += colorAttr.getZ(i); + colorCount[idx]++; + } } const faceCount = n / 3; const faces = new Int32Array(faceCount * 3); for (let i = 0; i < n; i++) faces[i] = indexRemap[i]; - return { positions: positions.subarray(0, vertCount * 3), faces, vertCount, faceCount }; + let colors = null; + if (colorSums) { + colors = new Float32Array(vertCount * 3); + for (let v = 0; v < vertCount; v++) { + const c = colorCount[v] || 1; + colors[v * 3] = colorSums[v * 3] / c; + colors[v * 3 + 1] = colorSums[v * 3 + 1] / c; + colors[v * 3 + 2] = colorSums[v * 3 + 2] / c; + } + } + + return { positions: positions.subarray(0, vertCount * 3), faces, vertCount, faceCount, colors }; } // (adjacency helpers replaced by buildLinkedAdj and _unlinkSlot/_moveSlot above) -function buildOutput(positions, faces, faceCount) { +function buildOutput(positions, faces, faceCount, colors = null) { let activeFaces = 0; for (let f = 0; f < faceCount; f++) { if (faces[f * 3] >= 0) activeFaces++; } const posArray = new Float32Array(activeFaces * 9); + // Only allocate a non-indexed color buffer when color-threading was active. + const colorOut = colors ? new Float32Array(activeFaces * 9) : null; let out = 0; for (let f = 0; f < faceCount; f++) { if (faces[f * 3] < 0) continue; for (let v = 0; v < 3; v++) { const vi = faces[f * 3 + v]; - posArray[out++] = positions[vi * 3]; - posArray[out++] = positions[vi * 3 + 1]; - posArray[out++] = positions[vi * 3 + 2]; + posArray[out] = positions[vi * 3]; + posArray[out + 1] = positions[vi * 3 + 1]; + posArray[out + 2] = positions[vi * 3 + 2]; + if (colorOut) { + colorOut[out] = colors[vi * 3]; + colorOut[out + 1] = colors[vi * 3 + 1]; + colorOut[out + 2] = colors[vi * 3 + 2]; + } + out += 3; } } @@ -630,6 +684,9 @@ 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 (colorOut) { + geo.setAttribute('color', new THREE.BufferAttribute(colorOut, 3)); + } return geo; } diff --git a/js/displacement.js b/js/displacement.js index f41938a..3988468 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -501,6 +501,11 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const out = new THREE.BufferGeometry(); out.setAttribute('position', new THREE.BufferAttribute(newPos, 3)); out.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3)); + // Forward excludeWeight (per-vertex 0..1, 1.0 = excluded by user paint or angle mask) + // so downstream consumers like colorBake.js can honor the same masking the + // displacement pass already respected. Same length as position; the underlying + // typed array is shared with the input — safe because nothing writes it later. + if (ewAttr) out.setAttribute('excludeWeight', new THREE.BufferAttribute(ewAttr.array, 1)); return out; } diff --git a/js/exporter.js b/js/exporter.js index 4d27297..046f4b7 100644 --- a/js/exporter.js +++ b/js/exporter.js @@ -91,11 +91,31 @@ export function exportSTL(geometry, filename = 'textured.stl') { * * @param {THREE.BufferGeometry} geometry – non-indexed with position attribute * @param {string} [filename] + * @param {{palette?: Uint8Array, triPaletteIndices?: Uint16Array}} [options] + * Optional 3MF Materials Extension v1.0.1 colorgroup data: + * - `palette`: flat `Uint8Array(N*3)` of RGB triplets (0..255 each). + * - `triPaletteIndices`: `Uint16Array(triCount)` mapping each triangle to a + * palette entry. When both present and `palette.length > 0`, the model + * declares the `m` namespace, emits an `` block + * before the ``, and adds `pid="3" p1=""` per triangle. + * When absent or empty, the emitted XML is byte-identical to the + * geometry-only output (backward-compat path). */ -export function export3MF(geometry, filename = 'textured.3mf') { +export function export3MF(geometry, filename = 'textured.3mf', options = {}) { const posArr = geometry.attributes.position.array; const triCount = (posArr.length / 9) | 0; + // ── Color export prep (3MF Materials Extension v1.0.1) ─────────────────── + // Activate only when the caller provided BOTH a non-empty palette AND a + // matching per-triangle index buffer. Anything else falls through to the + // unchanged geometry-only path below. + const palette = options && options.palette; + const triPidx = options && options.triPaletteIndices; + const hasColor = !!( + palette && palette.length > 0 && + triPidx && triPidx.length >= triCount + ); + // ── Deduplicate vertices ───────────────────────────────────────────────── // Key on fixed-precision position strings. 4 decimals = 0.0001 mm, safely // below the resolution of any FDM/SLA printer and far tighter than float32 @@ -127,15 +147,57 @@ export function export3MF(geometry, filename = 'textured.3mf') { // Stream into an array of string chunks then join once — much faster than // repeated concatenation for large meshes. const chunks = []; - chunks.push( - '\n', - '\n', - '\n', - '\n', - '\n', - '\n' - ); + if (hasColor) { + // Materials-extended model header: declare the `m` namespace and require + // the extension so compliant readers know to honor pid/p1 attributes. + chunks.push( + '\n', + '\n', + '\n' + ); + // Colorgroup MUST precede per the Materials Extension spec. + // id="3" leaves room for a future basematerials block at id=2; the + // object below stays at id=1. + const palCount = (palette.length / 3) | 0; + chunks.push('\n'); + const hex2 = (n) => { + // Uppercase 2-char hex; clamp defensively. + const v = n < 0 ? 0 : n > 255 ? 255 : n | 0; + const s = v.toString(16).toUpperCase(); + return s.length === 1 ? '0' + s : s; + }; + for (let i = 0; i < palCount; i++) { + const b = i * 3; + // Slicers (Bambu/Orca/Prusa) want 8-char RGBA; alpha is always FF. + chunks.push( + '\n' + ); + } + chunks.push('\n'); + // The per-triangle pid is sufficient — do NOT put pid on . + chunks.push( + '\n', + '\n', + '\n' + ); + } else { + chunks.push( + '\n', + '\n', + '\n', + '\n', + '\n', + '\n' + ); + } // Vertices: trim trailing zeros to keep the file compact. const fmt = (n) => { @@ -156,14 +218,30 @@ export function export3MF(geometry, filename = 'textured.3mf') { chunks.push('\n\n'); - for (let i = 0; i < triCount; i++) { - const b = i * 3; - chunks.push( - '\n' - ); + if (hasColor) { + // Per-triangle color: pid references the colorgroup (id="3"); p1 is the + // palette entry index for v1. Omitting p2/p3 means flat-coloring the + // whole triangle from p1, which is exactly what we want. + for (let i = 0; i < triCount; i++) { + const b = i * 3; + chunks.push( + '\n' + ); + } + } else { + for (let i = 0; i < triCount; i++) { + const b = i * 3; + chunks.push( + '\n' + ); + } } chunks.push( diff --git a/js/gradientEditor.js b/js/gradientEditor.js new file mode 100644 index 0000000..365efcb --- /dev/null +++ b/js/gradientEditor.js @@ -0,0 +1,508 @@ +/** + * gradientEditor.js — N-stop gradient editor widget (Unit D). + * + * `class GradientEditor` provides: + * .mount(containerEl) + * .setStops([{pos: 0..1, color: '#RRGGBB'}, ...]) + * .getStops() → array (deep copy) + * .onChange(cb) cb(stops) fires whenever stops mutate + * + * Interaction model: + * - Click empty area on bar → add stop (with interpolated color) + * - Click stop → select it + * - Drag stop horizontally → move stop, resort by position, emit change + * - Drag stop vertically beyond + * a threshold → remove stop (≥2 enforced) + * - Right-click a stop → remove stop (≥2 enforced) + * - Selected stop's color edited + * via the adjacent native + * → emit change + * + * Self-contained: zero global state, pointer events scoped to the container. + * + * Also exports `wireColorSectionVisibility()` — a small helper that toggles + * the per-source sub-section visibility via a `data-source` attribute on the + * #color-section container. This is the only side-channel by which Unit D's + * UI controls visibility without main.js edits — main.js's wireColorPaintUI + * already mirrors `settings.colorAutoSource` into the radio buttons; we just + * propagate that into a CSS-driven attribute on the section. + */ + +const REMOVE_DRAG_THRESHOLD_PX = 32; // vertical drag distance to remove a stop +const STOP_HANDLE_SIZE_PX = 16; +const BAR_HEIGHT_PX = 24; + +function clamp01(x) { return x < 0 ? 0 : (x > 1 ? 1 : x); } + +function parseHex(hex) { + // Accept '#rgb', '#rrggbb'; return [r, g, b] in 0..255. + if (typeof hex !== 'string') return [0, 0, 0]; + let h = hex.trim().replace(/^#/, ''); + if (h.length === 3) h = h.split('').map(c => c + c).join(''); + if (h.length !== 6) return [0, 0, 0]; + const n = parseInt(h, 16); + if (!Number.isFinite(n)) return [0, 0, 0]; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +function toHex(r, g, b) { + const c = (n) => { + const v = Math.max(0, Math.min(255, Math.round(n))).toString(16); + return v.length === 1 ? '0' + v : v; + }; + return '#' + c(r) + c(g) + c(b); +} + +function lerpColor(a, b, t) { + const ca = parseHex(a), cb = parseHex(b); + return toHex( + ca[0] + (cb[0] - ca[0]) * t, + ca[1] + (cb[1] - ca[1]) * t, + ca[2] + (cb[2] - ca[2]) * t, + ); +} + +/** Sample the gradient at position p ∈ [0,1] from a sorted stops array. */ +function sampleAt(stops, p) { + if (!stops.length) return '#000000'; + if (p <= stops[0].pos) return stops[0].color; + if (p >= stops[stops.length - 1].pos) return stops[stops.length - 1].color; + for (let i = 0; i < stops.length - 1; i++) { + const a = stops[i], b = stops[i + 1]; + if (p >= a.pos && p <= b.pos) { + const span = b.pos - a.pos; + const t = span > 1e-9 ? (p - a.pos) / span : 0; + return lerpColor(a.color, b.color, t); + } + } + return stops[stops.length - 1].color; +} + +/** + * Defensive normalization: clamp positions, enforce ≥2 stops, sort by position. + * Returns a NEW array; never mutates input. + */ +function normalizeStops(input) { + let stops = Array.isArray(input) + ? input + .filter(s => s && typeof s === 'object') + .map(s => ({ + pos: clamp01(typeof s.pos === 'number' ? s.pos : 0), + color: (typeof s.color === 'string' ? s.color : '#888888'), + // `lockedToBase` ties this stop's color to the user's base-color + // setting. The editor displays it with a lock indicator and skips + // color-input edits for it. Stored on the stop so it persists + // through .bumpmesh / undo / sessionStorage round-trips. + lockedToBase: !!s.lockedToBase, + })) + : []; + // Ensure ≥2 stops; pad with sensible defaults at endpoints. + if (stops.length === 0) { + stops = [ + { pos: 0, color: '#222222', lockedToBase: false }, + { pos: 1, color: '#dddddd', lockedToBase: false }, + ]; + } else if (stops.length === 1) { + const only = stops[0]; + if (only.pos < 1) stops.push({ pos: 1, color: only.color, lockedToBase: false }); + else stops.unshift({ pos: 0, color: only.color, lockedToBase: false }); + } + stops.sort((a, b) => a.pos - b.pos); + return stops; +} + +export class GradientEditor { + constructor() { + this._stops = normalizeStops([]); + this._onChange = null; + this._onSelect = null; // callback for selection changes (stop-props panel) + this._mountEl = null; + this._barEl = null; + this._stopsLayerEl = null; + this._selectedIdx = 0; + this._dragState = null; // { idx, startX, startY, startPos, removed } + this._suppressEmit = false; + + // Bound handlers for cleanup-friendly attachment. + this._onBarPointerDown = this._onBarPointerDown.bind(this); + this._onWindowPointerMove = this._onWindowPointerMove.bind(this); + this._onWindowPointerUp = this._onWindowPointerUp.bind(this); + } + + mount(containerEl) { + if (!containerEl) return; + this._mountEl = containerEl; + containerEl.classList.add('gradient-editor'); + containerEl.innerHTML = ''; + + // Bar + handles. The selected stop's color/position/lock are now edited + // in a separate properties panel owned by the orchestrator (main.js), + // not in a floating color input. + const barWrap = document.createElement('div'); + barWrap.className = 'gradient-bar-wrap'; + + const bar = document.createElement('div'); + bar.className = 'gradient-bar'; + bar.style.height = BAR_HEIGHT_PX + 'px'; + bar.addEventListener('pointerdown', this._onBarPointerDown); + // Suppress browser default contextmenu on the bar (right-click → remove). + bar.addEventListener('contextmenu', (ev) => ev.preventDefault()); + + const stopsLayer = document.createElement('div'); + stopsLayer.className = 'gradient-stops-layer'; + + barWrap.appendChild(bar); + barWrap.appendChild(stopsLayer); + containerEl.appendChild(barWrap); + + this._barEl = bar; + this._stopsLayerEl = stopsLayer; + + this._render(); + this._notifySelect(); + } + + setStops(stops) { + this._stops = normalizeStops(stops); + if (this._selectedIdx >= this._stops.length) this._selectedIdx = 0; + this._render(); + this._notifySelect(); + // setStops is "external apply"; do NOT emit change to avoid feedback loops. + } + + // ─── Public API used by the stop-properties panel (main.js) ──────────── + + /** Returns the currently-selected stop index, or -1 if none. */ + getSelectedIdx() { + return (this._selectedIdx >= 0 && this._selectedIdx < this._stops.length) + ? this._selectedIdx : -1; + } + + /** Returns a deep copy of the currently-selected stop, or null. */ + getSelectedStop() { + const i = this.getSelectedIdx(); + if (i < 0) return null; + const s = this._stops[i]; + return { pos: s.pos, color: s.color, lockedToBase: !!s.lockedToBase }; + } + + /** Set selectedIdx, clamped. Emits select but not change. */ + setSelectedIdx(idx) { + if (!Number.isInteger(idx)) return; + const next = Math.max(0, Math.min(idx, this._stops.length - 1)); + if (next === this._selectedIdx) return; + this._selectedIdx = next; + this._render(); + this._notifySelect(); + } + + /** Edit the selected stop's color. Auto-unlocks if it was locked. */ + setSelectedColor(hex) { + const i = this.getSelectedIdx(); + if (i < 0 || typeof hex !== 'string') return; + const s = this._stops[i]; + if (s.color === hex && !s.lockedToBase) return; + if (s.lockedToBase) s.lockedToBase = false; + s.color = hex; + this._render(); + this._notifySelect(); + this._emitChange(); + } + + /** Edit the selected stop's position (0..1). Resorts and updates index. */ + setSelectedPos(pos) { + const i = this.getSelectedIdx(); + if (i < 0 || typeof pos !== 'number' || !Number.isFinite(pos)) return; + const s = this._stops[i]; + const np = clamp01(pos); + if (s.pos === np) return; + s.pos = np; + const ref = s; + this._stops.sort((a, b) => a.pos - b.pos); + this._selectedIdx = this._stops.indexOf(ref); + if (this._selectedIdx < 0) this._selectedIdx = 0; + this._render(); + this._notifySelect(); + this._emitChange(); + } + + /** Toggle / set the selected stop's lockedToBase. When locking, color snaps to base. */ + setSelectedLocked(locked) { + const i = this.getSelectedIdx(); + if (i < 0) return; + const s = this._stops[i]; + const next = !!locked; + if (s.lockedToBase === next) return; + s.lockedToBase = next; + if (next && typeof this._baseColor === 'string') s.color = this._baseColor; + this._render(); + this._notifySelect(); + this._emitChange(); + } + + /** Remove the currently-selected stop. Refuses if it would leave < 2 stops. */ + removeSelected() { + this._removeStopAt(this._selectedIdx); + } + + /** Restore a sensible 2-stop greyscale default. */ + resetToDefault() { + this._stops = normalizeStops([ + { pos: 0, color: '#222222' }, + { pos: 1, color: '#dddddd' }, + ]); + this._selectedIdx = 0; + this._render(); + this._notifySelect(); + this._emitChange(); + } + + /** Register a selection callback (called whenever the selected stop changes). */ + onSelect(cb) { this._onSelect = typeof cb === 'function' ? cb : null; } + + getStops() { + return this._stops.map(s => ({ + pos: s.pos, + color: s.color, + lockedToBase: !!s.lockedToBase, + })); + } + + onChange(cb) { + this._onChange = typeof cb === 'function' ? cb : null; + } + + /** + * Set the base color used by stops with `lockedToBase: true`. Called by the + * orchestrator (main.js) whenever the user changes the base-color picker. + * Updates locked stops in-place and re-renders. Emits a change event iff + * any locked stop's color actually moved. + */ + setBaseColor(hex) { + if (typeof hex !== 'string') return; + this._baseColor = hex; + let mutated = false; + for (const s of this._stops) { + if (s.lockedToBase && s.color !== hex) { + s.color = hex; + mutated = true; + } + } + this._render(); + if (mutated) this._emitChange(); + } + + // ─── Internal: rendering ─────────────────────────────────────────────── + + _render() { + if (!this._barEl || !this._stopsLayerEl) return; + // Build the CSS background gradient. + const sortedCopy = this._stops.slice().sort((a, b) => a.pos - b.pos); + const css = sortedCopy + .map(s => `${s.color} ${(s.pos * 100).toFixed(2)}%`) + .join(', '); + this._barEl.style.background = `linear-gradient(to right, ${css})`; + + // Stops layer: clear and rebuild. + this._stopsLayerEl.innerHTML = ''; + for (let i = 0; i < this._stops.length; i++) { + const stop = this._stops[i]; + const handle = document.createElement('div'); + handle.className = 'gradient-stop-handle'; + if (i === this._selectedIdx) handle.classList.add('selected'); + if (stop.lockedToBase) handle.classList.add('locked-to-base'); + handle.style.left = (stop.pos * 100) + '%'; + handle.style.width = STOP_HANDLE_SIZE_PX + 'px'; + handle.style.height = STOP_HANDLE_SIZE_PX + 'px'; + handle.style.background = stop.color; + handle.dataset.idx = String(i); + handle.title = stop.lockedToBase + ? `${(stop.pos * 100).toFixed(0)}% — locked to base color (${stop.color}). Alt-click to unlock.` + : `${(stop.pos * 100).toFixed(0)}% — ${stop.color}. Alt-click to lock to base color.`; + handle.addEventListener('pointerdown', (ev) => this._onStopPointerDown(ev, i)); + handle.addEventListener('contextmenu', (ev) => { + ev.preventDefault(); + this._removeStopAt(i); + }); + this._stopsLayerEl.appendChild(handle); + } + + } + + _notifySelect() { + if (typeof this._onSelect === 'function') { + try { this._onSelect(this.getSelectedStop(), this.getSelectedIdx()); } + catch (err) { console.warn('GradientEditor onSelect threw:', err); } + } + } + + _emitChange() { + if (this._suppressEmit) return; + if (typeof this._onChange === 'function') { + try { this._onChange(this.getStops()); } + catch (err) { console.warn('GradientEditor onChange threw:', err); } + } + } + + // ─── Internal: pointer interaction ───────────────────────────────────── + + _xToPos(clientX) { + const rect = this._barEl.getBoundingClientRect(); + if (rect.width <= 0) return 0; + return clamp01((clientX - rect.left) / rect.width); + } + + _onBarPointerDown(ev) { + // Only main button. Right-click on empty bar is a no-op. + if (ev.button !== 0) return; + // If the actual click landed on a handle, the handle's own listener will + // run first; we still get this event because handles are on a sibling + // layer. Ignore if the event came from a handle. + if (ev.target && ev.target.classList && ev.target.classList.contains('gradient-stop-handle')) return; + ev.preventDefault(); + const pos = this._xToPos(ev.clientX); + const color = sampleAt(this._stops.slice().sort((a, b) => a.pos - b.pos), pos); + this._stops.push({ pos, color }); + this._stops.sort((a, b) => a.pos - b.pos); + this._selectedIdx = this._stops.findIndex(s => s.pos === pos && s.color === color); + if (this._selectedIdx < 0) this._selectedIdx = 0; + this._render(); + this._notifySelect(); + this._emitChange(); + } + + _onStopPointerDown(ev, idx) { + if (ev.button === 2) { + // Right-click handled by contextmenu listener. + return; + } + if (ev.button !== 0) return; + ev.preventDefault(); + ev.stopPropagation(); + + // Alt-click toggles "locked to base color" for this stop. When locking, + // the stop's color snaps to the current base color immediately. + if (ev.altKey) { + const s = this._stops[idx]; + if (!s) return; + s.lockedToBase = !s.lockedToBase; + if (s.lockedToBase && typeof this._baseColor === 'string') { + s.color = this._baseColor; + } + this._selectedIdx = idx; + this._render(); + this._notifySelect(); + this._emitChange(); + return; + } + + this._selectedIdx = idx; + this._render(); + this._notifySelect(); + // Begin drag. + this._dragState = { + idx, + startX: ev.clientX, + startY: ev.clientY, + startPos: this._stops[idx].pos, + removed: false, + moved: false, + }; + window.addEventListener('pointermove', this._onWindowPointerMove); + window.addEventListener('pointerup', this._onWindowPointerUp); + } + + _onWindowPointerMove(ev) { + const ds = this._dragState; + if (!ds) return; + ev.preventDefault(); + const dy = ev.clientY - ds.startY; + if (Math.abs(dy) > REMOVE_DRAG_THRESHOLD_PX) { + // Mark visually as "about to remove" — semi-transparent. + const handles = this._stopsLayerEl.querySelectorAll('.gradient-stop-handle'); + const h = handles[ds.idx]; + if (h) h.classList.add('drag-remove'); + ds.removed = true; + return; + } + // Restore visuals if the user dragged back into the keep zone. + if (ds.removed) { + const handles = this._stopsLayerEl.querySelectorAll('.gradient-stop-handle'); + const h = handles[ds.idx]; + if (h) h.classList.remove('drag-remove'); + ds.removed = false; + } + // Update position horizontally (in-place; resort on commit). + const newPos = this._xToPos(ev.clientX); + const stop = this._stops[ds.idx]; + if (stop && stop.pos !== newPos) { + stop.pos = newPos; + ds.moved = true; + this._render(); + this._emitChange(); + } + } + + _onWindowPointerUp(ev) { + const ds = this._dragState; + if (!ds) return; + window.removeEventListener('pointermove', this._onWindowPointerMove); + window.removeEventListener('pointerup', this._onWindowPointerUp); + this._dragState = null; + if (ds.removed) { + this._removeStopAt(ds.idx); + return; + } + // Commit final sort + selection by reference. + const ref = this._stops[ds.idx]; + this._stops.sort((a, b) => a.pos - b.pos); + this._selectedIdx = this._stops.indexOf(ref); + if (this._selectedIdx < 0) this._selectedIdx = 0; + this._render(); + this._notifySelect(); + if (ds.moved) this._emitChange(); + } + + _removeStopAt(idx) { + if (this._stops.length <= 2) { + // Reject — re-render to drop the drag-remove visual. + this._render(); + return; + } + this._stops.splice(idx, 1); + if (this._selectedIdx >= this._stops.length) this._selectedIdx = this._stops.length - 1; + if (this._selectedIdx < 0) this._selectedIdx = 0; + this._render(); + this._notifySelect(); + this._emitChange(); + } +} + +/** + * Wire CSS-driven sub-section visibility for #color-section based on the + * checked auto-source radio. Idempotent: safe to call once on DOMContentLoaded. + */ +export function wireColorSectionVisibility() { + const section = document.getElementById('color-section'); + if (!section) return; + const radios = document.querySelectorAll('input[name="color-auto-source"]'); + if (!radios.length) return; + const sync = () => { + const checked = Array.from(radios).find(r => r.checked); + section.dataset.source = checked ? checked.value : 'none'; + }; + radios.forEach(r => r.addEventListener('change', sync)); + sync(); +} + +// Auto-wire when DOM is ready. The orchestrator's wireColorPaintUI doesn't +// touch the section's data-source attribute, so we own this side-channel. +if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', wireColorSectionVisibility); + } else { + // Defer to the next tick so any sibling code initializing radio defaults + // (e.g. main.js's wireColorPaintUI) has a chance to run first. + Promise.resolve().then(wireColorSectionVisibility); + } +} diff --git a/js/i18n/de.js b/js/i18n/de.js index 4c026d4..c3669d2 100644 --- a/js/i18n/de.js +++ b/js/i18n/de.js @@ -206,5 +206,31 @@ export default { "ui.cylinderPanelLabel": "Zylinderprojektion festlegen", "ui.cylinderNoModel1": "Modell laden, um die", "ui.cylinderNoModel2": "Zylinderachse zu setzen", - "ui.cylinderPanelMinimize": "Minimieren / Wiederherstellen" + "ui.cylinderPanelMinimize": "Minimieren / Wiederherstellen", + "color.heading": "Color export (3MF)", + "color.enable": "Enable color export", + "color.sourceLabel": "Auto color source", + "color.sourceNone": "None", + "color.sourceGradient": "Gradient (height-based)", + "color.sourceImage": "Color image", + "color.gradientHint": "Click bar to add a stop · Click handle to select · Drag to move", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "color.paletteSize": "Export colors", + "tooltips.colorPaletteSize": "How many distinct colors the exported 3MF contains. Slicers map these to filament slots; pick the number you actually plan to print with.", + "color.stopPropsHeading": "Selected stop", + "color.stopColor": "Color", + "color.stopPosition": "Position", + "color.stopLockToBase": "Lock to base color", + "color.stopRemove": "Remove stop", + "color.paletteLabel": "Will export as:", + "color.snapPreview": "Show as printed colors", + "color.gradientReset": "↺ Reset", + "tooltips.colorSnapPreview": "Show the live preview using exactly the exported colors instead of a smooth gradient. More accurate to what your slicer will print.", + "tooltips.colorGradientReset": "Restore the default 2-stop greyscale gradient.", + "tooltips.colorExportEnabled": "When on, exported 3MF includes a colorgroup. STL stays geometry-only. Slicers map colors to filament slots." }; diff --git a/js/i18n/en.js b/js/i18n/en.js index 1eec785..79fecfd 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -206,5 +206,31 @@ export default { "ui.cylinderPanelLabel": "Define cylinder projection", "ui.cylinderNoModel1": "Load a model to position", "ui.cylinderNoModel2": "the cylinder axis", - "ui.cylinderPanelMinimize": "Minimize / restore" + "ui.cylinderPanelMinimize": "Minimize / restore", + "color.heading": "Color export (3MF)", + "color.enable": "Enable color export", + "color.sourceLabel": "Auto color source", + "color.sourceNone": "None", + "color.sourceGradient": "Gradient (height-based)", + "color.sourceImage": "Color image", + "color.gradientHint": "Click bar to add a stop · Click handle to select · Drag to move", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "color.paletteSize": "Export colors", + "tooltips.colorPaletteSize": "How many distinct colors the exported 3MF contains. Slicers map these to filament slots; pick the number you actually plan to print with.", + "color.stopPropsHeading": "Selected stop", + "color.stopColor": "Color", + "color.stopPosition": "Position", + "color.stopLockToBase": "Lock to base color", + "color.stopRemove": "Remove stop", + "color.paletteLabel": "Will export as:", + "color.snapPreview": "Show as printed colors", + "color.gradientReset": "↺ Reset", + "tooltips.colorSnapPreview": "Show the live preview using exactly the exported colors instead of a smooth gradient. More accurate to what your slicer will print.", + "tooltips.colorGradientReset": "Restore the default 2-stop greyscale gradient.", + "tooltips.colorExportEnabled": "When on, exported 3MF includes a colorgroup. STL stays geometry-only. Slicers map colors to filament slots." }; diff --git a/js/i18n/es.js b/js/i18n/es.js index 6476c20..e1c3a7d 100644 --- a/js/i18n/es.js +++ b/js/i18n/es.js @@ -206,5 +206,31 @@ export default { "ui.cylinderPanelLabel": "Definir proyección cilíndrica", "ui.cylinderNoModel1": "Carga un modelo para", "ui.cylinderNoModel2": "colocar el eje del cilindro", - "ui.cylinderPanelMinimize": "Minimizar / restaurar" + "ui.cylinderPanelMinimize": "Minimizar / restaurar", + "color.heading": "Color export (3MF)", + "color.enable": "Enable color export", + "color.sourceLabel": "Auto color source", + "color.sourceNone": "None", + "color.sourceGradient": "Gradient (height-based)", + "color.sourceImage": "Color image", + "color.gradientHint": "Click bar to add a stop · Click handle to select · Drag to move", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "color.paletteSize": "Export colors", + "tooltips.colorPaletteSize": "How many distinct colors the exported 3MF contains. Slicers map these to filament slots; pick the number you actually plan to print with.", + "color.stopPropsHeading": "Selected stop", + "color.stopColor": "Color", + "color.stopPosition": "Position", + "color.stopLockToBase": "Lock to base color", + "color.stopRemove": "Remove stop", + "color.paletteLabel": "Will export as:", + "color.snapPreview": "Show as printed colors", + "color.gradientReset": "↺ Reset", + "tooltips.colorSnapPreview": "Show the live preview using exactly the exported colors instead of a smooth gradient. More accurate to what your slicer will print.", + "tooltips.colorGradientReset": "Restore the default 2-stop greyscale gradient.", + "tooltips.colorExportEnabled": "When on, exported 3MF includes a colorgroup. STL stays geometry-only. Slicers map colors to filament slots." }; diff --git a/js/i18n/fr.js b/js/i18n/fr.js index b996106..2375e44 100644 --- a/js/i18n/fr.js +++ b/js/i18n/fr.js @@ -206,5 +206,31 @@ export default { "ui.cylinderPanelLabel": "Définir la projection cylindrique", "ui.cylinderNoModel1": "Chargez un modèle pour", "ui.cylinderNoModel2": "positionner l'axe du cylindre", - "ui.cylinderPanelMinimize": "Réduire / restaurer" + "ui.cylinderPanelMinimize": "Réduire / restaurer", + "color.heading": "Color export (3MF)", + "color.enable": "Enable color export", + "color.sourceLabel": "Auto color source", + "color.sourceNone": "None", + "color.sourceGradient": "Gradient (height-based)", + "color.sourceImage": "Color image", + "color.gradientHint": "Click bar to add a stop · Click handle to select · Drag to move", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "color.paletteSize": "Export colors", + "tooltips.colorPaletteSize": "How many distinct colors the exported 3MF contains. Slicers map these to filament slots; pick the number you actually plan to print with.", + "color.stopPropsHeading": "Selected stop", + "color.stopColor": "Color", + "color.stopPosition": "Position", + "color.stopLockToBase": "Lock to base color", + "color.stopRemove": "Remove stop", + "color.paletteLabel": "Will export as:", + "color.snapPreview": "Show as printed colors", + "color.gradientReset": "↺ Reset", + "tooltips.colorSnapPreview": "Show the live preview using exactly the exported colors instead of a smooth gradient. More accurate to what your slicer will print.", + "tooltips.colorGradientReset": "Restore the default 2-stop greyscale gradient.", + "tooltips.colorExportEnabled": "When on, exported 3MF includes a colorgroup. STL stays geometry-only. Slicers map colors to filament slots." }; diff --git a/js/i18n/it.js b/js/i18n/it.js index f323508..5241549 100644 --- a/js/i18n/it.js +++ b/js/i18n/it.js @@ -206,5 +206,31 @@ export default { "ui.cylinderPanelLabel": "Definisci proiezione cilindrica", "ui.cylinderNoModel1": "Carica un modello per", "ui.cylinderNoModel2": "posizionare l'asse del cilindro", - "ui.cylinderPanelMinimize": "Riduci / ripristina" + "ui.cylinderPanelMinimize": "Riduci / ripristina", + "color.heading": "Color export (3MF)", + "color.enable": "Enable color export", + "color.sourceLabel": "Auto color source", + "color.sourceNone": "None", + "color.sourceGradient": "Gradient (height-based)", + "color.sourceImage": "Color image", + "color.gradientHint": "Click bar to add a stop · Click handle to select · Drag to move", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "color.paletteSize": "Export colors", + "tooltips.colorPaletteSize": "How many distinct colors the exported 3MF contains. Slicers map these to filament slots; pick the number you actually plan to print with.", + "color.stopPropsHeading": "Selected stop", + "color.stopColor": "Color", + "color.stopPosition": "Position", + "color.stopLockToBase": "Lock to base color", + "color.stopRemove": "Remove stop", + "color.paletteLabel": "Will export as:", + "color.snapPreview": "Show as printed colors", + "color.gradientReset": "↺ Reset", + "tooltips.colorSnapPreview": "Show the live preview using exactly the exported colors instead of a smooth gradient. More accurate to what your slicer will print.", + "tooltips.colorGradientReset": "Restore the default 2-stop greyscale gradient.", + "tooltips.colorExportEnabled": "When on, exported 3MF includes a colorgroup. STL stays geometry-only. Slicers map colors to filament slots." }; diff --git a/js/i18n/ja.js b/js/i18n/ja.js index adba228..b027cba 100644 --- a/js/i18n/ja.js +++ b/js/i18n/ja.js @@ -206,5 +206,31 @@ export default { "ui.cylinderPanelLabel": "シリンダー投影を設定", "ui.cylinderNoModel1": "モデルを読み込んで", "ui.cylinderNoModel2": "シリンダー軸を配置してください", - "ui.cylinderPanelMinimize": "最小化 / 復元" + "ui.cylinderPanelMinimize": "最小化 / 復元", + "color.heading": "Color export (3MF)", + "color.enable": "Enable color export", + "color.sourceLabel": "Auto color source", + "color.sourceNone": "None", + "color.sourceGradient": "Gradient (height-based)", + "color.sourceImage": "Color image", + "color.gradientHint": "Click bar to add a stop · Click handle to select · Drag to move", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "color.paletteSize": "Export colors", + "tooltips.colorPaletteSize": "How many distinct colors the exported 3MF contains. Slicers map these to filament slots; pick the number you actually plan to print with.", + "color.stopPropsHeading": "Selected stop", + "color.stopColor": "Color", + "color.stopPosition": "Position", + "color.stopLockToBase": "Lock to base color", + "color.stopRemove": "Remove stop", + "color.paletteLabel": "Will export as:", + "color.snapPreview": "Show as printed colors", + "color.gradientReset": "↺ Reset", + "tooltips.colorSnapPreview": "Show the live preview using exactly the exported colors instead of a smooth gradient. More accurate to what your slicer will print.", + "tooltips.colorGradientReset": "Restore the default 2-stop greyscale gradient.", + "tooltips.colorExportEnabled": "When on, exported 3MF includes a colorgroup. STL stays geometry-only. Slicers map colors to filament slots." }; diff --git a/js/i18n/ko.js b/js/i18n/ko.js index 913b89f..28444ba 100644 --- a/js/i18n/ko.js +++ b/js/i18n/ko.js @@ -206,5 +206,33 @@ export default { "ui.cylinderPanelLabel": "원통 투영 정의", "ui.cylinderNoModel1": "모델을 불러와", "ui.cylinderNoModel2": "원통 축을 배치하세요", - "ui.cylinderPanelMinimize": "최소화 / 복원" + "ui.cylinderPanelMinimize": "최소화 / 복원", + + // Color export — English fallback strings; native Korean translation TBD. + "color.heading": "Color export (3MF)", + "color.enable": "Enable color export", + "color.sourceLabel": "Auto color source", + "color.sourceNone": "None", + "color.sourceGradient": "Gradient (height-based)", + "color.sourceImage": "Color image", + "color.gradientHint": "Click bar to add a stop · Click handle to select · Drag to move", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "color.paletteSize": "Export colors", + "tooltips.colorPaletteSize": "How many distinct colors the exported 3MF contains. Slicers map these to filament slots; pick the number you actually plan to print with.", + "color.stopPropsHeading": "Selected stop", + "color.stopColor": "Color", + "color.stopPosition": "Position", + "color.stopLockToBase": "Lock to base color", + "color.stopRemove": "Remove stop", + "color.paletteLabel": "Will export as:", + "color.snapPreview": "Show as printed colors", + "color.gradientReset": "↺ Reset", + "tooltips.colorSnapPreview": "Show the live preview using exactly the exported colors instead of a smooth gradient. More accurate to what your slicer will print.", + "tooltips.colorGradientReset": "Restore the default 2-stop greyscale gradient.", + "tooltips.colorExportEnabled": "When on, exported 3MF includes a colorgroup. STL stays geometry-only. Slicers map colors to filament slots." }; diff --git a/js/i18n/pt.js b/js/i18n/pt.js index 1abe323..f5ef9f0 100644 --- a/js/i18n/pt.js +++ b/js/i18n/pt.js @@ -206,5 +206,31 @@ export default { "ui.cylinderPanelLabel": "Definir projeção cilíndrica", "ui.cylinderNoModel1": "Carrega um modelo para", "ui.cylinderNoModel2": "posicionar o eixo do cilindro", - "ui.cylinderPanelMinimize": "Minimizar / restaurar" + "ui.cylinderPanelMinimize": "Minimizar / restaurar", + "color.heading": "Color export (3MF)", + "color.enable": "Enable color export", + "color.sourceLabel": "Auto color source", + "color.sourceNone": "None", + "color.sourceGradient": "Gradient (height-based)", + "color.sourceImage": "Color image", + "color.gradientHint": "Click bar to add a stop · Click handle to select · Drag to move", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "color.paletteSize": "Export colors", + "tooltips.colorPaletteSize": "How many distinct colors the exported 3MF contains. Slicers map these to filament slots; pick the number you actually plan to print with.", + "color.stopPropsHeading": "Selected stop", + "color.stopColor": "Color", + "color.stopPosition": "Position", + "color.stopLockToBase": "Lock to base color", + "color.stopRemove": "Remove stop", + "color.paletteLabel": "Will export as:", + "color.snapPreview": "Show as printed colors", + "color.gradientReset": "↺ Reset", + "tooltips.colorSnapPreview": "Show the live preview using exactly the exported colors instead of a smooth gradient. More accurate to what your slicer will print.", + "tooltips.colorGradientReset": "Restore the default 2-stop greyscale gradient.", + "tooltips.colorExportEnabled": "When on, exported 3MF includes a colorgroup. STL stays geometry-only. Slicers map colors to filament slots." }; diff --git a/js/main.js b/js/main.js index 55a1c47..f7c414e 100644 --- a/js/main.js +++ b/js/main.js @@ -7,13 +7,16 @@ import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWirefram setRotationGizmo, isGizmoDragging } from './viewer.js'; import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; import { loadAllThumbnails, loadFullPreset, loadCustomTexture, IMAGE_PRESETS } from './presetTextures.js'; -import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; +import { createPreviewMaterial, updateMaterial, setColorPreview } from './previewMaterial.js'; import { subdivide } from './subdivision.js'; import { applyDisplacement } from './displacement.js'; import { decimate } from './decimation.js'; import { exportSTL, export3MF } from './exporter.js'; import { buildAdjacency, bucketFill, buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js'; +import { applyColors } from './colorBake.js'; +import { medianCut } from './quantize.js'; +import { GradientEditor } from './gradientEditor.js'; import { runFastDiagnostics, runExpensiveDiagnostics, getEdgePositions, getShellAssignments } from './meshValidation.js'; import { t, initLang, setLang, getLang, applyTranslations, TRANSLATIONS } from './i18n.js'; @@ -57,6 +60,16 @@ let _rotateOriginalPositions = null; // Float32Array snapshot before any rotati const _raycaster = new THREE.Raycaster(); let _lastPaintHitPoint = null; // THREE.Vector3 — last brush paint position for shift-line let _shiftLineMesh = null; // THREE.Line — preview line from last paint to cursor + +// ── Color image state ───────────────────────────────────────────────────────── +// Uploaded color image (separate from displacement texture). Held at module scope +// rather than in `settings` so the binary asset never lands in sessionStorage — +// it persists in `.bumpmesh` projects via a separate `color.png` zip entry. +let _lastColorMap = null; // { name, imageData, width, height, fullCanvas } +let _colorImageTexture = null; // THREE.CanvasTexture wrapping _lastColorMap.fullCanvas +let _gradientLUTCanvas = null; // 256×1 canvas, rebuilt from gradient stops +let _gradientLUTTexture = null; // THREE.CanvasTexture wrapping _gradientLUTCanvas + let _lastEffectiveTexture = null; let _effectiveMapCache = null; let _effectiveMapCacheKey = null; @@ -91,6 +104,26 @@ const settings = { cylinderCenterY: null, cylinderRadius: null, cylinderPanelMinimized: false, + // ── Color export (3MF) ──────────────────────────────────────────────────── + colorExportEnabled: false, + colorAutoSource: 'none', // 'none' | 'gradient' | 'image' + colorBaseColor: '#ffffff', // applied to non-textured / excluded faces + // Number of distinct palette entries in the exported 3MF. Caps median-cut so + // slicers don't see N=32 filament slots when the user only has e.g. 4. The + // dropdown in the UI offers 2/3/4/6/8/16/32; default 4 matches typical AMS. + // Only used when colorAutoSource === 'image' (gradient mode snaps to + // control points and ignores this). + colorPaletteSize: 4, + // When true, the live preview's gradient LUT is built as a stepped + // (per-stop) palette instead of a smooth interpolation, so the on-screen + // tint matches what the snap-to-control-points export will produce. + colorSnapPreview: false, + // N-stop gradient: array of { pos: 0..1, color: '#RRGGBB', lockedToBase: bool }. + // Two stops minimum, enforced by the gradient editor widget. + colorGradientStops: [ + { pos: 0, color: '#222222' }, + { pos: 1, color: '#dddddd' }, + ], }; // ── Canvas filter support (Safari / iOS WebView don't support ctx.filter) ──── @@ -1704,6 +1737,552 @@ function wireEvents() { document.addEventListener('keyup', (e) => { if (e.key === 'Control') _clearShiftLinePreview(); }); + + // ── Color export UI wiring ──────────────────────────────────────────────── + // Mounts the gradient editor and binds the master toggle, source radio, + // base-color picker, and color-image upload. Safe no-op when DOM elements + // are missing. + wireColorExportUI(); +} + +// ── Color export UI wiring ─────────────────────────────────────────────────── +// Wires the master toggle, source radio, gradient editor, base color picker, +// and color image upload. Runs once from wireEvents(). +function wireColorExportUI() { + // 1) Mount the gradient editor on its placeholder div. + const gradMount = document.getElementById('color-gradient-mount'); + if (gradMount && typeof GradientEditor === 'function') { + try { + const editor = new GradientEditor(); + editor.mount(gradMount); + editor.setStops(settings.colorGradientStops); + editor.onChange((stops) => { + // Deep-copy on assign so settings never aliases the editor's internal array. + settings.colorGradientStops = stops.map(s => ({ + pos: s.pos, color: s.color, lockedToBase: !!s.lockedToBase, + })); + _refreshStopPropsPanel(); + _refreshPalettePreview(); + _pushColorPreviewState(); + _scheduleUndoCapture(); + const sp = document.getElementById('settings-panel'); + if (sp) sp.dispatchEvent(new Event('change', { bubbles: true })); + }); + editor.onSelect(() => _refreshStopPropsPanel()); + window._gradientEditor = editor; + } catch (err) { + console.warn('GradientEditor failed to mount:', err); + } + } + + // 1b) Stop properties panel — drives the currently-selected stop. + const stopColorEl = document.getElementById('stop-color-input'); + const stopHexEl = document.getElementById('stop-hex-input'); + const stopPosEl = document.getElementById('stop-pos-input'); + const stopLockEl = document.getElementById('stop-lock-input'); + const stopRemoveEl = document.getElementById('stop-remove-btn'); + if (stopColorEl) stopColorEl.addEventListener('input', () => { + if (window._gradientEditor) window._gradientEditor.setSelectedColor(stopColorEl.value); + }); + if (stopHexEl) stopHexEl.addEventListener('change', () => { + let v = (stopHexEl.value || '').trim(); + if (!v.startsWith('#')) v = '#' + v; + if (/^#[0-9a-fA-F]{6}$/.test(v) && window._gradientEditor) { + window._gradientEditor.setSelectedColor(v.toLowerCase()); + } else { + _refreshStopPropsPanel(); // revert to current value + } + }); + if (stopPosEl) stopPosEl.addEventListener('change', () => { + const n = parseFloat(stopPosEl.value); + if (Number.isFinite(n) && window._gradientEditor) { + window._gradientEditor.setSelectedPos(Math.max(0, Math.min(100, n)) / 100); + } + }); + if (stopLockEl) stopLockEl.addEventListener('change', () => { + if (window._gradientEditor) window._gradientEditor.setSelectedLocked(stopLockEl.checked); + }); + if (stopRemoveEl) stopRemoveEl.addEventListener('click', () => { + if (window._gradientEditor) window._gradientEditor.removeSelected(); + }); + + // 1c) Snap-preview toggle. + const snapEl = document.getElementById('color-snap-preview'); + if (snapEl) { + snapEl.checked = !!settings.colorSnapPreview; + snapEl.addEventListener('change', () => { + settings.colorSnapPreview = !!snapEl.checked; + _pushColorPreviewState(); + }); + } + + // 1d) Reset gradient button. + const resetEl = document.getElementById('color-gradient-reset'); + if (resetEl) { + resetEl.addEventListener('click', () => { + if (window._gradientEditor) window._gradientEditor.resetToDefault(); + }); + } + + // 2) Master enable toggle. + const enableEl = document.getElementById('color-export-toggle'); + if (enableEl) { + enableEl.checked = !!settings.colorExportEnabled; + enableEl.addEventListener('change', () => { + settings.colorExportEnabled = !!enableEl.checked; + _pushColorPreviewState(); + }); + } + + // 3) Auto-source radio (None / Gradient / Image). + document.querySelectorAll('input[name="color-auto-source"]').forEach(el => { + if (el.value === settings.colorAutoSource) el.checked = true; + el.addEventListener('change', () => { + if (el.checked) { + settings.colorAutoSource = el.value; + _pushColorPreviewState(); + } + }); + }); + + // 4) Base color picker. Also propagates to the gradient editor so any + // stop with lockedToBase: true tracks this color in real time. + const baseEl = document.getElementById('color-base-picker'); + if (baseEl) { + baseEl.value = settings.colorBaseColor; + baseEl.addEventListener('change', () => { + settings.colorBaseColor = baseEl.value; + if (window._gradientEditor && typeof window._gradientEditor.setBaseColor === 'function') { + window._gradientEditor.setBaseColor(baseEl.value); + } + _pushColorPreviewState(); + }); + } + // Initial sync so locked stops carry the right color from the get-go. + if (window._gradientEditor && typeof window._gradientEditor.setBaseColor === 'function') { + window._gradientEditor.setBaseColor(settings.colorBaseColor); + } + + // 4b) Palette size selector — controls how many distinct colors land in the + // 3MF colorgroup, so the slicer maps to a sane number of filament slots. + const paletteSizeEl = document.getElementById('color-palette-size'); + if (paletteSizeEl) { + paletteSizeEl.value = String(settings.colorPaletteSize); + paletteSizeEl.addEventListener('change', () => { + const n = parseInt(paletteSizeEl.value, 10); + if (Number.isFinite(n) && n >= 2 && n <= 32) settings.colorPaletteSize = n; + }); + } + + // 5) Color image upload. + const colorFileInput = document.getElementById('color-image-input'); + if (colorFileInput) { + colorFileInput.addEventListener('change', async (ev) => { + const file = ev.target.files && ev.target.files[0]; + colorFileInput.value = ''; + if (!file) return; + try { + const bmp = await createImageBitmap(file); + const cvs = document.createElement('canvas'); + cvs.width = bmp.width; cvs.height = bmp.height; + const ctx = cvs.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(bmp, 0, 0); + const imgData = ctx.getImageData(0, 0, bmp.width, bmp.height); + _lastColorMap = { + name: file.name, + imageData: imgData, + width: bmp.width, + height: bmp.height, + fullCanvas: cvs, + }; + bmp.close && bmp.close(); + if (typeof window._refreshColorImageUI === 'function') window._refreshColorImageUI(); + _pushColorPreviewState(); + _scheduleUndoCapture(); + } catch (err) { + console.error('Color image load failed:', err); + alert(t('alerts.colorImageFailed') || ('Could not load color image: ' + err.message)); + } + }); + } + + // 6) Color image remove button. + const colorRemoveBtn = document.getElementById('color-image-remove'); + if (colorRemoveBtn) { + colorRemoveBtn.addEventListener('click', () => { + _lastColorMap = null; + if (typeof window._refreshColorImageUI === 'function') window._refreshColorImageUI(); + _pushColorPreviewState(); + _scheduleUndoCapture(); + }); + } + + // 7) Color image thumbnail / label refresh, used by upload, remove, reset, + // and .bumpmesh import paths. + window._refreshColorImageUI = refreshColorImageUI; + refreshColorImageUI(); + // Wire base-color and source changes to the palette preview too. + if (baseEl) baseEl.addEventListener('change', _refreshPalettePreview); + document.querySelectorAll('input[name="color-auto-source"]').forEach(el => { + el.addEventListener('change', _refreshPalettePreview); + }); + // Initial sync — runs once after the gradient editor mounts and settings + // are populated. Subsequent changes flow through the listeners above. + _refreshStopPropsPanel(); + _refreshPalettePreview(); + _pushColorPreviewState(); +} + +// ── Color preview helpers ──────────────────────────────────────────────────── +// Build a 256×1 LUT canvas from gradient stops; the fragment shader samples +// this with the displacement greyvalue to produce the gradient color. +function _rebuildGradientLUT() { + if (!_gradientLUTCanvas) { + _gradientLUTCanvas = document.createElement('canvas'); + _gradientLUTCanvas.width = 256; _gradientLUTCanvas.height = 1; + } + const ctx = _gradientLUTCanvas.getContext('2d'); + const stops = (Array.isArray(settings.colorGradientStops) && settings.colorGradientStops.length >= 2) + ? settings.colorGradientStops.slice().sort((a, b) => a.pos - b.pos) + : [{ pos: 0, color: '#222222' }, { pos: 1, color: '#dddddd' }]; + + if (settings.colorSnapPreview) { + // Stepped LUT: each pixel takes the color of the nearest stop by pos. + // This produces the same visual the export will see, so the live preview + // matches the printed result exactly (no smooth interpolation between + // stops that won't appear in the colorgroup). + const img = ctx.createImageData(256, 1); + for (let x = 0; x < 256; x++) { + const t = x / 255; + let best = stops[0]; + let bestD = Math.abs(stops[0].pos - t); + for (let i = 1; i < stops.length; i++) { + const d = Math.abs(stops[i].pos - t); + if (d < bestD) { bestD = d; best = stops[i]; } + } + const rgb = _hexToRGB01(best.color); + img.data[x * 4] = Math.round(rgb[0] * 255); + img.data[x * 4 + 1] = Math.round(rgb[1] * 255); + img.data[x * 4 + 2] = Math.round(rgb[2] * 255); + img.data[x * 4 + 3] = 255; + } + ctx.putImageData(img, 0, 0); + } else { + // Smooth interpolation via Canvas2D's built-in gradient. + const grad = ctx.createLinearGradient(0, 0, 256, 0); + for (const s of stops) { + const p = Math.max(0, Math.min(1, +s.pos)); + grad.addColorStop(p, s.color); + } + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 256, 1); + } + + if (!_gradientLUTTexture) { + _gradientLUTTexture = new THREE.CanvasTexture(_gradientLUTCanvas); + _gradientLUTTexture.wrapS = _gradientLUTTexture.wrapT = THREE.ClampToEdgeWrapping; + } else { + _gradientLUTTexture.needsUpdate = true; + } + // Use NearestFilter when snapped so adjacent stop boundaries stay crisp; + // LinearFilter for smooth so the interpolation isn't visibly stepped. + const filter = settings.colorSnapPreview ? THREE.NearestFilter : THREE.LinearFilter; + _gradientLUTTexture.minFilter = filter; + _gradientLUTTexture.magFilter = filter; + return _gradientLUTTexture; +} + +function _rebuildColorImageTexture() { + if (!_lastColorMap || !_lastColorMap.fullCanvas) { + if (_colorImageTexture) { _colorImageTexture.dispose && _colorImageTexture.dispose(); } + _colorImageTexture = null; + return null; + } + if (!_colorImageTexture || _colorImageTexture.image !== _lastColorMap.fullCanvas) { + if (_colorImageTexture) _colorImageTexture.dispose && _colorImageTexture.dispose(); + _colorImageTexture = new THREE.CanvasTexture(_lastColorMap.fullCanvas); + _colorImageTexture.wrapS = _colorImageTexture.wrapT = THREE.RepeatWrapping; + _colorImageTexture.minFilter = THREE.LinearFilter; + _colorImageTexture.magFilter = THREE.LinearFilter; + } else { + _colorImageTexture.needsUpdate = true; + } + return _colorImageTexture; +} + +function _hexToRGB01(hex) { + if (typeof hex !== 'string') return [1, 1, 1]; + const h = hex.trim().replace(/^#/, ''); + if (h.length !== 6) return [1, 1, 1]; + const n = parseInt(h, 16); + if (!Number.isFinite(n)) return [1, 1, 1]; + return [((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255]; +} + +function _hexToRGB255(hex) { + const c01 = _hexToRGB01(hex); + return [Math.round(c01[0] * 255), Math.round(c01[1] * 255), Math.round(c01[2] * 255)]; +} + +/** + * Snap each triangle in `triRGB` to the nearest gradient stop OR the base + * color (Euclidean RGB distance). Returns { palette, triPaletteIndices } + * matching the shape that export3MF expects. + * + * The output palette is exactly the user's control points (deduplicated): + * gradient stop colors first, base color last. No statistical averaging, + * so the exported colors are pixel-exact what the user picked. Slicer-side + * AMS slot assignment is predictable. + */ +/** + * Reduce color-region fragmentation by reassigning isolated triangles to + * their majority-neighbor color. The output of medianCut / snapToStops can + * produce a "speckle" pattern at color boundaries — triangles whose averaged + * color happened to land closer to a non-dominant palette entry. Slicers like + * Bambu Studio / OrcaSlicer treat each color as a distinct filament region; + * tens of thousands of single-triangle speckles produce a chaotic slice plan + * with non-manifold-style errors at every region boundary. + * + * Algorithm: for each triangle, look at its ≤3 face-adjacent neighbors. If + * 2+ neighbors share a color that isn't the triangle's own, switch. Iterate + * until stable (capped). Geometry untouched; only triPaletteIndices change. + * + * @param {Uint16Array} indices triangle → palette index (mutated in place) + * @param {Array>} adjacency + * @param {number} maxIters + * @returns {number} total reassignments across all iterations + */ +function _smoothPaletteRegions(indices, adjacency, maxIters = 3) { + const triCount = indices.length; + let totalChanges = 0; + // Double-buffer so each pass sees a consistent snapshot. + const next = new Uint16Array(triCount); + + // Pass A — conservative: flip iff ≥2 of my (≤3) neighbors share a non-self + // color. Preserves genuine sharp boundary triangles. + for (let iter = 0; iter < maxIters; iter++) { + next.set(indices); + let changes = 0; + for (let t = 0; t < triCount; t++) { + const nbrs = adjacency[t]; + if (!nbrs || nbrs.length < 2) continue; + const me = indices[t]; + let p0 = -1, c0 = 0, p1 = -1, c1 = 0, p2 = -1, c2 = 0; + for (let k = 0; k < nbrs.length; k++) { + const p = indices[nbrs[k].neighbor]; + if (p === p0) c0++; + else if (p === p1) c1++; + else if (p === p2) c2++; + else if (p0 < 0) { p0 = p; c0 = 1; } + else if (p1 < 0) { p1 = p; c1 = 1; } + else { p2 = p; c2 = 1; } + } + let bestPid = -1, bestCount = 0; + if (c0 > bestCount) { bestPid = p0; bestCount = c0; } + if (c1 > bestCount) { bestPid = p1; bestCount = c1; } + if (c2 > bestCount) { bestPid = p2; bestCount = c2; } + if (bestPid >= 0 && bestPid !== me && bestCount >= 2) { + next[t] = bestPid; + changes++; + } + } + if (changes === 0) break; + indices.set(next); + totalChanges += changes; + } + + // Pass B — aggressive cleanup of fully-surrounded triangles only. After + // the conservative passes have run to fixpoint, any triangle whose ALL ≥3 + // neighbors disagree with self is a true outlier (typically a "trijunction" + // where 3+ regions meet). Flip to whichever neighbor color appears most + // (lowest pid breaks ties), since "any neighbor color" is strictly better + // than "an island of one". A single pass is enough — by definition this + // doesn't propagate. + next.set(indices); + let aggChanges = 0; + for (let t = 0; t < triCount; t++) { + const nbrs = adjacency[t]; + if (!nbrs || nbrs.length < 2) continue; + const me = indices[t]; + let agree = 0; + let p0 = -1, c0 = 0, p1 = -1, c1 = 0, p2 = -1, c2 = 0; + for (let k = 0; k < nbrs.length; k++) { + const p = indices[nbrs[k].neighbor]; + if (p === me) { agree++; } + if (p === p0) c0++; + else if (p === p1) c1++; + else if (p === p2) c2++; + else if (p0 < 0) { p0 = p; c0 = 1; } + else if (p1 < 0) { p1 = p; c1 = 1; } + else { p2 = p; c2 = 1; } + } + if (agree > 0) continue; // not a fully-surrounded speckle + // All neighbors disagree. Pick the most common neighbor color (any + // neighbor color is better than self, which has 0 local support). + let bestPid = p0, bestCount = c0; + if (c1 > bestCount || (c1 === bestCount && p1 < bestPid)) { bestPid = p1; bestCount = c1; } + if (c2 > bestCount || (c2 === bestCount && p2 < bestPid)) { bestPid = p2; bestCount = c2; } + if (bestPid >= 0 && bestPid !== me) { + next[t] = bestPid; + aggChanges++; + } + } + if (aggChanges > 0) { + indices.set(next); + totalChanges += aggChanges; + } + return totalChanges; +} + +function _snapToControlPoints(triRGB, s) { + const stops = Array.isArray(s.colorGradientStops) ? s.colorGradientStops : []; + const palRGB = []; // array of [r, g, b] in 0..255 + const palSet = new Set(); + const pushIfNew = (rgb) => { + const key = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; + if (palSet.has(key)) return; + palSet.add(key); palRGB.push(rgb); + }; + for (const stop of stops) pushIfNew(_hexToRGB255(stop.color)); + pushIfNew(_hexToRGB255(s.colorBaseColor || '#ffffff')); + if (palRGB.length === 0) palRGB.push([255, 255, 255]); // defensive + + const triCount = (triRGB.length / 3) | 0; + const indices = new Uint16Array(triCount); + for (let i = 0; i < triCount; i++) { + const r = triRGB[i * 3], g = triRGB[i * 3 + 1], b = triRGB[i * 3 + 2]; + let best = 0, bestD = Infinity; + for (let j = 0; j < palRGB.length; j++) { + const p = palRGB[j]; + const dr = r - p[0], dg = g - p[1], db = b - p[2]; + const d = dr * dr + dg * dg + db * db; + if (d < bestD) { bestD = d; best = j; } + } + indices[i] = best; + } + const palette = new Uint8Array(palRGB.length * 3); + for (let i = 0; i < palRGB.length; i++) { + palette[i * 3] = palRGB[i][0]; + palette[i * 3 + 1] = palRGB[i][1]; + palette[i * 3 + 2] = palRGB[i][2]; + } + return { palette, triPaletteIndices: indices }; +} + +// Refresh the stop-properties panel inputs to match the currently-selected +// gradient stop. Called by the gradient editor's onSelect / onChange callbacks +// so the panel always reflects the live state without manual sync code. +function _refreshStopPropsPanel() { + const ge = window._gradientEditor; + if (!ge) return; + const stop = ge.getSelectedStop(); + const colorEl = document.getElementById('stop-color-input'); + const hexEl = document.getElementById('stop-hex-input'); + const posEl = document.getElementById('stop-pos-input'); + const lockEl = document.getElementById('stop-lock-input'); + const removeEl = document.getElementById('stop-remove-btn'); + const panel = document.getElementById('color-stop-props'); + if (!stop) { + if (panel) panel.classList.add('disabled'); + return; + } + if (panel) panel.classList.remove('disabled'); + if (colorEl) colorEl.value = stop.color; + if (hexEl) hexEl.value = stop.color; + if (posEl) posEl.value = String(Math.round(stop.pos * 100)); + if (lockEl) lockEl.checked = !!stop.lockedToBase; + // Color picker is greyed out when locked — clearer than letting the user + // edit and silently auto-unlock. + if (colorEl) colorEl.disabled = !!stop.lockedToBase; + if (hexEl) hexEl.disabled = !!stop.lockedToBase; + // Disable Remove if we'd drop below the 2-stop minimum. + if (removeEl) removeEl.disabled = (ge.getStops().length <= 2); +} + +// Render the live palette preview row — exact swatches of what will appear +// in the exported 3MF. Gradient mode shows {stop colors} ∪ {base}, +// deduplicated. Image and none modes hide (their palette isn't pre-known). +function _refreshPalettePreview() { + const wrap = document.querySelector('.palette-preview-wrap'); + const row = document.getElementById('color-palette-preview'); + if (!wrap || !row) return; + if (settings.colorAutoSource !== 'gradient') { + wrap.classList.add('hidden'); + return; + } + wrap.classList.remove('hidden'); + const stops = (Array.isArray(settings.colorGradientStops) ? settings.colorGradientStops : []); + const colors = []; + const seen = new Set(); + const add = (hex) => { + const h = (typeof hex === 'string' ? hex : '#000000').toLowerCase(); + if (seen.has(h)) return; + seen.add(h); colors.push(h); + }; + for (const s of stops) add(s.color); + add(settings.colorBaseColor || '#ffffff'); + row.innerHTML = ''; + for (const c of colors) { + const sw = document.createElement('div'); + sw.className = 'palette-swatch'; + sw.style.background = c; + sw.title = c; + row.appendChild(sw); + } + const count = document.createElement('span'); + count.className = 'palette-count'; + count.textContent = colors.length === 1 ? '1 color' : `${colors.length} colors`; + row.appendChild(count); +} + +// Push the current color settings into the live preview material. +// Call this after any settings change that should affect the on-screen tint. +function _pushColorPreviewState() { + if (!previewMaterial) return; + setColorPreview(previewMaterial, { + enabled: !!settings.colorExportEnabled, + autoSource: settings.colorAutoSource || 'none', + baseRGB: _hexToRGB01(settings.colorBaseColor || '#ffffff'), + gradientLUT: _rebuildGradientLUT(), + colorImage: _rebuildColorImageTexture(), + // Color image's own aspect — colorBake.js applies the same correction + // separately from the displacement texture's aspect, so we must too or + // the live preview will be scale-mismatched against the export. + colorImageW: _lastColorMap ? _lastColorMap.width : 0, + colorImageH: _lastColorMap ? _lastColorMap.height : 0, + }); + requestRender(); +} + +function refreshColorImageUI() { + const thumb = document.getElementById('color-image-thumb'); + const removeBtn = document.getElementById('color-image-remove'); + const uploadLabel = document.querySelector('label[for="color-image-input"] span'); + + if (thumb && thumb.getContext) { + const ctx = thumb.getContext('2d'); + const w = thumb.width || 64; + const h = thumb.height || 64; + ctx.clearRect(0, 0, w, h); + + if (_lastColorMap && _lastColorMap.fullCanvas) { + const sw = _lastColorMap.fullCanvas.width; + const sh = _lastColorMap.fullCanvas.height; + const scale = Math.min(w / sw, h / sh); + const dw = sw * scale; + const dh = sh * scale; + ctx.drawImage(_lastColorMap.fullCanvas, (w - dw) / 2, (h - dh) / 2, dw, dh); + } + } + + if (removeBtn) removeBtn.disabled = !(_lastColorMap && _lastColorMap.fullCanvas); + if (uploadLabel) { + const key = _lastColorMap && _lastColorMap.fullCanvas + ? 'color.imageReplace' + : 'color.imageUpload'; + uploadLabel.setAttribute('data-i18n', key); + uploadLabel.textContent = t(key); + } } // ── Exclusion helpers ───────────────────────────────────────────────────────── @@ -3662,6 +4241,7 @@ function updatePreview() { if (!previewMaterial) { previewMaterial = createPreviewMaterial(effectiveEntry.texture, fullSettings); loadGeometry(activeGeo, previewMaterial); + _pushColorPreviewState(); } else { updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings); } @@ -4073,6 +4653,7 @@ async function toggleDisplacementPreview(enable) { previewMaterial = createPreviewMaterial(getEffectiveMapEntry().texture, fullSettings); setMeshGeometry(dispPreviewGeometry); setMeshMaterial(previewMaterial); + _pushColorPreviewState(); } catch (err) { @@ -4162,7 +4743,8 @@ async function handleExport(format = 'stl') { : null; let safetyCapHit; - ({ geometry: subdivided, safetyCapHit } = await subdivide( + let faceParentId; // Int32Array: subdivided face → original face index (used by colorBake) + ({ geometry: subdivided, safetyCapHit, faceParentId } = await subdivide( currentGeometry, settings.refineLength, (p, triCount, longestEdge) => { const label = triCount != null @@ -4194,6 +4776,26 @@ async function handleExport(format = 'stl') { // Free subdivided geometry — displacement created a separate copy subdivided.dispose(); + // ── Color bake (3MF only) ───────────────────────────────────────────── + // Writes a per-vertex `color` Float32×3 BufferAttribute on `displaced` + // before decimation, so QEM threads it through to the final mesh. + // No-op when colorExportEnabled is off; STL exports never read the attribute. + if (format === '3mf' && settings.colorExportEnabled) { + setProgress(0.69, t('progress.bakingColor') || 'Baking color…'); + await yieldFrame(); + if (exportToken !== myToken) return; + try { + applyColors(displaced, faceParentId, exportEntry.imageData, + exportEntry.width, exportEntry.height, + _lastColorMap && _lastColorMap.imageData ? _lastColorMap.imageData : null, + _lastColorMap ? _lastColorMap.width : 0, + _lastColorMap ? _lastColorMap.height : 0, + settings, currentBounds); + } catch (err) { + console.warn('Color bake failed; exporting without color:', err); + } + } + const dispTriCount = displaced.attributes.position.count / 3; const needsDecimation = dispTriCount > settings.maxTriangles; triLimitWarning.classList.toggle('hidden', !safetyCapHit); @@ -4212,7 +4814,8 @@ async function handleExport(format = 'stl') { 0.71 + p * 0.25, t('progress.decimating', { cur: cur.toLocaleString(), to: settings.maxTriangles.toLocaleString() }) ); - } + }, + { preserveColor: format === '3mf' && settings.colorExportEnabled } ) ); // Free pre-decimation geometry — decimate created a separate copy @@ -4258,7 +4861,60 @@ async function handleExport(format = 'stl') { setProgress(0.97, t('progress.writing3mf')); await yieldFrame(); if (exportToken !== myToken) return; - export3MF(finalGeometry, `${baseName}.3mf`); + + // Quantize per-vertex colors to a small palette (≤32 entries) when + // color export is enabled and the bake produced a `color` attribute. + // Falls through to a geometry-only 3MF when either is absent — + // unchanged backward-compat path. + let exportOpts = undefined; + if (settings.colorExportEnabled && finalGeometry.attributes.color) { + const colAttr = finalGeometry.attributes.color.array; + const triCount = (finalGeometry.attributes.position.count / 3) | 0; + const triRGB = new Uint8Array(triCount * 3); + for (let i = 0; i < triCount; i++) { + // Average the 3 vertex colors for the triangle. + const off = i * 9; // 3 verts * 3 components + const r = (colAttr[off] + colAttr[off + 3] + colAttr[off + 6]) / 3; + const g = (colAttr[off+1] + colAttr[off + 4] + colAttr[off + 7]) / 3; + const b = (colAttr[off+2] + colAttr[off + 5] + colAttr[off + 8]) / 3; + triRGB[i * 3] = Math.round(Math.max(0, Math.min(1, r)) * 255); + triRGB[i * 3 + 1] = Math.round(Math.max(0, Math.min(1, g)) * 255); + triRGB[i * 3 + 2] = Math.round(Math.max(0, Math.min(1, b)) * 255); + } + try { + if (settings.colorAutoSource === 'gradient') { + // Snap each triangle's color to the nearest of the user-defined + // gradient stops + the base color. The user picks the colors; + // no statistical averaging — the exported palette is exactly + // the control points they specified. AMS slot assignment is + // predictable: stop-count + maybe-1 (base) entries. + exportOpts = _snapToControlPoints(triRGB, settings); + } else { + // Image source (or fallback): median-cut at the user-chosen + // palette size. Image data has no natural "control points" so + // statistical clustering is the right tool here. + const paletteCap = Math.max(2, Math.min(32, +settings.colorPaletteSize || 4)); + const { palette, indices } = medianCut(triRGB, paletteCap); + exportOpts = { palette, triPaletteIndices: indices }; + } + + // Region smoothing: collapse speckle triangles into their majority- + // neighbor color. With per-vertex baking + decimation averaging, + // boundary triangles can land on an isolated palette entry that + // disagrees with all their neighbors — the slicer then treats every + // such triangle as its own filament region and produces a chaotic + // slice plan (non-manifold-style errors, fractured fill paths). + // 3 passes is plenty empirically — converges by then for most meshes. + setProgress(0.96, t('progress.smoothingColorRegions') || 'Smoothing color regions…'); + await yieldFrame(); + if (exportToken !== myToken) return; + const adj = buildAdjacency(finalGeometry); + _smoothPaletteRegions(exportOpts.triPaletteIndices, adj.adjacency, 3); + } catch (err) { + console.warn('Quantization failed; exporting without color:', err); + } + } + export3MF(finalGeometry, `${baseName}.3mf`, exportOpts); } else { setProgress(0.97, t('progress.writingStl')); await yieldFrame(); @@ -4621,11 +5277,26 @@ const PERSISTED_KEYS = [ // null means "fall back to AABB defaults", which is what fresh loads get. 'snapSeamlessWrap', 'cylinderCenterX', 'cylinderCenterY', 'cylinderRadius', 'cylinderPanelMinimized', + // Color export. The uploaded color image is intentionally NOT here — it's + // shipped as a separate `color.png` zip entry in .bumpmesh and never touches + // sessionStorage (would blow the 5MB quota). `_lastColorMap` holds the + // runtime cache. + 'colorExportEnabled', 'colorAutoSource', 'colorBaseColor', 'colorGradientStops', + 'colorPaletteSize', 'colorSnapPreview', ]; function getSettingsSnapshot() { const snap = {}; - for (const k of PERSISTED_KEYS) snap[k] = settings[k]; + for (const k of PERSISTED_KEYS) { + const v = settings[k]; + // Deep-copy gradient stops so undo snapshots don't share the live array + // (mutation of stop.pos / stop.color would otherwise contaminate history). + if (k === 'colorGradientStops' && Array.isArray(v)) { + snap[k] = v.map(s => ({ pos: s.pos, color: s.color })); + } else { + snap[k] = v; + } + } if (activeMapEntry) { snap.activeMapName = activeMapEntry.name; } else { @@ -4723,6 +5394,45 @@ function applySettingsSnapshot(snap) { cylinderPanel.classList.toggle('minimized', settings.cylinderPanelMinimized); } updateCylinderUIVisibility(); + + // ── Color export settings ───────────────────────────────────────────────── + // Dispatching change events on each control pulls them through the standard + // auto-save / undo / preview flow. Missing DOM elements are silently skipped. + if ('colorExportEnabled' in snap) { + settings.colorExportEnabled = !!snap.colorExportEnabled; + const el = document.getElementById('color-export-toggle'); + if (el) { el.checked = settings.colorExportEnabled; el.dispatchEvent(new Event('change', { bubbles: true })); } + } + if ('colorAutoSource' in snap && (snap.colorAutoSource === 'none' || snap.colorAutoSource === 'gradient' || snap.colorAutoSource === 'image')) { + settings.colorAutoSource = snap.colorAutoSource; + const el = document.querySelector(`input[name="color-auto-source"][value="${settings.colorAutoSource}"]`); + if (el) { el.checked = true; el.dispatchEvent(new Event('change', { bubbles: true })); } + } + if ('colorBaseColor' in snap && typeof snap.colorBaseColor === 'string') { + settings.colorBaseColor = snap.colorBaseColor; + const el = document.getElementById('color-base-picker'); + if (el) { el.value = settings.colorBaseColor; el.dispatchEvent(new Event('change', { bubbles: true })); } + } + if ('colorGradientStops' in snap && Array.isArray(snap.colorGradientStops) && snap.colorGradientStops.length >= 2) { + // Deep-copy so the live settings array is never aliased to a snapshot. + settings.colorGradientStops = snap.colorGradientStops.map(s => ({ pos: s.pos, color: s.color })); + if (window._gradientEditor && typeof window._gradientEditor.setStops === 'function') { + window._gradientEditor.setStops(settings.colorGradientStops); + } + } + if ('colorPaletteSize' in snap) { + const n = +snap.colorPaletteSize | 0; + if (n >= 2 && n <= 32) { + settings.colorPaletteSize = n; + const el = document.getElementById('color-palette-size'); + if (el) { el.value = String(n); el.dispatchEvent(new Event('change', { bubbles: true })); } + } + } + if ('colorSnapPreview' in snap) { + settings.colorSnapPreview = !!snap.colorSnapPreview; + const el = document.getElementById('color-snap-preview'); + if (el) { el.checked = settings.colorSnapPreview; el.dispatchEvent(new Event('change', { bubbles: true })); } + } } /** @@ -4794,6 +5504,16 @@ const DEFAULT_SETTINGS_SNAPSHOT = Object.freeze({ snapSeamlessWrap: true, cylinderCenterX: null, cylinderCenterY: null, cylinderRadius: null, cylinderPanelMinimized: false, + // Color export defaults — feature off, gradient stops form a neutral dark→light ramp. + colorExportEnabled: false, + colorAutoSource: 'none', + colorBaseColor: '#ffffff', + colorGradientStops: [ + { pos: 0, color: '#222222' }, + { pos: 1, color: '#dddddd' }, + ], + colorPaletteSize: 4, + colorSnapPreview: false, activeMapName: DEFAULT_PRESET_NAME, }); @@ -4828,6 +5548,8 @@ function resetSettingsToDefaults() { if (selectionMode) setSelectionMode(false); excludedFaces = new Set(); precisionExcludedFaces = new Set(); + _lastColorMap = null; + refreshColorImageUI(); if (currentGeometry) refreshExclusionOverlay(); const defaultIdx = IMAGE_PRESETS.findIndex(p => p.name === DEFAULT_PRESET_NAME); @@ -4907,6 +5629,15 @@ exportGoBtn.addEventListener('click', async () => { zipFiles['texture.png'] = new Uint8Array(await blob.arrayBuffer()); } + // Ship the uploaded color image, if any. Kept out of sessionStorage (would + // blow the 5MB quota); only ever lives in the .bumpmesh package. + if (_lastColorMap && _lastColorMap.fullCanvas) { + try { + const blob = await new Promise(r => _lastColorMap.fullCanvas.toBlob(r, 'image/png')); + zipFiles['color.png'] = new Uint8Array(await blob.arrayBuffer()); + } catch (err) { console.warn('Could not embed color image:', err); } + } + const zipped = zipSync(zipFiles); _downloadBlob(new Blob([zipped], { type: 'application/octet-stream' }), (currentStlName || 'bumpmesh') + '.bumpmesh'); @@ -4962,7 +5693,6 @@ function _collectCurrentMask() { } else { liveExcluded = excludedFaces; } - // Include-mode with zero painted = "mask everything" — also worth preserving. if (liveExcluded.size === 0 && !selectionMode) return null; return { selectionMode, excluded: [...liveExcluded] }; } @@ -4986,7 +5716,6 @@ function _restoreMask(mask) { return; } const triCount = (currentGeometry.attributes.position.count / 3) | 0; - // setSelectionMode clears any current paint, so flip mode FIRST then seed. if (mask.selectionMode === true) setSelectionMode(true); else if (mask.selectionMode === false && selectionMode) setSelectionMode(false); @@ -4994,6 +5723,8 @@ function _restoreMask(mask) { .filter(i => Number.isInteger(i) && i >= 0 && i < triCount); excludedFaces = new Set(valid); precisionExcludedFaces = new Set(); // precision rebuilds from this on demand + // Older .bumpmesh files (pre-paint-removal) may include `mask.coloredFaces`; + // we silently ignore it. The data isn't useful without the paint UI. refreshExclusionOverlay(); } @@ -5069,6 +5800,34 @@ async function importProject(file) { _selectPresetByName(data.activeMapName); } + // Restore color image if the project shipped one. Decodes off-thread via + // createImageBitmap into a backing canvas + ImageData so colorBake can sample it. + if (unzipped['color.png']) { + try { + const blob = new Blob([unzipped['color.png']], { type: 'image/png' }); + const bmp = await createImageBitmap(blob); + const cvs = document.createElement('canvas'); + cvs.width = bmp.width; + cvs.height = bmp.height; + const ctx = cvs.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(bmp, 0, 0); + const imgData = ctx.getImageData(0, 0, bmp.width, bmp.height); + _lastColorMap = { + name: 'imported-color.png', + imageData: imgData, + width: bmp.width, + height: bmp.height, + fullCanvas: cvs, + }; + // Update the preview thumbnail / file-input label if Unit D wired it. + if (typeof window._refreshColorImageUI === 'function') window._refreshColorImageUI(); + bmp.close && bmp.close(); + } catch (err) { console.warn('Could not restore color image:', err); } + } else { + _lastColorMap = null; + if (typeof window._refreshColorImageUI === 'function') window._refreshColorImageUI(); + } + _autoSaveSettings(); } finally { _undoApplyDepth--; @@ -5106,6 +5865,20 @@ function _undoSnapshotsEqual(a, b) { if (a === b) return true; if (!a || !b) return false; for (const k of PERSISTED_KEYS) { + // colorGradientStops is the only nested structure in PERSISTED_KEYS — compare + // by value rather than by reference (snapshots always deep-copy on capture). + if (k === 'colorGradientStops') { + const av = a.settings[k], bv = b.settings[k]; + if (av === bv) continue; + if (!Array.isArray(av) || !Array.isArray(bv)) return false; + if (av.length !== bv.length) return false; + let differs = false; + for (let i = 0; i < av.length; i++) { + if (av[i].pos !== bv[i].pos || av[i].color !== bv[i].color) { differs = true; break; } + } + if (differs) return false; + continue; + } if (a.settings[k] !== b.settings[k]) return false; } if ((a.settings.activeMapName || null) !== (b.settings.activeMapName || null)) return false; diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 7a79595..52b989f 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -43,6 +43,24 @@ const sharedGLSL = /* glsl */` uniform int useDisplacement; uniform vec2 textureAspect; + // ── Color preview uniforms (live colored preview, GPU-side) ────────────── + // colorPreviewEnabled gates the entire color path; when 0 the shader produces + // the historical teal+mask appearance unchanged. When 1, lit colors come from + // the gradient LUT (autoSource=1) or color image (autoSource=2), falling back + // to colorBaseRGB. UVs match the displacement pipeline exactly so the + // on-screen tint lines up with what the export will produce. + uniform sampler2D colorGradientLUT; // 256×1 RGBA, row sampled at the displacement value + uniform sampler2D colorImage; + uniform int colorPreviewEnabled; // 0|1 + uniform int colorAutoSource; // 0=none/base, 1=gradient, 2=image + uniform int hasColorImage; // 1 iff colorImage holds a real upload + uniform vec3 colorBaseRGB; // 0..1, applied to non-textured / excluded faces + // Aspect correction for the color image. Independent of textureAspect + // (which is the displacement texture's aspect) — the color image may have + // a different width:height ratio. colorBake.js applies this same correction + // on the export side, so preview UVs line up with baked UVs exactly. + uniform vec2 colorTextureAspect; // tmax/w, tmax/h for the color image + const float PI = 3.14159265358979; const float TWO_PI = 6.28318530717959; const float CUBIC_AXIS_EPSILON = 1e-4; @@ -99,6 +117,20 @@ const sharedGLSL = /* glsl */` return texture2D(displacementMap, uv).r; } + // Same UV pipeline as sampleMap but reads the user's color image. Uses + // colorTextureAspect (NOT textureAspect) because the color image's aspect + // ratio is independent of the displacement texture's. This matches + // colorBake.js, which builds a separate colSettings struct with the color + // image's aspect for its UV math. + vec3 sampleColorMap(vec2 rawUV) { + vec2 uv = (rawUV * colorTextureAspect) / scaleUV + offsetUV; + float c = cos(rotation); float s = sin(rotation); + uv -= 0.5; + uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y); + uv += 0.5; + return texture2D(colorImage, uv).rgb; + } + // Compute displacement height at a world-space point. // projN = face-stable projection normal (for axis selection) // blendN = smooth / interpolated normal (for blend weights) @@ -203,6 +235,104 @@ const sharedGLSL = /* glsl */` return hYZ * wts.x + hXZ * wts.y + hXY * wts.z; } } + + // RGB analogue of computeHeightAtPoint. Mirrors the structure exactly so a + // color image projected through the same UV pipeline as the displacement + // texture produces colors that line up with displacement texels on-screen. + // Only consulted when colorAutoSource == 2 (image mode). + vec3 computeColorAtPoint(vec3 pos, vec3 projN, vec3 blendN) { + vec3 rel = pos - boundsCenter; + float maxDim = max(boundsSize.x, max(boundsSize.y, boundsSize.z)); + float md = max(maxDim, 1e-4); + + if (mappingMode == 0) { + return sampleColorMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); + + } else if (mappingMode == 1) { + return sampleColorMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md)); + + } else if (mappingMode == 2) { + return sampleColorMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md)); + + } else if (mappingMode == 3) { + vec2 cylRel2 = pos.xy - cylinderCenter; + float r = max(cylinderRadius, 1e-4); + float C = TWO_PI * r; + float u_cyl = atan(cylRel2.y, cylRel2.x) / TWO_PI + 0.5; + float v_cyl = (pos.z - boundsMin.z) / C; + + float seamBand = seamBandWidth * 0.1; + float seamDist = min(u_cyl, 1.0 - u_cyl); + vec3 cSide; + if (seamBand > 0.001 && seamDist < seamBand) { + float d = u_cyl < 0.5 ? u_cyl : u_cyl - 1.0; + float t = smoothstep(0.0, 1.0, (d + seamBand) / (2.0 * seamBand)); + vec3 cLeft = sampleColorMap(vec2(1.0 + d, v_cyl)); + vec3 cRight = sampleColorMap(vec2(d, v_cyl)); + cSide = mix(cLeft, cRight, t); + } else { + cSide = sampleColorMap(vec2(u_cyl, v_cyl)); + } + + if (mappingBlend < 0.001) return cSide; + float capThreshold = cos(radians(capAngle)); + float blendHalf = seamBandWidth * 0.5; + float capW = smoothstep(capThreshold - blendHalf, capThreshold + blendHalf, abs(blendN.z)); + vec3 cCap = sampleColorMap(vec2(cylRel2.x / C + 0.5, cylRel2.y / C + 0.5)); + return mix(cSide, cCap, capW); + + } else if (mappingMode == 4) { + float r = length(rel); + float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0)); + float u_sph = atan(rel.y, rel.x) / TWO_PI + 0.5; + float v_sph = phi / PI; + + float seamBand = seamBandWidth * 0.1; + float seamDist = min(u_sph, 1.0 - u_sph); + if (seamBand > 0.001 && seamDist < seamBand) { + float d = u_sph < 0.5 ? u_sph : u_sph - 1.0; + float t = smoothstep(0.0, 1.0, (d + seamBand) / (2.0 * seamBand)); + vec3 cLeft = sampleColorMap(vec2(1.0 + d, v_sph)); + vec3 cRight = sampleColorMap(vec2(d, v_sph)); + return mix(cLeft, cRight, t); + } + return sampleColorMap(vec2(u_sph, v_sph)); + + } else if (mappingMode == 5) { + vec3 blend = abs(projN); + blend = pow(blend, vec3(4.0)); + blend /= dot(blend, vec3(1.0)) + 1e-4; + float yzU = (pos.y - boundsMin.y) / md; + if (projN.x < 0.0) yzU = -yzU; + float xzU = (pos.x - boundsMin.x) / md; + if (projN.y > 0.0) xzU = -xzU; + float xyU = (pos.x - boundsMin.x) / md; + if (projN.z < 0.0) xyU = -xyU; + vec3 cXY = sampleColorMap(vec2(xyU, (pos.y - boundsMin.y) / md)); + vec3 cXZ = sampleColorMap(vec2(xzU, (pos.z - boundsMin.z) / md)); + vec3 cYZ = sampleColorMap(vec2(yzU, (pos.z - boundsMin.z) / md)); + return cXY * blend.z + cXZ * blend.y + cYZ * blend.x; + + } else { + float yzU = (pos.y - boundsMin.y) / md; + if (projN.x < 0.0) yzU = -yzU; + float xzU = (pos.x - boundsMin.x) / md; + if (projN.y > 0.0) xzU = -xzU; + float xyU = (pos.x - boundsMin.x) / md; + if (projN.z < 0.0) xyU = -xyU; + vec3 cYZ = sampleColorMap(vec2(yzU, (pos.z - boundsMin.z) / md)); + vec3 cXZ = sampleColorMap(vec2(xzU, (pos.z - boundsMin.z) / md)); + vec3 cXY = sampleColorMap(vec2(xyU, (pos.y - boundsMin.y) / md)); + vec3 bN = blendN; + vec3 absFaceN = abs(projN); + float facePrimary = max(absFaceN.x, max(absFaceN.y, absFaceN.z)); + float faceSecondary = absFaceN.x + absFaceN.y + absFaceN.z - facePrimary + - min(absFaceN.x, min(absFaceN.y, absFaceN.z)); + if (facePrimary - faceSecondary <= CUBIC_AXIS_EPSILON) bN = projN; + vec3 wts = cubicBlendWeights(bN); + return cYZ * wts.x + cXZ * wts.y + cXY * wts.z; + } + } `; const vertexShader = /* glsl */` @@ -296,10 +426,33 @@ const fragmentShader = /* glsl */` return computeHeightAtPoint(vModelPos, PN, vModelNormal); } + // Resolve the surface base color for live preview. Mirrors the export + // pipeline's composition: gradient sample at the displacement greyvalue, + // image sample at the same UVs, or the user's base color. + // The rawGrey arg is the unsymmetrized 0..1 displacement texel value. + vec3 resolveSurfaceColor(float rawGrey) { + if (colorPreviewEnabled == 0) { + return vec3(0.22, 0.68, 0.68); // historical teal — preview unchanged when feature is off + } + if (colorAutoSource == 1) { + // Gradient: LUT row maps grey ∈ [0,1] → RGB. + return texture2D(colorGradientLUT, vec2(clamp(rawGrey, 0.0, 1.0), 0.5)).rgb; + } + if (colorAutoSource == 2 && hasColorImage == 1) { + vec3 _dpx = dFdx(vModelPos); + vec3 _dpy = dFdy(vModelPos); + vec3 _fN = cross(_dpx, _dpy); + vec3 PN = length(_fN) > 1e-10 ? normalize(_fN) : vModelNormal; + return computeColorAtPoint(vModelPos, PN, vModelNormal); + } + return colorBaseRGB; + } + void main() { // Flip normal for back faces so flipped-winding geometry still lights correctly. vec3 N = normalize(vNormal) * (gl_FrontFacing ? 1.0 : -1.0); - float h = getHeight(); + float rawGrey = getHeight(); + float h = rawGrey; if (symmetricDisplacement == 1) h = h - 0.5; // ── Bump mapping via screen-space height derivatives ────────────────── @@ -363,11 +516,13 @@ const fragmentShader = /* glsl */` bumpN = mix(smoothN, bumpN, maskBlend); // ── Shading ─────────────────────────────────────────────────────────── - // Compute lighting identically for ALL surfaces using the teal base so + // Compute lighting identically for ALL surfaces using one base so // 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); + // The base is the user's chosen color when colorPreviewEnabled is on, + // otherwise the historical teal so the unmodified preview look survives. + vec3 surfaceBase = resolveSurfaceColor(rawGrey); vec3 userMaskColor = vec3(0.85, 0.40, 0.15); vec3 angleMaskColor = vec3(0.45, 0.48, 0.50); @@ -381,10 +536,10 @@ const fragmentShader = /* glsl */` vec3 H1 = normalize(L1 + V); float spec = pow(max(dot(bumpN, H1), 0.0), 64.0) * 0.60; - // Lit teal (identical for textured and masked surfaces) - vec3 litTeal = tealBase * 0.55 - + tealBase * diff1 * vec3(1.00, 0.96, 0.88) * 0.55 - + tealBase * diff2 * vec3(0.80, 0.60, 0.50) * 0.15 + // Lit surface base (identical lighting model for textured and masked). + vec3 litTeal = surfaceBase * 0.55 + + surfaceBase * diff1 * vec3(1.00, 0.96, 0.88) * 0.55 + + surfaceBase * diff2 * vec3(0.80, 0.60, 0.50) * 0.15 + vec3(spec); // Mask tint: pick colour by mask type, compute same lighting with that base @@ -492,9 +647,63 @@ function buildUniforms(tex, settings) { boundaryEdgeCount: { value: 0 }, boundaryEdgeTexWidth: { value: 1.0 }, boundaryFalloffDist: { value: settings.boundaryFalloff ?? 0.0 }, + // Color preview (live colored shading via the same UV pipeline as displacement). + colorPreviewEnabled: { value: 0 }, + colorAutoSource: { value: 0 }, // 0 none, 1 gradient, 2 image + colorGradientLUT: { value: createFallbackTexture() }, + colorImage: { value: createFallbackTexture() }, + hasColorImage: { value: 0 }, + colorBaseRGB: { value: new THREE.Vector3(1, 1, 1) }, + // Aspect correction for the color image. Independent of textureAspect + // (displacement). Updated by setColorPreview when the image changes. + colorTextureAspect: { value: new THREE.Vector2(1, 1) }, }; } +/** + * Update the color-preview uniforms in-place. Called whenever the user changes + * the master toggle, source radio, base color, gradient stops (LUT rebuilt + * elsewhere), or color image. + * + * @param {THREE.ShaderMaterial} material + * @param {object} opts + * - enabled: bool + * - autoSource: 'none' | 'gradient' | 'image' + * - baseRGB: [r,g,b] in 0..1 + * - gradientLUT: THREE.Texture | null (256×1) + * - colorImage: THREE.Texture | null + * - colorImageW, colorImageH: integers — color image dimensions; used to + * compute aspect correction independent of the displacement texture. + * When omitted (or image absent) defaults to 1×1. + */ +export function setColorPreview(material, opts) { + if (!material || !material.uniforms) return; + const u = material.uniforms; + if (!u.colorPreviewEnabled) return; // material was created before color uniforms existed + u.colorPreviewEnabled.value = opts.enabled ? 1 : 0; + u.colorAutoSource.value = + opts.autoSource === 'gradient' ? 1 : + opts.autoSource === 'image' ? 2 : 0; + if (Array.isArray(opts.baseRGB) && opts.baseRGB.length === 3) { + u.colorBaseRGB.value.set(opts.baseRGB[0], opts.baseRGB[1], opts.baseRGB[2]); + } + if (opts.gradientLUT && u.colorGradientLUT.value !== opts.gradientLUT) { + u.colorGradientLUT.value = opts.gradientLUT; + } + if (opts.colorImage) { + u.colorImage.value = opts.colorImage; + u.hasColorImage.value = 1; + // Aspect correction matching colorBake.js: tmax/w, tmax/h. + const w = Math.max(1, opts.colorImageW || 1); + const h = Math.max(1, opts.colorImageH || 1); + const tmax = Math.max(w, h, 1); + u.colorTextureAspect.value.set(tmax / w, tmax / h); + } else { + u.hasColorImage.value = 0; + u.colorTextureAspect.value.set(1, 1); + } +} + function createFallbackTexture() { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 4; diff --git a/js/quantize.js b/js/quantize.js new file mode 100644 index 0000000..dc307d3 --- /dev/null +++ b/js/quantize.js @@ -0,0 +1,233 @@ +/** + * quantize.js — Median-cut palette quantization for per-triangle 3MF colors. + * + * Exports `medianCut(triRGBs, maxColors=32)` returning + * { palette: Uint8Array(N*3), indices: Uint16Array(triCount) } + * where N ≤ maxColors and indices[i] points triangle i at its bucket's mean RGB. + * + * Algorithm: + * 1. Place all triangles in one bucket. + * 2. Pick the bucket with the largest range-weighted score (with > 1 distinct + * colour), find its widest channel (max−min over R, G, B), sort that + * channel, then partition at a large color gap when one exists; otherwise + * partition near the median. + * 3. Repeat until bucket count == maxColors or no further bucket can be split. + * 4. Each bucket's palette entry = mean RGB of its members. + * + * Performance: O(triCount * log(maxColors)) with in-place index sort, no + * per-bucket allocation beyond `start, end, channel-stats` ints. + */ + +export function medianCut(triRGBs, maxColors = 32) { + const triCount = (triRGBs.length / 3) | 0; + if (triCount === 0) { + return { palette: new Uint8Array(0), indices: new Uint16Array(0) }; + } + + // `order` holds triangle indices; we partition slices of it in place. The + // input triRGBs is never copied — we read RGB through order[k] each time. + const order = new Uint32Array(triCount); + for (let i = 0; i < triCount; i++) order[i] = i; + + // Bucket bookkeeping: each bucket is the slice order[start..end) of `order`. + // Pre-size to maxColors (the upper bound on bucket count). + const cap = Math.max(1, maxColors | 0); + const bStart = new Int32Array(cap); + const bEnd = new Int32Array(cap); + // Cached per-bucket channel ranges (max - min) and dominant channel; recomputed + // on creation/split. We track: + // bRange — the largest channel range (used to pick split channel) + // bChan — which channel had that range (0=R, 1=G, 2=B) + // bSplittable — 1 iff bucket has > 1 distinct color (range > 0 in some channel) + const bRange = new Int32Array(cap); + const bChan = new Int8Array(cap); + const bSplittable = new Uint8Array(cap); + + // Initialize bucket 0 = full input. + bStart[0] = 0; + bEnd[0] = triCount; + let bCount = 1; + _computeBucketStats(triRGBs, order, 0, triCount, 0, bRange, bChan, bSplittable); + + // ── Split loop ──────────────────────────────────────────────────────────── + // Pick the splittable bucket with the largest population, split it. Repeat + // until we hit cap or no bucket is splittable. + while (bCount < cap) { + // Pick the splittable bucket with the largest channel range. Range-based + // selection (vs population-based) preserves small but distinctive clusters + // — e.g. a small white region on an otherwise wood-toned mesh — by + // prioritizing buckets where the color span is wide. Population-based + // splitting equalises bucket sizes, which dilutes outliers into nearby + // dominant clusters and the bucket mean becomes the dominant color, not the + // outlier's. Score by range × log(pop+1) so a tiny but high-range bucket + // doesn't beat a large but moderate-range one — empirically this keeps + // both gradients smooth and outliers crisp. + let bestIdx = -1; + let bestScore = 0; + for (let b = 0; b < bCount; b++) { + if (!bSplittable[b]) continue; + const pop = bEnd[b] - bStart[b]; + const score = bRange[b] * Math.log(pop + 1); + if (score > bestScore) { + bestScore = score; + bestIdx = b; + } + } + if (bestIdx < 0) break; + + const s = bStart[bestIdx]; + const e = bEnd[bestIdx]; + const chan = bChan[bestIdx]; + + // Sort the slice [s, e) of `order` by chan via insertion-friendly TimSort + // proxy: we use Array.prototype.sort on a Uint32Array subarray view. + // Using a closure here is acceptable — N stops, sort cost dominates anyway. + // Convert subarray to a plain Array for sort (Uint32Array has no comparator + // sort that handles arbitrary number ordering reliably across engines for + // our size — but TypedArray sort IS stable & sorts by numeric value of the + // element. We instead sort indices with a comparator on triRGBs[chan]). + const sub = order.subarray(s, e); + const tmp = Array.from(sub); + tmp.sort((a, b) => triRGBs[a * 3 + chan] - triRGBs[b * 3 + chan]); + for (let k = 0; k < tmp.length; k++) sub[k] = tmp[k]; + + // Split at a strong color boundary when one exists. A pure median split can + // bury a small distinctive cluster (for example a white masked face among + // thousands of wood-toned triangles) for many iterations. Gap-aware splits + // isolate those clusters immediately while smooth ramps still fall back to + // the count median. + const mid = _chooseSplitPoint(triRGBs, order, s, e, chan, bRange[bestIdx]); + if (mid <= s || mid >= e) { + // Degenerate — mark unsplittable (e.g. pop == 1). + bSplittable[bestIdx] = 0; + continue; + } + + // Left bucket inherits bestIdx slot; right takes a new slot. + const newIdx = bCount++; + bEnd[bestIdx] = mid; + bStart[newIdx] = mid; + bEnd[newIdx] = e; + + _computeBucketStats(triRGBs, order, s, mid, bestIdx, bRange, bChan, bSplittable); + _computeBucketStats(triRGBs, order, mid, e, newIdx, bRange, bChan, bSplittable); + } + + // ── Build palette + indices ─────────────────────────────────────────────── + const palette = new Uint8Array(bCount * 3); + const indices = new Uint16Array(triCount); + for (let b = 0; b < bCount; b++) { + const s = bStart[b], e = bEnd[b]; + const pop = e - s; + if (pop === 0) continue; + let sr = 0, sg = 0, sb = 0; + for (let k = s; k < e; k++) { + const t = order[k]; + sr += triRGBs[t * 3]; + sg += triRGBs[t * 3 + 1]; + sb += triRGBs[t * 3 + 2]; + indices[t] = b; + } + palette[b * 3] = Math.round(sr / pop); + palette[b * 3 + 1] = Math.round(sg / pop); + palette[b * 3 + 2] = Math.round(sb / pop); + } + + return { palette, indices }; +} + +/** + * Compute (max-min) per channel for the bucket slice order[s..e), pick + * the widest channel, and write into bRange/bChan/bSplittable at slot `b`. + */ +function _computeBucketStats(triRGBs, order, s, e, b, bRange, bChan, bSplittable) { + if (e <= s) { + bRange[b] = 0; + bChan[b] = 0; + bSplittable[b] = 0; + return; + } + let minR = 255, minG = 255, minB = 255; + let maxR = 0, maxG = 0, maxB = 0; + for (let k = s; k < e; k++) { + const t = order[k]; + const r = triRGBs[t * 3]; + const g = triRGBs[t * 3 + 1]; + const bb = triRGBs[t * 3 + 2]; + if (r < minR) minR = r; if (r > maxR) maxR = r; + if (g < minG) minG = g; if (g > maxG) maxG = g; + if (bb < minB) minB = bb; if (bb > maxB) maxB = bb; + } + const dR = maxR - minR; + const dG = maxG - minG; + const dB = maxB - minB; + let chan = 0, range = dR; + if (dG > range) { chan = 1; range = dG; } + if (dB > range) { chan = 2; range = dB; } + bRange[b] = range; + bChan[b] = chan; + // Splittable iff at least one channel has range > 0 AND we have ≥ 2 elements. + bSplittable[b] = (range > 0 && (e - s) > 1) ? 1 : 0; +} + +/** + * Pick a split point for a sorted bucket slice. + * + * Prefer a visually meaningful channel gap when present. This protects small + * but important regions from being averaged into a dominant cluster. For + * ordinary smooth gradients, adjacent gaps are small, so we use a median-ish + * split and nudge it to the nearest color boundary to avoid splitting a run of + * identical channel values. + */ +function _chooseSplitPoint(triRGBs, order, s, e, chan, range) { + const median = (s + e) >> 1; + let bestGap = 0; + let bestGapMid = -1; + + for (let k = s + 1; k < e; k++) { + const prev = triRGBs[order[k - 1] * 3 + chan]; + const cur = triRGBs[order[k] * 3 + chan]; + const gap = cur - prev; + if (gap > bestGap) { + bestGap = gap; + bestGapMid = k; + } + } + + // A gap this large is a real color boundary, not normal gradient stepping. + const gapThreshold = Math.max(8, range * 0.2); + if (bestGapMid > s && bestGapMid < e && bestGap >= gapThreshold) { + return bestGapMid; + } + + // Median fallback, nudged to the nearest distinct-value boundary so a bucket + // made mostly of duplicate colors does not produce redundant child buckets. + const leftVal = triRGBs[order[median - 1] * 3 + chan]; + const rightVal = triRGBs[order[median] * 3 + chan]; + if (leftVal !== rightVal) return median; + + let left = median - 1; + while (left > s) { + const a = triRGBs[order[left - 1] * 3 + chan]; + const b = triRGBs[order[left] * 3 + chan]; + if (a !== b) break; + left--; + } + + let right = median + 1; + while (right < e) { + const a = triRGBs[order[right - 1] * 3 + chan]; + const b = triRGBs[order[right] * 3 + chan]; + if (a !== b) break; + right++; + } + + const haveLeft = left > s; + const haveRight = right < e; + if (haveLeft && haveRight) { + return (median - left <= right - median) ? left : right; + } + if (haveLeft) return left; + if (haveRight) return right; + return median; +} diff --git a/style.css b/style.css index 9cff751..09ed7aa 100644 --- a/style.css +++ b/style.css @@ -1772,4 +1772,293 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } background: var(--border); border-color: var(--accent); color: var(--text); -} \ No newline at end of file +} + +/* ── Color export section (Unit D) ───────────────────────────────────── */ +#color-section .color-source-row { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} + +#color-section .color-source-radio { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text); + cursor: pointer; +} + +#color-section .color-source-radio input[type="radio"] { + accent-color: var(--accent); + margin: 0; +} + +/* CSS-driven sub-section visibility (data-source attribute is wired in + js/gradientEditor.js → wireColorSectionVisibility). */ +#color-section[data-source="none"] .gradient-sub, +#color-section[data-source="none"] .image-sub, +#color-section[data-source="gradient"] .image-sub, +#color-section[data-source="image"] .gradient-sub { + display: none; +} + +/* Palette-size dropdown applies only to image quantization. Gradient mode + uses the user's stop colors directly (snap-to-control-points), and "none" + has nothing to quantize. Hide the row outside image mode. */ +#color-section[data-source="none"] .palette-size-row, +#color-section[data-source="gradient"] .palette-size-row { + display: none; +} + +#color-section .color-sub { + margin: 8px 0 12px; + padding: 8px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +#color-section .color-hint { + margin-top: 6px; + font-size: 11px; + color: var(--text-muted); + line-height: 1.4; +} + +#color-section .color-image-row { + display: flex; + align-items: center; + gap: 10px; +} + +#color-section .color-image-thumb { + width: 64px; + height: 64px; + border: 1px solid var(--border); + border-radius: 4px; + background: + repeating-conic-gradient(var(--surface) 0% 25%, var(--bg) 0% 50%) 50% / 12px 12px; + flex-shrink: 0; +} + +#color-section .color-image-buttons { + display: flex; + flex-direction: column; + gap: 4px; +} + +#color-section .color-picker-row label { + flex: 1; +} + +#color-section .color-picker-row input[type="color"] { + width: 32px; + height: 24px; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + cursor: pointer; + padding: 0; + flex-shrink: 0; +} + +/* ── Gradient editor widget ──────────────────────────────────────────── */ +.gradient-editor { + width: 100%; +} + +.gradient-editor-row { + display: flex; + align-items: center; + gap: 8px; +} + +.gradient-bar-wrap { + position: relative; + flex: 1; + min-width: 120px; + padding-bottom: 18px; /* room for stop handles below the bar */ +} + +.gradient-bar { + position: relative; + width: 100%; + height: 24px; + border: 1px solid var(--border); + border-radius: 4px; + background: linear-gradient(to right, #222 0%, #ddd 100%); + cursor: copy; + user-select: none; + -webkit-user-select: none; +} + +.gradient-stops-layer { + position: absolute; + left: 0; + right: 0; + top: 26px; + height: 16px; + pointer-events: none; /* handles re-enable individually */ +} + +.gradient-stop-handle { + position: absolute; + top: 0; + width: 16px; + height: 16px; + margin-left: -8px; /* center the handle on its position */ + border: 2px solid var(--surface); + outline: 1px solid var(--border); + border-radius: 50%; + cursor: grab; + pointer-events: auto; + transition: transform 0.1s, outline-color 0.15s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.gradient-stop-handle:hover { + transform: scale(1.15); +} + +.gradient-stop-handle.selected { + outline: 2px solid var(--accent); + z-index: 2; +} + +.gradient-stop-handle.drag-remove { + opacity: 0.35; + transform: scale(0.85); +} + +/* Stops with lockedToBase: true track the base color picker. Indicate with + a small white lock badge in the top-right corner of the handle so users + can tell at a glance which stops are auto-managed. Alt-click toggles. */ +.gradient-stop-handle.locked-to-base { + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.85); +} +.gradient-stop-handle.locked-to-base::after { + content: ""; + position: absolute; + top: -4px; right: -4px; + width: 9px; height: 9px; + border-radius: 50%; + background: #fff; + border: 1px solid #000; + pointer-events: none; +} + +.gradient-stop-color-input { + width: 32px; + height: 28px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + cursor: pointer; + flex-shrink: 0; +} + +/* ── Stop properties panel (replaces alt-click + right-click affordances) ── */ +.stop-props { + margin-top: 10px; + padding: 8px 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 4px; + display: grid; + gap: 6px; +} +.stop-props.disabled { opacity: 0.45; pointer-events: none; } +.stop-props-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + margin-bottom: 2px; +} +.stop-props-row { + display: flex; + align-items: center; + gap: 8px; +} +.stop-props-row > label:not(.checkbox-label) { + flex: 0 0 70px; + font-size: 12px; + color: var(--muted); +} +.stop-props-row input[type="color"] { + width: 32px; + height: 26px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + cursor: pointer; + flex-shrink: 0; +} +.stop-props-row input[type="color"]:disabled { cursor: not-allowed; opacity: 0.5; } +.stop-props-row input[type="text"], +.stop-props-row input[type="number"] { + width: 90px; + padding: 3px 6px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 3px; +} +.stop-props-row input[type="text"]:disabled { opacity: 0.5; } +.stop-pos-suffix { color: var(--muted); font-size: 12px; } +#stop-remove-btn { font-size: 11px; } +#stop-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ── Palette preview (live "what slicer sees" swatches) ─────────────────── */ +.palette-preview-wrap { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 4px; +} +.palette-preview-wrap.hidden { display: none; } +.palette-preview-label { + font-size: 11px; + color: var(--muted); + letter-spacing: 0.02em; +} +.palette-preview { + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; +} +.palette-swatch { + width: 24px; + height: 24px; + border-radius: 4px; + border: 1px solid var(--border); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset; +} +.palette-count { + font-size: 11px; + color: var(--muted); + margin-left: 4px; +} + +/* ── Gradient toolbar (snap-preview + reset) ───────────────────────────── */ +.gradient-toolbar { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} +.gradient-toolbar .checkbox-label { font-size: 12px; } +#color-gradient-reset { font-size: 11px; } diff --git a/test-color-quantize.mjs b/test-color-quantize.mjs new file mode 100644 index 0000000..70cffc7 --- /dev/null +++ b/test-color-quantize.mjs @@ -0,0 +1,99 @@ +// Standalone checks for js/quantize.js. +// +// Run: node test-color-quantize.mjs + +import { medianCut } from './js/quantize.js'; + +let failed = 0; + +function check(label, cond, detail = '') { + if (cond) { + console.log(` ok ${label}`); + } else { + failed++; + console.error(` FAIL ${label}${detail ? ` :: ${detail}` : ''}`); + } +} + +function paletteTriplets(palette) { + const out = []; + for (let i = 0; i < palette.length; i += 3) { + out.push([palette[i], palette[i + 1], palette[i + 2]]); + } + return out; +} + +function findNearColor(palette, target, tolerance = 8) { + const pals = paletteTriplets(palette); + return pals.findIndex(([r, g, b]) => + Math.abs(r - target[0]) <= tolerance && + Math.abs(g - target[1]) <= tolerance && + Math.abs(b - target[2]) <= tolerance + ); +} + +console.log('\n[1] Empty input'); +{ + const { palette, indices } = medianCut(new Uint8Array(0), 32); + check('empty palette', palette.length === 0); + check('empty indices', indices.length === 0); +} + +console.log('\n[2] Single-color input'); +{ + const tri = new Uint8Array(10 * 3); + for (let i = 0; i < 10; i++) { + tri[i * 3] = 12; + tri[i * 3 + 1] = 34; + tri[i * 3 + 2] = 56; + } + const { palette, indices } = medianCut(tri, 32); + check('emits one bucket', palette.length === 3, JSON.stringify(paletteTriplets(palette))); + check('preserves source color', palette[0] === 12 && palette[1] === 34 && palette[2] === 56); + check('all triangles point to bucket 0', Array.from(indices).every(i => i === 0)); +} + +console.log('\n[3] Tiny outlier cluster'); +{ + const brown = [90, 60, 30]; + const white = [255, 255, 255]; + const tri = new Uint8Array(1003 * 3); + for (let i = 0; i < 1000; i++) { + tri[i * 3] = brown[0]; + tri[i * 3 + 1] = brown[1]; + tri[i * 3 + 2] = brown[2]; + } + for (let i = 1000; i < 1003; i++) { + tri[i * 3] = white[0]; + tri[i * 3 + 1] = white[1]; + tri[i * 3 + 2] = white[2]; + } + + const { palette, indices } = medianCut(tri, 4); + const whiteBucket = findNearColor(palette, white, 0); + const whiteCount = whiteBucket >= 0 + ? Array.from(indices).filter(i => i === whiteBucket).length + : 0; + + check('keeps a white palette entry', whiteBucket >= 0, JSON.stringify(paletteTriplets(palette))); + check('assigns the outlier triangles to white', whiteCount === 3, `whiteCount=${whiteCount}`); +} + +console.log('\n[4] Smooth ramp stays bounded'); +{ + const tri = new Uint8Array(256 * 3); + for (let i = 0; i < 256; i++) { + tri[i * 3] = i; + tri[i * 3 + 1] = i; + tri[i * 3 + 2] = i; + } + const { palette, indices } = medianCut(tri, 8); + check('uses requested cap', palette.length <= 8 * 3, `palette=${palette.length / 3}`); + check('produces one index per triangle', indices.length === 256); +} + +if (failed) { + console.error(`\nFAIL: ${failed} failure(s)`); + process.exit(1); +} +console.log('\nAll color quantize tests passed'); diff --git a/test_models/test_model_1.prt b/test_models/test_model_1.prt new file mode 100644 index 0000000..ec9fdbb Binary files /dev/null and b/test_models/test_model_1.prt differ diff --git a/test_models/test_model_1.stl b/test_models/test_model_1.stl new file mode 100644 index 0000000..25f97db Binary files /dev/null and b/test_models/test_model_1.stl differ