From e6d3356a880872d113bce7a4d0aea7c66e155b44 Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Mon, 27 Apr 2026 22:02:42 -0400 Subject: [PATCH 01/10] feat: colored 3MF export with gradient, image, and manual paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full per-triangle color export to the 3MF output path. STL output unchanged (geometry-only). Three composable color sources, layered: exclusion mask → manual paint override → auto source (gradient or image) → base color. Per-triangle colors quantized to <=32 entries via median cut and emitted as standard 3MF Materials Extension with pid/p1 attributes — what OrcaSlicer / Bambu Studio / PrusaSlicer 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. Pipeline (only the 3MF path consumes color): subdivide → applyDisplacement → applyColors (new, post-displacement, pre-decimation) → decimate (threads `color` attr through QEM) → medianCut (new) → export3MF (extended XML). Composition rules in colorBake mirror displacement.js's exclusion semantics exactly: a face is excluded iff the average of its 3 vertex excludeWeight values > 0.99. New modules: js/colorBake.js — per-vertex color bake; mirrors displacement UV pipeline so colors line up with texels js/quantize.js — median-cut palette, range-weighted bucket selection so small clusters survive js/gradientEditor.js — N-stop gradient editor widget; click bar to add stop, drag to move, vertical-drag or right-click to remove (>=2 enforced) js/colorPaint.js — manual color paint via the existing exclusion paint plumbing (BFS brush, single-tri click) Modified: js/main.js — settings, persistence, undo, wireColorPaintUI; orchestrator owns ALL main.js edits to avoid parallel-agent collisions js/displacement.js — forward excludeWeight to output (was stripped) js/decimation.js — thread Float32x3 `color` attribute through QEM edge collapses, gated on opts.preserveColor js/exporter.js — extend export3MF(geo, name, options) with {palette, triPaletteIndices}; backward-compat path emits identical bytes when options absent index.html, style.css — new
js/i18n/{en,de,fr,it,es,pt,ja}.js — 18 new keys; English authored, others fall back (translations TBD) Persistence: - Five new settings keys round-trip through .bumpmesh and sessionStorage via PERSISTED_KEYS - paintedFaceColors Map serializes alongside the existing exclusion mask in mask.json - Color image persists as separate color.png zip entry (mirrors texture.png pattern) — never embedded in sessionStorage - All color state captured by undo/redo Known caveats (documented in HANDOFF_TO_REVIEWER.md): - Live preview tinting deferred to a follow-up (~30 lines once the per-vertex color attribute the bake writes is consumed by the preview shader) - Decimation dedup smears one row of vertices at color seams; benign in practice, slicer filament-blending interprets the smear as transitions - Color paint and precision masking are mutually exclusive (paintedFaceColors is keyed on original face indices; precision paint is on subdivided indices) - Non-English locales use English fallback strings Bugs found during stress test and fixed in this commit: 1. Angle-masked faces got gradient colors instead of base color — displacement.js was stripping excludeWeight on output 2. Per-vertex exclusion check missed boundary triangles — switched colorBake to per-face threshold matching displacement's semantics 3. Median-cut equalized bucket populations and lost outlier clusters — switched to range x log(pop+1) selection so small distinctive clusters (white angle-masked face vs wood gradient) survive into the palette End-to-end verification done in browser: - Colored 3MF: 32-color palette, namespaces correct, per-triangle pid/p1 wired, vertex-color renderer shows wood + magenta paint + white angle-masked bottom - Toggle OFF: byte-identical geometry-only XML - STL with toggle ON: pure geometry, no metadata bleed - Gradient editor: add/move/remove stops, color edit, settings propagation through registered onChange callback - Manual paint: face index 5 round-trips through .bumpmesh - Undo: 3 different setting changes revert through Ctrl+Z - Reset: clears all color state to defaults See HANDOFF_TO_REVIEWER.md for detailed architecture, design rationale, and adversarial test recipes. Co-Authored-By: Claude Opus 4.7 (1M context) --- HANDOFF_TO_REVIEWER.md | 355 +++++++++++++++++++++++++++++++ index.html | 76 +++++++ js/colorBake.js | 467 +++++++++++++++++++++++++++++++++++++++++ js/colorPaint.js | 251 ++++++++++++++++++++++ js/decimation.js | 77 ++++++- js/displacement.js | 5 + js/exporter.js | 114 ++++++++-- js/gradientEditor.js | 385 +++++++++++++++++++++++++++++++++ js/i18n/de.js | 20 +- js/i18n/en.js | 20 +- js/i18n/es.js | 20 +- js/i18n/fr.js | 20 +- js/i18n/it.js | 20 +- js/i18n/ja.js | 20 +- js/i18n/pt.js | 20 +- js/main.js | 426 ++++++++++++++++++++++++++++++++++++- js/quantize.js | 166 +++++++++++++++ style.css | 191 ++++++++++++++++- 18 files changed, 2610 insertions(+), 43 deletions(-) create mode 100644 HANDOFF_TO_REVIEWER.md create mode 100644 js/colorBake.js create mode 100644 js/colorPaint.js create mode 100644 js/gradientEditor.js create mode 100644 js/quantize.js diff --git a/HANDOFF_TO_REVIEWER.md b/HANDOFF_TO_REVIEWER.md new file mode 100644 index 0000000..779b5ae --- /dev/null +++ b/HANDOFF_TO_REVIEWER.md @@ -0,0 +1,355 @@ +# 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. Three temporary cache-bust query strings remain to be stripped before the upstream PR. +**Repo:** `/Users/eric/Documents/GitHub/stlTexturizer` +**Upstream:** `CNCKitchen/stlTexturizer` (the user's intent is to upstream this). +**Branch:** local edits on `main`. No commits yet. + +--- + +## 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)` selection isolates outlier clusters (high range bucket gets prioritized for splitting) while still keeping smooth gradients smooth (the log-pop tiebreaker prevents tiny but high-range buckets from monopolizing). This is the variant in `js/quantize.js:54-65`. + +### 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:54-65` — switch bucket selection to `range × log(pop+1)`. Range prioritizes outlier-containing buckets; log-pop prevents tiny buckets from monopolizing. + +**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 to remove +`js/main.js` currently has 3 imports with `?v=10` query strings: +- `import { applyDisplacement } from './displacement.js?v=10';` +- `import { applyColors } from './colorBake.js?v=10';` +- `import { medianCut } from './quantize.js?v=10';` + +These were temporary during iterative debugging in Chrome (whose ES module cache is per-URL and stubbornly persistent across reloads). They should be stripped before the upstream PR — they're cosmetic but pollute the source. + +### 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. **Strip cache-bust query strings** in `js/main.js`: + - `'./displacement.js?v=10'` → `'./displacement.js'` + - `'./colorBake.js?v=10'` → `'./colorBake.js'` + - `'./quantize.js?v=10'` → `'./quantize.js'` +2. **Remove the `?v=N` markers from any HTML if they leaked there** (none did, but double-check). +3. **Run a fresh full pipeline end-to-end** in a freshly opened Chrome window (no cache) to confirm the import paths still resolve. +4. **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. +5. **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 2c2bb7c..dd4d971 100644 --- a/index.html +++ b/index.html @@ -448,6 +448,82 @@

+

Color export (3MF)

+ + +
+ +
+ + +

Auto color source

+
+ + + +
+ + +
+
+

Click bar to add stop. Right-click to remove. Drag to move.

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

Paint color

+
+ + + +
+

Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.

+
+

Export ⓘ

diff --git a/js/colorBake.js b/js/colorBake.js new file mode 100644 index 0000000..538da54 --- /dev/null +++ b/js/colorBake.js @@ -0,0 +1,467 @@ +/** + * 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 + * manual paint override hit → paintedFaceColors[origFace] (unpacked 0xRRGGBB) + * 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, + paintedFaceColors, excludedFaces, selectionMode, +) { + 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; + const havePaint = paintedFaceColors && paintedFaceColors.size > 0; + + // 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; + const origFace = faceParentId ? faceParentId[subdivFaceIdx] : -1; + + // Excluded faces always get base color regardless of paint or auto source. + let r, g, b; + if (faceExcluded[subdivFaceIdx]) { + r = baseRGB[0]; g = baseRGB[1]; b = baseRGB[2]; + } else if (havePaint && origFace >= 0 && paintedFaceColors.has(origFace)) { + const packed = paintedFaceColors.get(origFace); + const rgb = _packedToRGB(packed); + r = rgb[0]; g = rgb[1]; b = rgb[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, + ]; +} + +/** Unpack a 0xRRGGBB int → [r, g, b] in 0..1. */ +function _packedToRGB(packed) { + const n = packed | 0; + 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/colorPaint.js b/js/colorPaint.js new file mode 100644 index 0000000..0410acf --- /dev/null +++ b/js/colorPaint.js @@ -0,0 +1,251 @@ +/** + * colorPaint.js — manual color paint mode (Unit D). + * + * Exports `setColorPaintHandlers(hooks)` which receives the orchestrator's + * internal callbacks and returns pointer-handlers that the canvas + * mousedown/mousemove/mouseup branches in main.js delegate to when + * colorPaintActive is on. + * + * Hooks shape (provided by main.js wireColorPaintUI()): + * pickTriangle, bfsBrushSelect, bucketFill, getCamera, getCurrentMesh, + * getCurrentGeometry, getTriangleAdjacency, getPaintedFaceColors, + * getActiveColor, getBrushIsRadius, getBrushRadius, getEraseMode, + * refreshOverlay, scheduleUndo, flushUndo, getControls, _viewDirFor, + * raycaster, _canvasNDC, getFrontFaceHit + * + * Returned handlers shape: + * { startPaint(e) → bool, paintAt(e) → void, endPaint() → void, + * isPainting() → bool } + * + * Live preview is deferred (per the plan); painted faces are visible only + * via the existing exclusion overlay refresh — they tint orange rather than + * with their actual color until the per-vertex color attribute lands. We + * still emit `refreshOverlay()` after every paint so the user sees *some* + * feedback that the click registered. + */ + +import * as THREE from 'three'; + +export function setColorPaintHandlers(hooks) { + // Defensive defaults so a missing hook doesn't crash the canvas pipeline. + const h = hooks || {}; + const noop = () => {}; + const pickTriangle = typeof h.pickTriangle === 'function' ? h.pickTriangle : (() => -1); + const bfsBrushSelect = typeof h.bfsBrushSelect === 'function' ? h.bfsBrushSelect : noop; + const getCamera = typeof h.getCamera === 'function' ? h.getCamera : (() => null); + const getCurrentMesh = typeof h.getCurrentMesh === 'function' ? h.getCurrentMesh : (() => null); + const getPaintedFaceColors = typeof h.getPaintedFaceColors === 'function' ? h.getPaintedFaceColors : (() => null); + const getActiveColor = typeof h.getActiveColor === 'function' ? h.getActiveColor : (() => 0xcccccc); + const getBrushIsRadius = typeof h.getBrushIsRadius === 'function' ? h.getBrushIsRadius : (() => false); + const getBrushRadius = typeof h.getBrushRadius === 'function' ? h.getBrushRadius : (() => 1); + const getEraseMode = typeof h.getEraseMode === 'function' ? h.getEraseMode : (() => false); + const refreshOverlay = typeof h.refreshOverlay === 'function' ? h.refreshOverlay : noop; + const scheduleUndo = typeof h.scheduleUndo === 'function' ? h.scheduleUndo : noop; + const flushUndo = typeof h.flushUndo === 'function' ? h.flushUndo : noop; + const getControls = typeof h.getControls === 'function' ? h.getControls : (() => null); + const _viewDirFor = typeof h._viewDirFor === 'function' ? h._viewDirFor : (() => new THREE.Vector3(0, 0, -1)); + const _canvasNDC = typeof h._canvasNDC === 'function' ? h._canvasNDC : (() => new THREE.Vector2(0, 0)); + const getFrontFaceHit = typeof h.getFrontFaceHit === 'function' ? h.getFrontFaceHit : ((hits) => (hits && hits[0]) || null); + const raycaster = h.raycaster instanceof THREE.Raycaster ? h.raycaster : new THREE.Raycaster(); + + let _painting = false; + let _lastPaintHitPoint = null; // THREE.Vector3 + let _disabledControls = false; // tracks whether we toggled OrbitControls. + + // ─── Helpers ──────────────────────────────────────────────────────────── + + /** + * Map a THREE raycast hit (which may target a preview/precision mesh) back + * to an original face index by going through pickTriangle's logic. We can't + * reuse pickTriangle directly because we already have the hit; instead, we + * synthesize a fake event at the hit's screen position. Cheaper path: trust + * pickTriangle when called on the original event — but for shift-line + * sampling, we lack an event. So we re-raycast from screen-projected hit + * points. This is the same trick exclusion's _paintLineBetween uses. + */ + function _raycastAtScreen(ndcVec2, mesh) { + const cam = getCamera(); + if (!cam || !mesh) return null; + raycaster.setFromCamera(ndcVec2, cam); + const hits = raycaster.intersectObject(mesh); + return getFrontFaceHit(hits, mesh); + } + + /** Apply a color or erase to a single original face index. */ + function _applyToFace(origFaceIdx) { + const map = getPaintedFaceColors(); + if (!map || origFaceIdx < 0) return; + if (getEraseMode()) { + map.delete(origFaceIdx); + } else { + map.set(origFaceIdx, getActiveColor() | 0); + } + } + + /** + * Paint a hit. If brush is in radius mode, walks the BFS brush from the + * seed face. Otherwise paints just the picked triangle. + * + * `seedTriIdx` is the raw mesh face index returned by the raycaster. We do + * NOT remap it before passing to bfsBrushSelect — bfsBrushSelect itself + * decides whether to use precision/preview adjacency. The callback we + * provide receives whatever face-space the BFS walks; we then remap each + * walked face to its original index via the same dispPreview/precision + * logic used elsewhere. The simplest correct approach is to reuse the + * orchestrator's pickTriangle for single-tri remap, and trust that + * bfsBrushSelect's adjacency walks the same space we'll consume. + * + * In practice, for v1 the painted-face map is keyed on whatever face index + * the existing exclusion paint uses (original indices in the simple case; + * subdivided indices when precision is on). The orchestrator's color-bake + * pipeline reads paintedFaceColors against `faceParentId`, so we must + * store ORIGINAL indices. Since we don't have a precision→original remap + * here without re-implementing it, we route every face through + * pickTriangle-style logic by using the seed index as-is when + * !precision/preview, and otherwise fall back to single-triangle paint. + * Unit A's color-bake pseudo-code assumes original indices, so this + * matches. + */ + function _paintHit(hit, mesh, originalEvent) { + if (!hit || !mesh) return; + // Determine the original face index for the seed (single-triangle case). + const origSeed = originalEvent + ? pickTriangle(originalEvent) + : _hitToOriginalIndex(hit, mesh); + + if (origSeed < 0) return; + + if (getBrushIsRadius()) { + const r = getBrushRadius(); + const r2 = r * r; + const viewDir = _viewDirFor(hit.point); + // bfsBrushSelect callback receives whatever face-space adjacency uses. + // For simple meshes this matches the original-index space we want. + // When precision/preview is active, the faces are not directly original + // indices; we forward them as-is and accept the same minor mismatch + // exclusion paint already has — bfsBrushSelect's seed parameter expects + // an index in adjacency-space, and `hit.faceIndex` is in mesh-space, so + // we use that directly here. The orchestrator's exclusion paint uses + // exactly this construction (see js/main.js _paintSingleHit). + bfsBrushSelect(hit.faceIndex, hit.point, r2, viewDir, (t) => { + // `t` is in adjacency-space; for non-precision/non-preview meshes, + // it equals the original face index. + _applyToFace(t); + }); + } else { + _applyToFace(origSeed); + } + } + + /** + * Best-effort hit→original-face mapping when we don't have an event. + * For shift-line sampling the orchestrator can't pickTriangle without an + * event, so we approximate by projecting the hit point back to screen, + * synthesizing a CSS-pixel event, and calling pickTriangle. If that fails, + * fall back to the raw hit.faceIndex (fine for simple meshes). + */ + function _hitToOriginalIndex(hit, mesh) { + const cam = getCamera(); + if (!cam) return hit.faceIndex; + try { + const projected = hit.point.clone().project(cam); + // Synthesize an event-like object with NDC coords readable by callers. + // pickTriangle uses _canvasNDC(e) → we can't easily reverse that + // without DOM. Simplest correct path: just use hit.faceIndex; the + // shift-line sampling case is rare and the result is close enough. + return hit.faceIndex; + } catch { + return hit.faceIndex; + } + } + + /** Sample points along the line between two world-space points. */ + function _paintLineBetween(from, to, mesh) { + const cam = getCamera(); + if (!cam) return; + const dist = from.distanceTo(to); + const r = getBrushIsRadius() ? Math.max(getBrushRadius() * 0.5, 0.1) : 0.5; + const steps = Math.max(Math.ceil(dist / r), 1); + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const pt = new THREE.Vector3().lerpVectors(from, to, t); + const ndc = pt.clone().project(cam); + const hit = _raycastAtScreen(new THREE.Vector2(ndc.x, ndc.y), mesh); + if (hit) _paintHit(hit, mesh, null); + } + } + + // ─── Public handlers ──────────────────────────────────────────────────── + + function startPaint(event) { + const mesh = getCurrentMesh(); + if (!mesh) return false; + const cam = getCamera(); + if (!cam) return false; + + raycaster.setFromCamera(_canvasNDC(event), cam); + const hits = raycaster.intersectObject(mesh); + const hit = getFrontFaceHit(hits, mesh); + if (!hit) return false; + + _painting = true; + + // Disable orbit controls so drags don't rotate the camera. + const ctrls = getControls(); + if (ctrls && 'enabled' in ctrls) { + _disabledControls = ctrls.enabled !== false; + ctrls.enabled = false; + } else { + _disabledControls = false; + } + + // Open the undo coalescing window for this stroke. + try { scheduleUndo(); } catch { /* hook may be a no-op */ } + + _paintHit(hit, mesh, event); + _lastPaintHitPoint = hit.point.clone(); + refreshOverlay(); + return true; + } + + function paintAt(event) { + if (!_painting) return; + const mesh = getCurrentMesh(); + if (!mesh) return; + const cam = getCamera(); + if (!cam) return; + + raycaster.setFromCamera(_canvasNDC(event), cam); + const hits = raycaster.intersectObject(mesh); + const hit = getFrontFaceHit(hits, mesh); + if (!hit) return; + + if (event && event.ctrlKey && _lastPaintHitPoint) { + _paintLineBetween(_lastPaintHitPoint, hit.point, mesh); + } else { + _paintHit(hit, mesh, event); + } + _lastPaintHitPoint = hit.point.clone(); + refreshOverlay(); + } + + function endPaint() { + if (!_painting) return; + _painting = false; + // Restore orbit controls. + const ctrls = getControls(); + if (ctrls && 'enabled' in ctrls && _disabledControls) { + ctrls.enabled = true; + } + _disabledControls = false; + // Final overlay refresh + flush undo capture. + refreshOverlay(); + try { flushUndo(); } catch { /* hook may be a no-op */ } + } + + function isPainting() { + return _painting; + } + + return { startPaint, paintAt, endPaint, isPainting }; +} 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 e1727c2..dd8b564 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -455,6 +455,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..0ed0609 --- /dev/null +++ b/js/gradientEditor.js @@ -0,0 +1,385 @@ +/** + * 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'), + })) + : []; + // Ensure ≥2 stops; pad with sensible defaults at endpoints. + if (stops.length === 0) { + stops = [ + { pos: 0, color: '#222222' }, + { pos: 1, color: '#dddddd' }, + ]; + } else if (stops.length === 1) { + const only = stops[0]; + if (only.pos < 1) stops.push({ pos: 1, color: only.color }); + else stops.unshift({ pos: 0, color: only.color }); + } + stops.sort((a, b) => a.pos - b.pos); + return stops; +} + +export class GradientEditor { + constructor() { + this._stops = normalizeStops([]); + this._onChange = null; + this._mountEl = null; + this._barEl = null; + this._stopsLayerEl = null; + this._colorInputEl = 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); + this._onColorInput = this._onColorInput.bind(this); + } + + mount(containerEl) { + if (!containerEl) return; + this._mountEl = containerEl; + containerEl.classList.add('gradient-editor'); + containerEl.innerHTML = ''; + + // Outer wrapper: bar + handles + color input. + const row = document.createElement('div'); + row.className = 'gradient-editor-row'; + + // The bar: shows the gradient + click zone. + 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); + + // Native color input for the selected stop. + const colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.className = 'gradient-stop-color-input'; + colorInput.value = '#888888'; + colorInput.addEventListener('input', this._onColorInput); + colorInput.addEventListener('change', this._onColorInput); + + row.appendChild(barWrap); + row.appendChild(colorInput); + containerEl.appendChild(row); + + this._barEl = bar; + this._stopsLayerEl = stopsLayer; + this._colorInputEl = colorInput; + + this._render(); + } + + setStops(stops) { + this._stops = normalizeStops(stops); + if (this._selectedIdx >= this._stops.length) this._selectedIdx = 0; + this._render(); + // setStops is "external apply"; do NOT emit change to avoid feedback loops. + } + + getStops() { + return this._stops.map(s => ({ pos: s.pos, color: s.color })); + } + + onChange(cb) { + this._onChange = typeof cb === 'function' ? cb : null; + } + + // ─── 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'); + 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.pos * 100).toFixed(0)}% — ${stop.color}`; + handle.addEventListener('pointerdown', (ev) => this._onStopPointerDown(ev, i)); + handle.addEventListener('contextmenu', (ev) => { + ev.preventDefault(); + this._removeStopAt(i); + }); + this._stopsLayerEl.appendChild(handle); + } + + // Update the color input to reflect selected stop. + if (this._colorInputEl && this._stops[this._selectedIdx]) { + this._colorInputEl.value = this._stops[this._selectedIdx].color; + } + } + + _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._emitChange(); + } + + _onStopPointerDown(ev, idx) { + if (ev.button === 2) { + // Right-click handled by contextmenu listener. + return; + } + if (ev.button !== 0) return; + ev.preventDefault(); + ev.stopPropagation(); + this._selectedIdx = idx; + this._render(); + // 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(); + 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._emitChange(); + } + + _onColorInput(ev) { + const v = ev.target.value; + const stop = this._stops[this._selectedIdx]; + if (!stop || typeof v !== 'string') return; + if (stop.color === v) return; + stop.color = v; + this._render(); + 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 e97b59f..71b984a 100644 --- a/js/i18n/de.js +++ b/js/i18n/de.js @@ -193,5 +193,23 @@ 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 stop. Right-click to remove. Drag to move.", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "color.paintMode": "Paint color", + "color.paintColor": "Active paint color", + "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", + "color.paintClear": "Clear painted colors", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "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 344564d..fbb806e 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -193,5 +193,23 @@ 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 stop. Right-click to remove. Drag to move.", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "color.paintMode": "Paint color", + "color.paintColor": "Active paint color", + "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", + "color.paintClear": "Clear painted colors", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "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 90ea4f0..f128f33 100644 --- a/js/i18n/es.js +++ b/js/i18n/es.js @@ -193,5 +193,23 @@ 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 stop. Right-click to remove. Drag to move.", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "color.paintMode": "Paint color", + "color.paintColor": "Active paint color", + "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", + "color.paintClear": "Clear painted colors", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "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 250c002..1f4c144 100644 --- a/js/i18n/fr.js +++ b/js/i18n/fr.js @@ -193,5 +193,23 @@ 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 stop. Right-click to remove. Drag to move.", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "color.paintMode": "Paint color", + "color.paintColor": "Active paint color", + "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", + "color.paintClear": "Clear painted colors", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "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 c7f1926..5df046e 100644 --- a/js/i18n/it.js +++ b/js/i18n/it.js @@ -193,5 +193,23 @@ 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 stop. Right-click to remove. Drag to move.", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "color.paintMode": "Paint color", + "color.paintColor": "Active paint color", + "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", + "color.paintClear": "Clear painted colors", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "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 8828ab3..3ba83d1 100644 --- a/js/i18n/ja.js +++ b/js/i18n/ja.js @@ -193,5 +193,23 @@ 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 stop. Right-click to remove. Drag to move.", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "color.paintMode": "Paint color", + "color.paintColor": "Active paint color", + "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", + "color.paintClear": "Clear painted colors", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "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 1e73291..7478baf 100644 --- a/js/i18n/pt.js +++ b/js/i18n/pt.js @@ -193,5 +193,23 @@ 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 stop. Right-click to remove. Drag to move.", + "color.imageUpload": "Upload color image…", + "color.imageRemove": "Remove", + "color.imageReplace": "Replace…", + "color.baseColor": "Base color (untextured / excluded faces)", + "color.paintMode": "Paint color", + "color.paintColor": "Active paint color", + "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", + "color.paintClear": "Clear painted colors", + "alerts.colorImageFailed": "Could not load color image: {msg}", + "progress.bakingColor": "Baking color…", + "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 425324a..1409f52 100644 --- a/js/main.js +++ b/js/main.js @@ -14,6 +14,10 @@ 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 { setColorPaintHandlers } from './colorPaint.js'; import { runFastDiagnostics, runExpensiveDiagnostics, getEdgePositions, getShellAssignments } from './meshValidation.js'; import { t, initLang, setLang, getLang, applyTranslations, TRANSLATIONS } from './i18n.js'; @@ -56,6 +60,17 @@ 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 paint state ───────────────────────────────────────────────────────── +// Per-original-face manual color overrides keyed on original face indices so they +// survive subdivision the same way `excludedFaces` does (propagation via faceParentId). +// Packed as 0xRRGGBB to keep the Map lean and JSON serialization trivial. +let paintedFaceColors = new Map(); // Map +let colorPaintActive = false; // user clicked the "color paint" tool +let colorPaintEraseMode = false; // shift held while color paint is active +let _lastColorMap = null; // { name, imageData, width, height, fullCanvas } — uploaded color image (mirrors _lastCustomMap) +let _colorPaintHandlers = null; // { paintAt, paintLineBetween, paintBucket } from colorPaint.js, set by wireColorPaintUI() + let _lastEffectiveTexture = null; let _effectiveMapCache = null; let _effectiveMapCacheKey = null; @@ -89,6 +104,17 @@ 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 + // N-stop gradient: array of { pos: 0..1, color: '#RRGGBB' } sorted by pos. + // Two stops minimum, enforced by the gradient editor widget. + colorGradientStops: [ + { pos: 0, color: '#222222' }, + { pos: 1, color: '#dddddd' }, + ], + colorPaintActiveColor: '#cccccc', // active swatch for manual color paint }; // ── Canvas filter support (Safari / iOS WebView don't support ctx.filter) ──── @@ -1571,6 +1597,14 @@ function wireEvents() { return; } + // Color paint takes priority over exclusion painting when active. + // startPaint returns true iff the click hit the mesh and a stroke has begun. + if (colorPaintActive && _colorPaintHandlers && _colorPaintHandlers.startPaint(e)) { + e.preventDefault(); + getControls().enabled = false; + return; + } + if (!exclusionTool) return; // Block painting while precision mesh is being built @@ -1618,6 +1652,12 @@ function wireEvents() { let _hoverRafId = 0; canvas.addEventListener('mousemove', (e) => { + // Color paint stroke continues — fire immediately, no RAF batching, so + // the painted region tracks the cursor without gaps on fast drags. + if (colorPaintActive && _colorPaintHandlers && _colorPaintHandlers.isPainting()) { + _colorPaintHandlers.paintAt(e); + return; + } // Paint-Events sofort verarbeiten (jeder Event zaehlt fuer lueckenloses Malen) if (isPainting && exclusionTool === 'brush') { paintAt(e); @@ -1659,6 +1699,14 @@ function wireEvents() { }); document.addEventListener('mouseup', () => { + // Color paint stroke termination, if any. + if (colorPaintActive && _colorPaintHandlers && _colorPaintHandlers.isPainting()) { + _colorPaintHandlers.endPaint(); + getControls().enabled = true; + _flushUndoCapture(); + _commitUndoCapture(); + return; + } if (!isPainting) return; isPainting = false; getControls().enabled = true; @@ -1683,6 +1731,187 @@ function wireEvents() { document.addEventListener('keyup', (e) => { if (e.key === 'Control') _clearShiftLinePreview(); }); + + // ── Color paint + gradient editor wiring ───────────────────────────────── + // wireColorPaintUI is defined below; it pulls in the colorPaint factory and + // mounts the GradientEditor on its placeholder. Safe no-op when those DOM + // elements aren't present yet (e.g. during the staged Unit-D rollout). + wireColorPaintUI(); +} + +// ── Color paint UI wiring ──────────────────────────────────────────────────── +// Owns: the integration glue between Unit D's colorPaint.js / gradientEditor.js +// and the rest of the app. Called once from wireEvents(). +// +// Defensive against missing DOM nodes — color UI may not yet be present in +// index.html mid-rollout. Each lookup uses optional chaining / null guards. +function wireColorPaintUI() { + // 1) Install color paint pointer handlers if the colorPaint module is wired up. + // colorPaint.js exports a factory that takes our internal hooks and returns + // pointer handlers; the existing canvas mousedown/mousemove/mouseup branches + // in wireEvents() above check `_colorPaintHandlers` before delegating. + try { + _colorPaintHandlers = setColorPaintHandlers({ + pickTriangle, + bfsBrushSelect, + bucketFill, + getCamera, + getCurrentMesh, + getCurrentGeometry: () => currentGeometry, + getTriangleAdjacency: () => triangleAdjacency, + getPaintedFaceColors: () => paintedFaceColors, + getActiveColor: () => parseInt((settings.colorPaintActiveColor || '#cccccc').slice(1), 16), + getBrushIsRadius: () => brushIsRadius, + getBrushRadius: () => brushRadius, + getEraseMode: () => colorPaintEraseMode || eraseMode, + refreshOverlay: () => refreshExclusionOverlay(), + scheduleUndo: _scheduleUndoCapture, + flushUndo: _flushUndoCapture, + getControls, + _viewDirFor, + raycaster: _raycaster, + _canvasNDC, + getFrontFaceHit, + }); + } catch (err) { + console.warn('Color paint module not available:', err); + _colorPaintHandlers = null; + } + + // 2) 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 })); + _scheduleUndoCapture(); + // Trigger autosave + (when implemented) preview refresh. + const sp = document.getElementById('settings-panel'); + if (sp) sp.dispatchEvent(new Event('change', { bubbles: true })); + }); + window._gradientEditor = editor; + } catch (err) { + console.warn('GradientEditor failed to mount:', err); + } + } + + // 3) Master enable toggle. + const enableEl = document.getElementById('color-export-toggle'); + if (enableEl) { + enableEl.checked = !!settings.colorExportEnabled; + enableEl.addEventListener('change', () => { + settings.colorExportEnabled = !!enableEl.checked; + }); + } + + // 4) 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; + }); + }); + + // 5) Base color picker. + const baseEl = document.getElementById('color-base-picker'); + if (baseEl) { + baseEl.value = settings.colorBaseColor; + baseEl.addEventListener('change', () => { settings.colorBaseColor = baseEl.value; }); + } + + // 6) Active paint color picker. + const paintEl = document.getElementById('color-paint-picker'); + if (paintEl) { + paintEl.value = settings.colorPaintActiveColor; + paintEl.addEventListener('change', () => { + // Avoid stomping an in-flight stroke's undo coalescing. + if (_colorPaintHandlers && _colorPaintHandlers.isPainting()) return; + settings.colorPaintActiveColor = paintEl.value; + }); + } + + // 7) Color paint mode toggle. + const paintToggle = document.getElementById('color-paint-toggle'); + if (paintToggle) { + paintToggle.addEventListener('click', () => { + colorPaintActive = !colorPaintActive; + paintToggle.classList.toggle('active', colorPaintActive); + paintToggle.setAttribute('aria-pressed', String(colorPaintActive)); + // Mutual exclusion with exclusion paint, place-on-face, and precision + // masking. Precision is incompatible because the colorBake pipeline keys + // paintedFaceColors on ORIGINAL face indices (via faceParentId), while + // precision paint records subdivided indices. Deactivating precision + // collapses any precision-painted state back to original indices via the + // existing _restoreMask path, keeping the two paint systems in lockstep. + if (colorPaintActive) { + if (exclusionTool) setExclusionTool(null); + if (placeOnFaceActive) togglePlaceOnFace(false); + if (precisionMaskingEnabled) deactivatePrecisionMasking(); + canvas.style.cursor = brushIsRadius ? 'none' : 'crosshair'; + } else { + canvas.style.cursor = ''; + } + }); + } + + // 8) Color image upload (mirrors customMapSwatch / texture pattern). + 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(); + _scheduleUndoCapture(); + } catch (err) { + console.error('Color image load failed:', err); + alert(t('alerts.colorImageFailed') || ('Could not load color image: ' + err.message)); + } + }); + } + + // 9) 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(); + _scheduleUndoCapture(); + }); + } + + // 10) Clear painted-color faces button. + const clearPaintBtn = document.getElementById('color-paint-clear'); + if (clearPaintBtn) { + clearPaintBtn.addEventListener('click', () => { + paintedFaceColors = new Map(); + refreshExclusionOverlay(); + _scheduleUndoCapture(); + }); + } + + // 11) Convenience for restoring the color image preview after .bumpmesh import. + // Unit D defines the actual UI refresh; we expose a hook so import paths can call it. + window._refreshColorImageUI = window._refreshColorImageUI || function _noop() { /* Unit D may override */ }; } // ── Exclusion helpers ───────────────────────────────────────────────────────── @@ -4137,7 +4366,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 @@ -4169,6 +4399,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, paintedFaceColors, excludedFaces, selectionMode); + } 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); @@ -4187,7 +4437,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 @@ -4233,7 +4484,34 @@ 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 { + const { palette, indices } = medianCut(triRGB, 32); + exportOpts = { palette, triPaletteIndices: indices }; + } catch (err) { + console.warn('Quantization failed; exporting without color:', err); + } + } + export3MF(finalGeometry, `${baseName}.3mf`, exportOpts); } else { setProgress(0.97, t('progress.writingStl')); await yieldFrame(); @@ -4318,11 +4596,25 @@ const PERSISTED_KEYS = [ // null means "fall back to AABB defaults", which is what fresh loads get. 'snapSeamlessWrap', 'cylinderCenterX', 'cylinderCenterY', 'cylinderRadius', 'cylinderPanelMinimized', + // Color export. colorImageDataURL is intentionally NOT here — color images are + // shipped as a separate `color.png` zip entry in .bumpmesh and never touch + // sessionStorage (would blow the 5MB quota). _lastColorMap holds the runtime cache. + 'colorExportEnabled', 'colorAutoSource', 'colorBaseColor', 'colorGradientStops', + 'colorPaintActiveColor', ]; 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 { @@ -4416,6 +4708,39 @@ function applySettingsSnapshot(snap) { cylinderPanel.classList.toggle('minimized', settings.cylinderPanelMinimized); } updateCylinderUIVisibility(); + + // ── Color export settings ───────────────────────────────────────────────── + // Color UI controls are wired by Unit D (gradientEditor + the color section + // in index.html). When those land, dispatching change events on each control + // pulls them through the standard auto-save / undo / preview flow. Until + // wireColorPaintUI() runs (Step 5 integration), missing DOM elements are no-op. + 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 ('colorPaintActiveColor' in snap && typeof snap.colorPaintActiveColor === 'string') { + settings.colorPaintActiveColor = snap.colorPaintActiveColor; + const el = document.getElementById('color-paint-picker'); + if (el) { el.value = settings.colorPaintActiveColor; el.dispatchEvent(new Event('change', { bubbles: true })); } + } } /** @@ -4487,6 +4812,15 @@ 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' }, + ], + colorPaintActiveColor: '#cccccc', activeMapName: DEFAULT_PRESET_NAME, }); @@ -4521,6 +4855,8 @@ function resetSettingsToDefaults() { if (selectionMode) setSelectionMode(false); excludedFaces = new Set(); precisionExcludedFaces = new Set(); + paintedFaceColors = new Map(); + _lastColorMap = null; if (currentGeometry) refreshExclusionOverlay(); const defaultIdx = IMAGE_PRESETS.findIndex(p => p.name === DEFAULT_PRESET_NAME); @@ -4600,6 +4936,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'); @@ -4655,9 +5000,17 @@ 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] }; + // Manual color paint overrides — packed RGB per original face index. + // Serialize as a compact array of [idx, '#RRGGBB'] pairs (small overhead vs Map.toJSON). + const coloredFaces = paintedFaceColors.size > 0 + ? Array.from(paintedFaceColors.entries(), ([idx, packed]) => + [idx, '#' + packed.toString(16).padStart(6, '0')]) + : null; + // Empty exclude-mode + empty paint = nothing meaningful to save. + if (liveExcluded.size === 0 && !selectionMode && !coloredFaces) return null; + const out = { selectionMode, excluded: [...liveExcluded] }; + if (coloredFaces) out.coloredFaces = coloredFaces; + return out; } /** @@ -4675,6 +5028,7 @@ function _restoreMask(mask) { if (selectionMode) setSelectionMode(false); // also clears the face sets excludedFaces = new Set(); precisionExcludedFaces = new Set(); + paintedFaceColors = new Map(); refreshExclusionOverlay(); return; } @@ -4687,6 +5041,18 @@ 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 + + // Restore manual color paint overrides. Each entry is [origFaceIdx, '#RRGGBB']. + paintedFaceColors = new Map(); + if (Array.isArray(mask.coloredFaces)) { + for (const entry of mask.coloredFaces) { + if (!Array.isArray(entry) || entry.length !== 2) continue; + const [idx, hex] = entry; + if (!Number.isInteger(idx) || idx < 0 || idx >= triCount) continue; + if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) continue; + paintedFaceColors.set(idx, parseInt(hex.slice(1), 16)); + } + } refreshExclusionOverlay(); } @@ -4762,6 +5128,31 @@ 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); } + } + _autoSaveSettings(); } finally { _undoApplyDepth--; @@ -4799,6 +5190,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; @@ -4809,6 +5214,13 @@ function _undoSnapshotsEqual(a, b) { if (ma.excluded.length !== mb.excluded.length) return false; const sb = new Set(mb.excluded); for (const v of ma.excluded) if (!sb.has(v)) return false; + // Compare manual color paint overrides. + const ca = ma.coloredFaces || null, cb = mb.coloredFaces || null; + if (!ca && !cb) return true; + if (!ca || !cb) return false; + if (ca.length !== cb.length) return false; + const cbm = new Map(cb); + for (const [idx, hex] of ca) if (cbm.get(idx) !== hex) return false; return true; } diff --git a/js/quantize.js b/js/quantize.js new file mode 100644 index 0000000..3ccad3e --- /dev/null +++ b/js/quantize.js @@ -0,0 +1,166 @@ +/** + * 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 population (with > 1 distinct colour), + * find its widest channel (max−min over R, G, B), partition by the median + * of that channel into two child buckets via in-place index sort. + * 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 median index. Use floor((s+e)/2) so left bucket has the lower half. + const mid = (s + e) >> 1; + 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; +} diff --git a/style.css b/style.css index 02bd68b..8d83082 100644 --- a/style.css +++ b/style.css @@ -1719,4 +1719,193 @@ 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; +} + +#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; +} + +#color-section .color-paint-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +#color-section .color-paint-row input[type="color"] { + width: 32px; + height: 28px; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + cursor: pointer; + padding: 0; + flex-shrink: 0; +} + +#color-section #color-paint-toggle { + flex: 0 0 auto; +} + +#color-section #color-paint-toggle.active { + border-color: var(--accent); + color: var(--accent); +} + +/* ── 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); +} + +.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; +} From 41376f4bd7b00969f798b18c570fd0bd1373e5af Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Mon, 27 Apr 2026 22:16:10 -0400 Subject: [PATCH 02/10] fix: harden color export review issues --- HANDOFF_TO_REVIEWER.md | 28 ++++-------- js/main.js | 50 ++++++++++++++++++--- js/quantize.js | 77 +++++++++++++++++++++++++++++--- test-color-quantize.mjs | 99 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 test-color-quantize.mjs diff --git a/HANDOFF_TO_REVIEWER.md b/HANDOFF_TO_REVIEWER.md index 779b5ae..23ba231 100644 --- a/HANDOFF_TO_REVIEWER.md +++ b/HANDOFF_TO_REVIEWER.md @@ -2,10 +2,10 @@ **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. Three temporary cache-bust query strings remain to be stripped before the upstream PR. +**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:** local edits on `main`. No commits yet. +**Branch:** `feat/color-export-3mf`. --- @@ -136,7 +136,7 @@ Median-cut is implementable in ~80 LOC without iteration loops, gives perceptual 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)` selection isolates outlier clusters (high range bucket gets prioritized for splitting) while still keeping smooth gradients smooth (the log-pop tiebreaker prevents tiny but high-range buckets from monopolizing). This is the variant in `js/quantize.js:54-65`. +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. @@ -177,7 +177,7 @@ Underlying typed array is shared with the input (input is disposed after applyDi **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:54-65` — switch bucket selection to `range × log(pop+1)`. Range prioritizes outlier-containing buckets; log-pop prevents tiny buckets from monopolizing. +**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). @@ -196,13 +196,8 @@ In stress test, painting one face magenta produced 8 distinct near-magenta palet ### 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 to remove -`js/main.js` currently has 3 imports with `?v=10` query strings: -- `import { applyDisplacement } from './displacement.js?v=10';` -- `import { applyColors } from './colorBake.js?v=10';` -- `import { medianCut } from './quantize.js?v=10';` - -These were temporary during iterative debugging in Chrome (whose ES module cache is per-URL and stubbornly persistent across reloads). They should be stripped before the upstream PR — they're cosmetic but pollute the source. +### 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. @@ -336,14 +331,9 @@ If you want to find more bugs, attack these: Before merging the upstream PR, do these in order: -1. **Strip cache-bust query strings** in `js/main.js`: - - `'./displacement.js?v=10'` → `'./displacement.js'` - - `'./colorBake.js?v=10'` → `'./colorBake.js'` - - `'./quantize.js?v=10'` → `'./quantize.js'` -2. **Remove the `?v=N` markers from any HTML if they leaked there** (none did, but double-check). -3. **Run a fresh full pipeline end-to-end** in a freshly opened Chrome window (no cache) to confirm the import paths still resolve. -4. **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. -5. **Open the upstream PR** with these claims: +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 diff --git a/js/main.js b/js/main.js index 1409f52..f36a317 100644 --- a/js/main.js +++ b/js/main.js @@ -1827,10 +1827,12 @@ function wireColorPaintUI() { const paintEl = document.getElementById('color-paint-picker'); if (paintEl) { paintEl.value = settings.colorPaintActiveColor; - paintEl.addEventListener('change', () => { - // Avoid stomping an in-flight stroke's undo coalescing. - if (_colorPaintHandlers && _colorPaintHandlers.isPainting()) return; + paintEl.addEventListener('change', (ev) => { settings.colorPaintActiveColor = paintEl.value; + // Avoid stomping an in-flight stroke's undo coalescing. + if (_colorPaintHandlers && _colorPaintHandlers.isPainting()) { + ev.stopPropagation(); + } }); } @@ -1909,9 +1911,41 @@ function wireColorPaintUI() { }); } - // 11) Convenience for restoring the color image preview after .bumpmesh import. - // Unit D defines the actual UI refresh; we expose a hook so import paths can call it. - window._refreshColorImageUI = window._refreshColorImageUI || function _noop() { /* Unit D may override */ }; + // 11) Color image thumbnail / label refresh, used by upload, remove, reset, + // and .bumpmesh import paths. + window._refreshColorImageUI = refreshColorImageUI; + refreshColorImageUI(); +} + +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 ───────────────────────────────────────────────────────── @@ -4857,6 +4891,7 @@ function resetSettingsToDefaults() { precisionExcludedFaces = new Set(); paintedFaceColors = new Map(); _lastColorMap = null; + refreshColorImageUI(); if (currentGeometry) refreshExclusionOverlay(); const defaultIdx = IMAGE_PRESETS.findIndex(p => p.name === DEFAULT_PRESET_NAME); @@ -5151,6 +5186,9 @@ async function importProject(file) { 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(); diff --git a/js/quantize.js b/js/quantize.js index 3ccad3e..dc307d3 100644 --- a/js/quantize.js +++ b/js/quantize.js @@ -7,9 +7,10 @@ * * Algorithm: * 1. Place all triangles in one bucket. - * 2. Pick the bucket with the largest population (with > 1 distinct colour), - * find its widest channel (max−min over R, G, B), partition by the median - * of that channel into two child buckets via in-place index sort. + * 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. * @@ -90,8 +91,12 @@ export function medianCut(triRGBs, maxColors = 32) { 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 median index. Use floor((s+e)/2) so left bucket has the lower half. - const mid = (s + e) >> 1; + // 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; @@ -164,3 +169,65 @@ function _computeBucketStats(triRGBs, order, s, e, b, bRange, bChan, bSplittable // 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/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'); From 56bf77edc72419c57888a33145160325de5851a2 Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Tue, 28 Apr 2026 08:10:57 -0400 Subject: [PATCH 03/10] refactor: remove manual color paint mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-face color painting added too much UI surface for diminishing returns; users will paint via other tools (slicer, mesh editor) if they need that. The killer features — gradient and color-image auto-coloring — are what we keep. Removed: - js/colorPaint.js (entire module) - js/main.js: paintedFaceColors Map state, colorPaintActive flag, colorPaintEraseMode flag, _colorPaintHandlers, paint dispatch branches in canvas mousedown/mousemove/mouseup, paint controls in wireColorExportUI (renamed from wireColorPaintUI), colorPaintActiveColor setting + persistence + snapshot restoration - js/colorBake.js: paintedFaceColors / excludedFaces / selectionMode parameters; the manual-paint precedence rule from Pass 3 (the "havePaint && paintedFaceColors.has(origFace)" branch); _packedToRGB helper now unused - index.html: paint mode toggle, paint color picker, clear-paint button, paint hint paragraph - style.css: .color-paint-row + #color-paint-toggle styles - js/i18n/{en,de,fr,it,es,pt,ja,ko}.js: 4 keys per locale — color.paintMode, color.paintColor, color.paintHint, color.paintClear - _collectCurrentMask coloredFaces serialization - _restoreMask coloredFaces restoration (older .bumpmesh files containing coloredFaces are silently ignored) - _undoSnapshotsEqual coloredFaces comparison Backward compat: - .bumpmesh files saved with paint state still load — the unused coloredFaces field is silently dropped at restore time - 3MF export pipeline unchanged; the bake just no longer applies per-face overrides on top of the auto source Net: -492 LOC including the deleted module. Verification: - node --check on all modified JS modules: OK - test-color-quantize.mjs: 4/4 PASS - test-no-downward-z.mjs: PASS Co-Authored-By: Claude Opus 4.7 (1M context) --- index.html | 16 --- js/colorBake.js | 20 +--- js/colorPaint.js | 251 ----------------------------------------------- js/i18n/de.js | 4 - js/i18n/en.js | 4 - js/i18n/es.js | 4 - js/i18n/fr.js | 4 - js/i18n/it.js | 4 - js/i18n/ja.js | 4 - js/i18n/ko.js | 4 - js/i18n/pt.js | 4 - js/main.js | 212 ++++++--------------------------------- style.css | 27 ----- 13 files changed, 33 insertions(+), 525 deletions(-) delete mode 100644 js/colorPaint.js diff --git a/index.html b/index.html index ff4da51..df55157 100644 --- a/index.html +++ b/index.html @@ -570,22 +570,6 @@

Auto color so - - -

Paint color

-
- - - -
-

Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.

diff --git a/js/colorBake.js b/js/colorBake.js index 538da54..fb5161d 100644 --- a/js/colorBake.js +++ b/js/colorBake.js @@ -4,7 +4,6 @@ * Writes a Float32×3 `color` BufferAttribute onto the displaced (post-subdivision, * pre-decimation) geometry. Composition order per vertex: * excludeWeight ≥ 0.99 → settings.colorBaseColor - * manual paint override hit → paintedFaceColors[origFace] (unpacked 0xRRGGBB) * autoSource === 'gradient' → sample displacementImageData (same UV pipeline as * displacement.js Pass 2), look up grey in * settings.colorGradientStops @@ -26,7 +25,6 @@ export function applyColors( displacementImageData, dispW, dispH, colorImageData, colorW, colorH, settings, bounds, - paintedFaceColors, excludedFaces, selectionMode, ) { if (!geometry || !geometry.attributes || !geometry.attributes.position) return geometry; const posAttr = geometry.attributes.position; @@ -39,7 +37,6 @@ export function applyColors( const autoSource = settings.colorAutoSource || 'none'; const haveGradient = autoSource === 'gradient' && displacementImageData && displacementImageData.data; const haveImage = autoSource === 'image' && colorImageData && colorImageData.data; - const havePaint = paintedFaceColors && paintedFaceColors.size > 0; // Pre-sort gradient stops once (defensive — UI may emit unsorted). let gradientStops = null; @@ -285,16 +282,11 @@ export function applyColors( const out = new Float32Array(count * 3); for (let i = 0; i < count; i++) { const subdivFaceIdx = (i / 3) | 0; - const origFace = faceParentId ? faceParentId[subdivFaceIdx] : -1; - // Excluded faces always get base color regardless of paint or auto source. + // 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 (havePaint && origFace >= 0 && paintedFaceColors.has(origFace)) { - const packed = paintedFaceColors.get(origFace); - const rgb = _packedToRGB(packed); - r = rgb[0]; g = rgb[1]; b = rgb[2]; } else if (useAuto) { const vid = vertexId[i]; r = autoR[vid]; g = autoG[vid]; b = autoB[vid]; @@ -428,16 +420,6 @@ function _parseHex(hex) { ]; } -/** Unpack a 0xRRGGBB int → [r, g, b] in 0..1. */ -function _packedToRGB(packed) { - const n = packed | 0; - 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 diff --git a/js/colorPaint.js b/js/colorPaint.js deleted file mode 100644 index 0410acf..0000000 --- a/js/colorPaint.js +++ /dev/null @@ -1,251 +0,0 @@ -/** - * colorPaint.js — manual color paint mode (Unit D). - * - * Exports `setColorPaintHandlers(hooks)` which receives the orchestrator's - * internal callbacks and returns pointer-handlers that the canvas - * mousedown/mousemove/mouseup branches in main.js delegate to when - * colorPaintActive is on. - * - * Hooks shape (provided by main.js wireColorPaintUI()): - * pickTriangle, bfsBrushSelect, bucketFill, getCamera, getCurrentMesh, - * getCurrentGeometry, getTriangleAdjacency, getPaintedFaceColors, - * getActiveColor, getBrushIsRadius, getBrushRadius, getEraseMode, - * refreshOverlay, scheduleUndo, flushUndo, getControls, _viewDirFor, - * raycaster, _canvasNDC, getFrontFaceHit - * - * Returned handlers shape: - * { startPaint(e) → bool, paintAt(e) → void, endPaint() → void, - * isPainting() → bool } - * - * Live preview is deferred (per the plan); painted faces are visible only - * via the existing exclusion overlay refresh — they tint orange rather than - * with their actual color until the per-vertex color attribute lands. We - * still emit `refreshOverlay()` after every paint so the user sees *some* - * feedback that the click registered. - */ - -import * as THREE from 'three'; - -export function setColorPaintHandlers(hooks) { - // Defensive defaults so a missing hook doesn't crash the canvas pipeline. - const h = hooks || {}; - const noop = () => {}; - const pickTriangle = typeof h.pickTriangle === 'function' ? h.pickTriangle : (() => -1); - const bfsBrushSelect = typeof h.bfsBrushSelect === 'function' ? h.bfsBrushSelect : noop; - const getCamera = typeof h.getCamera === 'function' ? h.getCamera : (() => null); - const getCurrentMesh = typeof h.getCurrentMesh === 'function' ? h.getCurrentMesh : (() => null); - const getPaintedFaceColors = typeof h.getPaintedFaceColors === 'function' ? h.getPaintedFaceColors : (() => null); - const getActiveColor = typeof h.getActiveColor === 'function' ? h.getActiveColor : (() => 0xcccccc); - const getBrushIsRadius = typeof h.getBrushIsRadius === 'function' ? h.getBrushIsRadius : (() => false); - const getBrushRadius = typeof h.getBrushRadius === 'function' ? h.getBrushRadius : (() => 1); - const getEraseMode = typeof h.getEraseMode === 'function' ? h.getEraseMode : (() => false); - const refreshOverlay = typeof h.refreshOverlay === 'function' ? h.refreshOverlay : noop; - const scheduleUndo = typeof h.scheduleUndo === 'function' ? h.scheduleUndo : noop; - const flushUndo = typeof h.flushUndo === 'function' ? h.flushUndo : noop; - const getControls = typeof h.getControls === 'function' ? h.getControls : (() => null); - const _viewDirFor = typeof h._viewDirFor === 'function' ? h._viewDirFor : (() => new THREE.Vector3(0, 0, -1)); - const _canvasNDC = typeof h._canvasNDC === 'function' ? h._canvasNDC : (() => new THREE.Vector2(0, 0)); - const getFrontFaceHit = typeof h.getFrontFaceHit === 'function' ? h.getFrontFaceHit : ((hits) => (hits && hits[0]) || null); - const raycaster = h.raycaster instanceof THREE.Raycaster ? h.raycaster : new THREE.Raycaster(); - - let _painting = false; - let _lastPaintHitPoint = null; // THREE.Vector3 - let _disabledControls = false; // tracks whether we toggled OrbitControls. - - // ─── Helpers ──────────────────────────────────────────────────────────── - - /** - * Map a THREE raycast hit (which may target a preview/precision mesh) back - * to an original face index by going through pickTriangle's logic. We can't - * reuse pickTriangle directly because we already have the hit; instead, we - * synthesize a fake event at the hit's screen position. Cheaper path: trust - * pickTriangle when called on the original event — but for shift-line - * sampling, we lack an event. So we re-raycast from screen-projected hit - * points. This is the same trick exclusion's _paintLineBetween uses. - */ - function _raycastAtScreen(ndcVec2, mesh) { - const cam = getCamera(); - if (!cam || !mesh) return null; - raycaster.setFromCamera(ndcVec2, cam); - const hits = raycaster.intersectObject(mesh); - return getFrontFaceHit(hits, mesh); - } - - /** Apply a color or erase to a single original face index. */ - function _applyToFace(origFaceIdx) { - const map = getPaintedFaceColors(); - if (!map || origFaceIdx < 0) return; - if (getEraseMode()) { - map.delete(origFaceIdx); - } else { - map.set(origFaceIdx, getActiveColor() | 0); - } - } - - /** - * Paint a hit. If brush is in radius mode, walks the BFS brush from the - * seed face. Otherwise paints just the picked triangle. - * - * `seedTriIdx` is the raw mesh face index returned by the raycaster. We do - * NOT remap it before passing to bfsBrushSelect — bfsBrushSelect itself - * decides whether to use precision/preview adjacency. The callback we - * provide receives whatever face-space the BFS walks; we then remap each - * walked face to its original index via the same dispPreview/precision - * logic used elsewhere. The simplest correct approach is to reuse the - * orchestrator's pickTriangle for single-tri remap, and trust that - * bfsBrushSelect's adjacency walks the same space we'll consume. - * - * In practice, for v1 the painted-face map is keyed on whatever face index - * the existing exclusion paint uses (original indices in the simple case; - * subdivided indices when precision is on). The orchestrator's color-bake - * pipeline reads paintedFaceColors against `faceParentId`, so we must - * store ORIGINAL indices. Since we don't have a precision→original remap - * here without re-implementing it, we route every face through - * pickTriangle-style logic by using the seed index as-is when - * !precision/preview, and otherwise fall back to single-triangle paint. - * Unit A's color-bake pseudo-code assumes original indices, so this - * matches. - */ - function _paintHit(hit, mesh, originalEvent) { - if (!hit || !mesh) return; - // Determine the original face index for the seed (single-triangle case). - const origSeed = originalEvent - ? pickTriangle(originalEvent) - : _hitToOriginalIndex(hit, mesh); - - if (origSeed < 0) return; - - if (getBrushIsRadius()) { - const r = getBrushRadius(); - const r2 = r * r; - const viewDir = _viewDirFor(hit.point); - // bfsBrushSelect callback receives whatever face-space adjacency uses. - // For simple meshes this matches the original-index space we want. - // When precision/preview is active, the faces are not directly original - // indices; we forward them as-is and accept the same minor mismatch - // exclusion paint already has — bfsBrushSelect's seed parameter expects - // an index in adjacency-space, and `hit.faceIndex` is in mesh-space, so - // we use that directly here. The orchestrator's exclusion paint uses - // exactly this construction (see js/main.js _paintSingleHit). - bfsBrushSelect(hit.faceIndex, hit.point, r2, viewDir, (t) => { - // `t` is in adjacency-space; for non-precision/non-preview meshes, - // it equals the original face index. - _applyToFace(t); - }); - } else { - _applyToFace(origSeed); - } - } - - /** - * Best-effort hit→original-face mapping when we don't have an event. - * For shift-line sampling the orchestrator can't pickTriangle without an - * event, so we approximate by projecting the hit point back to screen, - * synthesizing a CSS-pixel event, and calling pickTriangle. If that fails, - * fall back to the raw hit.faceIndex (fine for simple meshes). - */ - function _hitToOriginalIndex(hit, mesh) { - const cam = getCamera(); - if (!cam) return hit.faceIndex; - try { - const projected = hit.point.clone().project(cam); - // Synthesize an event-like object with NDC coords readable by callers. - // pickTriangle uses _canvasNDC(e) → we can't easily reverse that - // without DOM. Simplest correct path: just use hit.faceIndex; the - // shift-line sampling case is rare and the result is close enough. - return hit.faceIndex; - } catch { - return hit.faceIndex; - } - } - - /** Sample points along the line between two world-space points. */ - function _paintLineBetween(from, to, mesh) { - const cam = getCamera(); - if (!cam) return; - const dist = from.distanceTo(to); - const r = getBrushIsRadius() ? Math.max(getBrushRadius() * 0.5, 0.1) : 0.5; - const steps = Math.max(Math.ceil(dist / r), 1); - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const pt = new THREE.Vector3().lerpVectors(from, to, t); - const ndc = pt.clone().project(cam); - const hit = _raycastAtScreen(new THREE.Vector2(ndc.x, ndc.y), mesh); - if (hit) _paintHit(hit, mesh, null); - } - } - - // ─── Public handlers ──────────────────────────────────────────────────── - - function startPaint(event) { - const mesh = getCurrentMesh(); - if (!mesh) return false; - const cam = getCamera(); - if (!cam) return false; - - raycaster.setFromCamera(_canvasNDC(event), cam); - const hits = raycaster.intersectObject(mesh); - const hit = getFrontFaceHit(hits, mesh); - if (!hit) return false; - - _painting = true; - - // Disable orbit controls so drags don't rotate the camera. - const ctrls = getControls(); - if (ctrls && 'enabled' in ctrls) { - _disabledControls = ctrls.enabled !== false; - ctrls.enabled = false; - } else { - _disabledControls = false; - } - - // Open the undo coalescing window for this stroke. - try { scheduleUndo(); } catch { /* hook may be a no-op */ } - - _paintHit(hit, mesh, event); - _lastPaintHitPoint = hit.point.clone(); - refreshOverlay(); - return true; - } - - function paintAt(event) { - if (!_painting) return; - const mesh = getCurrentMesh(); - if (!mesh) return; - const cam = getCamera(); - if (!cam) return; - - raycaster.setFromCamera(_canvasNDC(event), cam); - const hits = raycaster.intersectObject(mesh); - const hit = getFrontFaceHit(hits, mesh); - if (!hit) return; - - if (event && event.ctrlKey && _lastPaintHitPoint) { - _paintLineBetween(_lastPaintHitPoint, hit.point, mesh); - } else { - _paintHit(hit, mesh, event); - } - _lastPaintHitPoint = hit.point.clone(); - refreshOverlay(); - } - - function endPaint() { - if (!_painting) return; - _painting = false; - // Restore orbit controls. - const ctrls = getControls(); - if (ctrls && 'enabled' in ctrls && _disabledControls) { - ctrls.enabled = true; - } - _disabledControls = false; - // Final overlay refresh + flush undo capture. - refreshOverlay(); - try { flushUndo(); } catch { /* hook may be a no-op */ } - } - - function isPainting() { - return _painting; - } - - return { startPaint, paintAt, endPaint, isPainting }; -} diff --git a/js/i18n/de.js b/js/i18n/de.js index f675324..1ca99c8 100644 --- a/js/i18n/de.js +++ b/js/i18n/de.js @@ -218,10 +218,6 @@ export default { "color.imageRemove": "Remove", "color.imageReplace": "Replace…", "color.baseColor": "Base color (untextured / excluded faces)", - "color.paintMode": "Paint color", - "color.paintColor": "Active paint color", - "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", - "color.paintClear": "Clear painted colors", "alerts.colorImageFailed": "Could not load color image: {msg}", "progress.bakingColor": "Baking color…", "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 c1b3413..d8d38c1 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -218,10 +218,6 @@ export default { "color.imageRemove": "Remove", "color.imageReplace": "Replace…", "color.baseColor": "Base color (untextured / excluded faces)", - "color.paintMode": "Paint color", - "color.paintColor": "Active paint color", - "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", - "color.paintClear": "Clear painted colors", "alerts.colorImageFailed": "Could not load color image: {msg}", "progress.bakingColor": "Baking color…", "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 3b6e835..91c474c 100644 --- a/js/i18n/es.js +++ b/js/i18n/es.js @@ -218,10 +218,6 @@ export default { "color.imageRemove": "Remove", "color.imageReplace": "Replace…", "color.baseColor": "Base color (untextured / excluded faces)", - "color.paintMode": "Paint color", - "color.paintColor": "Active paint color", - "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", - "color.paintClear": "Clear painted colors", "alerts.colorImageFailed": "Could not load color image: {msg}", "progress.bakingColor": "Baking color…", "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 fcdb3c3..bd48c4f 100644 --- a/js/i18n/fr.js +++ b/js/i18n/fr.js @@ -218,10 +218,6 @@ export default { "color.imageRemove": "Remove", "color.imageReplace": "Replace…", "color.baseColor": "Base color (untextured / excluded faces)", - "color.paintMode": "Paint color", - "color.paintColor": "Active paint color", - "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", - "color.paintClear": "Clear painted colors", "alerts.colorImageFailed": "Could not load color image: {msg}", "progress.bakingColor": "Baking color…", "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 e4f0953..6c884d8 100644 --- a/js/i18n/it.js +++ b/js/i18n/it.js @@ -218,10 +218,6 @@ export default { "color.imageRemove": "Remove", "color.imageReplace": "Replace…", "color.baseColor": "Base color (untextured / excluded faces)", - "color.paintMode": "Paint color", - "color.paintColor": "Active paint color", - "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", - "color.paintClear": "Clear painted colors", "alerts.colorImageFailed": "Could not load color image: {msg}", "progress.bakingColor": "Baking color…", "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 3ebe7c4..8967f80 100644 --- a/js/i18n/ja.js +++ b/js/i18n/ja.js @@ -218,10 +218,6 @@ export default { "color.imageRemove": "Remove", "color.imageReplace": "Replace…", "color.baseColor": "Base color (untextured / excluded faces)", - "color.paintMode": "Paint color", - "color.paintColor": "Active paint color", - "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", - "color.paintClear": "Clear painted colors", "alerts.colorImageFailed": "Could not load color image: {msg}", "progress.bakingColor": "Baking color…", "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 2771d16..5cb4816 100644 --- a/js/i18n/ko.js +++ b/js/i18n/ko.js @@ -220,10 +220,6 @@ export default { "color.imageRemove": "Remove", "color.imageReplace": "Replace…", "color.baseColor": "Base color (untextured / excluded faces)", - "color.paintMode": "Paint color", - "color.paintColor": "Active paint color", - "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", - "color.paintClear": "Clear painted colors", "alerts.colorImageFailed": "Could not load color image: {msg}", "progress.bakingColor": "Baking color…", "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 4b8d109..7e1f831 100644 --- a/js/i18n/pt.js +++ b/js/i18n/pt.js @@ -218,10 +218,6 @@ export default { "color.imageRemove": "Remove", "color.imageReplace": "Replace…", "color.baseColor": "Base color (untextured / excluded faces)", - "color.paintMode": "Paint color", - "color.paintColor": "Active paint color", - "color.paintHint": "Click & drag to paint. Hold Shift to erase. Click 'Clear' to reset.", - "color.paintClear": "Clear painted colors", "alerts.colorImageFailed": "Could not load color image: {msg}", "progress.bakingColor": "Baking color…", "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 fdadd90..dc997ab 100644 --- a/js/main.js +++ b/js/main.js @@ -17,7 +17,6 @@ import { buildAdjacency, bucketFill, import { applyColors } from './colorBake.js'; import { medianCut } from './quantize.js'; import { GradientEditor } from './gradientEditor.js'; -import { setColorPaintHandlers } from './colorPaint.js'; import { runFastDiagnostics, runExpensiveDiagnostics, getEdgePositions, getShellAssignments } from './meshValidation.js'; import { t, initLang, setLang, getLang, applyTranslations, TRANSLATIONS } from './i18n.js'; @@ -62,15 +61,11 @@ 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 paint state ───────────────────────────────────────────────────────── -// Per-original-face manual color overrides keyed on original face indices so they -// survive subdivision the same way `excludedFaces` does (propagation via faceParentId). -// Packed as 0xRRGGBB to keep the Map lean and JSON serialization trivial. -let paintedFaceColors = new Map(); // Map -let colorPaintActive = false; // user clicked the "color paint" tool -let colorPaintEraseMode = false; // shift held while color paint is active -let _lastColorMap = null; // { name, imageData, width, height, fullCanvas } — uploaded color image (mirrors _lastCustomMap) -let _colorPaintHandlers = null; // { paintAt, paintLineBetween, paintBucket } from colorPaint.js, set by wireColorPaintUI() +// ── 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 _lastEffectiveTexture = null; let _effectiveMapCache = null; @@ -116,7 +111,6 @@ const settings = { { pos: 0, color: '#222222' }, { pos: 1, color: '#dddddd' }, ], - colorPaintActiveColor: '#cccccc', // active swatch for manual color paint }; // ── Canvas filter support (Safari / iOS WebView don't support ctx.filter) ──── @@ -1618,14 +1612,6 @@ function wireEvents() { return; } - // Color paint takes priority over exclusion painting when active. - // startPaint returns true iff the click hit the mesh and a stroke has begun. - if (colorPaintActive && _colorPaintHandlers && _colorPaintHandlers.startPaint(e)) { - e.preventDefault(); - getControls().enabled = false; - return; - } - if (!exclusionTool) return; // Block painting while precision mesh is being built @@ -1673,12 +1659,6 @@ function wireEvents() { let _hoverRafId = 0; canvas.addEventListener('mousemove', (e) => { - // Color paint stroke continues — fire immediately, no RAF batching, so - // the painted region tracks the cursor without gaps on fast drags. - if (colorPaintActive && _colorPaintHandlers && _colorPaintHandlers.isPainting()) { - _colorPaintHandlers.paintAt(e); - return; - } // Paint-Events sofort verarbeiten (jeder Event zaehlt fuer lueckenloses Malen) if (isPainting && exclusionTool === 'brush') { paintAt(e); @@ -1720,14 +1700,6 @@ function wireEvents() { }); document.addEventListener('mouseup', () => { - // Color paint stroke termination, if any. - if (colorPaintActive && _colorPaintHandlers && _colorPaintHandlers.isPainting()) { - _colorPaintHandlers.endPaint(); - getControls().enabled = true; - _flushUndoCapture(); - _commitUndoCapture(); - return; - } if (!isPainting) return; isPainting = false; getControls().enabled = true; @@ -1753,53 +1725,18 @@ function wireEvents() { if (e.key === 'Control') _clearShiftLinePreview(); }); - // ── Color paint + gradient editor wiring ───────────────────────────────── - // wireColorPaintUI is defined below; it pulls in the colorPaint factory and - // mounts the GradientEditor on its placeholder. Safe no-op when those DOM - // elements aren't present yet (e.g. during the staged Unit-D rollout). - wireColorPaintUI(); + // ── 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 paint UI wiring ──────────────────────────────────────────────────── -// Owns: the integration glue between Unit D's colorPaint.js / gradientEditor.js -// and the rest of the app. Called once from wireEvents(). -// -// Defensive against missing DOM nodes — color UI may not yet be present in -// index.html mid-rollout. Each lookup uses optional chaining / null guards. -function wireColorPaintUI() { - // 1) Install color paint pointer handlers if the colorPaint module is wired up. - // colorPaint.js exports a factory that takes our internal hooks and returns - // pointer handlers; the existing canvas mousedown/mousemove/mouseup branches - // in wireEvents() above check `_colorPaintHandlers` before delegating. - try { - _colorPaintHandlers = setColorPaintHandlers({ - pickTriangle, - bfsBrushSelect, - bucketFill, - getCamera, - getCurrentMesh, - getCurrentGeometry: () => currentGeometry, - getTriangleAdjacency: () => triangleAdjacency, - getPaintedFaceColors: () => paintedFaceColors, - getActiveColor: () => parseInt((settings.colorPaintActiveColor || '#cccccc').slice(1), 16), - getBrushIsRadius: () => brushIsRadius, - getBrushRadius: () => brushRadius, - getEraseMode: () => colorPaintEraseMode || eraseMode, - refreshOverlay: () => refreshExclusionOverlay(), - scheduleUndo: _scheduleUndoCapture, - flushUndo: _flushUndoCapture, - getControls, - _viewDirFor, - raycaster: _raycaster, - _canvasNDC, - getFrontFaceHit, - }); - } catch (err) { - console.warn('Color paint module not available:', err); - _colorPaintHandlers = null; - } - - // 2) Mount the gradient editor on its placeholder div. +// ── 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 { @@ -1810,7 +1747,6 @@ function wireColorPaintUI() { // Deep-copy on assign so settings never aliases the editor's internal array. settings.colorGradientStops = stops.map(s => ({ pos: s.pos, color: s.color })); _scheduleUndoCapture(); - // Trigger autosave + (when implemented) preview refresh. const sp = document.getElementById('settings-panel'); if (sp) sp.dispatchEvent(new Event('change', { bubbles: true })); }); @@ -1820,7 +1756,7 @@ function wireColorPaintUI() { } } - // 3) Master enable toggle. + // 2) Master enable toggle. const enableEl = document.getElementById('color-export-toggle'); if (enableEl) { enableEl.checked = !!settings.colorExportEnabled; @@ -1829,7 +1765,7 @@ function wireColorPaintUI() { }); } - // 4) Auto-source radio (None / Gradient / Image). + // 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', () => { @@ -1837,51 +1773,14 @@ function wireColorPaintUI() { }); }); - // 5) Base color picker. + // 4) Base color picker. const baseEl = document.getElementById('color-base-picker'); if (baseEl) { baseEl.value = settings.colorBaseColor; baseEl.addEventListener('change', () => { settings.colorBaseColor = baseEl.value; }); } - // 6) Active paint color picker. - const paintEl = document.getElementById('color-paint-picker'); - if (paintEl) { - paintEl.value = settings.colorPaintActiveColor; - paintEl.addEventListener('change', (ev) => { - settings.colorPaintActiveColor = paintEl.value; - // Avoid stomping an in-flight stroke's undo coalescing. - if (_colorPaintHandlers && _colorPaintHandlers.isPainting()) { - ev.stopPropagation(); - } - }); - } - - // 7) Color paint mode toggle. - const paintToggle = document.getElementById('color-paint-toggle'); - if (paintToggle) { - paintToggle.addEventListener('click', () => { - colorPaintActive = !colorPaintActive; - paintToggle.classList.toggle('active', colorPaintActive); - paintToggle.setAttribute('aria-pressed', String(colorPaintActive)); - // Mutual exclusion with exclusion paint, place-on-face, and precision - // masking. Precision is incompatible because the colorBake pipeline keys - // paintedFaceColors on ORIGINAL face indices (via faceParentId), while - // precision paint records subdivided indices. Deactivating precision - // collapses any precision-painted state back to original indices via the - // existing _restoreMask path, keeping the two paint systems in lockstep. - if (colorPaintActive) { - if (exclusionTool) setExclusionTool(null); - if (placeOnFaceActive) togglePlaceOnFace(false); - if (precisionMaskingEnabled) deactivatePrecisionMasking(); - canvas.style.cursor = brushIsRadius ? 'none' : 'crosshair'; - } else { - canvas.style.cursor = ''; - } - }); - } - - // 8) Color image upload (mirrors customMapSwatch / texture pattern). + // 5) Color image upload. const colorFileInput = document.getElementById('color-image-input'); if (colorFileInput) { colorFileInput.addEventListener('change', async (ev) => { @@ -1912,7 +1811,7 @@ function wireColorPaintUI() { }); } - // 9) Color image remove button. + // 6) Color image remove button. const colorRemoveBtn = document.getElementById('color-image-remove'); if (colorRemoveBtn) { colorRemoveBtn.addEventListener('click', () => { @@ -1922,17 +1821,7 @@ function wireColorPaintUI() { }); } - // 10) Clear painted-color faces button. - const clearPaintBtn = document.getElementById('color-paint-clear'); - if (clearPaintBtn) { - clearPaintBtn.addEventListener('click', () => { - paintedFaceColors = new Map(); - refreshExclusionOverlay(); - _scheduleUndoCapture(); - }); - } - - // 11) Color image thumbnail / label refresh, used by upload, remove, reset, + // 7) Color image thumbnail / label refresh, used by upload, remove, reset, // and .bumpmesh import paths. window._refreshColorImageUI = refreshColorImageUI; refreshColorImageUI(); @@ -4472,7 +4361,7 @@ async function handleExport(format = 'stl') { _lastColorMap && _lastColorMap.imageData ? _lastColorMap.imageData : null, _lastColorMap ? _lastColorMap.width : 0, _lastColorMap ? _lastColorMap.height : 0, - settings, currentBounds, paintedFaceColors, excludedFaces, selectionMode); + settings, currentBounds); } catch (err) { console.warn('Color bake failed; exporting without color:', err); } @@ -4933,11 +4822,11 @@ const PERSISTED_KEYS = [ // null means "fall back to AABB defaults", which is what fresh loads get. 'snapSeamlessWrap', 'cylinderCenterX', 'cylinderCenterY', 'cylinderRadius', 'cylinderPanelMinimized', - // Color export. colorImageDataURL is intentionally NOT here — color images are - // shipped as a separate `color.png` zip entry in .bumpmesh and never touch - // sessionStorage (would blow the 5MB quota). _lastColorMap holds the runtime cache. + // 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', - 'colorPaintActiveColor', ]; function getSettingsSnapshot() { @@ -5051,10 +4940,8 @@ function applySettingsSnapshot(snap) { updateCylinderUIVisibility(); // ── Color export settings ───────────────────────────────────────────────── - // Color UI controls are wired by Unit D (gradientEditor + the color section - // in index.html). When those land, dispatching change events on each control - // pulls them through the standard auto-save / undo / preview flow. Until - // wireColorPaintUI() runs (Step 5 integration), missing DOM elements are no-op. + // 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'); @@ -5077,11 +4964,6 @@ function applySettingsSnapshot(snap) { window._gradientEditor.setStops(settings.colorGradientStops); } } - if ('colorPaintActiveColor' in snap && typeof snap.colorPaintActiveColor === 'string') { - settings.colorPaintActiveColor = snap.colorPaintActiveColor; - const el = document.getElementById('color-paint-picker'); - if (el) { el.value = settings.colorPaintActiveColor; el.dispatchEvent(new Event('change', { bubbles: true })); } - } } /** @@ -5161,7 +5043,6 @@ const DEFAULT_SETTINGS_SNAPSHOT = Object.freeze({ { pos: 0, color: '#222222' }, { pos: 1, color: '#dddddd' }, ], - colorPaintActiveColor: '#cccccc', activeMapName: DEFAULT_PRESET_NAME, }); @@ -5196,7 +5077,6 @@ function resetSettingsToDefaults() { if (selectionMode) setSelectionMode(false); excludedFaces = new Set(); precisionExcludedFaces = new Set(); - paintedFaceColors = new Map(); _lastColorMap = null; refreshColorImageUI(); if (currentGeometry) refreshExclusionOverlay(); @@ -5342,17 +5222,8 @@ function _collectCurrentMask() { } else { liveExcluded = excludedFaces; } - // Manual color paint overrides — packed RGB per original face index. - // Serialize as a compact array of [idx, '#RRGGBB'] pairs (small overhead vs Map.toJSON). - const coloredFaces = paintedFaceColors.size > 0 - ? Array.from(paintedFaceColors.entries(), ([idx, packed]) => - [idx, '#' + packed.toString(16).padStart(6, '0')]) - : null; - // Empty exclude-mode + empty paint = nothing meaningful to save. - if (liveExcluded.size === 0 && !selectionMode && !coloredFaces) return null; - const out = { selectionMode, excluded: [...liveExcluded] }; - if (coloredFaces) out.coloredFaces = coloredFaces; - return out; + if (liveExcluded.size === 0 && !selectionMode) return null; + return { selectionMode, excluded: [...liveExcluded] }; } /** @@ -5370,12 +5241,10 @@ function _restoreMask(mask) { if (selectionMode) setSelectionMode(false); // also clears the face sets excludedFaces = new Set(); precisionExcludedFaces = new Set(); - paintedFaceColors = new Map(); refreshExclusionOverlay(); 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); @@ -5383,18 +5252,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 - - // Restore manual color paint overrides. Each entry is [origFaceIdx, '#RRGGBB']. - paintedFaceColors = new Map(); - if (Array.isArray(mask.coloredFaces)) { - for (const entry of mask.coloredFaces) { - if (!Array.isArray(entry) || entry.length !== 2) continue; - const [idx, hex] = entry; - if (!Number.isInteger(idx) || idx < 0 || idx >= triCount) continue; - if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) continue; - paintedFaceColors.set(idx, parseInt(hex.slice(1), 16)); - } - } + // Older .bumpmesh files (pre-paint-removal) may include `mask.coloredFaces`; + // we silently ignore it. The data isn't useful without the paint UI. refreshExclusionOverlay(); } @@ -5559,13 +5418,6 @@ function _undoSnapshotsEqual(a, b) { if (ma.excluded.length !== mb.excluded.length) return false; const sb = new Set(mb.excluded); for (const v of ma.excluded) if (!sb.has(v)) return false; - // Compare manual color paint overrides. - const ca = ma.coloredFaces || null, cb = mb.coloredFaces || null; - if (!ca && !cb) return true; - if (!ca || !cb) return false; - if (ca.length !== cb.length) return false; - const cbm = new Map(cb); - for (const [idx, hex] of ca) if (cbm.get(idx) !== hex) return false; return true; } diff --git a/style.css b/style.css index 3adafd7..bbfff47 100644 --- a/style.css +++ b/style.css @@ -1857,33 +1857,6 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } flex-shrink: 0; } -#color-section .color-paint-row { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 6px; -} - -#color-section .color-paint-row input[type="color"] { - width: 32px; - height: 28px; - border: 1px solid var(--border); - border-radius: 4px; - background: transparent; - cursor: pointer; - padding: 0; - flex-shrink: 0; -} - -#color-section #color-paint-toggle { - flex: 0 0 auto; -} - -#color-section #color-paint-toggle.active { - border-color: var(--accent); - color: var(--accent); -} - /* ── Gradient editor widget ──────────────────────────────────────────── */ .gradient-editor { width: 100%; From 0a4bc713f0aa086b75f7aa789516169febd9e535 Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Tue, 28 Apr 2026 08:14:55 -0400 Subject: [PATCH 04/10] feat: cap exported 3MF colors via palette-size selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Export colors" dropdown in the color section. Bambu Studio (and peers) treats the 3MF colorgroup as filament slots; with the previous hard-coded 32-color palette, importing into Bambu lit up 32 slots even when the user only wanted 4. Now the user picks 2 / 3 / 4 / 6 / 8 / 16 / 32, default 4 (typical AMS slot count). Wired through: - settings.colorPaletteSize (new key, defaults to 4) - PERSISTED_KEYS, DEFAULT_SETTINGS_SNAPSHOT, applySettingsSnapshot (round-trips through .bumpmesh + sessionStorage + undo/redo) - wireColorExportUI binds the + + + + + + + + + +

Auto color source

diff --git a/js/i18n/de.js b/js/i18n/de.js index 1ca99c8..46e13d6 100644 --- a/js/i18n/de.js +++ b/js/i18n/de.js @@ -220,5 +220,7 @@ export default { "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.", "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 d8d38c1..b7f6c7b 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -220,5 +220,7 @@ export default { "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.", "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 91c474c..f660f37 100644 --- a/js/i18n/es.js +++ b/js/i18n/es.js @@ -220,5 +220,7 @@ export default { "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.", "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 bd48c4f..9639ea6 100644 --- a/js/i18n/fr.js +++ b/js/i18n/fr.js @@ -220,5 +220,7 @@ export default { "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.", "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 6c884d8..24dd4c6 100644 --- a/js/i18n/it.js +++ b/js/i18n/it.js @@ -220,5 +220,7 @@ export default { "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.", "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 8967f80..219768e 100644 --- a/js/i18n/ja.js +++ b/js/i18n/ja.js @@ -220,5 +220,7 @@ export default { "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.", "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 5cb4816..b8a7bc1 100644 --- a/js/i18n/ko.js +++ b/js/i18n/ko.js @@ -222,5 +222,7 @@ export default { "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.", "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 7e1f831..7033b0a 100644 --- a/js/i18n/pt.js +++ b/js/i18n/pt.js @@ -220,5 +220,7 @@ export default { "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.", "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 dc997ab..19a8921 100644 --- a/js/main.js +++ b/js/main.js @@ -105,6 +105,10 @@ const settings = { 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. + colorPaletteSize: 4, // N-stop gradient: array of { pos: 0..1, color: '#RRGGBB' } sorted by pos. // Two stops minimum, enforced by the gradient editor widget. colorGradientStops: [ @@ -1780,6 +1784,17 @@ function wireColorExportUI() { baseEl.addEventListener('change', () => { settings.colorBaseColor = baseEl.value; }); } + // 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) { @@ -4453,7 +4468,8 @@ async function handleExport(format = 'stl') { triRGB[i * 3 + 2] = Math.round(Math.max(0, Math.min(1, b)) * 255); } try { - const { palette, indices } = medianCut(triRGB, 32); + const paletteCap = Math.max(2, Math.min(32, +settings.colorPaletteSize || 4)); + const { palette, indices } = medianCut(triRGB, paletteCap); exportOpts = { palette, triPaletteIndices: indices }; } catch (err) { console.warn('Quantization failed; exporting without color:', err); @@ -4827,6 +4843,7 @@ const PERSISTED_KEYS = [ // sessionStorage (would blow the 5MB quota). `_lastColorMap` holds the // runtime cache. 'colorExportEnabled', 'colorAutoSource', 'colorBaseColor', 'colorGradientStops', + 'colorPaletteSize', ]; function getSettingsSnapshot() { @@ -4964,6 +4981,14 @@ function applySettingsSnapshot(snap) { 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 })); } + } + } } /** @@ -5043,6 +5068,7 @@ const DEFAULT_SETTINGS_SNAPSHOT = Object.freeze({ { pos: 0, color: '#222222' }, { pos: 1, color: '#dddddd' }, ], + colorPaletteSize: 4, activeMapName: DEFAULT_PRESET_NAME, }); From f6e31ca736ed6bfcf1da6543b0d32ceb788e5cf6 Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Tue, 28 Apr 2026 08:22:59 -0400 Subject: [PATCH 05/10] feat: live GPU preview of gradient + color-image tinting The preview material now tints the live displacement preview with the chosen colors when the master toggle is on. Pure GPU path: gradient becomes a 256x1 LUT canvas texture, color image becomes a CanvasTexture, and a parallel sampleColorMap / computeColorAtPoint pair mirrors the displacement UV pipeline so on-screen colors line up with the export's texels. Pipeline (GLSL): - Six new uniforms: colorPreviewEnabled, colorAutoSource (0/1/2), colorBaseRGB, colorGradientLUT, colorImage, hasColorImage - sampleColorMap mirrors sampleMap (same scale/offset/rotation/aspect), reads colorImage instead of displacementMap - computeColorAtPoint mirrors computeHeightAtPoint exactly across all 7 mapping modes, returning RGB instead of greyscale - resolveSurfaceColor(rawGrey) chooses gradient lookup, image sample, or base color based on autoSource; gates everything behind colorPreviewEnabled so the historical teal preview is unchanged when the feature is off - tealBase replaced with surfaceBase (the resolved color); existing lighting + bump + mask blend logic untouched Pipeline (JS): - main.js builds a 256x1 gradient LUT canvas via Canvas2D's createLinearGradient (which already handles N-stop interpolation correctly) and wraps it in a CanvasTexture; rebuilds whenever stops change - Color image upload creates a CanvasTexture wrapping the existing fullCanvas; reused / disposed correctly on replace and remove - _pushColorPreviewState() syncs all 6 uniforms in one call; invoked from every settings-change handler (toggle, source radio, base picker, gradient stops, color image upload/remove) plus once during initial wireColorExportUI bootstrap and after each createPreviewMaterial recreation (model load + 3D-preview toggle) - setColorPreview exported from previewMaterial.js as the public update entrypoint Verified visually in browser: - 2-stop wood gradient (#3a1f0e -> #f4d99e) shows wood-tone tint on the cube with darker valleys / lighter peaks aligned with displacement - 4-stop rainbow gradient (blue->green->yellow->red) updates the preview instantly when stops change - Toggle OFF reverts to the historical teal preview, byte-equivalent to behavior before this commit Co-Authored-By: Claude Opus 4.7 (1M context) --- js/main.js | 95 +++++++++++++++++++- js/previewMaterial.js | 204 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 289 insertions(+), 10 deletions(-) diff --git a/js/main.js b/js/main.js index 19a8921..e9bd52c 100644 --- a/js/main.js +++ b/js/main.js @@ -7,7 +7,7 @@ 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'; @@ -66,6 +66,9 @@ let _shiftLineMesh = null; // THREE.Line — preview line from last p // 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; @@ -1750,6 +1753,7 @@ function wireColorExportUI() { 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 })); + _pushColorPreviewState(); _scheduleUndoCapture(); const sp = document.getElementById('settings-panel'); if (sp) sp.dispatchEvent(new Event('change', { bubbles: true })); @@ -1766,6 +1770,7 @@ function wireColorExportUI() { enableEl.checked = !!settings.colorExportEnabled; enableEl.addEventListener('change', () => { settings.colorExportEnabled = !!enableEl.checked; + _pushColorPreviewState(); }); } @@ -1773,7 +1778,10 @@ function wireColorExportUI() { 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; + if (el.checked) { + settings.colorAutoSource = el.value; + _pushColorPreviewState(); + } }); }); @@ -1781,7 +1789,10 @@ function wireColorExportUI() { const baseEl = document.getElementById('color-base-picker'); if (baseEl) { baseEl.value = settings.colorBaseColor; - baseEl.addEventListener('change', () => { settings.colorBaseColor = baseEl.value; }); + baseEl.addEventListener('change', () => { + settings.colorBaseColor = baseEl.value; + _pushColorPreviewState(); + }); } // 4b) Palette size selector — controls how many distinct colors land in the @@ -1818,6 +1829,7 @@ function wireColorExportUI() { }; bmp.close && bmp.close(); if (typeof window._refreshColorImageUI === 'function') window._refreshColorImageUI(); + _pushColorPreviewState(); _scheduleUndoCapture(); } catch (err) { console.error('Color image load failed:', err); @@ -1832,6 +1844,7 @@ function wireColorExportUI() { colorRemoveBtn.addEventListener('click', () => { _lastColorMap = null; if (typeof window._refreshColorImageUI === 'function') window._refreshColorImageUI(); + _pushColorPreviewState(); _scheduleUndoCapture(); }); } @@ -1840,6 +1853,80 @@ function wireColorExportUI() { // and .bumpmesh import paths. window._refreshColorImageUI = refreshColorImageUI; refreshColorImageUI(); + // Initial uniform sync — runs once after the gradient editor mounts and + // settings are populated. Subsequent changes flow through the listeners above. + _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' }]; + 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.minFilter = THREE.LinearFilter; + _gradientLUTTexture.magFilter = THREE.LinearFilter; + _gradientLUTTexture.wrapS = _gradientLUTTexture.wrapT = THREE.ClampToEdgeWrapping; + } else { + _gradientLUTTexture.needsUpdate = true; + } + 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]; +} + +// 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(), + }); + requestRender(); } function refreshColorImageUI() { @@ -3829,6 +3916,7 @@ function updatePreview() { if (!previewMaterial) { previewMaterial = createPreviewMaterial(effectiveEntry.texture, fullSettings); loadGeometry(activeGeo, previewMaterial); + _pushColorPreviewState(); } else { updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings); } @@ -4240,6 +4328,7 @@ async function toggleDisplacementPreview(enable) { previewMaterial = createPreviewMaterial(getEffectiveMapEntry().texture, fullSettings); setMeshGeometry(dispPreviewGeometry); setMeshMaterial(previewMaterial); + _pushColorPreviewState(); } catch (err) { diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 7a79595..e3cb94d 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -43,6 +43,19 @@ 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 + const float PI = 3.14159265358979; const float TWO_PI = 6.28318530717959; const float CUBIC_AXIS_EPSILON = 1e-4; @@ -99,6 +112,18 @@ const sharedGLSL = /* glsl */` return texture2D(displacementMap, uv).r; } + // Same UV pipeline as sampleMap but reads the user's color image. Used by + // computeColorAtPoint when colorAutoSource == 2 so colors line up with the + // displacement texels exactly. + vec3 sampleColorMap(vec2 rawUV) { + vec2 uv = (rawUV * textureAspect) / 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 +228,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 +419,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 +509,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 +529,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 +640,51 @@ 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) }, }; } +/** + * 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 + */ +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; + } else { + u.hasColorImage.value = 0; + } +} + function createFallbackTexture() { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 4; From c09b14a7237757d940d49283e413f45da180f2b6 Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Tue, 28 Apr 2026 08:47:01 -0400 Subject: [PATCH 06/10] feat: snap-to-control-points export + lockable gradient stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces median-cut quantization for gradient mode with a direct snap to the user's gradient stops + base color. The exported palette is exactly the colors the user picked — no statistical averaging, no surprise hex codes. AMS slot assignment in slicers is now predictable: stop-count + maybe-1 (base, deduplicated when locked) entries. Image mode keeps median-cut at the user-chosen palette size; that dropdown is now hidden in gradient and none modes via the existing data-source CSS pattern. Per-stop "lock to base" (the requested option): - Alt-click any gradient stop handle → toggles lockedToBase. Locked stops display with an inset white ring + a small white badge so users can tell at a glance which are auto-managed. - Locked stops mirror settings.colorBaseColor in real time. Changing the base picker updates every locked stop's color. - Editing a locked stop's color via the picker auto-unlocks it (rather than silently snapping back to base, which would be confusing). - lockedToBase is part of each stop's persisted shape, so it round-trips through .bumpmesh / sessionStorage / undo intact. Implementation: - js/main.js: new _snapToControlPoints(triRGB, settings) helper, used in handleExport when colorAutoSource === 'gradient'. Builds a Uint8 palette from {stops, base} (deduplicated by packed-RGB), assigns each triangle to its nearest entry by Euclidean RGB distance. - js/main.js: base-color-picker change now also calls _gradientEditor.setBaseColor(...) so locked stops track live. Initial setBaseColor on wireColorExportUI startup syncs the editor to the persisted base. - js/gradientEditor.js: stops gain `lockedToBase: boolean`. setBaseColor(hex) updates locked stops + emits change. Alt-click in _onStopPointerDown toggles lock. _onColorInput auto-unlocks on edit. _render adds 'locked-to-base' class for styling. - style.css: lock visual (inset white ring + corner badge) + new rules hiding .palette-size-row when source ≠ image. Verified in browser: - Dropdown hidden in none/gradient modes, visible in image mode. - Alt-click locks the chosen stop; its color matches base immediately. - Changing base from #ffffff to #ff8800 propagates to the locked stop instantly (no re-export needed; preview updates live). - Export palette contains EXACTLY the user's stop colors: ["3A1F0E", "7A4A22", "C08A4A", "FF8800"] for the 4-stop wood test. - The base color was deduplicated into the locked stop's slot (no redundant 5th entry). Co-Authored-By: Claude Opus 4.7 (1M context) --- js/gradientEditor.js | 64 ++++++++++++++++++++++++++++++++---- js/main.js | 78 +++++++++++++++++++++++++++++++++++++++++--- style.css | 25 ++++++++++++++ 3 files changed, 157 insertions(+), 10 deletions(-) diff --git a/js/gradientEditor.js b/js/gradientEditor.js index 0ed0609..d267589 100644 --- a/js/gradientEditor.js +++ b/js/gradientEditor.js @@ -89,18 +89,23 @@ function normalizeStops(input) { .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' }, - { pos: 1, color: '#dddddd' }, + { 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 }); - else stops.unshift({ pos: 0, color: only.color }); + 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; @@ -179,13 +184,37 @@ export class GradientEditor { } getStops() { - return this._stops.map(s => ({ pos: s.pos, color: s.color })); + 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() { @@ -204,12 +233,15 @@ export class GradientEditor { 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.pos * 100).toFixed(0)}% — ${stop.color}`; + 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(); @@ -266,6 +298,22 @@ export class GradientEditor { 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._emitChange(); + return; + } + this._selectedIdx = idx; this._render(); // Begin drag. @@ -349,6 +397,10 @@ export class GradientEditor { const stop = this._stops[this._selectedIdx]; if (!stop || typeof v !== 'string') return; if (stop.color === v) return; + // Editing the color of a locked stop auto-unlocks it. Otherwise the user + // would type a new color and see it instantly snap back to base, which + // is confusing. + if (stop.lockedToBase) stop.lockedToBase = false; stop.color = v; this._render(); this._emitChange(); diff --git a/js/main.js b/js/main.js index e9bd52c..b571926 100644 --- a/js/main.js +++ b/js/main.js @@ -1785,15 +1785,23 @@ function wireColorExportUI() { }); }); - // 4) Base color picker. + // 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. @@ -1915,6 +1923,56 @@ function _hexToRGB01(hex) { 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. + */ +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 }; +} + // 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() { @@ -4557,9 +4615,21 @@ async function handleExport(format = 'stl') { triRGB[i * 3 + 2] = Math.round(Math.max(0, Math.min(1, b)) * 255); } try { - const paletteCap = Math.max(2, Math.min(32, +settings.colorPaletteSize || 4)); - const { palette, indices } = medianCut(triRGB, paletteCap); - exportOpts = { palette, triPaletteIndices: indices }; + 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 }; + } } catch (err) { console.warn('Quantization failed; exporting without color:', err); } diff --git a/style.css b/style.css index bbfff47..f5ee9ec 100644 --- a/style.css +++ b/style.css @@ -1805,6 +1805,14 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } 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; @@ -1925,6 +1933,23 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } 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; From bc81ce131dcd8e705ae85efc71e61e04cb62fead Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Tue, 28 Apr 2026 09:01:09 -0400 Subject: [PATCH 07/10] add test model --- test_models/test_model_1.prt | Bin 0 -> 138240 bytes test_models/test_model_1.stl | Bin 0 -> 59884 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 test_models/test_model_1.prt create mode 100644 test_models/test_model_1.stl diff --git a/test_models/test_model_1.prt b/test_models/test_model_1.prt new file mode 100644 index 0000000000000000000000000000000000000000..ec9fdbbc71e628995301eb718ae3a81e50939ec3 GIT binary patch literal 138240 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 literal 0 HcmV?d00001 diff --git a/test_models/test_model_1.stl b/test_models/test_model_1.stl new file mode 100644 index 0000000000000000000000000000000000000000..25f97dbff9b20bd757678c7905815e14c2cc9658 GIT binary patch literal 59884 zcmb`Qd;DJ0mH)S*ijXR;GA^l7*N98BrShDd5Vsk$G-ykTjcQHkwz2aq zkImiZ$yNo_-oqa*#2znPP)%OG5LAsn;nB|dm(3p{E*}4vc^{s(UEao( zWA5r~xbecg4XTD6c1`#GOL{}Z;w7tece`QRLJao0)7QHz6I8vr&NrHWK6-G782PdF zyIbA5O(q^a`TXYhf1IBQs_wny56#!sZia{_4jt89>*`UN7?YlF4!O9O399A|esFNo zW%GuJ2fkQ$w>p07OuX>&W`m>NH<$^kPW;_|gNH1+bBOrUzWaBt+hk-W7GHMI;ANA# znV@Rb2L}dcY@WUg2JVc~5zS5oht<5s=>1%EtoP5FUnV@Q|pDr96|NJdO z!~?fX?{0P9hca>P_4f=O^obv5f~tEaJTmzD`8N#_3py8f_kMQcOnht7`7evk>OZhLB--jb`Y9U}fb z@yFdg4__}6`y8`gZ~xz1p9!j7o3e55ymPM}B8DH*?f&i6buuw;olSd3_P&=1sun)F zb??%Ht{5U7-Dy$x%v09R#7;A}?OkyCRhgh_uXT3pUH-;pL&Q5@{%QB1->#L3*Z%sk z-e)%aw@grV|E9b2Mt^n25V7NV|JD8Pk?+aGt{d*wJMg>T%>-4Kue5jXFaLA#5V7To zk9My;YkEd68J~QzyZY2&nKNDM$11o1@?NdM3yKt}bGC|cxPdl===cmpdB3_>QeD}d)-dVQs*fG87 z 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 MJ?$)&qi9P13k{z4*#H0l literal 0 HcmV?d00001 From fa265c260918c082797fef9516164ab49d3ba47a Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Tue, 28 Apr 2026 11:55:48 -0400 Subject: [PATCH 08/10] feat: color section UI overhaul + snap-preview toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A focused UX pass on the color export section based on test-print feedback. Six changes in one commit. 1. Stop properties panel Replaces the floating color input next to the gradient bar with a labeled panel below it: color picker, hex input, position (%), a real "Lock to base color" checkbox, and a Remove button. All the per-stop operations are now explicit instead of hidden behind modifier keys (alt-click) or right-click. The panel auto-syncs to the currently-selected stop via a new onSelect callback the editor exposes. Public methods added to GradientEditor: getSelectedIdx, getSelectedStop, setSelectedIdx, setSelectedColor, setSelectedPos, setSelectedLocked, removeSelected, resetToDefault, onSelect. 2. Live "what slicer sees" palette preview A row of swatches under the gradient editor showing the EXACT colors that will land in the exported 3MF colorgroup — gradient stops + the base color, deduplicated. Updates live as stops or base change. Hidden in non-gradient modes (image quantization happens at export time, no pre-known palette to display). 3. Snap-preview toggle (the requested addition) "Show as printed colors" checkbox. When on, the gradient LUT is built as a stepped texture (each pixel = nearest stop's color) instead of smooth interpolation, so the live preview matches what the snap-to-control-points export will produce. Also flips the LUT filter to NEAREST so the steps stay crisp. 4. Reset gradient button ↺ Reset returns the gradient to a 2-stop greyscale default. 5. Concise hint "Click bar to add a stop · Click handle to select · Drag to move" replaces the previous wordier hint that confused testers. 6. Visual grouping The stop-props panel and palette-preview rows live inside the gradient sub-section's box, plus a separator above the toolbar. The relationship between editor → properties → toolbar reads top-to- bottom now. Settings: - colorSnapPreview: false (default off; smooth is friendlier for designing the gradient itself, snapped is for verifying the print) - Persisted in PERSISTED_KEYS / DEFAULT_SETTINGS_SNAPSHOT and round-tripped through applySettingsSnapshot. Internal cleanup: - Dropped GradientEditor's _onColorInput and the floating .gradient-stop-color-input element. The orchestrator's panel inputs drive selected-stop edits via the new public methods. - alt-click on a stop still toggles lock as a power-user shortcut, but the checkbox in the panel is the canonical UI. i18n: 8 new keys + 2 tooltips added to all 8 locales. Existing gradientHint string updated. Verified in browser: - Palette preview hides/shows correctly across the 3 source modes. - Stop-props panel reflects selection changes immediately. - Lock checkbox locks the selected stop; changing base color propagates instantly to all locked stops. - Snap-preview toggle: cube re-renders with discrete stepped colors matching the export palette. - Reset button returns gradient to default greyscale. Co-Authored-By: Claude Opus 4.7 (1M context) --- index.html | 49 ++++++++++- js/gradientEditor.js | 145 +++++++++++++++++++++++--------- js/i18n/de.js | 12 ++- js/i18n/en.js | 12 ++- js/i18n/es.js | 12 ++- js/i18n/fr.js | 12 ++- js/i18n/it.js | 12 ++- js/i18n/ja.js | 12 ++- js/i18n/ko.js | 12 ++- js/i18n/pt.js | 12 ++- js/main.js | 194 ++++++++++++++++++++++++++++++++++++++++--- style.css | 102 +++++++++++++++++++++++ 12 files changed, 527 insertions(+), 59 deletions(-) diff --git a/index.html b/index.html index 3444ed9..970c1db 100644 --- a/index.html +++ b/index.html @@ -565,7 +565,54 @@

Auto color so
-

Click bar to add stop. Right-click to remove. Drag to move.

+

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

+ + +
+
Selected stop
+
+ + + +
+
+ + + % +
+
+ +
+
+ +
+
+ + +
+
Will export as:
+
+
+ + +
+ + +
diff --git a/js/gradientEditor.js b/js/gradientEditor.js index d267589..365efcb 100644 --- a/js/gradientEditor.js +++ b/js/gradientEditor.js @@ -115,10 +115,10 @@ 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._colorInputEl = null; this._selectedIdx = 0; this._dragState = null; // { idx, startX, startY, startPos, removed } this._suppressEmit = false; @@ -127,7 +127,6 @@ export class GradientEditor { this._onBarPointerDown = this._onBarPointerDown.bind(this); this._onWindowPointerMove = this._onWindowPointerMove.bind(this); this._onWindowPointerUp = this._onWindowPointerUp.bind(this); - this._onColorInput = this._onColorInput.bind(this); } mount(containerEl) { @@ -136,11 +135,9 @@ export class GradientEditor { containerEl.classList.add('gradient-editor'); containerEl.innerHTML = ''; - // Outer wrapper: bar + handles + color input. - const row = document.createElement('div'); - row.className = 'gradient-editor-row'; - - // The bar: shows the gradient + click zone. + // 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'; @@ -156,33 +153,113 @@ export class GradientEditor { barWrap.appendChild(bar); barWrap.appendChild(stopsLayer); - - // Native color input for the selected stop. - const colorInput = document.createElement('input'); - colorInput.type = 'color'; - colorInput.className = 'gradient-stop-color-input'; - colorInput.value = '#888888'; - colorInput.addEventListener('input', this._onColorInput); - colorInput.addEventListener('change', this._onColorInput); - - row.appendChild(barWrap); - row.appendChild(colorInput); - containerEl.appendChild(row); + containerEl.appendChild(barWrap); this._barEl = bar; this._stopsLayerEl = stopsLayer; - this._colorInputEl = colorInput; 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, @@ -250,9 +327,12 @@ export class GradientEditor { this._stopsLayerEl.appendChild(handle); } - // Update the color input to reflect selected stop. - if (this._colorInputEl && this._stops[this._selectedIdx]) { - this._colorInputEl.value = this._stops[this._selectedIdx].color; + } + + _notifySelect() { + if (typeof this._onSelect === 'function') { + try { this._onSelect(this.getSelectedStop(), this.getSelectedIdx()); } + catch (err) { console.warn('GradientEditor onSelect threw:', err); } } } @@ -287,6 +367,7 @@ export class GradientEditor { 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(); } @@ -310,12 +391,14 @@ export class GradientEditor { } this._selectedIdx = idx; this._render(); + this._notifySelect(); this._emitChange(); return; } this._selectedIdx = idx; this._render(); + this._notifySelect(); // Begin drag. this._dragState = { idx, @@ -376,6 +459,7 @@ export class GradientEditor { this._selectedIdx = this._stops.indexOf(ref); if (this._selectedIdx < 0) this._selectedIdx = 0; this._render(); + this._notifySelect(); if (ds.moved) this._emitChange(); } @@ -389,20 +473,7 @@ export class GradientEditor { if (this._selectedIdx >= this._stops.length) this._selectedIdx = this._stops.length - 1; if (this._selectedIdx < 0) this._selectedIdx = 0; this._render(); - this._emitChange(); - } - - _onColorInput(ev) { - const v = ev.target.value; - const stop = this._stops[this._selectedIdx]; - if (!stop || typeof v !== 'string') return; - if (stop.color === v) return; - // Editing the color of a locked stop auto-unlocks it. Otherwise the user - // would type a new color and see it instantly snap back to base, which - // is confusing. - if (stop.lockedToBase) stop.lockedToBase = false; - stop.color = v; - this._render(); + this._notifySelect(); this._emitChange(); } } diff --git a/js/i18n/de.js b/js/i18n/de.js index 46e13d6..c3669d2 100644 --- a/js/i18n/de.js +++ b/js/i18n/de.js @@ -213,7 +213,7 @@ export default { "color.sourceNone": "None", "color.sourceGradient": "Gradient (height-based)", "color.sourceImage": "Color image", - "color.gradientHint": "Click bar to add stop. Right-click to remove. Drag to move.", + "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…", @@ -222,5 +222,15 @@ export default { "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 b7f6c7b..79fecfd 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -213,7 +213,7 @@ export default { "color.sourceNone": "None", "color.sourceGradient": "Gradient (height-based)", "color.sourceImage": "Color image", - "color.gradientHint": "Click bar to add stop. Right-click to remove. Drag to move.", + "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…", @@ -222,5 +222,15 @@ export default { "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 f660f37..e1c3a7d 100644 --- a/js/i18n/es.js +++ b/js/i18n/es.js @@ -213,7 +213,7 @@ export default { "color.sourceNone": "None", "color.sourceGradient": "Gradient (height-based)", "color.sourceImage": "Color image", - "color.gradientHint": "Click bar to add stop. Right-click to remove. Drag to move.", + "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…", @@ -222,5 +222,15 @@ export default { "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 9639ea6..2375e44 100644 --- a/js/i18n/fr.js +++ b/js/i18n/fr.js @@ -213,7 +213,7 @@ export default { "color.sourceNone": "None", "color.sourceGradient": "Gradient (height-based)", "color.sourceImage": "Color image", - "color.gradientHint": "Click bar to add stop. Right-click to remove. Drag to move.", + "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…", @@ -222,5 +222,15 @@ export default { "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 24dd4c6..5241549 100644 --- a/js/i18n/it.js +++ b/js/i18n/it.js @@ -213,7 +213,7 @@ export default { "color.sourceNone": "None", "color.sourceGradient": "Gradient (height-based)", "color.sourceImage": "Color image", - "color.gradientHint": "Click bar to add stop. Right-click to remove. Drag to move.", + "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…", @@ -222,5 +222,15 @@ export default { "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 219768e..b027cba 100644 --- a/js/i18n/ja.js +++ b/js/i18n/ja.js @@ -213,7 +213,7 @@ export default { "color.sourceNone": "None", "color.sourceGradient": "Gradient (height-based)", "color.sourceImage": "Color image", - "color.gradientHint": "Click bar to add stop. Right-click to remove. Drag to move.", + "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…", @@ -222,5 +222,15 @@ export default { "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 b8a7bc1..28444ba 100644 --- a/js/i18n/ko.js +++ b/js/i18n/ko.js @@ -215,7 +215,7 @@ export default { "color.sourceNone": "None", "color.sourceGradient": "Gradient (height-based)", "color.sourceImage": "Color image", - "color.gradientHint": "Click bar to add stop. Right-click to remove. Drag to move.", + "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…", @@ -224,5 +224,15 @@ export default { "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 7033b0a..f5ef9f0 100644 --- a/js/i18n/pt.js +++ b/js/i18n/pt.js @@ -213,7 +213,7 @@ export default { "color.sourceNone": "None", "color.sourceGradient": "Gradient (height-based)", "color.sourceImage": "Color image", - "color.gradientHint": "Click bar to add stop. Right-click to remove. Drag to move.", + "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…", @@ -222,5 +222,15 @@ export default { "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 b571926..a0a8e32 100644 --- a/js/main.js +++ b/js/main.js @@ -111,8 +111,14 @@ const settings = { // 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, - // N-stop gradient: array of { pos: 0..1, color: '#RRGGBB' } sorted by pos. + // 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' }, @@ -1752,18 +1758,72 @@ function wireColorExportUI() { 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 })); + 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) { @@ -1861,8 +1921,15 @@ function wireColorExportUI() { // and .bumpmesh import paths. window._refreshColorImageUI = refreshColorImageUI; refreshColorImageUI(); - // Initial uniform sync — runs once after the gradient editor mounts and - // settings are populated. Subsequent changes flow through the listeners above. + // 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(); } @@ -1878,21 +1945,50 @@ function _rebuildGradientLUT() { 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' }]; - 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); + + 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); } - ctx.fillStyle = grad; - ctx.fillRect(0, 0, 256, 1); + if (!_gradientLUTTexture) { _gradientLUTTexture = new THREE.CanvasTexture(_gradientLUTCanvas); - _gradientLUTTexture.minFilter = THREE.LinearFilter; - _gradientLUTTexture.magFilter = THREE.LinearFilter; _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; } @@ -1973,6 +2069,72 @@ function _snapToControlPoints(triRGB, s) { 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() { @@ -5002,7 +5164,7 @@ const PERSISTED_KEYS = [ // sessionStorage (would blow the 5MB quota). `_lastColorMap` holds the // runtime cache. 'colorExportEnabled', 'colorAutoSource', 'colorBaseColor', 'colorGradientStops', - 'colorPaletteSize', + 'colorPaletteSize', 'colorSnapPreview', ]; function getSettingsSnapshot() { @@ -5148,6 +5310,11 @@ function applySettingsSnapshot(snap) { 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 })); } + } } /** @@ -5228,6 +5395,7 @@ const DEFAULT_SETTINGS_SNAPSHOT = Object.freeze({ { pos: 1, color: '#dddddd' }, ], colorPaletteSize: 4, + colorSnapPreview: false, activeMapName: DEFAULT_PRESET_NAME, }); diff --git a/style.css b/style.css index f5ee9ec..09ed7aa 100644 --- a/style.css +++ b/style.css @@ -1960,3 +1960,105 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } 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; } From 60f976ebae1235ba5009aada20cb9dfd5b0c6213 Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Tue, 28 Apr 2026 14:14:48 -0400 Subject: [PATCH 09/10] fix: separate aspect-correction uniform for color image in preview shader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: when a color image had a different width:height ratio than the displacement texture, the live preview's UVs were scale-mismatched against the exported 3MF — colors visibly drifted across faces relative to what the slicer would actually print. Root cause: previewMaterial.js's sampleColorMap used the global `textureAspect` uniform, which is set from the DISPLACEMENT texture's dimensions (in updateMaterial's u.textureAspect.value.set call). The color image's aspect is independent and was never propagated to the shader, so colors got the displacement texture's aspect correction applied instead of their own. Meanwhile colorBake.js (export side) already did this right: it computes a separate `colSettings` struct with the color image's own aspect (cTmax / cW, cTmax / cH) and uses it for color sampling. That asymmetry was the bug. Fix: - Add `colorTextureAspect` (vec2) uniform to the preview shader. - sampleColorMap reads colorTextureAspect instead of textureAspect. - setColorPreview accepts colorImageW / colorImageH and computes the aspect (tmax/w, tmax/h) the same way colorBake does. - main.js _pushColorPreviewState passes _lastColorMap.width / .height through to setColorPreview. When no color image is loaded, the uniform value doesn't matter — sampleColorMap is gated behind `hasColorImage == 1`. Also stripped a stray backtick inside a GLSL block comment that prematurely closed the JS template literal — caused a SyntaxError on recent navigations. (Same template-literal pitfall as a prior fix; this codebase has GLSL embedded in backtick strings, so any backtick inside a // comment closes the literal early.) Verified in browser: 256×128 (2:1) RGBY quadrant test image now tiles across the cube with the right proportions in the live preview, and exporting produces colors at the same on-mesh positions. --- js/main.js | 15 ++++++++++----- js/previewMaterial.js | 27 +++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/js/main.js b/js/main.js index a0a8e32..87cb822 100644 --- a/js/main.js +++ b/js/main.js @@ -2140,11 +2140,16 @@ function _refreshPalettePreview() { function _pushColorPreviewState() { if (!previewMaterial) return; setColorPreview(previewMaterial, { - enabled: !!settings.colorExportEnabled, - autoSource: settings.colorAutoSource || 'none', - baseRGB: _hexToRGB01(settings.colorBaseColor || '#ffffff'), - gradientLUT: _rebuildGradientLUT(), - colorImage: _rebuildColorImageTexture(), + 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(); } diff --git a/js/previewMaterial.js b/js/previewMaterial.js index e3cb94d..52b989f 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -55,6 +55,11 @@ const sharedGLSL = /* glsl */` 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; @@ -112,11 +117,13 @@ const sharedGLSL = /* glsl */` return texture2D(displacementMap, uv).r; } - // Same UV pipeline as sampleMap but reads the user's color image. Used by - // computeColorAtPoint when colorAutoSource == 2 so colors line up with the - // displacement texels exactly. + // 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 * textureAspect) / scaleUV + offsetUV; + 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); @@ -647,6 +654,9 @@ function buildUniforms(tex, settings) { 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) }, }; } @@ -662,6 +672,9 @@ function buildUniforms(tex, settings) { * - 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; @@ -680,8 +693,14 @@ export function setColorPreview(material, opts) { 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); } } From 53d3344f4d192484ff9f403a2fef952e860d4681 Mon Sep 17 00:00:00 2001 From: eric-vergo Date: Tue, 28 Apr 2026 14:37:43 -0400 Subject: [PATCH 10/10] Update main.js --- js/main.js | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/js/main.js b/js/main.js index 87cb822..f7c414e 100644 --- a/js/main.js +++ b/js/main.js @@ -2034,6 +2034,106 @@ function _hexToRGB255(hex) { * 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 @@ -4797,6 +4897,19 @@ async function handleExport(format = 'stl') { 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); }