Skip to content

Add Color Support#57

Draft
eric-vergo wants to merge 12 commits intoCNCKitchen:mainfrom
eric-vergo:main
Draft

Add Color Support#57
eric-vergo wants to merge 12 commits intoCNCKitchen:mainfrom
eric-vergo:main

Conversation

@eric-vergo
Copy link
Copy Markdown

Draft PR for adding color support

This initial PR contains the MVP for adding color support to this tool. Working prototype hosted at: https://eric-vergo.github.io/stlTexturizer/. There are still many bugs, rough edges, and it is far from optimized. Getting this to a production-ready state will take a lot of work and I'm sharing it in order solicit feedback/see if this is worth pursuing. I suspect it is, but would love peoples thoughts.


Basic workflow

One can use this to map the full gradient:
Screenshot 2026-04-28 at 2 46 28 PM

Get a preview of what it will look like using defined filament colors:
Screenshot 2026-04-28 at 2 46 36 PM

And successfully slice it:
Screenshot 2026-04-28 at 2 47 14 PM


Beauty shots

Brick prototype print:
Screenshot 2026-04-28 at 3 02 19 PM

Multiple color:
Screenshot 2026-04-28 at 12 18 23 PM

Wood grain:
Screenshot 2026-04-28 at 12 19 07 PM

Image support:
Screenshot 2026-04-28 at 12 21 02 PM

eric-vergo and others added 12 commits April 27, 2026 22:02
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 <m:colorgroup> 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 <section id="color-section">
  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<origFaceIdx, packedRGB> 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) <noreply@anthropic.com>
Brings the colored 3MF export feature onto main so GitHub Pages can
deploy it from this fork. The feature was developed on
feat/color-export-3mf (PR-style branch) and reviewed via cross-ruff.

Conflicts resolved:
  - index.html: kept BOTH the new "Advanced / Beta Features" section
    (noDownwardZ + Bake Textures, from main) AND the new "Color export
    (3MF)" section (from feat). They sit consecutively now, with Color
    immediately preceding Export so users see "configure colors → export"
    as a natural flow.

Other adjustments:
  - js/i18n/ko.js (new locale on main) gained the 18 color-related keys
    with English fallback strings, matching the policy used for de/fr/it/
    es/pt/ja. Native Korean translation TBD.
  - CNAME removed: it pointed at bumpmesh.com which belongs to upstream
    (CNCKitchen) and could not be claimed by this fork anyway. With
    CNAME gone, GH Pages will serve from the default
    eric-vergo.github.io/stlTexturizer URL. Re-add a CNAME later if a
    custom domain is desired.

Auto-merge handled cleanly:
  - js/displacement.js: noDownwardZ logic (main) and excludeWeight
    forwarding (feat) coexist — different lines, no overlap.
  - js/main.js, all i18n files, style.css: additive changes on both
    sides, auto-merged without conflict.

Verification:
  - node --check on all modified JS modules: OK
  - node test-color-quantize.mjs: 4/4 PASS (codex's regression suite)
  - node test-no-downward-z.mjs: PASS (main's regression suite)
  - HEAD-only smoke check from earlier still applies: toggle-OFF 3MF is
    byte-identical to today's output; toggle-ON emits standard
    <m:colorgroup> with pid/p1 attributes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <select> to settings
  - handleExport passes settings.colorPaletteSize into medianCut(...)
    (clamped to [2, 32] defensively)
  - i18n: color.paletteSize + tooltips.colorPaletteSize keys added to
    all 8 locales (English-fallback for non-English)

Verified end-to-end in browser:
  - At N=4: palette is exactly 4 entries; the angle-masked bottom-face
    white survives as 1022 tris in its own bucket (codex's gap-aware
    median-cut split is doing its job).
  - At N=2: 2 entries — one wood bucket + one pure white. Even at the
    most aggressive cap, outlier preservation works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ader

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant