From 2191c2ec4f6e399ea42f883212c918b629435e15 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 20 Jun 2026 21:25:23 +0300 Subject: [PATCH 01/31] Add auto-paint regression baseline --- CHANGELOG.md | 4 + docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 418 ++++++++ package.json | 1 + scripts/generate-auto-paint-goldens.ts | 67 ++ src/lib/autoPaint.ts | 2 +- tests/assets/auto-paint-goldens.json | 1218 ++++++++++++++++++++++++ tests/autoPaint.test.ts | 192 ++++ tests/autoPaintGoldenFixtures.ts | 122 +++ tests/autoPaintGoldens.test.ts | 118 +++ tests/imageFixtures.ts | 114 ++- tests/optimizer.test.ts | 70 ++ 11 files changed, 2317 insertions(+), 9 deletions(-) create mode 100644 docs/AUTOPAINT_IMPROVEMENT_PLAN.md create mode 100644 scripts/generate-auto-paint-goldens.ts create mode 100644 tests/assets/auto-paint-goldens.json create mode 100644 tests/autoPaint.test.ts create mode 100644 tests/autoPaintGoldenFixtures.ts create mode 100644 tests/autoPaintGoldens.test.ts create mode 100644 tests/optimizer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f46aa..4fb4b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,14 @@ All notable changes to Kromacut are documented in this file. ### Added +- **Auto-paint regression baseline** - Added focused layer-invariant coverage, per-algorithm seeded determinism checks, and 24 seeded stack snapshots across the 2-, 4-, and 8-filament profiles, both image fixtures, Enhanced matching states, and repeated-swap states. + ### Changed ### Fixed +- **Auto-paint layer cap** - Corrected the slice-data safety limit so exceptionally tall auto-paint stacks stop at 500 layers rather than returning 501. + ## v3.1.0 - 2026-06-18 ### Added diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..f35c4e2 --- /dev/null +++ b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md @@ -0,0 +1,418 @@ +# Auto-Paint Algorithm Improvement Plan + +> Status: **PLANNED — no implementation started.** +> Source: code review of the Auto-paint pipeline (2026-06-11). All file/line references +> are against `develop` at commit `6b55485`. + +This document records verified findings about the current Auto-paint algorithms and +turns them into a phased, testable improvement plan. Each phase is independently +shippable and ordered so that correctness fixes land before behavioral changes, and +measurement lands before everything. + +--- + +## 1. Invariants that must hold in every phase + +These come from product constraints and existing tests. Any phase that breaks one is +not shippable. + +- **Result schema stability**: `AutoPaintResult` and `autoPaintToSliceHeights` output + shapes are unchanged. Export (3MF/STL), persisted state (`ThreeDControlsStateShape`), + and saved profiles all consume these. +- **Printability**: foundation zone always present; slice heights are exact + `layerHeight` multiples (first = `max(firstLayerHeight, layerHeight)`); ≤500 layers + (`autoPaint.ts:1516`); no zero-thickness zones. +- **Layer snapping**: per-pixel snapping behavior in `ThreeDView.tsx` untouched unless a + phase explicitly targets it. +- **Determinism**: same inputs + same seed → byte-identical result. (Phase 3 makes the + no-seed case deterministic too; it must not break the seeded case.) +- **Worker responsiveness**: algorithm work stays in `autoPaint.worker.ts`; the main + thread never runs the optimizer. +- **User data compatibility**: persisted `optimizerAlgorithm` values + (`auto | exhaustive | simulated-annealing | genetic`), saved profiles (`.kfil`/`.kapp`), + and calibration data (`CalibrationResult`) keep their meaning. +- **Existing test suites stay green**: `tests/export3mf.test.ts` (manifoldness, color + collapse, seeded auto-paint regression stacks) and `tests/e2e/kromacut-flow.spec.ts`. + +--- + +## 2. Verified findings + +### F1 — Legacy enhanced path is unreachable in production + +`useAutoPaintWorker.ts:203-206` always sends `optimizerOptions` (at minimum +`{ algorithm }`), and `findBestFilamentOrder` (`autoPaint.ts:835-843`) routes to the +advanced optimizer whenever options are present. The legacy subset-aware search +(`findBestFilamentOrderLegacy`, `autoPaint.ts:974-1063`) only runs from tests and +`debugAutoPaint`. Production therefore always uses the weaker scorer (F2) and never +does subset selection (F3). + +### F2 — Advanced optimizer scores a different physical model than what gets built + +`scoreFilamentOrder` → `findBestAchievableColor` → `simulateStackAtHeight` +(`optimizer.ts:166-238`): + +- Transition thickness budget is `prevFilament.td * 3` (`optimizer.ts:226`) — keyed to + the **previous** filament's TD. The real zone model sizes zones by the **incoming** + filament's TD and ΔE convergence (`calculateTransitionThickness`, + `autoPaint.ts:284-325`). +- Because that budget is independent of color distance, the advanced score **cannot + see transition cost** (a yellow→purple order is not penalized for its expensive + transition). +- Samples 20 uniform heights; ignores foundation opacity, layer grid, and compression. +- Objective is pure weighted mean ΔE — it lacks the legacy scorer's height-spread, + layer-count (0.5/layer), and transition-waste (1.5/entry) penalties + (`scoreSequenceAgainstImage`, `autoPaint.ts:734-806`). +- Minor: `findBestAchievableColor`'s sampled height range includes the foundation TD + that `simulateStackAtHeight` never consumes (top samples are duplicates). + +The legacy scorer builds its palette via `calculateIdealHeight` — the same model that +`generateAutoLayers` builds geometry with and `autoPaintToSliceHeights` previews with. +The optimizer optimizes objective A; the build pipeline realizes model B. + +### F3 — Subset selection lost + +All three advanced algorithms permute the **full** filament set. Legacy evaluated all +non-empty subsets for ≤6 filaments (1,956 evaluations at N=6) and stopped greedy +addition when no filament helped for >6. Today nothing can drop a harmful filament; +repeated swaps can only add occurrences. + +### F4 — Region weighting is spatially blind and its mode detection is inverted + +- Swatches lose pixel positions at extraction (`useSwatches.ts:62-109` — pure color + histogram). +- `applyRegionWeightHeuristic` (`autoPaint.ts:868-924`) reduces the full W×H weight map + to mean+variance, then boosts clusters by **luminance band**, never by location. +- Numerically verified (replicated `generateCenterWeightedMapSimple` / + `generateEdgeWeightedMapSimple` + normalization): center maps have variance + ≈0.055-0.067 → classified `isHighContrast=true` → applies the **edge** adjustment; + edge maps on 1:1/4:3 images have variance ≈0.045-0.048 → applies the **center** + adjustment. The modes are swapped on square images and identical (both "edge") at + 16:9. Behavior depends on aspect ratio, never on image content. +- `generateAutoLayers:1267-1282` allocates the full Float32Array map per request + (~45 MB for the 3888×2916 fixture) only to compute two scalars. +- The repeated-swaps path ignores region weights entirely + (`buildRepeatedSwapSequence` clusters at `autoPaint.ts:1098` without them). + +### F5 — Optimizer cache returns stale results across region modes + +`OptimizerCache.getCacheKey` (`optimizer.ts:101-114`) hashes the first 20 cluster +colors but **not their weights**, and not `regionWeights` or SA/GA tuning params. +Region weighting only changes weights → identical key → with an explicit seed, +switching Region priority returns the cached previous-mode order (UI shows "Cache +hit"). + +### F6 — Non-deterministic by default + +Without a user seed, SA/GA seed from `Date.now()` (`optimizer.ts:525`): same image + +filaments produce different orders run to run. Caching is also disabled in that case. + +### F7 — Repeated swaps are a second, conflicting optimization + +`buildRepeatedSwapSequence` (`autoPaint.ts:1088-1185`): greedy insert-only; position +loop starts at 1 (`:1137`) so the foundation can never change; capped at 4 insertions; +scored with the **legacy** objective on top of a base order chosen by the **advanced** +objective; no remove/relocate moves. + +### F8 — Physics gaps shared by both paths + +- `blendColors` (`autoPaint.ts:217-241`) lerps in gamma sRGB with a **scalar** + transmission. Calibration already fits **per-channel TD** (`CalibrationResult.td: +[r,g,b]`, `calibration.ts:283-322`) that auto-paint never reads — only + `tdSingleValue`. +- `FRONTLIT_TD_SCALE = 0.1` (`autoPaint.ts:271`) is a hard-coded global fudge. +- Zone _i_ blends over the **pure** color of filament _i−1_ + (`buildAchievableColorPalette:656-663`, `autoPaintToSliceHeights:1499-1506`). Valid + pre-compression (zones are sized for ΔE convergence), wrong after `compressZones` — + compressed previews look cleaner than the print will, and ordering is never re-scored + under compression. +- ΔE is CIE76, which over-penalizes chroma differences in saturated regions. +- Note: gamma-space lerp is self-consistent with how calibration _measures_ + (`predictWorkingBlendRgb`, `calibration.ts:128-140` fits `tdSingleValue` against the + same gamma-space model). Any change to the blend model must move together with + calibration fitting or it invalidates calibrated TDs. + +### F9 — Worker protocol: no progress, terminate-only cancel + +`autoPaint.worker.ts` is single-shot request/response. Exhaustive at 8 filaments ≈ +40,320 permutations × ~670 stack sims each with zero progress feedback. The hook +terminates + recreates the worker per input change (`useAutoPaintWorker.ts:177-219`), +paying worker startup each time. + +### F10 — Minor issues + +- `luminanceToHeight` (`autoPaint.ts:1551`) is dead code (no callers). +- `scoreSequenceAgainstImage` multiplies weighted ΔE by `imageTargets.length` + (`autoPaint.ts:777`), so fixed penalty constants change relative strength with + cluster count. +- SA neighbor allows `i === j` (wasted iterations) (`optimizer.ts:333-335`). +- Zone caps (`td * 0.7`) produce off-grid zone boundaries (hidden by per-pixel re-snap). +- UI "Exhaustive (≤8 filaments)" implies subset search; it is full-set permutations + only. Hook silently downgrades exhaustive >8 to `auto` + (`useAutoPaintWorker.ts:189-192`), duplicating a guard in `ThreeDControls.tsx:130-134`. +- `tests/`: **no unit tests exist for `autoPaint.ts` or `optimizer.ts` internals.** + Coverage is indirect (export3mf seeded stacks, e2e flow). + +--- + +## 3. The plan + +Phases are ordered: measurement → low-risk correctness → objective unification → +search-space unification → polish → gated physics. Do not reorder Phase 4 before +Phase 3 (widening the search around a misaligned objective optimizes the wrong thing +harder). + +### Phase 0 — Test baseline + benchmark harness (prerequisite for everything) + +Goal: pin current behavior and make quality measurable before changing anything. + +- [x] **0.1 Unit tests for `autoPaint.ts` pure functions** (new + `tests/autoPaint.test.ts`): + - `calculateTransitionThickness`: returns ≥ `layerHeight`; respects `0.7×TD` cap; + early-exit for near-identical colors. + - `compressZones`: zones tile `[0, H]` contiguously (each `startHeight` equals + previous `endHeight`); ratio honored; no-op when under max. + - `calculateIdealHeight`: foundation = `max(baseThickness, td×1.3)`; zone count = + filament count. + - `autoPaintToSliceHeights`: every slice = `layerHeight` (first = + `max(firstLayerHeight, layerHeight)`); ≤500 layers; `virtualSwatches`, + `filamentSwatches`, `colorSliceHeights`, `colorOrder` lengths agree. +- [x] **0.2 Seeded golden snapshots** of `generateAutoLayers` for the + 2/4/8-color `.kapp` profiles × both fixture images (`tests/assets`), with + enhanced on/off and repeated swaps on/off: assert `filamentOrder`, zone + boundaries (±1e-6), `totalHeight`, `compressionRatio`. These get **re-baselined + deliberately** in Phases 3/4 — their job is making behavior changes visible, + not frozen. +- [x] **0.3 Determinism tests**: per algorithm, same seed twice → deep-equal result. +- [ ] **0.4 Benchmark harness** (`tests/benchmark/autoPaintBench.ts`, runnable via a + package script, not part of CI gating initially). Per image × profile × algorithm × + seed, emit JSON with: + - Weighted mean ΔE (report **both** CIE76 and CIEDE2000) from clustered targets to + nearest achievable palette color; weighted P95. + - Coverage@2.3 and @5.0 (fraction of image weight within JND thresholds). + - **End-to-end realized error**: run the ThreeDView polyline mapping (port of + `ThreeDView.tsx:753-1002`, reuse the port in `tests/export3mf.test.ts:~700-768`) + over fixture pixels; report pixel-weighted ΔE of mapped vs original. *This is the + primary metric — it measures the whole pipeline, not the scorer's opinion of itself.* + - Structure: total height, layer count, sequence length, wasted-layer fraction, + compression ratio under a fixed `maxHeight` scenario. + - Cost: wall time, evaluations, iterations. + - Stability: cross-seed rank agreement for SA/GA. +- [ ] **0.5 Acceptance rule for later phases** (documented in the harness README): + end-to-end realized ΔE improves on average across fixtures and regresses on no + fixture beyond tolerance (suggest 5%), within cost budgets (≤2 s for 8 filaments). + +Risk: none (additive). Estimated scope: tests only. + +### Phase 1 — Region weighting made spatially correct; delete the heuristic + +Goal: per-color weights actually reflect where colors sit in the image. Fixes F4. + +- [ ] **1.1** Extend swatch extraction (`useSwatches.ts`) to accumulate, per color, in + the same tile pass that builds the histogram: `centerWeight` and `edgeWeight` + (sum of the geometric per-pixel weight functions evaluated inline — no + Float32Array maps materialized). Both modes computed in one pass so switching + modes never rescans the image. Swatch entries gain optional fields; existing + consumers unaffected. +- [ ] **1.2** Thread the chosen mode's weighted count through + `useAutoPaintWorker` → worker request → `generateAutoLayers`: when mode ≠ + `uniform`, use the weighted count as `count` input to `clusterImageColors` + (which already weights by count). Keep raw count for display. +- [ ] **1.3** Delete `applyRegionWeightHeuristic` (`autoPaint.ts:868-924`) and the + per-request map generation in `generateAutoLayers:1267-1282`. Remove + `regionWeights` from `OptimizerOptions`/`ScoringContext` (or keep as deprecated + no-op field if persisted anywhere — verify; current persistence stores only the + mode string, so removal should be safe). +- [ ] **1.4** Repeated-swaps path uses the same weighted targets (fixes the + inconsistency at `autoPaint.ts:1098`). +- [ ] **1.5 Tests**: synthetic fixture (red center disc on blue border): + - `center` mode must rank red clusters above blue; `edge` mode the reverse. + - Mode `uniform` byte-identical to pre-change output (golden snapshot). + - No `Float32Array(width*height)` allocation in the worker path (can assert via + absence of the code path / memory not practical — code-level assertion). + +Risk: low-medium (plumbing). User-visible: center/edge modes start doing what their +labels say (today they are swapped or no-ops — treat as bug fix, note in CHANGELOG). + +### Phase 2 — Cache correctness + determinism by default + +Goal: fix F5, F6. Small, independent, immediately shippable. + +- [ ] **2.1** Cache key includes cluster **weights**, full cluster set (not first 20), + and all algorithm-relevant options (temperature, cooling, population, mutation, + elite, maxIterations). Simplest robust form: hash a canonical JSON of + `{filaments, clusters(L,a,b,weight), layerHeight, firstLayerHeight, algorithm, + seed, tuning}`. +- [ ] **2.2** Default seed = stable 32-bit hash of the same canonical inputs instead of + `Date.now()` (`optimizer.ts:525`). User-provided seed still overrides. This makes + every run reproducible and cacheable; remove the `hasExplicitSeed` cache gating. +- [ ] **2.3 Tests**: same inputs, no seed, twice → identical result. Toggling region + mode with a fixed seed → different cache entries (regression test for F5). + Changing only `temperature` → cache miss. + +Risk: low. User-visible: results stop changing between identical runs (improvement); +"Cache hit" indicator becomes trustworthy. + +### Phase 3 — Unify the objective (one scorer, zone-accurate) + +Goal: the optimizer optimizes the same model the pipeline builds. Fixes F2; partially +F8 (scoring side of the pure-background issue can be folded in here or deferred to +Phase 6b). + +- [ ] **3.1** Move/share the palette builder: expose `buildAchievableColorPalette` + + `scoreSequenceAgainstImage` (or a thin `scoreSequence(sequence, context)` + wrapper) for use by `optimizer.ts`. `ScoringContext` carries the weighted Lab + targets as today. +- [ ] **3.2** Replace `scoreFilamentOrder`/`findBestAchievableColor`/ + `simulateStackAtHeight` with the unified scorer in exhaustive, SA, and GA. +- [ ] **3.3 Performance work so per-eval cost stays acceptable**: + - Memoize `calculateTransitionThickness(bgColorHex, filamentId)` per optimizer run — + pair space ≤ N², eliminates the dominant inner loop. + - Memoize palette per unique sequence string (SA revisits neighbors). + - Fix the score-scale fragility while touching it: normalize by total target weight + instead of multiplying by `imageTargets.length` (`autoPaint.ts:777`), and rescale + the structural penalty constants to match (calibrate against Phase 0 harness so + rankings on fixtures are preserved or improved — this is a tuning task, use the + harness). +- [ ] **3.4** SA neighbor: redraw `j` until `j ≠ i` (F10). +- [ ] **3.5 Tests**: property test — the score the optimizer reports for its chosen + order equals `scoreSequence` of that order under the build model (was untrue + before). Re-baseline Phase 0 golden snapshots; harness must satisfy the Phase 0.5 + acceptance rule. Budget: ≤2 s for 8 filaments (exhaustive falls back per + existing UI guard). + +Risk: medium — orders will change for users (expected: improvement, verified by +harness). Determinism preserved (seeded). Schema unchanged. + +### Phase 4 — Variable-length search space (subsets + repeats, natively) + +Goal: one search over sequences, restoring subset selection (F3) and integrating +repeats (F7). Replaces `buildRepeatedSwapSequence`. + +Search space: sequences of filament occurrences, length 1..(N + 4), no consecutive +duplicates, repeats allowed **only when** `allowRepeatedSwaps` is on (otherwise +sequences are permutations of subsets). 500-layer guard enforced via scoring penalty + +hard cap. + +- [ ] **4.1** `exhaustive` (≤6 filaments): all permutations of all non-empty subsets + (1,956 evals at N=6 — legacy semantics restored) under the unified scorer. When + repeats are on, extend with the bounded insertion expansion as a post-pass _of + the same scorer_ (cheap and already consistent), or fold repeats into beam + search (4.2) and route there. Update UI label/threshold honestly (≤6, not ≤8; + keep the existing >threshold downgrade-to-auto behavior in one place — remove + the duplicate guard, F10). +- [ ] **4.2** **Beam search** (new internal algorithm, used by `auto` for 7-12 + filaments): build sequences bottom-up; at each depth keep top-K (K≈100, tunable + via harness) partial stacks scored with the unified scorer; candidate extensions + = any non-duplicate filament occurrence + "stop" (subset selection falls out + naturally). Deterministic, anytime, trivially reports progress (depth/maxDepth). +- [ ] **4.3** **Variable-length SA** (replaces permutation SA; used by + `simulated-annealing` and by `auto` for >12): moves = swap(i,j), relocate(i→j), + insert(filament, pos) [only if repeats allowed or filament unused], + remove(pos) [if length > 1], replace(pos, filament). Seeded move selection; + geometric cooling as today. +- [ ] **4.4** GA (`genetic` UI value): keep permutation GA over the full set initially + but wrap with subset-aware repair, or route `genetic` to variable-length SA with + a different default budget if GA quality on the harness is not competitive. + Decide on harness data, not in advance. +- [ ] **4.5** Delete `buildRepeatedSwapSequence`; `generateAutoLayers` passes + `allowRepeatedSwaps` into optimizer options instead + (`autoPaint.ts:1307-1316`). +- [ ] **4.6 Tests**: printability invariants (foundation exists; no consecutive + duplicate filament ids; sequence length caps; result schema unchanged; slice snapping + unchanged); per-seed determinism for each algorithm; harness non-regression per + Phase 0.5; specific scenario tests: + - A filament strictly worse than every alternative (e.g., a near-duplicate hue with + worse TD) gets dropped (subset regression — impossible today). + - Thin-white-over-red produces a pink intermediate when repeats are on (repeated-swap + regression). + - `allowRepeatedSwaps=false` → no filament appears twice. + +Risk: medium-high (largest behavioral change; biggest quality upside). UI: **no new +dropdown entries** — `auto/exhaustive/simulated-annealing/genetic` keep their persisted +values and re-map internally (decision log §4). + +### Phase 5 — Worker progress + lifecycle polish + +Goal: fix F9 ergonomics. Independent of phases 3-4 but nicer after them (beam search +has natural progress). + +- [ ] **5.1** Optimizer accepts `onProgress(iteration, total, bestScore)`; worker + throttles (~10 Hz) `postMessage({ type: 'progress', id, ... })`; final message + keeps the current shape (`{ id, result }`) for compatibility. +- [ ] **5.2** Hook surfaces `progress` in `UseAutoPaintWorkerResult`; AutoPaintTab + shows it next to the spinner ("Optimizing… 43%"). +- [ ] **5.3** Keep terminate-based cancel; optionally keep a warm worker between + requests and cancel via `id` checks instead of terminate (measure startup cost + first — only do this if it's noticeable). +- [ ] **5.4 Tests**: progress monotonic 0→1 (mirror `tests/algorithms-progress.test.ts` + pattern); stale progress messages (old `id`) ignored. + +Risk: low. + +### Phase 6 — Physics upgrades (each gated behind a constant + harness validation) + +Goal: close F8 where measurement proves it helps. Each item independent; ship only +with harness evidence. These change preview colors for existing projects — CHANGELOG +each. + +- [ ] **6a Per-channel TD blending where calibration exists**: `blendColors` uses + `calibration.td: [r,g,b]` (per-channel transmission, gamma-space — consistent + with how calibration measures) when present; scalar `tdSingleValue` fallback + unchanged for uncalibrated filaments. Touches `blendColors` call sites in + `autoPaint.ts` only; `Filament` already carries `calibration`. +- [ ] **6b Compression-aware backgrounds**: carry the actual blended end-color of zone + _i−1_ forward as zone _i_'s background in `buildAchievableColorPalette` and + `autoPaintToSliceHeights` (today: pure color, `autoPaint.ts:656-663, 1499-1506`). + Makes compressed previews honest and lets the (unified) scorer see compression + damage. Optionally: when `compressionRatio < 0.9`, re-score the top-k candidate + orders under compression and pick the best (bounded cost). +- [ ] **6c CIEDE2000** behind a metric constant for scoring + clustering distances + (~3-4× distance cost; affordable at ≤32 targets). Adopt only if harness shows + end-to-end improvement on saturated fixtures. +- [ ] **6d FRONTLIT_TD_SCALE**: leave at 0.1 for now; add a calibration-wizard-derived + frontlit factor as a future item (requires new measurement flow — out of scope). +- [ ] **6e Cleanup**: delete dead `luminanceToHeight` (`autoPaint.ts:1551`); update the + stale module doc header (`autoPaint.ts:1-17`). + +Risk: medium (visual shifts); mitigated by gating + harness + CHANGELOG. + +--- + +## 4. Decision log + +- **No new UI algorithm choices.** Persisted `optimizerAlgorithm` values keep their + names; internals re-map (`auto` → subset-exhaustive ≤6 / beam 7-12 / var-length SA + above; `exhaustive` → subset-exhaustive with honest ≤6 cap; `simulated-annealing` → + variable-length SA; `genetic` → keep or alias per Phase 4.4 harness data). Beam + search is an `auto` implementation detail, not a dropdown entry. Rationale: choices + are speed/quality trade-offs users already understand; renaming breaks saved + profiles/state. +- **Rejected: DP / shortest-path over discretized color states.** Reachable-color + state space (Lab × height with path-dependent blending) discretizes poorly; beam + search captures the same "build bottom-up, prune" idea without state-explosion risk. +- **Rejected (for now): variable-length genomes in GA.** Crossover design is fiddly; + at ≤16 filaments variable-length SA + beam cover the space. Revisit only if harness + shows SA stuck. +- **Gamma-space blending stays** (not switched to linear-light): calibration fits TDs + against the same gamma-space model, so the pair is self-consistent; changing it + would invalidate users' calibrated TDs (F8 note). Per-channel TD (6a) improves hue + realism without breaking that consistency. +- **`buildRepeatedSwapSequence` is deleted in Phase 4**, not improved in place — its + objective-mixing is structural (F7). + +## 5. Quick reference: finding → phase + +| Finding | Fixed in | +| ----------------------------------------- | ------------------- | +| F1 legacy path dead / weak scorer in prod | Phase 3 (+4) | +| F2 misaligned objective | Phase 3 | +| F3 subset selection lost | Phase 4 | +| F4 region weighting blind/inverted | Phase 1 | +| F5 stale cache across modes | Phase 2 | +| F6 non-deterministic default | Phase 2 | +| F7 repeated swaps bolt-on | Phase 4 | +| F8 physics gaps | Phase 6 (a-d) | +| F9 worker progress | Phase 5 | +| F10 minor issues | Phases 3.4, 4.1, 6e | +| No unit tests / no benchmark | Phase 0 | diff --git a/package.json b/package.json index 9b40379..3364639 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build && node scripts/generate-docs-seo-pages.mjs", "test": "node --no-warnings --experimental-strip-types tests/run-tests.ts", + "test:autopaint:update": "node --no-warnings --experimental-strip-types scripts/generate-auto-paint-goldens.ts", "test:e2e": "playwright test --grep @smoke", "test:e2e:matrix": "playwright test --grep @matrix", "test:e2e:stress": "playwright test --grep @stress", diff --git a/scripts/generate-auto-paint-goldens.ts b/scripts/generate-auto-paint-goldens.ts new file mode 100644 index 0000000..7b40b26 --- /dev/null +++ b/scripts/generate-auto-paint-goldens.ts @@ -0,0 +1,67 @@ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createServer } from 'vite'; + +import { autoPaintGoldenScenarios } from '../tests/autoPaintGoldenFixtures.ts'; + +type AutoPaintModule = typeof import('../src/lib/autoPaint.ts'); + +const LAYER_HEIGHT = 0.08; +const FIRST_LAYER_HEIGHT = 0.16; + +async function loadAutoPaintModule(): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { noDiscovery: true }, + resolve: { alias: { '@': resolve(process.cwd(), 'src') } }, + root: process.cwd(), + server: { hmr: false, middlewareMode: true }, + }); + + try { + return (await server.ssrLoadModule('/src/lib/autoPaint.ts')) as AutoPaintModule; + } finally { + await server.close(); + } +} + +const { generateAutoLayers } = await loadAutoPaintModule(); +const goldens = Object.fromEntries( + autoPaintGoldenScenarios().map((scenario) => { + const result = generateAutoLayers( + scenario.filaments, + scenario.imageSwatches, + LAYER_HEIGHT, + FIRST_LAYER_HEIGHT, + undefined, + scenario.enhancedColorMatch, + scenario.allowRepeatedSwaps, + { algorithm: 'auto', seed: scenario.seed }, + 'uniform', + scenario.imageDimensions + ); + + return [ + scenario.name, + { + filamentOrder: result.filamentOrder, + transitionZones: result.transitionZones.map((zone) => ({ + filamentId: zone.filamentId, + startHeight: zone.startHeight, + endHeight: zone.endHeight, + idealThickness: zone.idealThickness, + actualThickness: zone.actualThickness, + })), + totalHeight: result.totalHeight, + compressionRatio: result.compressionRatio, + }, + ]; + }) +); + +const outputPath = resolve('tests', 'assets', 'auto-paint-goldens.json'); +writeFileSync(outputPath, `${JSON.stringify(goldens, null, 2)}\n`); +console.log(`Wrote ${Object.keys(goldens).length} auto-paint goldens to ${outputPath}`); diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 56f98d8..2893847 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -1513,7 +1513,7 @@ export function autoPaintToSliceHeights( currentZ += thickness; layerIndex++; - if (layerIndex > 500) { + if (layerIndex >= 500) { console.warn('autoPaintToSliceHeights: too many layers, stopping at 500'); break; } diff --git a/tests/assets/auto-paint-goldens.json b/tests/assets/auto-paint-goldens.json new file mode 100644 index 0000000..33125d2 --- /dev/null +++ b/tests/assets/auto-paint-goldens.json @@ -0,0 +1,1218 @@ +{ + "B&W / logo-png / enhanced=false / repeats=false": { + "filamentOrder": [ + "plvjtmc", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.16, + "endHeight": 0.5800000000000001, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 0.5800000000000001, + "compressionRatio": 1 + }, + "B&W / logo-png / enhanced=false / repeats=true": { + "filamentOrder": [ + "plvjtmc", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.16, + "endHeight": 0.5800000000000001, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 0.5800000000000001, + "compressionRatio": 1 + }, + "B&W / logo-png / enhanced=true / repeats=false": { + "filamentOrder": [ + "p9c63ms", + "plvjtmc" + ], + "transitionZones": [ + { + "filamentId": "p9c63ms", + "startHeight": 0, + "endHeight": 0.7800000000000001, + "idealThickness": 0.7800000000000001, + "actualThickness": 0.7800000000000001 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.7800000000000001, + "endHeight": 0.8600000000000001, + "idealThickness": 0.08, + "actualThickness": 0.08 + } + ], + "totalHeight": 0.8600000000000001, + "compressionRatio": 1 + }, + "B&W / logo-png / enhanced=true / repeats=true": { + "filamentOrder": [ + "p9c63ms", + "plvjtmc", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "p9c63ms", + "startHeight": 0, + "endHeight": 0.7800000000000001, + "idealThickness": 0.7800000000000001, + "actualThickness": 0.7800000000000001 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.7800000000000001, + "endHeight": 0.8600000000000001, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.8600000000000001, + "endHeight": 1.2800000000000002, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 1.2800000000000002, + "compressionRatio": 1 + }, + "B&W / large-jpeg / enhanced=false / repeats=false": { + "filamentOrder": [ + "plvjtmc", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.16, + "endHeight": 0.5800000000000001, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 0.5800000000000001, + "compressionRatio": 1 + }, + "B&W / large-jpeg / enhanced=false / repeats=true": { + "filamentOrder": [ + "plvjtmc", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.16, + "endHeight": 0.5800000000000001, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 0.5800000000000001, + "compressionRatio": 1 + }, + "B&W / large-jpeg / enhanced=true / repeats=false": { + "filamentOrder": [ + "plvjtmc", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.16, + "endHeight": 0.5800000000000001, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 0.5800000000000001, + "compressionRatio": 1 + }, + "B&W / large-jpeg / enhanced=true / repeats=true": { + "filamentOrder": [ + "plvjtmc", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.16, + "endHeight": 0.5800000000000001, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 0.5800000000000001, + "compressionRatio": 1 + }, + "GH#27 / logo-png / enhanced=false / repeats=false": { + "filamentOrder": [ + "plvjtmc", + "h8zuocq", + "xud8mzr", + "y202l1e" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "h8zuocq", + "startHeight": 0.16, + "endHeight": 0.405, + "idealThickness": 0.245, + "actualThickness": 0.245 + }, + { + "filamentId": "xud8mzr", + "startHeight": 0.405, + "endHeight": 0.645, + "idealThickness": 0.24, + "actualThickness": 0.24 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.645, + "endHeight": 0.974, + "idealThickness": 0.329, + "actualThickness": 0.329 + } + ], + "totalHeight": 0.974, + "compressionRatio": 1 + }, + "GH#27 / logo-png / enhanced=false / repeats=true": { + "filamentOrder": [ + "plvjtmc", + "h8zuocq", + "xud8mzr", + "y202l1e" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "h8zuocq", + "startHeight": 0.16, + "endHeight": 0.405, + "idealThickness": 0.245, + "actualThickness": 0.245 + }, + { + "filamentId": "xud8mzr", + "startHeight": 0.405, + "endHeight": 0.645, + "idealThickness": 0.24, + "actualThickness": 0.24 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.645, + "endHeight": 0.974, + "idealThickness": 0.329, + "actualThickness": 0.329 + } + ], + "totalHeight": 0.974, + "compressionRatio": 1 + }, + "GH#27 / logo-png / enhanced=true / repeats=false": { + "filamentOrder": [ + "h8zuocq", + "xud8mzr", + "plvjtmc", + "y202l1e" + ], + "transitionZones": [ + { + "filamentId": "h8zuocq", + "startHeight": 0, + "endHeight": 0.45500000000000007, + "idealThickness": 0.45500000000000007, + "actualThickness": 0.45500000000000007 + }, + { + "filamentId": "xud8mzr", + "startHeight": 0.45500000000000007, + "endHeight": 0.6950000000000001, + "idealThickness": 0.24, + "actualThickness": 0.24 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.6950000000000001, + "endHeight": 0.775, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.775, + "endHeight": 1.104, + "idealThickness": 0.329, + "actualThickness": 0.329 + } + ], + "totalHeight": 1.104, + "compressionRatio": 1 + }, + "GH#27 / logo-png / enhanced=true / repeats=true": { + "filamentOrder": [ + "h8zuocq", + "xud8mzr", + "h8zuocq", + "y202l1e", + "plvjtmc", + "y202l1e", + "plvjtmc" + ], + "transitionZones": [ + { + "filamentId": "h8zuocq", + "startHeight": 0, + "endHeight": 0.45500000000000007, + "idealThickness": 0.45500000000000007, + "actualThickness": 0.45500000000000007 + }, + { + "filamentId": "xud8mzr", + "startHeight": 0.45500000000000007, + "endHeight": 0.6950000000000001, + "idealThickness": 0.24, + "actualThickness": 0.24 + }, + { + "filamentId": "h8zuocq", + "startHeight": 0.6950000000000001, + "endHeight": 0.935, + "idealThickness": 0.24, + "actualThickness": 0.24 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.935, + "endHeight": 1.264, + "idealThickness": 0.329, + "actualThickness": 0.329 + }, + { + "filamentId": "plvjtmc", + "startHeight": 1.264, + "endHeight": 1.344, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "y202l1e", + "startHeight": 1.344, + "endHeight": 1.673, + "idealThickness": 0.329, + "actualThickness": 0.329 + }, + { + "filamentId": "plvjtmc", + "startHeight": 1.673, + "endHeight": 1.7530000000000001, + "idealThickness": 0.08, + "actualThickness": 0.08 + } + ], + "totalHeight": 1.7530000000000001, + "compressionRatio": 1 + }, + "GH#27 / large-jpeg / enhanced=false / repeats=false": { + "filamentOrder": [ + "plvjtmc", + "h8zuocq", + "xud8mzr", + "y202l1e" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "h8zuocq", + "startHeight": 0.16, + "endHeight": 0.405, + "idealThickness": 0.245, + "actualThickness": 0.245 + }, + { + "filamentId": "xud8mzr", + "startHeight": 0.405, + "endHeight": 0.645, + "idealThickness": 0.24, + "actualThickness": 0.24 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.645, + "endHeight": 0.974, + "idealThickness": 0.329, + "actualThickness": 0.329 + } + ], + "totalHeight": 0.974, + "compressionRatio": 1 + }, + "GH#27 / large-jpeg / enhanced=false / repeats=true": { + "filamentOrder": [ + "plvjtmc", + "h8zuocq", + "xud8mzr", + "y202l1e" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "h8zuocq", + "startHeight": 0.16, + "endHeight": 0.405, + "idealThickness": 0.245, + "actualThickness": 0.245 + }, + { + "filamentId": "xud8mzr", + "startHeight": 0.405, + "endHeight": 0.645, + "idealThickness": 0.24, + "actualThickness": 0.24 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.645, + "endHeight": 0.974, + "idealThickness": 0.329, + "actualThickness": 0.329 + } + ], + "totalHeight": 0.974, + "compressionRatio": 1 + }, + "GH#27 / large-jpeg / enhanced=true / repeats=false": { + "filamentOrder": [ + "xud8mzr", + "y202l1e", + "plvjtmc", + "h8zuocq" + ], + "transitionZones": [ + { + "filamentId": "xud8mzr", + "startHeight": 0, + "endHeight": 0.4810000000000001, + "idealThickness": 0.4810000000000001, + "actualThickness": 0.4810000000000001 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.4810000000000001, + "endHeight": 0.81, + "idealThickness": 0.329, + "actualThickness": 0.329 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.81, + "endHeight": 0.89, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "h8zuocq", + "startHeight": 0.89, + "endHeight": 1.135, + "idealThickness": 0.245, + "actualThickness": 0.245 + } + ], + "totalHeight": 1.135, + "compressionRatio": 1 + }, + "GH#27 / large-jpeg / enhanced=true / repeats=true": { + "filamentOrder": [ + "xud8mzr", + "plvjtmc", + "xud8mzr", + "plvjtmc", + "y202l1e", + "xud8mzr", + "plvjtmc", + "h8zuocq" + ], + "transitionZones": [ + { + "filamentId": "xud8mzr", + "startHeight": 0, + "endHeight": 0.4810000000000001, + "idealThickness": 0.4810000000000001, + "actualThickness": 0.4810000000000001 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.4810000000000001, + "endHeight": 0.561, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "xud8mzr", + "startHeight": 0.561, + "endHeight": 0.8200000000000001, + "idealThickness": 0.259, + "actualThickness": 0.259 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.8200000000000001, + "endHeight": 0.9, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.9, + "endHeight": 1.229, + "idealThickness": 0.329, + "actualThickness": 0.329 + }, + { + "filamentId": "xud8mzr", + "startHeight": 1.229, + "endHeight": 1.488, + "idealThickness": 0.259, + "actualThickness": 0.259 + }, + { + "filamentId": "plvjtmc", + "startHeight": 1.488, + "endHeight": 1.568, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "h8zuocq", + "startHeight": 1.568, + "endHeight": 1.8130000000000002, + "idealThickness": 0.245, + "actualThickness": 0.245 + } + ], + "totalHeight": 1.8130000000000002, + "compressionRatio": 1 + }, + "Current 8 Colors / logo-png / enhanced=false / repeats=false": { + "filamentOrder": [ + "plvjtmc", + "azwg1yp", + "vsn9q6u", + "98z555k", + "upcjpfe", + "xyyxysq", + "w8cncoa", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "azwg1yp", + "startHeight": 0.16, + "endHeight": 0.356, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 0.356, + "endHeight": 0.6219999999999999, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "98z555k", + "startHeight": 0.6219999999999999, + "endHeight": 0.8949999999999998, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "upcjpfe", + "startHeight": 0.8949999999999998, + "endHeight": 1.2379999999999998, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "xyyxysq", + "startHeight": 1.2379999999999998, + "endHeight": 1.5949999999999998, + "idealThickness": 0.357, + "actualThickness": 0.357 + }, + { + "filamentId": "w8cncoa", + "startHeight": 1.5949999999999998, + "endHeight": 2.0359999999999996, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + }, + { + "filamentId": "p9c63ms", + "startHeight": 2.0359999999999996, + "endHeight": 2.4559999999999995, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 2.4559999999999995, + "compressionRatio": 1 + }, + "Current 8 Colors / logo-png / enhanced=false / repeats=true": { + "filamentOrder": [ + "plvjtmc", + "azwg1yp", + "vsn9q6u", + "98z555k", + "upcjpfe", + "xyyxysq", + "w8cncoa", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "azwg1yp", + "startHeight": 0.16, + "endHeight": 0.356, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 0.356, + "endHeight": 0.6219999999999999, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "98z555k", + "startHeight": 0.6219999999999999, + "endHeight": 0.8949999999999998, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "upcjpfe", + "startHeight": 0.8949999999999998, + "endHeight": 1.2379999999999998, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "xyyxysq", + "startHeight": 1.2379999999999998, + "endHeight": 1.5949999999999998, + "idealThickness": 0.357, + "actualThickness": 0.357 + }, + { + "filamentId": "w8cncoa", + "startHeight": 1.5949999999999998, + "endHeight": 2.0359999999999996, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + }, + { + "filamentId": "p9c63ms", + "startHeight": 2.0359999999999996, + "endHeight": 2.4559999999999995, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 2.4559999999999995, + "compressionRatio": 1 + }, + "Current 8 Colors / logo-png / enhanced=true / repeats=false": { + "filamentOrder": [ + "p9c63ms", + "plvjtmc", + "upcjpfe", + "azwg1yp", + "98z555k", + "xyyxysq", + "w8cncoa", + "vsn9q6u" + ], + "transitionZones": [ + { + "filamentId": "p9c63ms", + "startHeight": 0, + "endHeight": 0.7800000000000001, + "idealThickness": 0.7800000000000001, + "actualThickness": 0.7800000000000001 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.7800000000000001, + "endHeight": 0.8600000000000001, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "upcjpfe", + "startHeight": 0.8600000000000001, + "endHeight": 1.203, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "azwg1yp", + "startHeight": 1.203, + "endHeight": 1.399, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "98z555k", + "startHeight": 1.399, + "endHeight": 1.672, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "xyyxysq", + "startHeight": 1.672, + "endHeight": 2.029, + "idealThickness": 0.357, + "actualThickness": 0.357 + }, + { + "filamentId": "w8cncoa", + "startHeight": 2.029, + "endHeight": 2.4699999999999998, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 2.4699999999999998, + "endHeight": 2.7359999999999998, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + } + ], + "totalHeight": 2.7359999999999998, + "compressionRatio": 1 + }, + "Current 8 Colors / logo-png / enhanced=true / repeats=true": { + "filamentOrder": [ + "vsn9q6u", + "98z555k", + "vsn9q6u", + "azwg1yp", + "xyyxysq", + "w8cncoa", + "upcjpfe", + "azwg1yp", + "p9c63ms", + "plvjtmc", + "azwg1yp", + "w8cncoa" + ], + "transitionZones": [ + { + "filamentId": "vsn9q6u", + "startHeight": 0, + "endHeight": 0.49400000000000005, + "idealThickness": 0.49400000000000005, + "actualThickness": 0.49400000000000005 + }, + { + "filamentId": "98z555k", + "startHeight": 0.49400000000000005, + "endHeight": 0.767, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 0.767, + "endHeight": 1.033, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "azwg1yp", + "startHeight": 1.033, + "endHeight": 1.2289999999999999, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "xyyxysq", + "startHeight": 1.2289999999999999, + "endHeight": 1.5859999999999999, + "idealThickness": 0.357, + "actualThickness": 0.357 + }, + { + "filamentId": "w8cncoa", + "startHeight": 1.5859999999999999, + "endHeight": 2.0269999999999997, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + }, + { + "filamentId": "upcjpfe", + "startHeight": 2.0269999999999997, + "endHeight": 2.3699999999999997, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "azwg1yp", + "startHeight": 2.3699999999999997, + "endHeight": 2.566, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "p9c63ms", + "startHeight": 2.566, + "endHeight": 2.9859999999999998, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + }, + { + "filamentId": "plvjtmc", + "startHeight": 2.9859999999999998, + "endHeight": 3.066, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "azwg1yp", + "startHeight": 3.066, + "endHeight": 3.262, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "w8cncoa", + "startHeight": 3.262, + "endHeight": 3.703, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + } + ], + "totalHeight": 3.703, + "compressionRatio": 1 + }, + "Current 8 Colors / large-jpeg / enhanced=false / repeats=false": { + "filamentOrder": [ + "plvjtmc", + "azwg1yp", + "vsn9q6u", + "98z555k", + "upcjpfe", + "xyyxysq", + "w8cncoa", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "azwg1yp", + "startHeight": 0.16, + "endHeight": 0.356, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 0.356, + "endHeight": 0.6219999999999999, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "98z555k", + "startHeight": 0.6219999999999999, + "endHeight": 0.8949999999999998, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "upcjpfe", + "startHeight": 0.8949999999999998, + "endHeight": 1.2379999999999998, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "xyyxysq", + "startHeight": 1.2379999999999998, + "endHeight": 1.5949999999999998, + "idealThickness": 0.357, + "actualThickness": 0.357 + }, + { + "filamentId": "w8cncoa", + "startHeight": 1.5949999999999998, + "endHeight": 2.0359999999999996, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + }, + { + "filamentId": "p9c63ms", + "startHeight": 2.0359999999999996, + "endHeight": 2.4559999999999995, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 2.4559999999999995, + "compressionRatio": 1 + }, + "Current 8 Colors / large-jpeg / enhanced=false / repeats=true": { + "filamentOrder": [ + "plvjtmc", + "azwg1yp", + "vsn9q6u", + "98z555k", + "upcjpfe", + "xyyxysq", + "w8cncoa", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "plvjtmc", + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "azwg1yp", + "startHeight": 0.16, + "endHeight": 0.356, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 0.356, + "endHeight": 0.6219999999999999, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "98z555k", + "startHeight": 0.6219999999999999, + "endHeight": 0.8949999999999998, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "upcjpfe", + "startHeight": 0.8949999999999998, + "endHeight": 1.2379999999999998, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "xyyxysq", + "startHeight": 1.2379999999999998, + "endHeight": 1.5949999999999998, + "idealThickness": 0.357, + "actualThickness": 0.357 + }, + { + "filamentId": "w8cncoa", + "startHeight": 1.5949999999999998, + "endHeight": 2.0359999999999996, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + }, + { + "filamentId": "p9c63ms", + "startHeight": 2.0359999999999996, + "endHeight": 2.4559999999999995, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 2.4559999999999995, + "compressionRatio": 1 + }, + "Current 8 Colors / large-jpeg / enhanced=true / repeats=false": { + "filamentOrder": [ + "azwg1yp", + "98z555k", + "upcjpfe", + "xyyxysq", + "w8cncoa", + "vsn9q6u", + "plvjtmc", + "p9c63ms" + ], + "transitionZones": [ + { + "filamentId": "azwg1yp", + "startHeight": 0, + "endHeight": 0.364, + "idealThickness": 0.364, + "actualThickness": 0.364 + }, + { + "filamentId": "98z555k", + "startHeight": 0.364, + "endHeight": 0.637, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "upcjpfe", + "startHeight": 0.637, + "endHeight": 0.98, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "xyyxysq", + "startHeight": 0.98, + "endHeight": 1.337, + "idealThickness": 0.357, + "actualThickness": 0.357 + }, + { + "filamentId": "w8cncoa", + "startHeight": 1.337, + "endHeight": 1.778, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 1.778, + "endHeight": 2.044, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "plvjtmc", + "startHeight": 2.044, + "endHeight": 2.124, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "p9c63ms", + "startHeight": 2.124, + "endHeight": 2.544, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + } + ], + "totalHeight": 2.544, + "compressionRatio": 1 + }, + "Current 8 Colors / large-jpeg / enhanced=true / repeats=true": { + "filamentOrder": [ + "azwg1yp", + "98z555k", + "plvjtmc", + "p9c63ms", + "w8cncoa", + "xyyxysq", + "vsn9q6u", + "upcjpfe", + "98z555k", + "upcjpfe", + "plvjtmc", + "w8cncoa" + ], + "transitionZones": [ + { + "filamentId": "azwg1yp", + "startHeight": 0, + "endHeight": 0.364, + "idealThickness": 0.364, + "actualThickness": 0.364 + }, + { + "filamentId": "98z555k", + "startHeight": 0.364, + "endHeight": 0.637, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.637, + "endHeight": 0.717, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.717, + "endHeight": 1.137, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + }, + { + "filamentId": "w8cncoa", + "startHeight": 1.137, + "endHeight": 1.5779999999999998, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + }, + { + "filamentId": "xyyxysq", + "startHeight": 1.5779999999999998, + "endHeight": 1.9349999999999998, + "idealThickness": 0.357, + "actualThickness": 0.357 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 1.9349999999999998, + "endHeight": 2.2009999999999996, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "upcjpfe", + "startHeight": 2.2009999999999996, + "endHeight": 2.5439999999999996, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "98z555k", + "startHeight": 2.5439999999999996, + "endHeight": 2.8169999999999997, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "upcjpfe", + "startHeight": 2.8169999999999997, + "endHeight": 3.1599999999999997, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "plvjtmc", + "startHeight": 3.1599999999999997, + "endHeight": 3.2399999999999998, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "w8cncoa", + "startHeight": 3.2399999999999998, + "endHeight": 3.6809999999999996, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 + } + ], + "totalHeight": 3.6809999999999996, + "compressionRatio": 1 + } +} diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts new file mode 100644 index 0000000..5153d65 --- /dev/null +++ b/tests/autoPaint.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { createServer } from 'vite'; + +type AutoPaintModule = typeof import('../src/lib/autoPaint.ts'); + +const EPSILON = 1e-9; +let autoPaintModule: Promise | null = null; + +async function loadAutoPaintModule(): Promise { + autoPaintModule ??= loadViteModule('/src/lib/autoPaint.ts'); + return autoPaintModule; +} + +async function loadViteModule(modulePath: string): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { noDiscovery: true }, + resolve: { alias: { '@': resolve(process.cwd(), 'src') } }, + root: process.cwd(), + server: { hmr: false, middlewareMode: true }, + }); + + try { + return (await server.ssrLoadModule(modulePath)) as T; + } finally { + await server.close(); + } +} + +function assertAlmostEqual(actual: number, expected: number, message?: string) { + assert.ok( + Math.abs(actual - expected) <= EPSILON, + message ?? `expected ${actual} to be within ${EPSILON} of ${expected}` + ); +} + +test('transition thickness stays printable and respects its TD cap', async () => { + const { calculateTransitionThickness, hexToRgb } = await loadAutoPaintModule(); + const layerHeight = 0.1; + const td = 1; + const thickness = calculateTransitionThickness( + hexToRgb('#000000'), + hexToRgb('#ffffff'), + td, + layerHeight + ); + + assert.ok(thickness >= layerHeight, 'a transition must contain at least one layer'); + assert.ok(thickness <= td * 0.7 + EPSILON, 'a transition must not exceed the TD cap'); + + const nearIdenticalThickness = calculateTransitionThickness( + hexToRgb('#112233'), + hexToRgb('#112234'), + td, + layerHeight + ); + assert.equal( + nearIdenticalThickness, + layerHeight, + 'near-identical colors should use exactly one layer' + ); +}); + +test('ideal-height zones include a foundation and remain contiguous when compressed', async () => { + const { calculateIdealHeight, compressZones } = await loadAutoPaintModule(); + const layerHeight = 0.1; + const baseThickness = 0.6; + const { idealHeight, zones } = calculateIdealHeight( + [ + { id: 'black', color: '#000000', td: 0.5 }, + { id: 'red', color: '#ff0000', td: 0.8 }, + { id: 'white', color: '#ffffff', td: 1.1 }, + ], + layerHeight, + baseThickness + ); + + assert.equal(zones.length, 3, 'each filament should produce one zone'); + assertAlmostEqual( + zones[0].actualThickness, + Math.max(baseThickness, 0.5 * 1.3), + 'the foundation must be thick enough to be opaque' + ); + assertAlmostEqual(zones[0].startHeight, 0); + assertAlmostEqual(zones[zones.length - 1].endHeight, idealHeight); + + const { compressedZones, compressionRatio } = compressZones(zones, idealHeight / 2); + assertAlmostEqual(compressionRatio, 0.5); + assert.equal(compressedZones.length, zones.length); + assertAlmostEqual( + compressedZones[compressedZones.length - 1].endHeight, + idealHeight / 2, + 'compressed zones should end exactly at the requested height' + ); + + for (let index = 0; index < compressedZones.length; index++) { + const zone = compressedZones[index]; + assert.ok(zone.actualThickness > 0, `zone ${index} must have positive thickness`); + if (index > 0) { + assertAlmostEqual( + zone.startHeight, + compressedZones[index - 1].endHeight, + `zone ${index} must start where the previous zone ends` + ); + } + } + + const noCompression = compressZones(zones, idealHeight + 1); + assert.equal(noCompression.compressionRatio, 1, 'a sufficiently tall limit should not compress'); + assert.deepEqual(noCompression.compressedZones, zones); +}); + +test('auto-paint slice data stays synchronized and uses print-layer heights', async () => { + const { autoPaintToSliceHeights, generateAutoLayers } = await loadAutoPaintModule(); + const layerHeight = 0.1; + const firstLayerHeight = 0.2; + const result = generateAutoLayers( + [ + { id: 'black', color: '#000000', td: 1 }, + { id: 'white', color: '#ffffff', td: 1.5 }, + ], + [ + { hex: '#000000', count: 10 }, + { hex: '#ffffff', count: 10 }, + ], + layerHeight, + firstLayerHeight, + undefined, + false + ); + const slices = autoPaintToSliceHeights(result, layerHeight, firstLayerHeight); + + assert.ok(slices.colorSliceHeights.length > 0, 'a valid stack should produce slices'); + assert.equal(slices.colorSliceHeights[0], firstLayerHeight); + for (const height of slices.colorSliceHeights.slice(1)) { + assert.equal(height, layerHeight, 'all later slices should use the configured layer height'); + } + assert.ok(slices.colorSliceHeights.length <= 500, 'slice output must respect the safety limit'); + assert.equal(slices.virtualSwatches.length, slices.colorSliceHeights.length); + assert.equal(slices.filamentSwatches.length, slices.colorSliceHeights.length); + assert.equal(slices.colorOrder.length, slices.colorSliceHeights.length); + assert.deepEqual( + slices.colorOrder, + slices.colorOrder.map((_, index) => index), + 'slice ordering should be sequential' + ); +}); + +test('auto-paint slice data never returns more than 500 layers', async () => { + const { autoPaintToSliceHeights } = await loadAutoPaintModule(); + const tallResult = { + layers: [ + { + filamentId: 'black', + filamentColor: '#000000', + startHeight: 0, + endHeight: 100, + }, + ], + totalHeight: 100, + idealHeight: 100, + autoHeight: 100, + compressionRatio: 1, + filamentOrder: ['black'], + transitionZones: [ + { + filamentId: 'black', + filamentColor: '#000000', + filamentTd: 1, + startHeight: 0, + endHeight: 100, + idealThickness: 100, + actualThickness: 100, + }, + ], + confidence: 1, + confidenceFactors: { + calibrationQuality: 1, + filamentCoverage: 1, + compressionImpact: 1, + }, + }; + + const slices = autoPaintToSliceHeights(tallResult, 0.1, 0.2); + assert.ok(slices.colorSliceHeights.length <= 500); + assert.equal(slices.colorSliceHeights.length, 500); +}); diff --git a/tests/autoPaintGoldenFixtures.ts b/tests/autoPaintGoldenFixtures.ts new file mode 100644 index 0000000..371c331 --- /dev/null +++ b/tests/autoPaintGoldenFixtures.ts @@ -0,0 +1,122 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + colorSwatchesFromJpegBlocks, + logoFixturePath, + largeIssueFixturePath, + readPngFixture, + testAssetsRoot, + type PngImage, +} from './imageFixtures.ts'; + +export interface AutoPaintGoldenScenario { + name: string; + filaments: Array<{ id: string; color: string; td: number }>; + imageSwatches: Array<{ hex: string; count: number }>; + imageDimensions: { width: number; height: number }; + enhancedColorMatch: boolean; + allowRepeatedSwaps: boolean; + seed: number; +} + +interface FilamentProfileFixture { + name: string; + filaments: Array<{ id: string; color: string; td: number }>; +} + +const PROFILE_FILES = [ + ['2_Colors.kapp', 2], + ['4_Colors.kapp', 4], + ['8_Colors.kapp', 8], +] as const; +const SWATCH_CAP = 2 ** 14; + +function readProfile(fileName: string, expectedFilamentCount: number): FilamentProfileFixture { + const profilePath = resolve(testAssetsRoot, 'filament-profiles', fileName); + const parsed = JSON.parse(readFileSync(profilePath, 'utf8')) as Partial; + + if (!parsed.name || !parsed.filaments || parsed.filaments.length !== expectedFilamentCount) { + throw new Error(`Unexpected auto-paint profile fixture: ${fileName}`); + } + + return { + name: parsed.name, + filaments: parsed.filaments.map((filament) => ({ + id: filament.id, + color: filament.color, + td: filament.td, + })), + }; +} + +function rgbToHex(r: number, g: number, b: number) { + return `#${[r, g, b] + .map((value) => value.toString(16).padStart(2, '0')) + .join('')}`; +} + +function pngSwatches(image: PngImage): Array<{ hex: string; count: number }> { + const counts = new Map(); + + for (let index = 0; index < image.rgba.length; index += 4) { + if (image.rgba[index + 3] === 0) continue; + + const hex = rgbToHex(image.rgba[index], image.rgba[index + 1], image.rgba[index + 2]); + counts.set(hex, (counts.get(hex) ?? 0) + 1); + } + + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, SWATCH_CAP) + .map(([hex, count]) => ({ hex, count })); +} + +function scenarioSeed(profileIndex: number, fixtureIndex: number, enhanced: boolean, repeats: boolean) { + return ( + 0x4b524f4d + + profileIndex * 1009 + + fixtureIndex * 101 + + (enhanced ? 17 : 0) + + (repeats ? 3 : 0) + ); +} + +export function autoPaintGoldenScenarios(): AutoPaintGoldenScenario[] { + const png = readPngFixture(logoFixturePath); + const fixtures = [ + { + name: 'logo-png', + imageSwatches: pngSwatches(png), + imageDimensions: { width: png.width, height: png.height }, + }, + { + name: 'large-jpeg', + imageSwatches: colorSwatchesFromJpegBlocks(largeIssueFixturePath), + imageDimensions: { width: 3888, height: 2916 }, + }, + ]; + + return PROFILE_FILES.flatMap(([fileName, filamentCount], profileIndex) => { + const profile = readProfile(fileName, filamentCount); + + return fixtures.flatMap((fixture, fixtureIndex) => + [false, true].flatMap((enhancedColorMatch) => + [false, true].map((allowRepeatedSwaps) => ({ + name: `${profile.name} / ${fixture.name} / enhanced=${enhancedColorMatch} / repeats=${allowRepeatedSwaps}`, + filaments: profile.filaments, + imageSwatches: fixture.imageSwatches, + imageDimensions: fixture.imageDimensions, + enhancedColorMatch, + allowRepeatedSwaps, + seed: scenarioSeed( + profileIndex, + fixtureIndex, + enhancedColorMatch, + allowRepeatedSwaps + ), + })) + ) + ); + }); +} diff --git a/tests/autoPaintGoldens.test.ts b/tests/autoPaintGoldens.test.ts new file mode 100644 index 0000000..4c313af --- /dev/null +++ b/tests/autoPaintGoldens.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test, { type TestContext } from 'node:test'; +import { createServer } from 'vite'; + +import { autoPaintGoldenScenarios } from './autoPaintGoldenFixtures.ts'; + +type AutoPaintModule = typeof import('../src/lib/autoPaint.ts'); + +interface AutoPaintGolden { + filamentOrder: string[]; + transitionZones: Array<{ + filamentId: string; + startHeight: number; + endHeight: number; + idealThickness: number; + actualThickness: number; + }>; + totalHeight: number; + compressionRatio: number; +} + +const LAYER_HEIGHT = 0.08; +const FIRST_LAYER_HEIGHT = 0.16; +const EPSILON = 1e-6; +const goldenPath = resolve('tests', 'assets', 'auto-paint-goldens.json'); +let autoPaintModule: Promise | null = null; + +async function loadAutoPaintModule(): Promise { + autoPaintModule ??= loadViteModule('/src/lib/autoPaint.ts'); + return autoPaintModule; +} + +async function loadViteModule(modulePath: string): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { noDiscovery: true }, + resolve: { alias: { '@': resolve(process.cwd(), 'src') } }, + root: process.cwd(), + server: { hmr: false, middlewareMode: true }, + }); + + try { + return (await server.ssrLoadModule(modulePath)) as T; + } finally { + await server.close(); + } +} + +function snapshot(result: ReturnType): AutoPaintGolden { + return { + filamentOrder: result.filamentOrder, + transitionZones: result.transitionZones.map((zone) => ({ + filamentId: zone.filamentId, + startHeight: zone.startHeight, + endHeight: zone.endHeight, + idealThickness: zone.idealThickness, + actualThickness: zone.actualThickness, + })), + totalHeight: result.totalHeight, + compressionRatio: result.compressionRatio, + }; +} + +function assertApproximateGolden(actual: AutoPaintGolden, expected: AutoPaintGolden) { + assert.deepEqual(actual.filamentOrder, expected.filamentOrder, 'filament order changed'); + assert.equal(actual.transitionZones.length, expected.transitionZones.length, 'zone count changed'); + assert.ok( + Math.abs(actual.totalHeight - expected.totalHeight) <= EPSILON, + `total height changed from ${expected.totalHeight} to ${actual.totalHeight}` + ); + assert.ok( + Math.abs(actual.compressionRatio - expected.compressionRatio) <= EPSILON, + `compression ratio changed from ${expected.compressionRatio} to ${actual.compressionRatio}` + ); + + actual.transitionZones.forEach((zone, index) => { + const expectedZone = expected.transitionZones[index]; + assert.equal(zone.filamentId, expectedZone.filamentId, `zone ${index} filament changed`); + + for (const key of ['startHeight', 'endHeight', 'idealThickness', 'actualThickness'] as const) { + assert.ok( + Math.abs(zone[key] - expectedZone[key]) <= EPSILON, + `zone ${index} ${key} changed from ${expectedZone[key]} to ${zone[key]}` + ); + } + }); +} + +test('seeded auto-paint stack goldens stay deliberate', async (t: TestContext) => { + const expected = JSON.parse(readFileSync(goldenPath, 'utf8')) as Record; + const scenarios = autoPaintGoldenScenarios(); + assert.deepEqual(Object.keys(expected).sort(), scenarios.map((scenario) => scenario.name).sort()); + + const { generateAutoLayers } = await loadAutoPaintModule(); + for (const scenario of scenarios) { + await t.test(scenario.name, () => { + const result = generateAutoLayers( + scenario.filaments, + scenario.imageSwatches, + LAYER_HEIGHT, + FIRST_LAYER_HEIGHT, + undefined, + scenario.enhancedColorMatch, + scenario.allowRepeatedSwaps, + { algorithm: 'auto', seed: scenario.seed }, + 'uniform', + scenario.imageDimensions + ); + + assertApproximateGolden(snapshot(result), expected[scenario.name]); + }); + } +}); diff --git a/tests/imageFixtures.ts b/tests/imageFixtures.ts index 460945b..2fb0842 100644 --- a/tests/imageFixtures.ts +++ b/tests/imageFixtures.ts @@ -41,6 +41,11 @@ interface JpegLuminanceBlocks { blockWidth: number; blockHeight: number; values: Float32Array; + componentIds: number[]; + componentBlocks: Map< + number, + { blockWidth: number; blockHeight: number; values: Float32Array } + >; } export const testAssetsRoot = resolve(dirname(fileURLToPath(import.meta.url)), 'assets'); @@ -446,6 +451,22 @@ function readJpegLuminanceBlocks(filePath: string): JpegLuminanceBlocks { const values = new Float32Array(blockWidth * blockHeight); values.fill(255); + const componentBlocks = new Map< + number, + { blockWidth: number; blockHeight: number; values: Float32Array } + >(); + for (const component of components) { + const componentBlockWidth = mcusX * component.horizontalSampling; + const componentBlockHeight = mcusY * component.verticalSampling; + const componentValues = new Float32Array(componentBlockWidth * componentBlockHeight); + componentValues.fill(128); + componentBlocks.set(component.id, { + blockWidth: componentBlockWidth, + blockHeight: componentBlockHeight, + values: componentValues, + }); + } + const reader = new JpegBitReader(data, entropyOffset); const dcPredictors = new Map(); const luminanceComponentId = components[0].id; @@ -482,15 +503,25 @@ function readJpegLuminanceBlocks(filePath: string): JpegLuminanceBlocks { dcPredictors.set(component.id, dc); skipJpegBlockAc(reader, acTable); - if (component.id === luminanceComponentId) { - const blockX = mcuX * maxHorizontalSampling + bx; - const blockY = mcuY * maxVerticalSampling + by; + const componentBlock = componentBlocks.get(component.id); + assert.ok(componentBlock, `Missing block storage for component ${component.id}`); + const blockX = mcuX * component.horizontalSampling + bx; + const blockY = mcuY * component.verticalSampling + by; + const average = Math.max( + 0, + Math.min(255, (dc * quantizationTable[0]) / 8 + 128) + ); + + if ( + blockX < componentBlock.blockWidth && + blockY < componentBlock.blockHeight + ) { + componentBlock.values[blockY * componentBlock.blockWidth + blockX] = + average; + } + if (component.id === luminanceComponentId) { if (blockX < blockWidth && blockY < blockHeight) { - const average = Math.max( - 0, - Math.min(255, (dc * quantizationTable[0]) / 8 + 128) - ); values[blockY * blockWidth + blockX] = average; } } @@ -502,11 +533,78 @@ function readJpegLuminanceBlocks(filePath: string): JpegLuminanceBlocks { } } - const result = { width, height, blockWidth, blockHeight, values }; + const result = { + width, + height, + blockWidth, + blockHeight, + values, + componentIds: components.map((component) => component.id), + componentBlocks, + }; jpegLuminanceBlockCache.set(filePath, result); return result; } +/** + * Create a compact, stable color histogram from a baseline JPEG's DC blocks. + * + * The existing test decoder intentionally skips AC coefficients because mesh + * fixtures only need image structure. For auto-paint regression coverage we + * reuse those block averages: they preserve broad image colors while keeping + * the fixture fast to load and dependency-free. + */ +export function colorSwatchesFromJpegBlocks( + filePath: string, + maxColors: number = 4096 +): Array<{ hex: string; count: number }> { + const image = readJpegLuminanceBlocks(filePath); + const [luminanceId, cbId, crId] = image.componentIds; + const luminanceBlocks = image.componentBlocks.get(luminanceId); + const cbBlocks = cbId === undefined ? undefined : image.componentBlocks.get(cbId); + const crBlocks = crId === undefined ? undefined : image.componentBlocks.get(crId); + assert.ok(luminanceBlocks, 'JPEG fixture should include a luminance component'); + + const counts = new Map(); + const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value))); + const quantize = (value: number) => Math.min(255, Math.round(value / 16) * 16); + const sample = ( + component: { blockWidth: number; blockHeight: number; values: Float32Array }, + x: number, + y: number + ) => { + const sourceX = Math.min( + component.blockWidth - 1, + Math.floor((x * component.blockWidth) / image.blockWidth) + ); + const sourceY = Math.min( + component.blockHeight - 1, + Math.floor((y * component.blockHeight) / image.blockHeight) + ); + return component.values[sourceY * component.blockWidth + sourceX]; + }; + + for (let y = 0; y < image.blockHeight; y++) { + for (let x = 0; x < image.blockWidth; x++) { + const luminance = sample(luminanceBlocks, x, y); + const cb = cbBlocks ? sample(cbBlocks, x, y) : 128; + const cr = crBlocks ? sample(crBlocks, x, y) : 128; + const r = quantize(clampByte(luminance + 1.402 * (cr - 128))); + const g = quantize(clampByte(luminance - 0.344136 * (cb - 128) - 0.714136 * (cr - 128))); + const b = quantize(clampByte(luminance + 1.772 * (cb - 128))); + const hex = `#${[r, g, b] + .map((value) => value.toString(16).padStart(2, '0')) + .join('')}`; + counts.set(hex, (counts.get(hex) ?? 0) + 1); + } + } + + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, maxColors) + .map(([hex, count]) => ({ hex, count })); +} + export function maskFromPngAlpha(filePath: string, maxSide: number): RasterMask { const image = readPng(filePath); const sampleSize = Math.max(1, Math.ceil(Math.max(image.width, image.height) / maxSide)); diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts new file mode 100644 index 0000000..84cff3c --- /dev/null +++ b/tests/optimizer.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { createServer } from 'vite'; + +type OptimizerModule = typeof import('../src/lib/optimizer.ts'); + +const filaments = [ + { id: 'black', color: '#101010', td: 0.8 }, + { id: 'blue', color: '#2557a7', td: 1.4 }, + { id: 'red', color: '#bb4b3b', td: 1.8 }, + { id: 'white', color: '#eeeeee', td: 2.2 }, +]; +const context = { + imageColors: [ + { L: 16, a: 0, b: 0, weight: 0.2 }, + { L: 38, a: 12, b: -34, weight: 0.25 }, + { L: 51, a: 38, b: 26, weight: 0.3 }, + { L: 91, a: 0, b: 0, weight: 0.25 }, + ], + layerHeight: 0.08, + firstLayerHeight: 0.16, +}; +let optimizerModule: Promise | null = null; + +async function loadOptimizerModule(): Promise { + optimizerModule ??= loadViteModule('/src/lib/optimizer.ts'); + return optimizerModule; +} + +async function loadViteModule(modulePath: string): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { noDiscovery: true }, + resolve: { alias: { '@': resolve(process.cwd(), 'src') } }, + root: process.cwd(), + server: { hmr: false, middlewareMode: true }, + }); + + try { + return (await server.ssrLoadModule(modulePath)) as T; + } finally { + await server.close(); + } +} + +test('each optimizer produces the same result for the same seed', async (t) => { + const { optimizeFilamentOrder } = await loadOptimizerModule(); + const algorithms = ['exhaustive', 'simulated-annealing', 'genetic', 'auto'] as const; + + for (const algorithm of algorithms) { + await t.test(algorithm, () => { + const options = { + algorithm, + seed: 0x4b524f4d, + cachingEnabled: false, + maxIterations: 30, + populationSize: 16, + }; + + const first = optimizeFilamentOrder(filaments, context, options); + const second = optimizeFilamentOrder(filaments, context, options); + + assert.deepEqual(second, first); + }); + } +}); From 43eb66e3d2be16b3e95d4751070006f666992cc7 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 20 Jun 2026 21:30:04 +0300 Subject: [PATCH 02/31] Add auto-paint benchmark harness --- CHANGELOG.md | 1 + docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 4 +- package.json | 1 + tests/benchmark/README.md | 7 ++ tests/benchmark/autoPaintBench.ts | 121 +++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 tests/benchmark/README.md create mode 100644 tests/benchmark/autoPaintBench.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb4b2f..a4f938a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Kromacut are documented in this file. ### Added - **Auto-paint regression baseline** - Added focused layer-invariant coverage, per-algorithm seeded determinism checks, and 24 seeded stack snapshots across the 2-, 4-, and 8-filament profiles, both image fixtures, Enhanced matching states, and repeated-swap states. +- **Auto-paint benchmark harness** - Added an on-demand JSON benchmark that measures palette and preview-realized color error, coverage, stack cost, compression impact, runtime, and optimizer iterations across the saved fixture profiles. ### Changed diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md index f35c4e2..fe88a09 100644 --- a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md +++ b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md @@ -184,7 +184,7 @@ Goal: pin current behavior and make quality measurable before changing anything. deliberately** in Phases 3/4 — their job is making behavior changes visible, not frozen. - [x] **0.3 Determinism tests**: per algorithm, same seed twice → deep-equal result. -- [ ] **0.4 Benchmark harness** (`tests/benchmark/autoPaintBench.ts`, runnable via a +- [x] **0.4 Benchmark harness** (`tests/benchmark/autoPaintBench.ts`, runnable via a package script, not part of CI gating initially). Per image × profile × algorithm × seed, emit JSON with: - Weighted mean ΔE (report **both** CIE76 and CIEDE2000) from clustered targets to @@ -198,7 +198,7 @@ Goal: pin current behavior and make quality measurable before changing anything. compression ratio under a fixed `maxHeight` scenario. - Cost: wall time, evaluations, iterations. - Stability: cross-seed rank agreement for SA/GA. -- [ ] **0.5 Acceptance rule for later phases** (documented in the harness README): +- [x] **0.5 Acceptance rule for later phases** (documented in the harness README): end-to-end realized ΔE improves on average across fixtures and regresses on no fixture beyond tolerance (suggest 5%), within cost budgets (≤2 s for 8 filaments). diff --git a/package.json b/package.json index 3364639..e3aa684 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "tsc -b && vite build && node scripts/generate-docs-seo-pages.mjs", "test": "node --no-warnings --experimental-strip-types tests/run-tests.ts", "test:autopaint:update": "node --no-warnings --experimental-strip-types scripts/generate-auto-paint-goldens.ts", + "benchmark:autopaint": "node --no-warnings --experimental-strip-types tests/benchmark/autoPaintBench.ts", "test:e2e": "playwright test --grep @smoke", "test:e2e:matrix": "playwright test --grep @matrix", "test:e2e:stress": "playwright test --grep @stress", diff --git a/tests/benchmark/README.md b/tests/benchmark/README.md new file mode 100644 index 0000000..4a9481f --- /dev/null +++ b/tests/benchmark/README.md @@ -0,0 +1,7 @@ +# Auto-paint benchmark + +Run `npm run benchmark:autopaint > benchmark.json` to create a local JSON report. It is deliberately outside normal tests: it measures quality and cost across the saved profiles and image fixtures. + +The main number is `realizedError.weightedMean`. It replays the preview's Lab-space color-to-height projection and compares the virtual color at that printable height with the target color. Lower is better. + +For Phase 3 and later, accept a change only when average realized error improves, no fixture regresses by more than 5%, and the 8-filament case stays within a 2-second budget on the comparison machine. Record the machine and the command when comparing reports. diff --git a/tests/benchmark/autoPaintBench.ts b/tests/benchmark/autoPaintBench.ts new file mode 100644 index 0000000..84f4a41 --- /dev/null +++ b/tests/benchmark/autoPaintBench.ts @@ -0,0 +1,121 @@ +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import { createServer } from 'vite'; + +import { autoPaintGoldenScenarios } from '../autoPaintGoldenFixtures.ts'; + +type AutoPaintModule = typeof import('../../src/lib/autoPaint.ts'); +type Algorithm = 'auto' | 'exhaustive' | 'simulated-annealing' | 'genetic'; +type Lab = { L: number; a: number; b: number }; + +const LAYER_HEIGHT = 0.08; +const FIRST_LAYER_HEIGHT = 0.16; +const COMPRESSED_MAX_HEIGHT = 1.2; +const SEEDS = [0x4b524f4d, 0x4b524f4e, 0x4b524f4f]; + +async function loadAutoPaintModule(): Promise { + const server = await createServer({ + appType: 'custom', cacheDir: 'dist/.vite-test-cache', configFile: false, logLevel: 'error', + optimizeDeps: { noDiscovery: true }, resolve: { alias: { '@': resolve(process.cwd(), 'src') } }, + root: process.cwd(), server: { hmr: false, middlewareMode: true }, + }); + try { return (await server.ssrLoadModule('/src/lib/autoPaint.ts')) as AutoPaintModule; } + finally { await server.close(); } +} + +function ciede2000(left: Lab, right: Lab) { + const c1 = Math.hypot(left.a, left.b); + const c2 = Math.hypot(right.a, right.b); + const averageC = (c1 + c2) / 2; + const g = 0.5 * (1 - Math.sqrt(averageC ** 7 / (averageC ** 7 + 25 ** 7))); + const a1 = (1 + g) * left.a; + const a2 = (1 + g) * right.a; + const c1p = Math.hypot(a1, left.b); + const c2p = Math.hypot(a2, right.b); + const h = (a: number, b: number) => (Math.atan2(b, a) * 180 / Math.PI + 360) % 360; + const h1 = h(a1, left.b), h2 = h(a2, right.b); + const deltaL = right.L - left.L, deltaC = c2p - c1p; + const deltaHAngle = c1p * c2p === 0 ? 0 : Math.abs(h2 - h1) <= 180 ? h2 - h1 : h2 <= h1 ? h2 - h1 + 360 : h2 - h1 - 360; + const deltaH = 2 * Math.sqrt(c1p * c2p) * Math.sin((deltaHAngle / 2) * Math.PI / 180); + const meanL = (left.L + right.L) / 2, meanC = (c1p + c2p) / 2; + const meanH = c1p * c2p === 0 ? h1 + h2 : Math.abs(h1 - h2) <= 180 ? (h1 + h2) / 2 : h1 + h2 < 360 ? (h1 + h2 + 360) / 2 : (h1 + h2 - 360) / 2; + const t = 1 - 0.17 * Math.cos((meanH - 30) * Math.PI / 180) + 0.24 * Math.cos(2 * meanH * Math.PI / 180) + 0.32 * Math.cos((3 * meanH + 6) * Math.PI / 180) - 0.2 * Math.cos((4 * meanH - 63) * Math.PI / 180); + const sl = 1 + 0.015 * (meanL - 50) ** 2 / Math.sqrt(20 + (meanL - 50) ** 2); + const sc = 1 + 0.045 * meanC; + const sh = 1 + 0.015 * meanC * t; + const rt = -2 * Math.sqrt(meanC ** 7 / (meanC ** 7 + 25 ** 7)) * Math.sin((60 * Math.exp(-(((meanH - 275) / 25) ** 2))) * Math.PI / 180); + return Math.sqrt((deltaL / sl) ** 2 + (deltaC / sc) ** 2 + (deltaH / sh) ** 2 + rt * (deltaC / sc) * (deltaH / sh)); +} + +function weightedSummary(samples: Array<{ value: number; weight: number }>) { + const totalWeight = samples.reduce((sum, sample) => sum + sample.weight, 0); + const weightedMean = samples.reduce((sum, sample) => sum + sample.value * sample.weight, 0) / totalWeight; + const ordered = [...samples].sort((a, b) => a.value - b.value); + let cumulative = 0; + const p95 = ordered.find((sample) => (cumulative += sample.weight) >= totalWeight * 0.95)?.value ?? 0; + return { weightedMean, p95, totalWeight }; +} + +function cumulativeHeights(sliceHeights: number[], colorOrder: number[]) { + let total = 0; + return colorOrder.map((index, position) => { + total += position === 0 ? Math.max(sliceHeights[index], FIRST_LAYER_HEIGHT) : sliceHeights[index]; + return total; + }); +} + +function projectHeight(target: Lab, nodes: Array<{ lab: Lab; min: number; max: number }>, cumulative: number[]) { + let bestDistance = Infinity, height = cumulative[0] ?? 0; + for (const node of nodes) { + const distance = Math.hypot(target.L - node.lab.L, target.a - node.lab.a, target.b - node.lab.b); + if (distance < bestDistance) { bestDistance = distance; height = (node.min + node.max) / 2; } + } + for (let index = 0; index < nodes.length - 1; index++) { + const from = nodes[index], to = nodes[index + 1]; + const dL = to.lab.L - from.lab.L, da = to.lab.a - from.lab.a, db = to.lab.b - from.lab.b; + const lengthSq = dL * dL + da * da + db * db; + if (lengthSq < 0.01) continue; + const t = Math.max(0, Math.min(1, ((target.L - from.lab.L) * dL + (target.a - from.lab.a) * da + (target.b - from.lab.b) * db) / lengthSq)); + const distance = Math.hypot(target.L - (from.lab.L + t * dL), target.a - (from.lab.a + t * da), target.b - (from.lab.b + t * db)); + if (distance < bestDistance) { bestDistance = distance; height = from.max + t * (to.min - from.max); } + } + return Math.max(cumulative[0] ?? 0, Math.min(cumulative.at(-1) ?? 0, height)); +} + +const autoPaint = await loadAutoPaintModule(); +const output: unknown[] = []; + +for (const scenario of autoPaintGoldenScenarios().filter( + (scenario) => scenario.enhancedColorMatch && scenario.allowRepeatedSwaps +)) { + const profileSize = scenario.filaments.length; + const algorithms: Algorithm[] = profileSize <= 6 + ? ['auto', 'exhaustive', 'simulated-annealing', 'genetic'] + : ['auto', 'simulated-annealing', 'genetic']; + for (const algorithm of algorithms) { + const seeds = algorithm === 'simulated-annealing' || algorithm === 'genetic' ? SEEDS : [SEEDS[0]]; + for (const seed of seeds) { + const start = performance.now(); + const result = autoPaint.generateAutoLayers(scenario.filaments, scenario.imageSwatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, undefined, scenario.enhancedColorMatch, scenario.allowRepeatedSwaps, { algorithm, seed }, 'uniform', scenario.imageDimensions); + const elapsedMs = performance.now() - start; + const slices = autoPaint.autoPaintToSliceHeights(result, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const palette = slices.virtualSwatches.map((swatch) => autoPaint.rgbToLab(autoPaint.hexToRgb(swatch.hex))); + const targets = scenario.imageSwatches.map((swatch) => ({ lab: autoPaint.rgbToLab(autoPaint.hexToRgb(swatch.hex)), weight: swatch.count })); + const errors76 = targets.map((target) => ({ weight: target.weight, value: Math.min(...palette.map((entry) => autoPaint.deltaELab(target.lab, entry))) })); + const errors2000 = targets.map((target) => ({ weight: target.weight, value: Math.min(...palette.map((entry) => ciede2000(target.lab, entry))) })); + const coverage = (limit: number) => errors76.filter((sample) => sample.value <= limit).reduce((sum, sample) => sum + sample.weight, 0) / errors76.reduce((sum, sample) => sum + sample.weight, 0); + const heights = cumulativeHeights(slices.colorSliceHeights, slices.colorOrder); + const nodes = palette.map((lab, index) => ({ lab, min: heights[index], max: heights[index] })); + const realized = targets.map((target) => { + const height = projectHeight(target.lab, nodes, heights); + const slice = Math.min(heights.length - 1, heights.findIndex((value) => value >= height)); + return { weight: target.weight, value: autoPaint.deltaELab(target.lab, palette[Math.max(0, slice)]) }; + }); + const compressed = autoPaint.generateAutoLayers(scenario.filaments, scenario.imageSwatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, COMPRESSED_MAX_HEIGHT, scenario.enhancedColorMatch, scenario.allowRepeatedSwaps, { algorithm, seed }, 'uniform', scenario.imageDimensions); + const used = new Set(errors76.map((_, targetIndex) => palette.reduce((best, entry, index) => autoPaint.deltaELab(targets[targetIndex].lab, entry) < autoPaint.deltaELab(targets[targetIndex].lab, palette[best]) ? index : best, 0))); + output.push({ scenario: scenario.name, algorithm, seed, colorError: { cie76: weightedSummary(errors76), ciede2000: weightedSummary(errors2000), coverageAt2_3: coverage(2.3), coverageAt5: coverage(5) }, realizedError: weightedSummary(realized), structure: { totalHeight: result.totalHeight, layerCount: slices.colorSliceHeights.length, sequenceLength: result.filamentOrder.length, wastedLayerFraction: slices.colorSliceHeights.length ? (slices.colorSliceHeights.length - used.size) / slices.colorSliceHeights.length : 0, compressedMaxHeight: COMPRESSED_MAX_HEIGHT, compressionRatio: compressed.compressionRatio }, cost: { wallTimeMs: elapsedMs, iterations: result.optimizerMetadata?.iterations ?? 0 } }); + } + } +} + +console.log(JSON.stringify({ generatedAt: new Date().toISOString(), results: output }, null, 2)); From 1544270fe2bc6fb60cf4a38e8b33bfb7f4d01e28 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 21 Jun 2026 10:08:47 +0300 Subject: [PATCH 03/31] Fix auto-paint region priority --- CHANGELOG.md | 2 + README.md | 2 +- docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 10 +-- src/components/ThreeDControls.tsx | 1 - src/docs/3d-mode.md | 2 + src/hooks/useAutoPaintWorker.ts | 43 ++++++--- src/hooks/useSwatches.ts | 29 +++++- src/lib/autoPaint.ts | 124 ++------------------------ src/lib/optimizer.ts | 2 - src/lib/regionWeighting.ts | 76 ++++++++++------ src/types/index.ts | 11 ++- src/workers/autoPaint.worker.ts | 6 +- tests/autoPaintGoldens.test.ts | 4 +- tests/benchmark/autoPaintBench.ts | 4 +- tests/export3mf.test.ts | 8 +- tests/regionWeighting.test.ts | 138 +++++++++++++++++++++++++++++ 16 files changed, 273 insertions(+), 189 deletions(-) create mode 100644 tests/regionWeighting.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f938a..2186dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to Kromacut are documented in this file. ### Fixed +- **Auto-paint region priority** - Center and Edge priority now use the actual locations of each image color. Center prioritizes colors near the image middle; Edge prioritizes colors near the outer border. The optimizer no longer allocates a full-image weight map or guesses location from color brightness. + - **Auto-paint layer cap** - Corrected the slice-data safety limit so exceptionally tall auto-paint stacks stop at 500 layers rather than returning 501. ## v3.1.0 - 2026-06-18 diff --git a/README.md b/README.md index 053e906..9679a1a 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Prioritize accuracy in specific image regions during optimization: |---|---| | **Uniform** | All pixels weighted equally (default). | | **Center** | Gaussian falloff from center — faces and subjects in the middle get higher priority. | -| **Edge** | Sobel edge detection — high-contrast boundaries prioritized over flat regions. | +| **Edge** | Colors nearer the outer edges of the image get higher priority. | Region weighting is most useful when filament budget is limited and you want the optimizer to focus on visually important areas. diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md index fe88a09..20c3132 100644 --- a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md +++ b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md @@ -208,24 +208,24 @@ Risk: none (additive). Estimated scope: tests only. Goal: per-color weights actually reflect where colors sit in the image. Fixes F4. -- [ ] **1.1** Extend swatch extraction (`useSwatches.ts`) to accumulate, per color, in +- [x] **1.1** Extend swatch extraction (`useSwatches.ts`) to accumulate, per color, in the same tile pass that builds the histogram: `centerWeight` and `edgeWeight` (sum of the geometric per-pixel weight functions evaluated inline — no Float32Array maps materialized). Both modes computed in one pass so switching modes never rescans the image. Swatch entries gain optional fields; existing consumers unaffected. -- [ ] **1.2** Thread the chosen mode's weighted count through +- [x] **1.2** Thread the chosen mode's weighted count through `useAutoPaintWorker` → worker request → `generateAutoLayers`: when mode ≠ `uniform`, use the weighted count as `count` input to `clusterImageColors` (which already weights by count). Keep raw count for display. -- [ ] **1.3** Delete `applyRegionWeightHeuristic` (`autoPaint.ts:868-924`) and the +- [x] **1.3** Delete `applyRegionWeightHeuristic` (`autoPaint.ts:868-924`) and the per-request map generation in `generateAutoLayers:1267-1282`. Remove `regionWeights` from `OptimizerOptions`/`ScoringContext` (or keep as deprecated no-op field if persisted anywhere — verify; current persistence stores only the mode string, so removal should be safe). -- [ ] **1.4** Repeated-swaps path uses the same weighted targets (fixes the +- [x] **1.4** Repeated-swaps path uses the same weighted targets (fixes the inconsistency at `autoPaint.ts:1098`). -- [ ] **1.5 Tests**: synthetic fixture (red center disc on blue border): +- [x] **1.5 Tests**: synthetic fixture (red center disc on blue border): - `center` mode must rank red clusters above blue; `edge` mode the reverse. - Mode `uniform` byte-identical to pre-change output (golden snapshot). - No `Float32Array(width*height)` allocation in the worker path (can assert via diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index 5ab499a..93b9f4b 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -222,7 +222,6 @@ export default function ThreeDControls({ optimizerAlgorithm, optimizerSeed, regionWeightingMode, - imageDimensions, }); const autoPaintSliceData = useMemo(() => { diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index 9cf25eb..0a0c450 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -104,6 +104,8 @@ Flat Paint and **Smooth Meshing** are mutually exclusive. Turning one on turns t Use **Auto (smart selection)** unless you have a reason to compare algorithms. +**Region priority** changes which source colors the optimizer values most: **Center-weighted** gives more importance to colors that occur near the middle of the image, while **Edge-weighted** favors colors nearer its outer edges. It does not crop or change the image itself. + ## Transition Zones And Confidence After Auto-paint computes a result, Kromacut can show: diff --git a/src/hooks/useAutoPaintWorker.ts b/src/hooks/useAutoPaintWorker.ts index 9562473..d28f53c 100644 --- a/src/hooks/useAutoPaintWorker.ts +++ b/src/hooks/useAutoPaintWorker.ts @@ -12,13 +12,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AutoPaintResult } from '../lib/autoPaint'; -import type { Filament } from '../types'; +import type { Filament, Swatch } from '../types'; import type { AutoPaintWorkerRequest, AutoPaintWorkerResponse } from '../workers/autoPaint.worker'; export interface UseAutoPaintWorkerOptions { paintMode: 'manual' | 'autopaint'; filaments: Filament[]; - filtered: Array<{ hex: string; a: number } & Record>; + filtered: Swatch[]; layerHeight: number; slicerFirstLayerHeight: number; autoPaintMaxHeight?: number; @@ -27,7 +27,6 @@ export interface UseAutoPaintWorkerOptions { optimizerAlgorithm: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'; optimizerSeed?: number; regionWeightingMode: 'uniform' | 'center' | 'edge'; - imageDimensions?: { width: number; height: number } | null; } export interface UseAutoPaintWorkerResult { @@ -59,7 +58,6 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain optimizerAlgorithm, optimizerSeed, regionWeightingMode, - imageDimensions, } = opts; const [autoPaintResult, setAutoPaintResult] = useState(undefined); @@ -107,17 +105,39 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain .join(';'); }, [filaments]); + const selectedImageSwatches = useMemo(() => { + const rawSwatches = filtered.map((swatch) => ({ + hex: swatch.hex, + rawCount: swatch.count ?? 1, + weightedCount: + regionWeightingMode === 'center' + ? swatch.centerWeight + : regionWeightingMode === 'edge' + ? swatch.edgeWeight + : undefined, + })); + const totalWeight = rawSwatches.reduce( + (total, swatch) => total + (swatch.weightedCount ?? 0), + 0 + ); + + return rawSwatches.map((swatch) => ({ + hex: swatch.hex, + count: + regionWeightingMode !== 'uniform' && totalWeight > 0 + ? swatch.weightedCount ?? 0 + : swatch.rawCount, + })); + }, [filtered, regionWeightingMode]); + const filteredKey = useMemo(() => { - return filtered.map((s) => `${s.hex}:${(s.count as number | undefined) ?? 0}`).join(';'); - }, [filtered]); + return selectedImageSwatches.map((s) => `${s.hex}:${s.count}`).join(';'); + }, [selectedImageSwatches]); // Keep stable references when only array identity changes but content does not. const stableFilaments = useStableValueByKey(filaments, filamentsKey); const stableImageSwatches = useStableValueByKey( - filtered.map((s) => ({ - hex: s.hex, - count: s.count as number | undefined, - })), + selectedImageSwatches, filteredKey ); @@ -204,8 +224,6 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain algorithm, ...(optimizerSeed !== undefined && { seed: optimizerSeed }), }, - regionWeightingMode, - imageDimensions: imageDimensions ?? undefined, }; worker.postMessage(request); @@ -238,7 +256,6 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain optimizerAlgorithm, optimizerSeed, regionWeightingMode, - imageDimensions, getWorker, stableFilaments, stableImageSwatches, diff --git a/src/hooks/useSwatches.ts b/src/hooks/useSwatches.ts index 143159e..a246084 100644 --- a/src/hooks/useSwatches.ts +++ b/src/hooks/useSwatches.ts @@ -1,11 +1,14 @@ import { useEffect, useRef, useState } from 'react'; import { rgbToHsl } from '../lib/color'; +import { centerWeightAt, edgeWeightAt } from '../lib/regionWeighting'; // Manages swatch computation with cancellation & immediate override export interface SwatchEntry { hex: string; a: number; count: number; + centerWeight?: number; + edgeWeight?: number; isTransparent?: boolean; } @@ -59,7 +62,10 @@ export function useSwatches(imageSrc: string | null) { const h = img.naturalHeight; setImageDimensions({ width: w, height: h, opaqueWidth: w, opaqueHeight: h }); const TILE = 1024; - const map = new Map(); + const map = new Map< + number, + { count: number; centerWeight: number; edgeWeight: number } + >(); const tile = document.createElement('canvas'); const tctx = tile.getContext('2d', { willReadFrequently: true, @@ -101,14 +107,23 @@ export function useSwatches(imageSrc: string | null) { const g = data[i + 1]; const b = data[i + 2]; const key = ((r << 24) | (g << 16) | (b << 8) | a) >>> 0; - map.set(key, (map.get(key) || 0) + 1); + const centerWeight = centerWeightAt(px, py, w, h); + const edgeWeight = edgeWeightAt(px, py, w, h); + const existing = map.get(key); + if (existing) { + existing.count++; + existing.centerWeight += centerWeight; + existing.edgeWeight += edgeWeight; + } else { + map.set(key, { count: 1, centerWeight, edgeWeight }); + } } } await new Promise((r) => setTimeout(r, 0)); if (runId !== runRef.current || cancelled) return; } const top = Array.from(map.entries()) - .sort((a, b) => b[1] - a[1]) + .sort((a, b) => b[1].count - a[1].count) .slice(0, Math.min(map.size, SWATCH_CAP)) .map((entry) => { const key = entry[0]; @@ -123,7 +138,9 @@ export function useSwatches(imageSrc: string | null) { hex, a, hsl: rgbToHsl(r, g, b), - freq: entry[1], + freq: entry[1].count, + centerWeight: entry[1].centerWeight, + edgeWeight: entry[1].edgeWeight, }; }); top.sort((a, b) => { @@ -145,6 +162,8 @@ export function useSwatches(imageSrc: string | null) { hex: t.hex, a: typeof t.a === 'number' ? t.a : 255, count: t.freq, + centerWeight: t.centerWeight, + edgeWeight: t.edgeWeight, isTransparent: typeof t.a === 'number' ? t.a === 0 : false, })); if (transparentCount > 0) { @@ -153,6 +172,8 @@ export function useSwatches(imageSrc: string | null) { hex: '#000000', a: 0, count: transparentCount, + centerWeight: 0, + edgeWeight: 0, isTransparent: true, }); } diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 2893847..caee487 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -23,7 +23,6 @@ import { type OptimizerResult, type ScoringContext, } from './optimizer'; -import { generateCenterWeightedMapSimple, generateEdgeWeightedMapSimple } from './regionWeighting'; import { computeProfileConfidence } from './calibration'; export { LAYER_ACTIVATION_EPSILON } from './layerActivation'; @@ -466,7 +465,7 @@ export function compressZones( * @param threshold - DeltaE merge threshold (default 5.0) * @returns Weighted Lab targets, normalized so weights sum to 1.0 */ -function clusterImageColors( +export function clusterImageColors( swatches: Array<{ hex: string; count?: number }>, maxClusters: number = 32, threshold: number = 5.0 @@ -853,76 +852,6 @@ function findBestFilamentOrder( }; } -/** - * Apply region weighting heuristic to clustered colors. - * - * This is an approximation since spatial information is lost during clustering. - * We analyze the region weight distribution and adjust cluster weights accordingly: - * - High-weight regions (center or edges) boost colors commonly found there - * - Uses luminance as a proxy for spatial distribution (centers tend brighter, edges darker) - * - * @param clusters Weighted Lab color clusters - * @param regionWeights Per-pixel region importance weights - * @returns Adjusted color clusters with modified weights - */ -function applyRegionWeightHeuristic( - clusters: WeightedLab[], - regionWeights: Float32Array -): WeightedLab[] { - if (clusters.length === 0 || regionWeights.length === 0) return clusters; - - // Calculate average region weight to determine mode strength - let sumWeight = 0; - for (let i = 0; i < regionWeights.length; i++) { - sumWeight += regionWeights[i]; - } - const avgWeight = sumWeight / regionWeights.length; - - // Calculate luminance variance to detect contrast distribution - // Higher contrast (edge mode) vs more uniform (center mode) - let sumSqDiff = 0; - for (let i = 0; i < regionWeights.length; i++) { - const diff = regionWeights[i] - avgWeight; - sumSqDiff += diff * diff; - } - const variance = sumSqDiff / regionWeights.length; - const isHighContrast = variance > 0.05; // Threshold for edge-weighted pattern - - // Apply heuristic adjustments - let totalAdjustedWeight = 0; - const adjustedClusters = clusters.map((cluster) => { - let modifier = 1.0; - - if (isHighContrast) { - // Edge-weighted mode: boost high-contrast colors (very light or very dark) - const isHighContrast = cluster.L < 30 || cluster.L > 70; - modifier = isHighContrast ? 1.3 : 0.85; - } else { - // Center-weighted mode: boost mid-luminance colors (typical of center regions) - const isMidLuminance = cluster.L >= 35 && cluster.L <= 65; - modifier = isMidLuminance ? 1.2 : 0.9; - } - - const adjustedWeight = cluster.weight * modifier; - totalAdjustedWeight += adjustedWeight; - - return { - ...cluster, - weight: adjustedWeight, - }; - }); - - // Renormalize to sum to 1.0 - if (totalAdjustedWeight > 0) { - return adjustedClusters.map((c) => ({ - ...c, - weight: c.weight / totalAdjustedWeight, - })); - } - - return clusters; -} - /** * Advanced optimizer path using simulated annealing / genetic algorithm */ @@ -933,22 +862,14 @@ function findBestFilamentOrderWithOptimizer( firstLayerHeight: number, optimizerOptions: Partial ): { sortedFilaments: Filament[]; result: OptimizerResult } { - // Cluster image colors into weighted Lab targets - let imageTargets = clusterImageColors(imageSwatches, 32, 5.0); - - // Apply region weight heuristic if region weights are provided - // Note: This is an approximation since we've lost pixel positions during clustering. - // Proper implementation would require weighting pixels before aggregation. - if (optimizerOptions.regionWeights) { - imageTargets = applyRegionWeightHeuristic(imageTargets, optimizerOptions.regionWeights); - } + // Spatial weighting has already been folded into swatch counts by the caller. + const imageTargets = clusterImageColors(imageSwatches, 32, 5.0); // Build scoring context const context: ScoringContext = { imageColors: imageTargets, layerHeight, firstLayerHeight, - regionWeights: optimizerOptions.regionWeights, }; // Apply frontlit TD scale @@ -1204,9 +1125,7 @@ function buildRepeatedSwapSequence( * @param maxHeight - Optional maximum height constraint (undefined = auto) * @param enhancedColorMatch - If true, optimize filament ordering for best color reproduction * @param allowRepeatedSwaps - If true, allow filaments to appear multiple times in the stack - * @param optimizerOptions - Advanced optimizer settings (algorithm, seeding, region weighting) - * @param regionWeightingMode - Region weighting strategy: uniform, center, or edge - * @param imageDimensions - Image width and height for region weight map generation + * @param optimizerOptions - Advanced optimizer settings (algorithm and seeding) * @returns Generated layer segments with zone information */ export function generateAutoLayers( @@ -1217,9 +1136,7 @@ export function generateAutoLayers( maxHeight?: number, enhancedColorMatch?: boolean, allowRepeatedSwaps?: boolean, - optimizerOptions?: Partial, - regionWeightingMode: 'uniform' | 'center' | 'edge' = 'uniform', - imageDimensions?: { width: number; height: number } | null + optimizerOptions?: Partial ): AutoPaintResult { // --- STEP 1: VALIDATION --- if (filaments.length === 0) { @@ -1262,35 +1179,6 @@ export function generateAutoLayers( let sortedFilaments: Filament[]; let optimizerResult: OptimizerResult | undefined; - // Generate region weight map if dimensions are available and mode is not uniform - let regionWeights: Float32Array | undefined; - if (imageDimensions && regionWeightingMode !== 'uniform') { - if (regionWeightingMode === 'center') { - // Center-weighted: prioritize center of image - regionWeights = generateCenterWeightedMapSimple( - imageDimensions.width, - imageDimensions.height, - 0.5 // strength parameter - ); - } else if (regionWeightingMode === 'edge') { - // Edge-weighted (geometry-based): prioritize border regions. - regionWeights = generateEdgeWeightedMapSimple( - imageDimensions.width, - imageDimensions.height - ); - } - } - - // Merge region weights into optimizer options - const mergedOptimizerOptions: Partial | undefined = optimizerOptions - ? { - ...optimizerOptions, - regionWeights: regionWeights ?? optimizerOptions.regionWeights, - } - : regionWeights - ? { regionWeights } - : undefined; - if (enhancedColorMatch) { // Enhanced: find the ordering that best covers the image's color palette const orderingResult = findBestFilamentOrder( @@ -1298,7 +1186,7 @@ export function generateAutoLayers( imageSwatches, layerHeight, firstLayerHeight, - mergedOptimizerOptions + optimizerOptions ); sortedFilaments = orderingResult.sortedFilaments; diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index 8cfd373..4da41d7 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -26,7 +26,6 @@ export interface OptimizerOptions { populationSize?: number; // Population size for GA mutationRate?: number; // Mutation probability for GA eliteCount?: number; // Number of elite individuals to preserve in GA - regionWeights?: Float32Array; // Per-pixel importance weights (0-1) cachingEnabled?: boolean; // Enable result caching } @@ -43,7 +42,6 @@ export interface ScoringContext { imageColors: Array; // Weighted Lab colors from image layerHeight: number; firstLayerHeight: number; - regionWeights?: Float32Array; // Per-pixel importance } // ============================================================================ diff --git a/src/lib/regionWeighting.ts b/src/lib/regionWeighting.ts index 3360f5f..37f9d45 100644 --- a/src/lib/regionWeighting.ts +++ b/src/lib/regionWeighting.ts @@ -20,6 +20,51 @@ export interface RegionWeightOptions { customMask?: Float32Array; // User-provided weights } +const DEFAULT_CENTER_WEIGHT_STRENGTH = 0.5; + +function normalizedDistanceAt(x: number, y: number, width: number, height: number): number { + const centerX = width / 2; + const centerY = height / 2; + const maxDistance = Math.hypot(centerX, centerY); + + if (maxDistance === 0) return 0; + return Math.hypot(x - centerX, y - centerY) / maxDistance; +} + +function normalizedWeight(raw: number, min: number, max: number): number { + const range = max - min; + return range > 0 ? (raw - min) / range : 1; +} + +function nearestCenterDistance(width: number, height: number): number { + const nearestX = Math.abs(Math.floor(width / 2) - width / 2); + const nearestY = Math.abs(Math.floor(height / 2) - height / 2); + const maxDistance = Math.hypot(width / 2, height / 2); + return maxDistance > 0 ? Math.hypot(nearestX, nearestY) / maxDistance : 0; +} + +/** Scalar equivalent of generateCenterWeightedMapSimple. */ +export function centerWeightAt( + x: number, + y: number, + width: number, + height: number, + strength: number = DEFAULT_CENTER_WEIGHT_STRENGTH +): number { + const denominator = 2 * (1 - strength); + const raw = Math.exp(-(normalizedDistanceAt(x, y, width, height) ** 2 / denominator)); + const min = Math.exp(-1 / denominator); + const max = Math.exp(-(nearestCenterDistance(width, height) ** 2 / denominator)); + + return normalizedWeight(raw, min, max); +} + +/** Scalar equivalent of generateEdgeWeightedMapSimple. */ +export function edgeWeightAt(x: number, y: number, width: number, height: number): number { + const raw = normalizedDistanceAt(x, y, width, height) ** 1.35; + return normalizedWeight(raw, nearestCenterDistance(width, height) ** 1.35, 1); +} + // ============================================================================ // Weight Map Generation // ============================================================================ @@ -76,26 +121,12 @@ export function generateCenterWeightedMapSimple( strength: number = 0.5 ): Float32Array { const weights = new Float32Array(width * height); - const centerX = width / 2; - const centerY = height / 2; - const maxDist = Math.sqrt(centerX * centerX + centerY * centerY); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const dx = x - centerX; - const dy = y - centerY; - const dist = Math.sqrt(dx * dx + dy * dy); - const normalizedDist = dist / maxDist; - - // Gaussian fall-off: weight = 1 at center, decreases with distance - // strength controls how quickly weight falls off - const weight = Math.exp(-((normalizedDist * normalizedDist) / (2 * (1 - strength)))); - - weights[y * width + x] = weight; + weights[y * width + x] = centerWeightAt(x, y, width, height, strength); } } - - normalizeWeights(weights); return weights; } @@ -105,23 +136,12 @@ export function generateCenterWeightedMapSimple( */ export function generateEdgeWeightedMapSimple(width: number, height: number): Float32Array { const weights = new Float32Array(width * height); - const centerX = width / 2; - const centerY = height / 2; - const maxDist = Math.sqrt(centerX * centerX + centerY * centerY); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const dx = x - centerX; - const dy = y - centerY; - const dist = Math.sqrt(dx * dx + dy * dy); - const normalizedDist = maxDist > 0 ? dist / maxDist : 0; - - // Low in center, high toward borders - weights[y * width + x] = Math.pow(normalizedDist, 1.35); + weights[y * width + x] = edgeWeightAt(x, y, width, height); } } - - normalizeWeights(weights); return weights; } @@ -435,4 +455,4 @@ function heatmapColor(value: number): { r: number; g: number; b: number } { } return { r, g, b }; -} \ No newline at end of file +} diff --git a/src/types/index.ts b/src/types/index.ts index 1955e62..aea169d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,16 @@ import type { AutoPaintResult } from '../lib/autoPaint'; import type { CalibrationResult } from '../lib/calibration'; -export type Swatch = { hex: string; a: number }; +export type Swatch = { + hex: string; + a: number; + /** Raw pixel count, kept for display and non-spatial consumers. */ + count?: number; + /** Sum of center-priority weights for this color's source pixels. */ + centerWeight?: number; + /** Sum of edge-priority weights for this color's source pixels. */ + edgeWeight?: number; +}; export interface CustomPalette { id: string; diff --git a/src/workers/autoPaint.worker.ts b/src/workers/autoPaint.worker.ts index 3936f8b..8b91d0a 100644 --- a/src/workers/autoPaint.worker.ts +++ b/src/workers/autoPaint.worker.ts @@ -21,8 +21,6 @@ export interface AutoPaintWorkerRequest { enhancedColorMatch?: boolean; allowRepeatedSwaps?: boolean; optimizerOptions?: Partial; - regionWeightingMode: 'uniform' | 'center' | 'edge'; - imageDimensions?: { width: number; height: number } | null; } export interface AutoPaintWorkerResponse { @@ -43,9 +41,7 @@ self.onmessage = (e: MessageEvent) => { req.maxHeight, req.enhancedColorMatch, req.allowRepeatedSwaps, - req.optimizerOptions, - req.regionWeightingMode, - req.imageDimensions + req.optimizerOptions ); const response: AutoPaintWorkerResponse = { id: req.id, result }; diff --git a/tests/autoPaintGoldens.test.ts b/tests/autoPaintGoldens.test.ts index 4c313af..3d515b5 100644 --- a/tests/autoPaintGoldens.test.ts +++ b/tests/autoPaintGoldens.test.ts @@ -107,9 +107,7 @@ test('seeded auto-paint stack goldens stay deliberate', async (t: TestContext) = undefined, scenario.enhancedColorMatch, scenario.allowRepeatedSwaps, - { algorithm: 'auto', seed: scenario.seed }, - 'uniform', - scenario.imageDimensions + { algorithm: 'auto', seed: scenario.seed } ); assertApproximateGolden(snapshot(result), expected[scenario.name]); diff --git a/tests/benchmark/autoPaintBench.ts b/tests/benchmark/autoPaintBench.ts index 84f4a41..c5a99d7 100644 --- a/tests/benchmark/autoPaintBench.ts +++ b/tests/benchmark/autoPaintBench.ts @@ -96,7 +96,7 @@ for (const scenario of autoPaintGoldenScenarios().filter( const seeds = algorithm === 'simulated-annealing' || algorithm === 'genetic' ? SEEDS : [SEEDS[0]]; for (const seed of seeds) { const start = performance.now(); - const result = autoPaint.generateAutoLayers(scenario.filaments, scenario.imageSwatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, undefined, scenario.enhancedColorMatch, scenario.allowRepeatedSwaps, { algorithm, seed }, 'uniform', scenario.imageDimensions); + const result = autoPaint.generateAutoLayers(scenario.filaments, scenario.imageSwatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, undefined, scenario.enhancedColorMatch, scenario.allowRepeatedSwaps, { algorithm, seed }); const elapsedMs = performance.now() - start; const slices = autoPaint.autoPaintToSliceHeights(result, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); const palette = slices.virtualSwatches.map((swatch) => autoPaint.rgbToLab(autoPaint.hexToRgb(swatch.hex))); @@ -111,7 +111,7 @@ for (const scenario of autoPaintGoldenScenarios().filter( const slice = Math.min(heights.length - 1, heights.findIndex((value) => value >= height)); return { weight: target.weight, value: autoPaint.deltaELab(target.lab, palette[Math.max(0, slice)]) }; }); - const compressed = autoPaint.generateAutoLayers(scenario.filaments, scenario.imageSwatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, COMPRESSED_MAX_HEIGHT, scenario.enhancedColorMatch, scenario.allowRepeatedSwaps, { algorithm, seed }, 'uniform', scenario.imageDimensions); + const compressed = autoPaint.generateAutoLayers(scenario.filaments, scenario.imageSwatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, COMPRESSED_MAX_HEIGHT, scenario.enhancedColorMatch, scenario.allowRepeatedSwaps, { algorithm, seed }); const used = new Set(errors76.map((_, targetIndex) => palette.reduce((best, entry, index) => autoPaint.deltaELab(targets[targetIndex].lab, entry) < autoPaint.deltaELab(targets[targetIndex].lab, palette[best]) ? index : best, 0))); output.push({ scenario: scenario.name, algorithm, seed, colorError: { cie76: weightedSummary(errors76), ciede2000: weightedSummary(errors2000), coverageAt2_3: coverage(2.3), coverageAt5: coverage(5) }, realizedError: weightedSummary(realized), structure: { totalHeight: result.totalHeight, layerCount: slices.colorSliceHeights.length, sequenceLength: result.filamentOrder.length, wastedLayerFraction: slices.colorSliceHeights.length ? (slices.colorSliceHeights.length - used.size) / slices.colorSliceHeights.length : 0, compressedMaxHeight: COMPRESSED_MAX_HEIGHT, compressionRatio: compressed.compressionRatio }, cost: { wallTimeMs: elapsedMs, iterations: result.optimizerMetadata?.iterations ?? 0 } }); } diff --git a/tests/export3mf.test.ts b/tests/export3mf.test.ts index 508b4e3..e02c31a 100644 --- a/tests/export3mf.test.ts +++ b/tests/export3mf.test.ts @@ -40,9 +40,7 @@ interface AutoPaintModule { maxHeight?: number, enhancedColorMatch?: boolean, allowRepeatedSwaps?: boolean, - optimizerOptions?: { algorithm: OptimizerAlgorithm; seed?: number }, - regionWeightingMode?: 'uniform' | 'center' | 'edge', - imageDimensions?: { width: number; height: number } | null + optimizerOptions?: { algorithm: OptimizerAlgorithm; seed?: number } ): unknown; autoPaintToSliceHeights( result: unknown, @@ -789,9 +787,7 @@ async function buildAutoPaintLogoRegressionStack( { algorithm: 'auto', seed, - }, - 'uniform', - { width: image.width, height: image.height } + } ); const sliceData = autoPaintToSliceHeights(autoPaintResult, layerHeight, firstLayerHeight); const cumulativeHeights = buildCumulativeHeights( diff --git a/tests/regionWeighting.test.ts b/tests/regionWeighting.test.ts new file mode 100644 index 0000000..0ea304f --- /dev/null +++ b/tests/regionWeighting.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { createServer } from 'vite'; + +import { + centerWeightAt, + edgeWeightAt, + generateCenterWeightedMapSimple, + generateEdgeWeightedMapSimple, +} from '../src/lib/regionWeighting.ts'; + +type AutoPaintModule = typeof import('../src/lib/autoPaint.ts'); +let autoPaintModule: Promise | null = null; + +async function loadAutoPaintModule(): Promise { + autoPaintModule ??= loadViteModule('/src/lib/autoPaint.ts'); + return autoPaintModule; +} + +async function loadViteModule(modulePath: string): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { noDiscovery: true }, + resolve: { alias: { '@': resolve(process.cwd(), 'src') } }, + root: process.cwd(), + server: { hmr: false, middlewareMode: true }, + }); + + try { + return (await server.ssrLoadModule(modulePath)) as T; + } finally { + await server.close(); + } +} + +test('scalar region weights match the existing center and edge maps', () => { + for (const [width, height] of [ + [1, 1], + [4, 4], + [5, 3], + [16, 9], + ]) { + const center = generateCenterWeightedMapSimple(width, height); + const edge = generateEdgeWeightedMapSimple(width, height); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = y * width + x; + assert.ok(Math.abs(center[index] - centerWeightAt(x, y, width, height)) < 1e-6); + assert.ok(Math.abs(edge[index] - edgeWeightAt(x, y, width, height)) < 1e-6); + } + } + } +}); + +test('center and edge weights favor opposite image regions', () => { + const width = 9; + const height = 9; + const center = centerWeightAt(4, 4, width, height); + const corner = centerWeightAt(0, 0, width, height); + const edgeCenter = edgeWeightAt(4, 4, width, height); + const edgeCorner = edgeWeightAt(0, 0, width, height); + + assert.ok(center > corner, 'center mode should favor the center'); + assert.ok(edgeCorner > edgeCenter, 'edge mode should favor the border'); +}); + +test('spatial color totals prioritize a center disc or an edge border as selected', async () => { + const { clusterImageColors, deltaELab, hexToRgb, rgbToLab } = await loadAutoPaintModule(); + const width = 25; + const height = 25; + let redCenter = 0; + let blueCenter = 0; + let redEdge = 0; + let blueEdge = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const isRedCenterDisc = Math.hypot(x - width / 2, y - height / 2) <= 9; + const centerWeight = centerWeightAt(x, y, width, height); + const edgeWeight = edgeWeightAt(x, y, width, height); + if (isRedCenterDisc) { + redCenter += centerWeight; + redEdge += edgeWeight; + } else { + blueCenter += centerWeight; + blueEdge += edgeWeight; + } + } + } + + const redLab = rgbToLab(hexToRgb('#ff0000')); + const blueLab = rgbToLab(hexToRgb('#0000ff')); + const weightFor = (clusters: ReturnType, lab: typeof redLab) => + clusters.reduce((best, cluster) => + deltaELab(cluster, lab) < deltaELab(best, lab) ? cluster : best + ).weight; + + const centerClusters = clusterImageColors( + [ + { hex: '#ff0000', count: redCenter }, + { hex: '#0000ff', count: blueCenter }, + ], + 2, + 0.1 + ); + const edgeClusters = clusterImageColors( + [ + { hex: '#ff0000', count: redEdge }, + { hex: '#0000ff', count: blueEdge }, + ], + 2, + 0.1 + ); + + assert.ok( + weightFor(centerClusters, redLab) > weightFor(centerClusters, blueLab), + 'center mode should rank the red center disc above the blue border' + ); + assert.ok( + weightFor(edgeClusters, blueLab) > weightFor(edgeClusters, redLab), + 'edge mode should rank the blue border above the red center disc' + ); +}); + +test('the auto-paint worker path does not create an image-sized region map', () => { + const autoPaintSource = readFileSync(resolve('src', 'lib', 'autoPaint.ts'), 'utf8'); + const workerSource = readFileSync(resolve('src', 'workers', 'autoPaint.worker.ts'), 'utf8'); + + assert.doesNotMatch(autoPaintSource, /generate(?:Center|Edge)WeightedMapSimple/); + assert.doesNotMatch(autoPaintSource, /regionWeights/); + assert.doesNotMatch(workerSource, /regionWeightingMode|imageDimensions/); +}); From 966c13afbc133a4ffd422aec14ebe21c98461e93 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 21 Jun 2026 10:15:04 +0300 Subject: [PATCH 04/31] Make auto-paint optimization deterministic --- CHANGELOG.md | 2 + README.md | 6 +- docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 6 +- src/components/AutoPaintTab.tsx | 2 +- src/docs/3d-mode.md | 2 +- src/lib/optimizer.ts | 107 +++++++++++++++-------------- tests/optimizer.test.ts | 71 +++++++++++++++++++ 7 files changed, 138 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2186dae..dd3e499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to Kromacut are documented in this file. ### Fixed +- **Auto-paint optimizer cache and default seed** - Cache entries now include all target-color weights, every target cluster, and optimizer tuning values. Blank seeds now derive a stable value from the active inputs, making identical runs repeatable and cacheable instead of randomly changing. + - **Auto-paint region priority** - Center and Edge priority now use the actual locations of each image color. Center prioritizes colors near the image middle; Edge prioritizes colors near the outer border. The optimizer no longer allocates a full-image weight map or guesses location from color brightness. - **Auto-paint layer cap** - Corrected the slice-data safety limit so exceptionally tall auto-paint stacks stop at 500 layers rather than returning 501. diff --git a/README.md b/README.md index 9679a1a..7a41447 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ The optimizer displays metadata after generation: - **Converged** — Whether the algorithm reached a stable solution - **Cache hit** — Whether results were retrieved from cache (instant) -**Optimizer seed** — Set a random seed for reproducible results across runs. Useful for A/B testing different configurations. +**Optimizer seed** — Leave blank for an automatic stable seed, or enter a number to use a specific repeatable seed for A/B comparisons. ### Region Weighting @@ -179,8 +179,8 @@ Region weighting is most useful when filament budget is limited and you want the | **Flat Paint (flat face-down print)** | Builds a uniform-thickness slab printed image-side down instead of a stepped relief. Each pixel column's layer order is reversed so the artwork sits against the build plate (already mirrored — don't mirror in the slicer) under a transparent carrier layer, and the back is filled with the foundation filament so every layer has the full footprint. The result has a smooth, glass-flat face — great for bookmarks and coasters. Requires a multi-material printer (AMS/toolchanger); export as 3MF, which contains one object per filament plus the clear carrier object. Flat Paint and Smooth Meshing toggle each other off because flat prints always use the full-footprint slab layout. | | **Dither line width** | (Requires Height dithering) Controls the minimum dot size for the dither pattern in mm. This should roughly match your printer's line/nozzle width so dither dots are actually printable. Default: `0.42 mm`. | | **Optimizer algorithm** | Choose which optimization algorithm to use: Auto (recommended), Exhaustive, Simulated Annealing, or Genetic. Auto selects the best algorithm based on search space size. | -| **Optimizer seed** | Set a random seed for reproducible optimizer results. Leave blank for random behavior. Useful for testing and comparing configurations. | -| **Region weighting** | Prioritize specific image regions: Uniform (equal), Center (Gaussian falloff), or Edge (Sobel detection). Helps focus quality budget on important areas. | +| **Optimizer seed** | Leave blank for an automatic stable seed, or enter a number for a specific repeatable run. | +| **Region weighting** | Prioritize specific image regions: Uniform (equal), Center (image middle), or Edge (outer image border). Helps focus quality budget on important areas. | ### Filament profiles diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md index 20c3132..2efc33b 100644 --- a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md +++ b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md @@ -238,15 +238,15 @@ labels say (today they are swapped or no-ops — treat as bug fix, note in CHANG Goal: fix F5, F6. Small, independent, immediately shippable. -- [ ] **2.1** Cache key includes cluster **weights**, full cluster set (not first 20), +- [x] **2.1** Cache key includes cluster **weights**, full cluster set (not first 20), and all algorithm-relevant options (temperature, cooling, population, mutation, elite, maxIterations). Simplest robust form: hash a canonical JSON of `{filaments, clusters(L,a,b,weight), layerHeight, firstLayerHeight, algorithm, seed, tuning}`. -- [ ] **2.2** Default seed = stable 32-bit hash of the same canonical inputs instead of +- [x] **2.2** Default seed = stable 32-bit hash of the same canonical inputs instead of `Date.now()` (`optimizer.ts:525`). User-provided seed still overrides. This makes every run reproducible and cacheable; remove the `hasExplicitSeed` cache gating. -- [ ] **2.3 Tests**: same inputs, no seed, twice → identical result. Toggling region +- [x] **2.3 Tests**: same inputs, no seed, twice → identical result. Toggling region mode with a fixed seed → different cache entries (regression test for F5). Changing only `temperature` → cache miss. diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 5f93e40..17d2569 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -738,7 +738,7 @@ export default function AutoPaintTab({ setLocalOptimizerSeed(e.target.value)} onBlur={() => { diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index 0a0c450..a19241e 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -100,7 +100,7 @@ Flat Paint and **Smooth Meshing** are mutually exclusive. Turning one on turns t | --------------- | ------------------------------------------------------------ | | Algorithm | Auto, Exhaustive, Simulated Annealing, or Genetic Algorithm. | | Region priority | Uniform, Center-weighted, or Edge-weighted matching. | -| Seed (optional) | A number that makes optimizer results repeatable. | +| Seed (optional) | Overrides the automatic stable seed for an intentional comparison. | Use **Auto (smart selection)** unless you have a reason to compare algorithms. diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index 4da41d7..3a9bbf4 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -5,7 +5,6 @@ * for multi-material lithophanes. Supports: * - Simulated Annealing: Probabilistic global optimization with temperature scheduling * - Genetic Algorithm: Population-based evolutionary optimization - * - Region Weighting: Prioritize important image areas (faces, focal points) * - Deterministic Seeding: Reproducible results for A/B testing * - Result Caching: Skip redundant computations */ @@ -89,48 +88,11 @@ class OptimizerCache { private cache = new Map(); private maxSize = 100; - private getCacheKey( - filaments: Filament[], - context: ScoringContext, - algorithm?: string, - seed?: number - ): string { - // Create stable key from filaments and context - const filamentKey = filaments - .map((f) => `${f.color}:${f.td.toFixed(2)}`) - .sort() - .join('|'); - - const imageKey = context.imageColors - .slice(0, 20) // Sample first 20 colors for hash - .map((c) => `${c.L.toFixed(1)},${c.a.toFixed(1)},${c.b.toFixed(1)}`) - .join('|'); - - const algoKey = algorithm ?? 'auto'; - const seedKey = seed ?? 0; - - return `${filamentKey}__${imageKey}__${context.layerHeight}__${context.firstLayerHeight}__${algoKey}__${seedKey}`; - } - - get( - filaments: Filament[], - context: ScoringContext, - algorithm?: string, - seed?: number - ): OptimizerResult | null { - const key = this.getCacheKey(filaments, context, algorithm, seed); + get(key: string): OptimizerResult | null { return this.cache.get(key) || null; } - set( - filaments: Filament[], - context: ScoringContext, - result: OptimizerResult, - algorithm?: string, - seed?: number - ): void { - const key = this.getCacheKey(filaments, context, algorithm, seed); - + set(key: string, result: OptimizerResult): void { // Evict oldest if at capacity if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; @@ -151,6 +113,52 @@ class OptimizerCache { const globalCache = new OptimizerCache(); +function tuningFingerprint(options: OptimizerOptions) { + return { + maxIterations: options.maxIterations ?? null, + temperature: options.temperature ?? null, + coolingRate: options.coolingRate ?? null, + populationSize: options.populationSize ?? null, + mutationRate: options.mutationRate ?? null, + eliteCount: options.eliteCount ?? null, + }; +} + +function canonicalOptimizerInput( + filaments: Filament[], + context: ScoringContext, + algorithm: string, + options: OptimizerOptions, + seed?: number +): string { + return JSON.stringify({ + filaments: filaments.map((filament) => ({ + id: filament.id, + color: filament.color, + td: filament.td, + })), + clusters: context.imageColors.map((color) => ({ + L: color.L, + a: color.a, + b: color.b, + weight: color.weight, + })), + layerHeight: context.layerHeight, + firstLayerHeight: context.firstLayerHeight, + algorithm, + seed: seed ?? null, + tuning: tuningFingerprint(options), + }); +} + +function stableHash32(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index++) { + hash = Math.imul(hash ^ value.charCodeAt(index), 0x01000193); + } + return hash >>> 0; +} + // ============================================================================ // Scoring Functions // ============================================================================ @@ -515,12 +523,8 @@ export function optimizeFilamentOrder( context: ScoringContext, options: Partial = {} ): OptimizerResult { - // Determine if user provided explicit seed (for caching purposes) - const hasExplicitSeed = options.seed !== undefined; - const opts: OptimizerOptions = { algorithm: 'auto', - seed: Date.now(), cachingEnabled: true, ...options, }; @@ -537,9 +541,13 @@ export function optimizeFilamentOrder( } } - // Only check cache if user provided explicit seed (random seeds should not be cached) - if (opts.cachingEnabled && hasExplicitSeed) { - const cached = globalCache.get(filaments, context, algorithm, opts.seed); + const defaultSeedInput = canonicalOptimizerInput(filaments, context, algorithm, opts); + const seed = opts.seed ?? stableHash32(defaultSeedInput); + opts.seed = seed; + const cacheKey = canonicalOptimizerInput(filaments, context, algorithm, opts, seed); + + if (opts.cachingEnabled) { + const cached = globalCache.get(cacheKey); if (cached) { return { ...cached, cacheHit: true }; } @@ -564,9 +572,8 @@ export function optimizeFilamentOrder( // Tag the result with the resolved algorithm result.resolvedAlgorithm = algorithm; - // Only cache if user provided explicit seed (don't cache random results) - if (opts.cachingEnabled && hasExplicitSeed) { - globalCache.set(filaments, context, result, algorithm, opts.seed); + if (opts.cachingEnabled) { + globalCache.set(cacheKey, result); } return result; diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index 84cff3c..d97e25e 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -68,3 +68,74 @@ test('each optimizer produces the same result for the same seed', async (t) => { }); } }); + +function withoutCacheState(result: T): Omit { + const outcome = { ...result }; + delete outcome.cacheHit; + return outcome as Omit; +} + +test('cache keys include all weighted clusters and optimizer tuning', async () => { + const { clearOptimizerCache, getOptimizerCacheStats, optimizeFilamentOrder } = + await loadOptimizerModule(); + clearOptimizerCache(); + + const manyClusters = { + ...context, + imageColors: Array.from({ length: 21 }, (_, index) => ({ + L: 10 + index * 3, + a: index - 10, + b: 10 - index, + weight: index === 20 ? 0.01 : 1, + })), + }; + const options = { + algorithm: 'simulated-annealing' as const, + seed: 12345, + maxIterations: 30, + temperature: 75, + cachingEnabled: true, + }; + + const first = optimizeFilamentOrder(filaments, manyClusters, options); + const cached = optimizeFilamentOrder(filaments, manyClusters, options); + assert.equal(first.cacheHit, undefined); + assert.equal(cached.cacheHit, true); + assert.equal(getOptimizerCacheStats().size, 1); + + const regionWeightedClusters = { + ...manyClusters, + imageColors: manyClusters.imageColors.map((cluster, index) => ({ + ...cluster, + weight: index === 20 ? 0.5 : cluster.weight, + })), + }; + const changedWeight = optimizeFilamentOrder(filaments, regionWeightedClusters, options); + assert.equal(changedWeight.cacheHit, undefined, 'a changed spatial weight must miss cache'); + assert.equal(getOptimizerCacheStats().size, 2); + + const changedTemperature = optimizeFilamentOrder(filaments, manyClusters, { + ...options, + temperature: 76, + }); + assert.equal(changedTemperature.cacheHit, undefined, 'a changed temperature must miss cache'); + assert.equal(getOptimizerCacheStats().size, 3); +}); + +test('default optimizer seeds are stable and cacheable', async () => { + const { clearOptimizerCache, optimizeFilamentOrder } = await loadOptimizerModule(); + clearOptimizerCache(); + + const options = { + algorithm: 'genetic' as const, + maxIterations: 20, + populationSize: 16, + cachingEnabled: true, + }; + const first = optimizeFilamentOrder(filaments, context, options); + const second = optimizeFilamentOrder(filaments, context, options); + + assert.equal(first.cacheHit, undefined); + assert.equal(second.cacheHit, true); + assert.deepEqual(withoutCacheState(second), withoutCacheState(first)); +}); From bc1d1552ac7d7d34e6f2285c2e75784ca0e38750 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 21 Jun 2026 11:50:56 +0300 Subject: [PATCH 05/31] Unify auto-paint optimizer scoring --- CHANGELOG.md | 2 + docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 14 +- src/lib/autoPaint.ts | 253 ++++++----- src/lib/optimizer.ts | 145 +++---- tests/assets/auto-paint-goldens.json | 610 +++++++++++---------------- tests/optimizer.test.ts | 32 ++ 6 files changed, 504 insertions(+), 552 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3e499..63c4c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to Kromacut are documented in this file. ### Changed +- **Auto-paint optimizer objective** - Enhanced color matching now evaluates the same zone-compressed, layer-snapped color-to-height path used by the printable preview. All optimizer algorithms share that scorer, including Max Height constraints, so selected filament orders better match the finished model. Repeated optical calculations are memoized during searches. + ### Fixed - **Auto-paint optimizer cache and default seed** - Cache entries now include all target-color weights, every target cluster, and optimizer tuning values. Blank seeds now derive a stable value from the active inputs, making identical runs repeatable and cacheable instead of randomly changing. diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md index 2efc33b..68cbefd 100644 --- a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md +++ b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md @@ -259,13 +259,13 @@ Goal: the optimizer optimizes the same model the pipeline builds. Fixes F2; part F8 (scoring side of the pure-background issue can be folded in here or deferred to Phase 6b). -- [ ] **3.1** Move/share the palette builder: expose `buildAchievableColorPalette` + +- [x] **3.1** Move/share the palette builder: expose `buildAchievableColorPalette` + `scoreSequenceAgainstImage` (or a thin `scoreSequence(sequence, context)` wrapper) for use by `optimizer.ts`. `ScoringContext` carries the weighted Lab targets as today. -- [ ] **3.2** Replace `scoreFilamentOrder`/`findBestAchievableColor`/ +- [x] **3.2** Replace `scoreFilamentOrder`/`findBestAchievableColor`/ `simulateStackAtHeight` with the unified scorer in exhaustive, SA, and GA. -- [ ] **3.3 Performance work so per-eval cost stays acceptable**: +- [x] **3.3 Performance work so per-eval cost stays acceptable**: - Memoize `calculateTransitionThickness(bgColorHex, filamentId)` per optimizer run — pair space ≤ N², eliminates the dominant inner loop. - Memoize palette per unique sequence string (SA revisits neighbors). @@ -274,12 +274,16 @@ Phase 6b). the structural penalty constants to match (calibrate against Phase 0 harness so rankings on fixtures are preserved or improved — this is a tuning task, use the harness). -- [ ] **3.4** SA neighbor: redraw `j` until `j ≠ i` (F10). -- [ ] **3.5 Tests**: property test — the score the optimizer reports for its chosen +- [x] **3.4** SA neighbor: redraw `j` until `j ≠ i` (F10). +- [x] **3.5 Tests**: property test — the score the optimizer reports for its chosen order equals `scoreSequence` of that order under the build model (was untrue before). Re-baseline Phase 0 golden snapshots; harness must satisfy the Phase 0.5 acceptance rule. Budget: ≤2 s for 8 filaments (exhaustive falls back per existing UI guard). + Validated on the development machine with `npm run benchmark:autopaint` against + Phase 2 commit `966c13a`: Auto-mode average realized ΔE improved from 34.75 to + 30.23, every fixture held or improved, and the slowest 8-filament Auto run was + 42 ms. Risk: medium — orders will change for users (expected: improvement, verified by harness). Determinism preserved (seeded). Schema unchanged. diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index caee487..8cc930c 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -42,7 +42,7 @@ export interface Lab { } /** Lab color with a frequency weight (0-1, normalized) */ -interface WeightedLab extends Lab { +export interface WeightedLab extends Lab { weight: number; } @@ -332,12 +332,14 @@ export function calculateTransitionThickness( * @param sortedFilaments - Filaments sorted dark to light * @param layerHeight - Physical layer height * @param baseThickness - Minimum thickness for the first (darkest) layer + * @param transitionThicknessCache - Optional cache shared by optimizer evaluations * @returns Object with ideal height and zone breakdown */ export function calculateIdealHeight( sortedFilaments: Array<{ id: string; color: string; td: number }>, layerHeight: number, - baseThickness: number = 0.6 + baseThickness: number = 0.6, + transitionThicknessCache?: Map ): { idealHeight: number; zones: TransitionZone[] } { if (sortedFilaments.length === 0) { return { idealHeight: baseThickness, zones: [] }; @@ -374,12 +376,26 @@ export function calculateIdealHeight( const filamentRgb = hexToRgb(filament.color); // Calculate how thick this zone needs to be - const transitionThickness = calculateTransitionThickness( - currentBackgroundColor, - filamentRgb, + const transitionKey = [ + currentBackgroundColor.r, + currentBackgroundColor.g, + currentBackgroundColor.b, + filamentRgb.r, + filamentRgb.g, + filamentRgb.b, filament.td, - layerHeight - ); + layerHeight, + ].join(':'); + let transitionThickness = transitionThicknessCache?.get(transitionKey); + if (transitionThickness === undefined) { + transitionThickness = calculateTransitionThickness( + currentBackgroundColor, + filamentRgb, + filament.td, + layerHeight + ); + transitionThicknessCache?.set(transitionKey, transitionThickness); + } zones.push({ filamentId: filament.id, @@ -597,12 +613,16 @@ function permutations(arr: T[]): T[][] { * @param sequence - Ordered filament sequence (can include repeats) * @param layerHeight - Physical layer height * @param firstLayerHeight - First layer height + * @param maxHeight - Optional height constraint applied before sampling the palette + * @param transitionThicknessCache - Optional cache shared by optimizer evaluations * @returns Array of achievable { height, lab, rgb } at each layer step */ -function buildAchievableColorPalette( +export function buildAchievableColorPalette( sequence: Array<{ id: string; color: string; td: number }>, layerHeight: number, - firstLayerHeight: number + firstLayerHeight: number, + maxHeight?: number, + transitionThicknessCache?: Map ): Array<{ height: number; lab: Lab; rgb: RGB }> { if (sequence.length === 0) return []; @@ -610,12 +630,15 @@ function buildAchievableColorPalette( const { zones } = calculateIdealHeight( sequence.map((f) => ({ id: f.id, color: f.color, td: f.td })), layerHeight, - Math.max(firstLayerHeight, layerHeight) + Math.max(firstLayerHeight, layerHeight), + transitionThicknessCache ); if (zones.length === 0) return []; - const totalHeight = zones[zones.length - 1].endHeight; + const activeZones = + maxHeight === undefined ? zones : compressZones(zones, maxHeight).compressedZones; + const totalHeight = activeZones[activeZones.length - 1].endHeight; const palette: Array<{ height: number; lab: Lab; rgb: RGB }> = []; let currentZ = 0; @@ -628,31 +651,32 @@ function buildAchievableColorPalette( // Find active zone let activeZoneIndex = 0; - for (let zi = 0; zi < zones.length; zi++) { - if (currentZ >= zones[zi].startHeight && currentZ < zones[zi].endHeight) { + for (let zi = 0; zi < activeZones.length; zi++) { + if (currentZ >= activeZones[zi].startHeight && currentZ < activeZones[zi].endHeight) { activeZoneIndex = zi; break; } - if (currentZ >= zones[zi].startHeight) { + if (currentZ >= activeZones[zi].startHeight) { activeZoneIndex = zi; } } if (activeZoneIndex !== prevZoneIndex) { - thicknessInCurrentZone = currentZ - zones[activeZoneIndex].startHeight + thickness; + thicknessInCurrentZone = + currentZ - activeZones[activeZoneIndex].startHeight + thickness; prevZoneIndex = activeZoneIndex; } else { thicknessInCurrentZone += thickness; } - const zone = zones[activeZoneIndex]; + const zone = activeZones[activeZoneIndex]; const filamentColor = hexToRgb(zone.filamentColor); let blendedColor: RGB; if (activeZoneIndex === 0) { blendedColor = filamentColor; } else { - const bgColor = hexToRgb(zones[activeZoneIndex - 1].filamentColor); + const bgColor = hexToRgb(activeZones[activeZoneIndex - 1].filamentColor); blendedColor = blendColors( bgColor, filamentColor, @@ -670,54 +694,18 @@ function buildAchievableColorPalette( currentZ += thickness; layerIndex++; - if (layerIndex > 500) break; + if (layerIndex >= 500) break; } return palette; } -/** - * Deduplicate a palette by collapsing consecutive entries whose colors - * are within a DeltaE threshold. Each cluster is represented by its - * midpoint height, giving the best possible height spread. - */ -function deduplicatePalette( - palette: Array<{ height: number; lab: Lab; rgb: RGB }>, - threshold: number = 3.0 -): Array<{ height: number; lab: Lab; rgb: RGB }> { - if (palette.length === 0) return []; - - const result: Array<{ height: number; lab: Lab; rgb: RGB }> = []; - let clusterStart = 0; - - for (let i = 1; i <= palette.length; i++) { - const prev = palette[i - 1]; - const curr = i < palette.length ? palette[i] : null; - - const shouldBreak = - !curr || - Math.sqrt( - (curr.lab.L - prev.lab.L) ** 2 + - (curr.lab.a - prev.lab.a) ** 2 + - (curr.lab.b - prev.lab.b) ** 2 - ) >= threshold; - - if (shouldBreak) { - // Use the midpoint entry of this cluster - const midIdx = Math.floor((clusterStart + (i - 1)) / 2); - result.push(palette[midIdx]); - clusterStart = i; - } - } - - return result; -} - /** * Score a filament sequence against weighted image target colors. * * The score combines: - * 1. Weighted color accuracy — for each target, min DeltaE × weight. + * 1. Preview-realized color accuracy — project each target onto the printable + * Lab path, then evaluate the discrete layer that the preview will render. * Dominant image colors contribute more to the score, so the optimizer * prioritizes filament orderings that nail the most common colors. * 2. Height spread — penalizes when distinct image colors collapse to @@ -730,29 +718,30 @@ function deduplicatePalette( * any target color. These are "wasted" intermediate layers that exist * only as transitions and contribute no useful color to the image. */ -function scoreSequenceAgainstImage( +export function scoreSequenceAgainstImage( palette: Array<{ height: number; lab: Lab; rgb: RGB }>, imageTargets: WeightedLab[] ): number { if (palette.length === 0) return Infinity; - // Deduplicate: collapse consecutive near-identical colors - const reduced = deduplicatePalette(palette, 3.0); - if (reduced.length === 0) return Infinity; + // Mirror enhanced auto-paint mapping: project each image color onto the + // palette's Lab polyline, then use the printable layer at that height. + const paletteEntries = palette; + if (paletteEntries.length === 0) return Infinity; - // 1. Weighted color accuracy: sum of (min DeltaE × weight) per target + // 1. Weighted preview-realized DeltaE per target let weightedDeltaE = 0; const bestMatchHeights: number[] = []; - // Also track which reduced palette entries are "useful" (matched by a target) + // Also track which printable palette entries are useful to a target. const usedPaletteEntries = new Set(); for (const target of imageTargets) { let minDE = Infinity; - let bestHeight = reduced[0].height; + let bestHeight = paletteEntries[0].height; let bestIdx = 0; - for (let ri = 0; ri < reduced.length; ri++) { - const entry = reduced[ri]; + for (let ri = 0; ri < paletteEntries.length; ri++) { + const entry = paletteEntries[ri]; const de = Math.sqrt( (entry.lab.L - target.L) ** 2 + (entry.lab.a - target.a) ** 2 + @@ -765,23 +754,67 @@ function scoreSequenceAgainstImage( if (de < 0.5) break; } } - weightedDeltaE += minDE * target.weight; + + for (let ri = 0; ri < paletteEntries.length - 1; ri++) { + const start = paletteEntries[ri]; + const end = paletteEntries[ri + 1]; + const dL = end.lab.L - start.lab.L; + const da = end.lab.a - start.lab.a; + const db = end.lab.b - start.lab.b; + const lengthSquared = dL * dL + da * da + db * db; + if (lengthSquared < 0.01) continue; + + const t = Math.max( + 0, + Math.min( + 1, + ((target.L - start.lab.L) * dL + + (target.a - start.lab.a) * da + + (target.b - start.lab.b) * db) / + lengthSquared + ) + ); + const projectedL = start.lab.L + t * dL; + const projectedA = start.lab.a + t * da; + const projectedB = start.lab.b + t * db; + const projectedDistance = Math.sqrt( + (target.L - projectedL) ** 2 + + (target.a - projectedA) ** 2 + + (target.b - projectedB) ** 2 + ); + if (projectedDistance < minDE) { + minDE = projectedDistance; + bestHeight = start.height + t * (end.height - start.height); + } + } + + const mappedIdx = paletteEntries.findIndex((entry) => entry.height >= bestHeight); + bestIdx = mappedIdx >= 0 ? mappedIdx : paletteEntries.length - 1; + const mappedColor = paletteEntries[bestIdx].lab; + const mappedDeltaE = Math.sqrt( + (mappedColor.L - target.L) ** 2 + + (mappedColor.a - target.a) ** 2 + + (mappedColor.b - target.b) ** 2 + ); + + weightedDeltaE += mappedDeltaE * target.weight; bestMatchHeights.push(bestHeight); - // Mark this palette entry as useful if it's a decent match - if (minDE < 15) usedPaletteEntries.add(bestIdx); + // Mark this palette entry as useful if its printable color is a decent match. + if (mappedDeltaE < 15) usedPaletteEntries.add(bestIdx); } - // Scale up so the magnitude is comparable to pre-weighting scores - weightedDeltaE *= imageTargets.length; + const totalTargetWeight = imageTargets.reduce((sum, target) => sum + target.weight, 0); + weightedDeltaE = totalTargetWeight > 0 ? weightedDeltaE / totalTargetWeight : Infinity; // 2. Height spread penalty: penalize when distinct image colors // collapse to the same height (leading to flat surfaces) - if (bestMatchHeights.length > 1 && reduced.length > 1) { - const totalModelHeight = reduced[reduced.length - 1].height - reduced[0].height; + if (bestMatchHeights.length > 1 && paletteEntries.length > 1) { + const totalModelHeight = + paletteEntries[paletteEntries.length - 1].height - paletteEntries[0].height; if (totalModelHeight > 0) { const uniqueHeights = new Set(bestMatchHeights.map((h) => Math.round(h * 100))); const spreadRatio = uniqueHeights.size / imageTargets.length; - const spreadPenalty = (1 - spreadRatio) * imageTargets.length * 5; + const spreadPenalty = (1 - spreadRatio); weightedDeltaE += spreadPenalty; } } @@ -789,16 +822,15 @@ function scoreSequenceAgainstImage( // 3. Total layer count penalty: raw palette size reflects actual model height. // A sequence with expensive transitions (dissimilar hues) produces many // layers; smooth transitions (similar hues) produce few. - // penalty = 0.5 per raw layer — small per layer but adds up significantly - // for wasteful sequences (e.g., 40 layers vs 15 layers = +12.5 penalty) - weightedDeltaE += palette.length * 0.5; + // This is deliberately small so color accuracy remains the deciding factor. + weightedDeltaE += palette.length * 0.005; // 4. Transition waste penalty: palette entries not matched by any target. - // If a reduced palette entry is not the best match for any image target, + // If a printable palette entry is not the best match for any image target, // the transition height that produced it is wasted model space. - if (reduced.length > 1) { - const wastedEntries = reduced.length - usedPaletteEntries.size; - weightedDeltaE += wastedEntries * 1.5; + if (paletteEntries.length > 1) { + const wastedEntries = paletteEntries.length - usedPaletteEntries.size; + weightedDeltaE += wastedEntries * 0.015; } return weightedDeltaE; @@ -824,7 +856,8 @@ function findBestFilamentOrder( imageSwatches: Array<{ hex: string; count?: number }>, layerHeight: number, firstLayerHeight: number, - optimizerOptions?: Partial + optimizerOptions?: Partial, + maxHeight?: number ): { sortedFilaments: Filament[]; result?: OptimizerResult } { if (filaments.length <= 1) { return { sortedFilaments: [...filaments] }; @@ -837,7 +870,8 @@ function findBestFilamentOrder( imageSwatches, layerHeight, firstLayerHeight, - optimizerOptions + optimizerOptions, + maxHeight ); } @@ -847,7 +881,8 @@ function findBestFilamentOrder( filaments, imageSwatches, layerHeight, - firstLayerHeight + firstLayerHeight, + maxHeight ), }; } @@ -860,7 +895,8 @@ function findBestFilamentOrderWithOptimizer( imageSwatches: Array<{ hex: string; count?: number }>, layerHeight: number, firstLayerHeight: number, - optimizerOptions: Partial + optimizerOptions: Partial, + maxHeight?: number ): { sortedFilaments: Filament[]; result: OptimizerResult } { // Spatial weighting has already been folded into swatch counts by the caller. const imageTargets = clusterImageColors(imageSwatches, 32, 5.0); @@ -870,6 +906,7 @@ function findBestFilamentOrderWithOptimizer( imageColors: imageTargets, layerHeight, firstLayerHeight, + maxHeight, }; // Apply frontlit TD scale @@ -896,7 +933,8 @@ function findBestFilamentOrderLegacy( filaments: Filament[], imageSwatches: Array<{ hex: string; count?: number }>, layerHeight: number, - firstLayerHeight: number + firstLayerHeight: number, + maxHeight?: number ): Filament[] { if (filaments.length <= 1) return [...filaments]; @@ -920,7 +958,12 @@ function findBestFilamentOrderLegacy( for (const subset of subsets) { const perms = permutations(subset); for (const perm of perms) { - const palette = buildAchievableColorPalette(perm, layerHeight, firstLayerHeight); + const palette = buildAchievableColorPalette( + perm, + layerHeight, + firstLayerHeight, + maxHeight + ); const score = scoreSequenceAgainstImage(palette, imageTargets); if (score < bestScore) { bestScore = score; @@ -944,7 +987,12 @@ function findBestFilamentOrderLegacy( const remaining = scaledFilaments.filter((sf) => sf.id !== startFilament.id); const sequence = [startFilament]; - let palette = buildAchievableColorPalette(sequence, layerHeight, firstLayerHeight); + let palette = buildAchievableColorPalette( + sequence, + layerHeight, + firstLayerHeight, + maxHeight + ); let currentScore = scoreSequenceAgainstImage(palette, imageTargets); const pool = [...remaining]; @@ -957,7 +1005,8 @@ function findBestFilamentOrderLegacy( const candidatePalette = buildAchievableColorPalette( candidate, layerHeight, - firstLayerHeight + firstLayerHeight, + maxHeight ); const candidateScore = scoreSequenceAgainstImage(candidatePalette, imageTargets); if (candidateScore < bestScore) { @@ -970,7 +1019,12 @@ function findBestFilamentOrderLegacy( if (bestIdx < 0 || currentScore - bestScore < 0.5) break; sequence.push(pool.splice(bestIdx, 1)[0]); - palette = buildAchievableColorPalette(sequence, layerHeight, firstLayerHeight); + palette = buildAchievableColorPalette( + sequence, + layerHeight, + firstLayerHeight, + maxHeight + ); currentScore = bestScore; } @@ -1011,7 +1065,8 @@ function buildRepeatedSwapSequence( allFilaments: Filament[], imageSwatches: Array<{ hex: string; count?: number }>, layerHeight: number, - firstLayerHeight: number + firstLayerHeight: number, + maxHeight?: number ): Filament[] { if (baseFilaments.length === 0) return []; @@ -1028,7 +1083,8 @@ function buildRepeatedSwapSequence( let currentPalette = buildAchievableColorPalette( currentSequence, layerHeight, - firstLayerHeight + firstLayerHeight, + maxHeight ); let currentScore = scoreSequenceAgainstImage(currentPalette, imageTargets); @@ -1041,7 +1097,8 @@ function buildRepeatedSwapSequence( // Maximum number of extra swaps to try (avoid runaway sequences) const MAX_EXTRA_SWAPS = Math.min(4, allFilaments.length); // Minimum improvement threshold — stop if gains are diminishing - const MIN_IMPROVEMENT = 2.0; + // The scorer is normalized by target weight, so this is a fractional delta. + const MIN_IMPROVEMENT = 0.001; for (let iter = 0; iter < MAX_EXTRA_SWAPS; iter++) { let bestCandidate: (typeof candidates)[0] | null = null; @@ -1069,7 +1126,8 @@ function buildRepeatedSwapSequence( const trialPalette = buildAchievableColorPalette( trial, layerHeight, - firstLayerHeight + firstLayerHeight, + maxHeight ); const trialScore = scoreSequenceAgainstImage(trialPalette, imageTargets); @@ -1093,7 +1151,8 @@ function buildRepeatedSwapSequence( currentPalette = buildAchievableColorPalette( currentSequence, layerHeight, - firstLayerHeight + firstLayerHeight, + maxHeight ); currentScore = bestScore; } @@ -1186,7 +1245,8 @@ export function generateAutoLayers( imageSwatches, layerHeight, firstLayerHeight, - optimizerOptions + optimizerOptions, + maxHeight ); sortedFilaments = orderingResult.sortedFilaments; @@ -1199,7 +1259,8 @@ export function generateAutoLayers( filaments, imageSwatches, layerHeight, - firstLayerHeight + firstLayerHeight, + maxHeight ); } } else { diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index 3a9bbf4..fdc8cc2 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -10,7 +10,11 @@ */ import type { Filament } from '../types'; -import { rgbToLab, deltaELab, hexToRgb, blendColors, type RGB, type Lab } from './autoPaint'; +import { + buildAchievableColorPalette, + scoreSequenceAgainstImage, + type WeightedLab, +} from './autoPaint'; // ============================================================================ // Type Definitions @@ -38,9 +42,10 @@ export interface OptimizerResult { } export interface ScoringContext { - imageColors: Array; // Weighted Lab colors from image + imageColors: WeightedLab[]; layerHeight: number; firstLayerHeight: number; + maxHeight?: number; } // ============================================================================ @@ -145,6 +150,7 @@ function canonicalOptimizerInput( })), layerHeight: context.layerHeight, firstLayerHeight: context.firstLayerHeight, + maxHeight: context.maxHeight ?? null, algorithm, seed: seed ?? null, tuning: tuningFingerprint(options), @@ -163,91 +169,40 @@ function stableHash32(value: string): number { // Scoring Functions // ============================================================================ -/** - * Calculate quality score for a filament ordering. - * Lower score = better color reproduction. - * - * Score is weighted deltaE between image colors and achievable blended colors. - */ -function scoreFilamentOrder(filaments: Filament[], context: ScoringContext): number { - if (filaments.length === 0) return Infinity; - - let totalError = 0; - let totalWeight = 0; - - // For each image color, find the best achievable match using this filament stack - for (const targetColor of context.imageColors) { - const achievableColor = findBestAchievableColor(targetColor, filaments); - const error = deltaELab(targetColor, achievableColor); - - // Apply region weight if provided - totalError += error * targetColor.weight; - totalWeight += targetColor.weight; - } - - return totalWeight > 0 ? totalError / totalWeight : Infinity; -} - -/** - * Find the best color achievable by stacking filaments to a certain height. - * Uses Beer-Lambert simulation to predict the blended color at various heights. - */ -function findBestAchievableColor(targetLab: Lab, filaments: Filament[]): Lab { - if (filaments.length === 0) return { L: 0, a: 0, b: 0 }; - if (filaments.length === 1) { - return rgbToLab(hexToRgb(filaments[0].color)); - } - - // Sample heights from base to full stack - const maxHeight = filaments.reduce((sum, f) => sum + f.td * 3, 0); // ~3x TD per filament - const steps = 20; - let bestLab = rgbToLab(hexToRgb(filaments[0].color)); - let bestDelta = deltaELab(targetLab, bestLab); - - for (let i = 0; i <= steps; i++) { - const height = (i / steps) * maxHeight; - const blendedColor = simulateStackAtHeight(filaments, height); - const blendedLab = rgbToLab(blendedColor); - const delta = deltaELab(targetLab, blendedLab); - - if (delta < bestDelta) { - bestDelta = delta; - bestLab = blendedLab; +export function createSequenceScorer(context: ScoringContext): (filaments: Filament[]) => number { + const paletteCache = new Map>(); + const transitionThicknessCache = new Map(); + + return (filaments) => { + if (filaments.length === 0) return Infinity; + const sequenceKey = filaments.map((filament) => `${filament.id}:${filament.color}:${filament.td}`).join('|'); + let palette = paletteCache.get(sequenceKey); + if (!palette) { + palette = buildAchievableColorPalette( + filaments, + context.layerHeight, + context.firstLayerHeight, + context.maxHeight, + transitionThicknessCache + ); + paletteCache.set(sequenceKey, palette); } - } - - return bestLab; + return scoreSequenceAgainstImage(palette, context.imageColors); + }; } -/** - * Simulate the blended color of stacked filaments at a given height. - */ -function simulateStackAtHeight(filaments: Filament[], targetHeight: number): RGB { - let currentHeight = 0; - let blendedColor = hexToRgb(filaments[0].color); - - for (let i = 1; i < filaments.length && currentHeight < targetHeight; i++) { - const prevFilament = filaments[i - 1]; - const currentFilament = filaments[i]; - const transitionHeight = Math.min(prevFilament.td * 3, targetHeight - currentHeight); - - if (transitionHeight <= 0) break; - - const bgColor = blendedColor; - const fgColor = hexToRgb(currentFilament.color); - blendedColor = blendColors(bgColor, fgColor, currentFilament.td, transitionHeight); - - currentHeight += transitionHeight; - } - - return blendedColor; +export function scoreFilamentSequence(filaments: Filament[], context: ScoringContext): number { + return createSequenceScorer(context)(filaments); } // ============================================================================ // Exhaustive Search (Optimal but slow for >8 filaments) // ============================================================================ -function optimizeExhaustive(filaments: Filament[], context: ScoringContext): OptimizerResult { +function optimizeExhaustive( + filaments: Filament[], + scoreSequence: (filaments: Filament[]) => number +): OptimizerResult { if (filaments.length === 0) { return { order: [], @@ -260,21 +215,21 @@ function optimizeExhaustive(filaments: Filament[], context: ScoringContext): Opt if (filaments.length === 1) { return { order: [filaments[0]], - score: scoreFilamentOrder(filaments, context), + score: scoreSequence(filaments), iterations: 1, converged: true, }; } let bestOrder = filaments; - let bestScore = scoreFilamentOrder(filaments, context); + let bestScore = scoreSequence(filaments); let iterations = 0; // Generate all permutations const permute = (arr: Filament[], start = 0): void => { if (start === arr.length - 1) { iterations++; - const score = scoreFilamentOrder(arr, context); + const score = scoreSequence(arr); if (score < bestScore) { bestScore = score; bestOrder = [...arr]; @@ -311,21 +266,21 @@ function optimizeExhaustive(filaments: Filament[], context: ScoringContext): Opt */ function optimizeSimulatedAnnealing( filaments: Filament[], - context: ScoringContext, + scoreSequence: (filaments: Filament[]) => number, options: OptimizerOptions ): OptimizerResult { if (filaments.length <= 1) { - return optimizeExhaustive(filaments, context); + return optimizeExhaustive(filaments, scoreSequence); } const rng = new SeededRandom(options.seed); const maxIterations = options.maxIterations ?? Math.max(1000, filaments.length * 100); - const initialTemp = options.temperature ?? 100.0; + const initialTemp = options.temperature ?? 10.0; const coolingRate = options.coolingRate ?? 0.995; const minTemp = 0.01; let currentOrder = rng.shuffle(filaments); - let currentScore = scoreFilamentOrder(currentOrder, context); + let currentScore = scoreSequence(currentOrder); let bestOrder = [...currentOrder]; let bestScore = currentScore; let temperature = initialTemp; @@ -337,10 +292,11 @@ function optimizeSimulatedAnnealing( // Generate neighbor by swapping two random filaments const newOrder = [...currentOrder]; const i = rng.nextInt(0, newOrder.length); - const j = rng.nextInt(0, newOrder.length); + let j = rng.nextInt(0, newOrder.length - 1); + if (j >= i) j++; [newOrder[i], newOrder[j]] = [newOrder[j], newOrder[i]]; - const newScore = scoreFilamentOrder(newOrder, context); + const newScore = scoreSequence(newOrder); const deltaE = newScore - currentScore; // Accept if better, or with probability exp(-ΔE/T) if worse @@ -382,11 +338,11 @@ function optimizeSimulatedAnnealing( */ function optimizeGenetic( filaments: Filament[], - context: ScoringContext, + scoreSequence: (filaments: Filament[]) => number, options: OptimizerOptions ): OptimizerResult { if (filaments.length <= 1) { - return optimizeExhaustive(filaments, context); + return optimizeExhaustive(filaments, scoreSequence); } const rng = new SeededRandom(options.seed); @@ -399,7 +355,7 @@ function optimizeGenetic( let population: Array<{ order: Filament[]; score: number }> = []; for (let i = 0; i < populationSize; i++) { const order = rng.shuffle(filaments); - const score = scoreFilamentOrder(order, context); + const score = scoreSequence(order); population.push({ order, score }); } @@ -444,7 +400,7 @@ function optimizeGenetic( [child[i], child[j]] = [child[j], child[i]]; } - const score = scoreFilamentOrder(child, context); + const score = scoreSequence(child); nextGeneration.push({ order: child, score }); } @@ -554,16 +510,17 @@ export function optimizeFilamentOrder( } let result: OptimizerResult; + const scoreSequence = createSequenceScorer(context); switch (algorithm) { case 'exhaustive': - result = optimizeExhaustive(filaments, context); + result = optimizeExhaustive(filaments, scoreSequence); break; case 'simulated-annealing': - result = optimizeSimulatedAnnealing(filaments, context, opts); + result = optimizeSimulatedAnnealing(filaments, scoreSequence, opts); break; case 'genetic': - result = optimizeGenetic(filaments, context, opts); + result = optimizeGenetic(filaments, scoreSequence, opts); break; default: throw new Error(`Unknown algorithm: ${algorithm}`); diff --git a/tests/assets/auto-paint-goldens.json b/tests/assets/auto-paint-goldens.json index 33125d2..2ecbba8 100644 --- a/tests/assets/auto-paint-goldens.json +++ b/tests/assets/auto-paint-goldens.json @@ -49,58 +49,50 @@ }, "B&W / logo-png / enhanced=true / repeats=false": { "filamentOrder": [ - "p9c63ms", - "plvjtmc" + "plvjtmc", + "p9c63ms" ], "transitionZones": [ { - "filamentId": "p9c63ms", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.7800000000000001, - "idealThickness": 0.7800000000000001, - "actualThickness": 0.7800000000000001 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { - "filamentId": "plvjtmc", - "startHeight": 0.7800000000000001, - "endHeight": 0.8600000000000001, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "p9c63ms", + "startHeight": 0.16, + "endHeight": 0.5800000000000001, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 } ], - "totalHeight": 0.8600000000000001, + "totalHeight": 0.5800000000000001, "compressionRatio": 1 }, "B&W / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ - "p9c63ms", "plvjtmc", "p9c63ms" ], "transitionZones": [ - { - "filamentId": "p9c63ms", - "startHeight": 0, - "endHeight": 0.7800000000000001, - "idealThickness": 0.7800000000000001, - "actualThickness": 0.7800000000000001 - }, { "filamentId": "plvjtmc", - "startHeight": 0.7800000000000001, - "endHeight": 0.8600000000000001, - "idealThickness": 0.08, - "actualThickness": 0.08 + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { "filamentId": "p9c63ms", - "startHeight": 0.8600000000000001, - "endHeight": 1.2800000000000002, + "startHeight": 0.16, + "endHeight": 0.5800000000000001, "idealThickness": 0.42000000000000004, "actualThickness": 0.42000000000000004 } ], - "totalHeight": 1.2800000000000002, + "totalHeight": 0.5800000000000001, "compressionRatio": 1 }, "B&W / large-jpeg / enhanced=false / repeats=false": { @@ -281,106 +273,90 @@ }, "GH#27 / logo-png / enhanced=true / repeats=false": { "filamentOrder": [ - "h8zuocq", - "xud8mzr", "plvjtmc", - "y202l1e" + "y202l1e", + "h8zuocq", + "xud8mzr" ], "transitionZones": [ { - "filamentId": "h8zuocq", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.45500000000000007, - "idealThickness": 0.45500000000000007, - "actualThickness": 0.45500000000000007 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { - "filamentId": "xud8mzr", - "startHeight": 0.45500000000000007, - "endHeight": 0.6950000000000001, - "idealThickness": 0.24, - "actualThickness": 0.24 + "filamentId": "y202l1e", + "startHeight": 0.16, + "endHeight": 0.489, + "idealThickness": 0.329, + "actualThickness": 0.329 }, { - "filamentId": "plvjtmc", - "startHeight": 0.6950000000000001, - "endHeight": 0.775, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "h8zuocq", + "startHeight": 0.489, + "endHeight": 0.734, + "idealThickness": 0.245, + "actualThickness": 0.245 }, { - "filamentId": "y202l1e", - "startHeight": 0.775, - "endHeight": 1.104, - "idealThickness": 0.329, - "actualThickness": 0.329 + "filamentId": "xud8mzr", + "startHeight": 0.734, + "endHeight": 0.974, + "idealThickness": 0.24, + "actualThickness": 0.24 } ], - "totalHeight": 1.104, + "totalHeight": 0.974, "compressionRatio": 1 }, "GH#27 / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ - "h8zuocq", - "xud8mzr", - "h8zuocq", - "y202l1e", "plvjtmc", "y202l1e", - "plvjtmc" + "h8zuocq", + "y202l1e", + "xud8mzr" ], "transitionZones": [ { - "filamentId": "h8zuocq", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.45500000000000007, - "idealThickness": 0.45500000000000007, - "actualThickness": 0.45500000000000007 - }, - { - "filamentId": "xud8mzr", - "startHeight": 0.45500000000000007, - "endHeight": 0.6950000000000001, - "idealThickness": 0.24, - "actualThickness": 0.24 - }, - { - "filamentId": "h8zuocq", - "startHeight": 0.6950000000000001, - "endHeight": 0.935, - "idealThickness": 0.24, - "actualThickness": 0.24 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { "filamentId": "y202l1e", - "startHeight": 0.935, - "endHeight": 1.264, + "startHeight": 0.16, + "endHeight": 0.489, "idealThickness": 0.329, "actualThickness": 0.329 }, { - "filamentId": "plvjtmc", - "startHeight": 1.264, - "endHeight": 1.344, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "h8zuocq", + "startHeight": 0.489, + "endHeight": 0.734, + "idealThickness": 0.245, + "actualThickness": 0.245 }, { "filamentId": "y202l1e", - "startHeight": 1.344, - "endHeight": 1.673, + "startHeight": 0.734, + "endHeight": 1.063, "idealThickness": 0.329, "actualThickness": 0.329 }, { - "filamentId": "plvjtmc", - "startHeight": 1.673, - "endHeight": 1.7530000000000001, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "xud8mzr", + "startHeight": 1.063, + "endHeight": 1.322, + "idealThickness": 0.259, + "actualThickness": 0.259 } ], - "totalHeight": 1.7530000000000001, + "totalHeight": 1.322, "compressionRatio": 1 }, "GH#27 / large-jpeg / enhanced=false / repeats=false": { @@ -465,114 +441,90 @@ }, "GH#27 / large-jpeg / enhanced=true / repeats=false": { "filamentOrder": [ - "xud8mzr", - "y202l1e", "plvjtmc", - "h8zuocq" + "y202l1e", + "h8zuocq", + "xud8mzr" ], "transitionZones": [ { - "filamentId": "xud8mzr", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.4810000000000001, - "idealThickness": 0.4810000000000001, - "actualThickness": 0.4810000000000001 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { "filamentId": "y202l1e", - "startHeight": 0.4810000000000001, - "endHeight": 0.81, + "startHeight": 0.16, + "endHeight": 0.489, "idealThickness": 0.329, "actualThickness": 0.329 }, - { - "filamentId": "plvjtmc", - "startHeight": 0.81, - "endHeight": 0.89, - "idealThickness": 0.08, - "actualThickness": 0.08 - }, { "filamentId": "h8zuocq", - "startHeight": 0.89, - "endHeight": 1.135, + "startHeight": 0.489, + "endHeight": 0.734, "idealThickness": 0.245, "actualThickness": 0.245 + }, + { + "filamentId": "xud8mzr", + "startHeight": 0.734, + "endHeight": 0.974, + "idealThickness": 0.24, + "actualThickness": 0.24 } ], - "totalHeight": 1.135, + "totalHeight": 0.974, "compressionRatio": 1 }, "GH#27 / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ - "xud8mzr", - "plvjtmc", - "xud8mzr", "plvjtmc", "y202l1e", - "xud8mzr", - "plvjtmc", - "h8zuocq" + "h8zuocq", + "y202l1e", + "xud8mzr" ], "transitionZones": [ - { - "filamentId": "xud8mzr", - "startHeight": 0, - "endHeight": 0.4810000000000001, - "idealThickness": 0.4810000000000001, - "actualThickness": 0.4810000000000001 - }, { "filamentId": "plvjtmc", - "startHeight": 0.4810000000000001, - "endHeight": 0.561, - "idealThickness": 0.08, - "actualThickness": 0.08 + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { - "filamentId": "xud8mzr", - "startHeight": 0.561, - "endHeight": 0.8200000000000001, - "idealThickness": 0.259, - "actualThickness": 0.259 + "filamentId": "y202l1e", + "startHeight": 0.16, + "endHeight": 0.489, + "idealThickness": 0.329, + "actualThickness": 0.329 }, { - "filamentId": "plvjtmc", - "startHeight": 0.8200000000000001, - "endHeight": 0.9, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "h8zuocq", + "startHeight": 0.489, + "endHeight": 0.734, + "idealThickness": 0.245, + "actualThickness": 0.245 }, { "filamentId": "y202l1e", - "startHeight": 0.9, - "endHeight": 1.229, + "startHeight": 0.734, + "endHeight": 1.063, "idealThickness": 0.329, "actualThickness": 0.329 }, { "filamentId": "xud8mzr", - "startHeight": 1.229, - "endHeight": 1.488, + "startHeight": 1.063, + "endHeight": 1.322, "idealThickness": 0.259, "actualThickness": 0.259 - }, - { - "filamentId": "plvjtmc", - "startHeight": 1.488, - "endHeight": 1.568, - "idealThickness": 0.08, - "actualThickness": 0.08 - }, - { - "filamentId": "h8zuocq", - "startHeight": 1.568, - "endHeight": 1.8130000000000002, - "idealThickness": 0.245, - "actualThickness": 0.245 } ], - "totalHeight": 1.8130000000000002, + "totalHeight": 1.322, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=false / repeats=false": { @@ -721,178 +673,154 @@ }, "Current 8 Colors / logo-png / enhanced=true / repeats=false": { "filamentOrder": [ - "p9c63ms", "plvjtmc", - "upcjpfe", "azwg1yp", - "98z555k", "xyyxysq", "w8cncoa", - "vsn9q6u" + "upcjpfe", + "p9c63ms", + "vsn9q6u", + "98z555k" ], "transitionZones": [ - { - "filamentId": "p9c63ms", - "startHeight": 0, - "endHeight": 0.7800000000000001, - "idealThickness": 0.7800000000000001, - "actualThickness": 0.7800000000000001 - }, { "filamentId": "plvjtmc", - "startHeight": 0.7800000000000001, - "endHeight": 0.8600000000000001, - "idealThickness": 0.08, - "actualThickness": 0.08 - }, - { - "filamentId": "upcjpfe", - "startHeight": 0.8600000000000001, - "endHeight": 1.203, - "idealThickness": 0.343, - "actualThickness": 0.343 + "startHeight": 0, + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { "filamentId": "azwg1yp", - "startHeight": 1.203, - "endHeight": 1.399, + "startHeight": 0.16, + "endHeight": 0.356, "idealThickness": 0.19599999999999998, "actualThickness": 0.19599999999999998 }, - { - "filamentId": "98z555k", - "startHeight": 1.399, - "endHeight": 1.672, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.27299999999999996 - }, { "filamentId": "xyyxysq", - "startHeight": 1.672, - "endHeight": 2.029, + "startHeight": 0.356, + "endHeight": 0.713, "idealThickness": 0.357, "actualThickness": 0.357 }, { "filamentId": "w8cncoa", - "startHeight": 2.029, - "endHeight": 2.4699999999999998, + "startHeight": 0.713, + "endHeight": 1.154, "idealThickness": 0.44099999999999995, "actualThickness": 0.44099999999999995 }, + { + "filamentId": "upcjpfe", + "startHeight": 1.154, + "endHeight": 1.4969999999999999, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "p9c63ms", + "startHeight": 1.4969999999999999, + "endHeight": 1.9169999999999998, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + }, { "filamentId": "vsn9q6u", - "startHeight": 2.4699999999999998, - "endHeight": 2.7359999999999998, + "startHeight": 1.9169999999999998, + "endHeight": 2.183, "idealThickness": 0.26599999999999996, "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "98z555k", + "startHeight": 2.183, + "endHeight": 2.456, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 } ], - "totalHeight": 2.7359999999999998, + "totalHeight": 2.456, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ - "vsn9q6u", - "98z555k", - "vsn9q6u", + "plvjtmc", "azwg1yp", "xyyxysq", "w8cncoa", "upcjpfe", - "azwg1yp", "p9c63ms", - "plvjtmc", - "azwg1yp", - "w8cncoa" + "w8cncoa", + "vsn9q6u", + "98z555k" ], "transitionZones": [ { - "filamentId": "vsn9q6u", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.49400000000000005, - "idealThickness": 0.49400000000000005, - "actualThickness": 0.49400000000000005 - }, - { - "filamentId": "98z555k", - "startHeight": 0.49400000000000005, - "endHeight": 0.767, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.27299999999999996 - }, - { - "filamentId": "vsn9q6u", - "startHeight": 0.767, - "endHeight": 1.033, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.26599999999999996 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { "filamentId": "azwg1yp", - "startHeight": 1.033, - "endHeight": 1.2289999999999999, + "startHeight": 0.16, + "endHeight": 0.356, "idealThickness": 0.19599999999999998, "actualThickness": 0.19599999999999998 }, { "filamentId": "xyyxysq", - "startHeight": 1.2289999999999999, - "endHeight": 1.5859999999999999, + "startHeight": 0.356, + "endHeight": 0.713, "idealThickness": 0.357, "actualThickness": 0.357 }, { "filamentId": "w8cncoa", - "startHeight": 1.5859999999999999, - "endHeight": 2.0269999999999997, + "startHeight": 0.713, + "endHeight": 1.154, "idealThickness": 0.44099999999999995, "actualThickness": 0.44099999999999995 }, { "filamentId": "upcjpfe", - "startHeight": 2.0269999999999997, - "endHeight": 2.3699999999999997, + "startHeight": 1.154, + "endHeight": 1.4969999999999999, "idealThickness": 0.343, "actualThickness": 0.343 }, - { - "filamentId": "azwg1yp", - "startHeight": 2.3699999999999997, - "endHeight": 2.566, - "idealThickness": 0.19599999999999998, - "actualThickness": 0.19599999999999998 - }, { "filamentId": "p9c63ms", - "startHeight": 2.566, - "endHeight": 2.9859999999999998, + "startHeight": 1.4969999999999999, + "endHeight": 1.9169999999999998, "idealThickness": 0.42000000000000004, "actualThickness": 0.42000000000000004 }, { - "filamentId": "plvjtmc", - "startHeight": 2.9859999999999998, - "endHeight": 3.066, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "w8cncoa", + "startHeight": 1.9169999999999998, + "endHeight": 2.3579999999999997, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 }, { - "filamentId": "azwg1yp", - "startHeight": 3.066, - "endHeight": 3.262, - "idealThickness": 0.19599999999999998, - "actualThickness": 0.19599999999999998 + "filamentId": "vsn9q6u", + "startHeight": 2.3579999999999997, + "endHeight": 2.6239999999999997, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 }, { - "filamentId": "w8cncoa", - "startHeight": 3.262, - "endHeight": 3.703, - "idealThickness": 0.44099999999999995, - "actualThickness": 0.44099999999999995 + "filamentId": "98z555k", + "startHeight": 2.6239999999999997, + "endHeight": 2.897, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 } ], - "totalHeight": 3.703, + "totalHeight": 2.897, "compressionRatio": 1 }, "Current 8 Colors / large-jpeg / enhanced=false / repeats=false": { @@ -1041,178 +969,146 @@ }, "Current 8 Colors / large-jpeg / enhanced=true / repeats=false": { "filamentOrder": [ + "upcjpfe", + "plvjtmc", + "p9c63ms", "azwg1yp", + "w8cncoa", "98z555k", - "upcjpfe", "xyyxysq", - "w8cncoa", - "vsn9q6u", - "plvjtmc", - "p9c63ms" + "vsn9q6u" ], "transitionZones": [ { - "filamentId": "azwg1yp", + "filamentId": "upcjpfe", "startHeight": 0, - "endHeight": 0.364, - "idealThickness": 0.364, - "actualThickness": 0.364 + "endHeight": 0.6370000000000001, + "idealThickness": 0.6370000000000001, + "actualThickness": 0.6370000000000001 }, { - "filamentId": "98z555k", - "startHeight": 0.364, - "endHeight": 0.637, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.27299999999999996 + "filamentId": "plvjtmc", + "startHeight": 0.6370000000000001, + "endHeight": 0.7170000000000001, + "idealThickness": 0.08, + "actualThickness": 0.08 }, { - "filamentId": "upcjpfe", - "startHeight": 0.637, - "endHeight": 0.98, - "idealThickness": 0.343, - "actualThickness": 0.343 + "filamentId": "p9c63ms", + "startHeight": 0.7170000000000001, + "endHeight": 1.137, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 }, { - "filamentId": "xyyxysq", - "startHeight": 0.98, - "endHeight": 1.337, - "idealThickness": 0.357, - "actualThickness": 0.357 + "filamentId": "azwg1yp", + "startHeight": 1.137, + "endHeight": 1.333, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 }, { "filamentId": "w8cncoa", - "startHeight": 1.337, - "endHeight": 1.778, + "startHeight": 1.333, + "endHeight": 1.774, "idealThickness": 0.44099999999999995, "actualThickness": 0.44099999999999995 }, { - "filamentId": "vsn9q6u", - "startHeight": 1.778, - "endHeight": 2.044, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.26599999999999996 + "filamentId": "98z555k", + "startHeight": 1.774, + "endHeight": 2.047, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 }, { - "filamentId": "plvjtmc", - "startHeight": 2.044, - "endHeight": 2.124, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "xyyxysq", + "startHeight": 2.047, + "endHeight": 2.404, + "idealThickness": 0.357, + "actualThickness": 0.357 }, { - "filamentId": "p9c63ms", - "startHeight": 2.124, - "endHeight": 2.544, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.42000000000000004 + "filamentId": "vsn9q6u", + "startHeight": 2.404, + "endHeight": 2.67, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 } ], - "totalHeight": 2.544, + "totalHeight": 2.67, "compressionRatio": 1 }, "Current 8 Colors / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ - "azwg1yp", - "98z555k", - "plvjtmc", - "p9c63ms", - "w8cncoa", - "xyyxysq", "vsn9q6u", + "xyyxysq", + "w8cncoa", "upcjpfe", "98z555k", - "upcjpfe", "plvjtmc", - "w8cncoa" + "p9c63ms", + "azwg1yp" ], "transitionZones": [ { - "filamentId": "azwg1yp", + "filamentId": "vsn9q6u", "startHeight": 0, - "endHeight": 0.364, - "idealThickness": 0.364, - "actualThickness": 0.364 - }, - { - "filamentId": "98z555k", - "startHeight": 0.364, - "endHeight": 0.637, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.27299999999999996 - }, - { - "filamentId": "plvjtmc", - "startHeight": 0.637, - "endHeight": 0.717, - "idealThickness": 0.08, - "actualThickness": 0.08 - }, - { - "filamentId": "p9c63ms", - "startHeight": 0.717, - "endHeight": 1.137, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.42000000000000004 - }, - { - "filamentId": "w8cncoa", - "startHeight": 1.137, - "endHeight": 1.5779999999999998, - "idealThickness": 0.44099999999999995, - "actualThickness": 0.44099999999999995 + "endHeight": 0.49400000000000005, + "idealThickness": 0.49400000000000005, + "actualThickness": 0.49400000000000005 }, { "filamentId": "xyyxysq", - "startHeight": 1.5779999999999998, - "endHeight": 1.9349999999999998, + "startHeight": 0.49400000000000005, + "endHeight": 0.851, "idealThickness": 0.357, "actualThickness": 0.357 }, { - "filamentId": "vsn9q6u", - "startHeight": 1.9349999999999998, - "endHeight": 2.2009999999999996, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.26599999999999996 + "filamentId": "w8cncoa", + "startHeight": 0.851, + "endHeight": 1.2919999999999998, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 }, { "filamentId": "upcjpfe", - "startHeight": 2.2009999999999996, - "endHeight": 2.5439999999999996, + "startHeight": 1.2919999999999998, + "endHeight": 1.6349999999999998, "idealThickness": 0.343, "actualThickness": 0.343 }, { "filamentId": "98z555k", - "startHeight": 2.5439999999999996, - "endHeight": 2.8169999999999997, + "startHeight": 1.6349999999999998, + "endHeight": 1.9079999999999997, "idealThickness": 0.27299999999999996, "actualThickness": 0.27299999999999996 }, - { - "filamentId": "upcjpfe", - "startHeight": 2.8169999999999997, - "endHeight": 3.1599999999999997, - "idealThickness": 0.343, - "actualThickness": 0.343 - }, { "filamentId": "plvjtmc", - "startHeight": 3.1599999999999997, - "endHeight": 3.2399999999999998, + "startHeight": 1.9079999999999997, + "endHeight": 1.9879999999999998, "idealThickness": 0.08, "actualThickness": 0.08 }, { - "filamentId": "w8cncoa", - "startHeight": 3.2399999999999998, - "endHeight": 3.6809999999999996, - "idealThickness": 0.44099999999999995, - "actualThickness": 0.44099999999999995 + "filamentId": "p9c63ms", + "startHeight": 1.9879999999999998, + "endHeight": 2.408, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 + }, + { + "filamentId": "azwg1yp", + "startHeight": 2.408, + "endHeight": 2.604, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 } ], - "totalHeight": 3.6809999999999996, + "totalHeight": 2.604, "compressionRatio": 1 } } diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index d97e25e..2fd634f 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -139,3 +139,35 @@ test('default optimizer seeds are stable and cacheable', async () => { assert.equal(second.cacheHit, true); assert.deepEqual(withoutCacheState(second), withoutCacheState(first)); }); + +test('optimizer scores its selected order with the shared build-model scorer', async () => { + const { optimizeFilamentOrder, scoreFilamentSequence } = await loadOptimizerModule(); + const result = optimizeFilamentOrder(filaments, context, { + algorithm: 'exhaustive', + seed: 99, + cachingEnabled: false, + }); + + assert.equal(result.score, scoreFilamentSequence(result.order, context)); +}); + +test('the shared scorer evaluates the compressed stack when Max Height is set', async () => { + const { optimizeFilamentOrder, scoreFilamentSequence } = await loadOptimizerModule(); + const compressedContext = { ...context, maxHeight: 0.24 }; + const options = { + algorithm: 'exhaustive' as const, + seed: 99, + cachingEnabled: false, + }; + + const unconstrainedScore = scoreFilamentSequence(filaments, context); + const compressedScore = scoreFilamentSequence(filaments, compressedContext); + const result = optimizeFilamentOrder(filaments, compressedContext, options); + + assert.notEqual( + compressedScore, + unconstrainedScore, + 'compression must change the palette being scored' + ); + assert.equal(result.score, scoreFilamentSequence(result.order, compressedContext)); +}); From 6d41030e36c069c39504f15f4159a0f0d0130d54 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 21 Jun 2026 15:26:34 +0300 Subject: [PATCH 06/31] Add variable-length auto-paint search --- CHANGELOG.md | 1 + README.md | 14 +- docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 16 +- src/components/AutoPaintTab.tsx | 4 +- src/components/ThreeDControls.tsx | 2 +- src/docs/3d-mode.md | 5 + src/hooks/useAutoPaintWorker.ts | 7 +- src/lib/autoPaint.ts | 341 ++---------------- src/lib/optimizer.ts | 504 +++++++++++++++++---------- tests/assets/auto-paint-goldens.json | 274 ++++++++------- tests/autoPaint.test.ts | 40 +++ tests/optimizer.test.ts | 113 ++++++ 12 files changed, 681 insertions(+), 640 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c4c49..66836d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to Kromacut are documented in this file. ### Changed - **Auto-paint optimizer objective** - Enhanced color matching now evaluates the same zone-compressed, layer-snapped color-to-height path used by the printable preview. All optimizer algorithms share that scorer, including Max Height constraints, so selected filament orders better match the finished model. Repeated optical calculations are memoized during searches. +- **Auto-paint sequence search** - Enhanced matching can now omit filaments that do not improve the printable stack and can natively add up to four non-adjacent repeated swaps when they create useful blends. Auto uses exact subset search through six filaments, deterministic beam search through twelve, and variable-length annealing beyond that. ### Fixed diff --git a/README.md b/README.md index 7a41447..a7d6e7a 100644 --- a/README.md +++ b/README.md @@ -142,10 +142,10 @@ When **Enhanced color matching** is enabled, Kromacut uses advanced optimization | Algorithm | When Used | Description | |---|---|---| -| **Exhaustive search** | 1-4 filaments | Evaluates all possible orderings to guarantee the optimal solution. Fast for small sets. | -| **Simulated Annealing** | 5-8 filaments | Physics-inspired probabilistic search that escapes local minima via controlled randomness. Balances quality and speed. | -| **Genetic Algorithm** | 9+ filaments | Evolution-inspired population-based search with crossover and mutation. Best for large filament sets where exhaustive search is prohibitive (8! = 40,320 permutations). | -| **Auto** | Default | Automatically selects the best algorithm based on filament count and search space size. | +| **Exhaustive search** | Up to 6 filaments | Evaluates every ordered, non-empty subset to guarantee the best available stack. | +| **Beam search** | Auto, 7-12 filaments | Keeps the strongest partial stacks as it grows them, letting Auto omit unhelpful filaments without an expensive full enumeration. | +| **Variable-length annealing** | Auto, 13+ filaments; manual Simulated Annealing or Genetic | Searches swaps, moves, insertions, removals, and replacements. The Genetic setting currently uses this search with a larger budget. | +| **Auto** | Default | Chooses exhaustive search, beam search, or variable-length annealing from the filament count. | The optimizer displays metadata after generation: - **Algorithm used** — Which method was selected @@ -173,12 +173,12 @@ Region weighting is most useful when filament budget is limited and you want the | Option | Description | |---|---| | **Max Height** | Constrains the total model height (mm). When set below the auto-calculated ideal, zones are uniformly compressed. Leave blank or click `Auto` for the physics-derived default. | -| **Enhanced color matching** | Optimizes filament ordering for best color reproduction rather than simple luminance sorting. Uses advanced algorithms (exhaustive, simulated annealing, genetic) automatically selected based on filament count. Scoring considers weighted DeltaE accuracy, height spread, layer count, and transition waste. | -| **Allow repeated filament swaps** | (Requires Enhanced color matching) Allows a filament to appear more than once in the stack. This creates intermediate blended colors — for example, a thin white layer over red produces pink. The algorithm greedily inserts up to 4 extra swaps, each at the position that best improves the score. | +| **Enhanced color matching** | Optimizes the printable filament sequence rather than simply sorting by luminance. It may omit filaments that do not improve the result and evaluates the actual preview color-to-height path. | +| **Allow repeated filament swaps** | (Requires Enhanced color matching) Lets the optimizer use a filament more than once, up to four extra occurrences, when that creates a useful color transition. For example, a thin white layer over red can create pink. | | **Height dithering** | (Requires Enhanced color matching) Applies block-aware Floyd-Steinberg error diffusion to the quantized height map. Instead of sharp stair-steps between layer heights, dithering produces a stippled gradient that simulates intermediate heights, resulting in smoother tonal transitions in the print. Edge pixels between different heights are protected from dithering to avoid staircase artifacts. | | **Flat Paint (flat face-down print)** | Builds a uniform-thickness slab printed image-side down instead of a stepped relief. Each pixel column's layer order is reversed so the artwork sits against the build plate (already mirrored — don't mirror in the slicer) under a transparent carrier layer, and the back is filled with the foundation filament so every layer has the full footprint. The result has a smooth, glass-flat face — great for bookmarks and coasters. Requires a multi-material printer (AMS/toolchanger); export as 3MF, which contains one object per filament plus the clear carrier object. Flat Paint and Smooth Meshing toggle each other off because flat prints always use the full-footprint slab layout. | | **Dither line width** | (Requires Height dithering) Controls the minimum dot size for the dither pattern in mm. This should roughly match your printer's line/nozzle width so dither dots are actually printable. Default: `0.42 mm`. | -| **Optimizer algorithm** | Choose which optimization algorithm to use: Auto (recommended), Exhaustive, Simulated Annealing, or Genetic. Auto selects the best algorithm based on search space size. | +| **Optimizer algorithm** | Choose Auto (recommended), Exhaustive (up to 6 filaments), Simulated Annealing, or Genetic. Auto uses exhaustive search through 6 filaments, beam search through 12, then variable-length annealing. | | **Optimizer seed** | Leave blank for an automatic stable seed, or enter a number for a specific repeatable run. | | **Region weighting** | Prioritize specific image regions: Uniform (equal), Center (image middle), or Edge (outer image border). Helps focus quality budget on important areas. | diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md index 68cbefd..e03ab76 100644 --- a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md +++ b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md @@ -298,31 +298,31 @@ duplicates, repeats allowed **only when** `allowRepeatedSwaps` is on (otherwise sequences are permutations of subsets). 500-layer guard enforced via scoring penalty + hard cap. -- [ ] **4.1** `exhaustive` (≤6 filaments): all permutations of all non-empty subsets +- [x] **4.1** `exhaustive` (≤6 filaments): all permutations of all non-empty subsets (1,956 evals at N=6 — legacy semantics restored) under the unified scorer. When repeats are on, extend with the bounded insertion expansion as a post-pass _of the same scorer_ (cheap and already consistent), or fold repeats into beam search (4.2) and route there. Update UI label/threshold honestly (≤6, not ≤8; keep the existing >threshold downgrade-to-auto behavior in one place — remove the duplicate guard, F10). -- [ ] **4.2** **Beam search** (new internal algorithm, used by `auto` for 7-12 +- [x] **4.2** **Beam search** (new internal algorithm, used by `auto` for 7-12 filaments): build sequences bottom-up; at each depth keep top-K (K≈100, tunable via harness) partial stacks scored with the unified scorer; candidate extensions = any non-duplicate filament occurrence + "stop" (subset selection falls out naturally). Deterministic, anytime, trivially reports progress (depth/maxDepth). -- [ ] **4.3** **Variable-length SA** (replaces permutation SA; used by +- [x] **4.3** **Variable-length SA** (replaces permutation SA; used by `simulated-annealing` and by `auto` for >12): moves = swap(i,j), relocate(i→j), insert(filament, pos) [only if repeats allowed or filament unused], remove(pos) [if length > 1], replace(pos, filament). Seeded move selection; geometric cooling as today. -- [ ] **4.4** GA (`genetic` UI value): keep permutation GA over the full set initially +- [x] **4.4** GA (`genetic` UI value): keep permutation GA over the full set initially but wrap with subset-aware repair, or route `genetic` to variable-length SA with a different default budget if GA quality on the harness is not competitive. Decide on harness data, not in advance. -- [ ] **4.5** Delete `buildRepeatedSwapSequence`; `generateAutoLayers` passes +- [x] **4.5** Delete `buildRepeatedSwapSequence`; `generateAutoLayers` passes `allowRepeatedSwaps` into optimizer options instead (`autoPaint.ts:1307-1316`). -- [ ] **4.6 Tests**: printability invariants (foundation exists; no consecutive +- [x] **4.6 Tests**: printability invariants (foundation exists; no consecutive duplicate filament ids; sequence length caps; result schema unchanged; slice snapping unchanged); per-seed determinism for each algorithm; harness non-regression per Phase 0.5; specific scenario tests: @@ -331,6 +331,10 @@ hard cap. - Thin-white-over-red produces a pink intermediate when repeats are on (repeated-swap regression). - `allowRepeatedSwaps=false` → no filament appears twice. + Validated on the development machine with `npm run benchmark:autopaint` against + Phase 3 commit `bc1d155`: Auto-mode average realized ΔE improved from 30.23 to + 29.96, every fixture held or improved, and the slowest 8-filament Auto run was + 331 ms. Risk: medium-high (largest behavioral change; biggest quality upside). UI: **no new dropdown entries** — `auto/exhaustive/simulated-annealing/genetic` keep their persisted diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 17d2569..a8dae08 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -681,9 +681,9 @@ export default function AutoPaintTab({ 8} + disabled={filaments.length > 6} > - Exhaustive (≤8 filaments) + Exhaustive (≤6 filaments) { - if (optimizerAlgorithm === 'exhaustive' && filaments.length > 8) { + if (optimizerAlgorithm === 'exhaustive' && filaments.length > 6) { setOptimizerAlgorithm('auto'); } }, [filaments.length, optimizerAlgorithm]); diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index a19241e..a26e5ea 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -103,6 +103,11 @@ Flat Paint and **Smooth Meshing** are mutually exclusive. Turning one on turns t | Seed (optional) | Overrides the automatic stable seed for an intentional comparison. | Use **Auto (smart selection)** unless you have a reason to compare algorithms. +Auto uses exact ordered-subset search through 6 filaments, beam search from 7 to 12, +then variable-length simulated annealing for larger profiles. With Enhanced color +matching, Kromacut can omit filaments that do not improve the printable stack. When +Repeated swaps is enabled, it can also add up to four non-adjacent repeated filament +occurrences when they improve the blend path. **Region priority** changes which source colors the optimizer values most: **Center-weighted** gives more importance to colors that occur near the middle of the image, while **Edge-weighted** favors colors nearer its outer edges. It does not crop or change the image itself. diff --git a/src/hooks/useAutoPaintWorker.ts b/src/hooks/useAutoPaintWorker.ts index d28f53c..689249f 100644 --- a/src/hooks/useAutoPaintWorker.ts +++ b/src/hooks/useAutoPaintWorker.ts @@ -206,11 +206,6 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain debounceTimerRef.current = setTimeout(() => { try { const worker = getWorker(); - const algorithm = - optimizerAlgorithm === 'exhaustive' && stableFilaments.length > 8 - ? 'auto' - : optimizerAlgorithm; - const request: AutoPaintWorkerRequest = { id, filaments: stableFilaments, @@ -221,7 +216,7 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain enhancedColorMatch, allowRepeatedSwaps, optimizerOptions: { - algorithm, + algorithm: optimizerAlgorithm, ...(optimizerSeed !== undefined && { seed: optimizerSeed }), }, }; diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 8cc930c..36000da 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -571,39 +571,6 @@ export function clusterImageColors( // ENHANCED COLOR MATCHING — ORDERING OPTIMIZATION // ============================================================================= -/** - * Generate all non-empty subsets of an array. - * For N items, produces 2^N - 1 subsets. - */ -function nonEmptySubsets(arr: T[]): T[][] { - const result: T[][] = []; - const n = arr.length; - for (let mask = 1; mask < 1 << n; mask++) { - const subset: T[] = []; - for (let i = 0; i < n; i++) { - if (mask & (1 << i)) subset.push(arr[i]); - } - result.push(subset); - } - return result; -} - -/** - * Generate all permutations of an array. - * Only used when array.length <= 7 (5040 permutations max). - */ -function permutations(arr: T[]): T[][] { - if (arr.length <= 1) return [arr]; - const result: T[][] = []; - for (let i = 0; i < arr.length; i++) { - const rest = [...arr.slice(0, i), ...arr.slice(i + 1)]; - for (const perm of permutations(rest)) { - result.push([arr[i], ...perm]); - } - } - return result; -} - /** * Build the achievable color palette for a given filament sequence. * @@ -839,15 +806,9 @@ export function scoreSequenceAgainstImage( /** * Find the best filament ordering for the image colors. * - * If optimizer options are provided, uses the advanced optimizer (simulated annealing, genetic algorithm). - * Otherwise, falls back to legacy exhaustive/greedy search. - * - * NOT all filaments need to be used — the algorithm evaluates subsets - * and only includes filaments that improve color reproduction. - * - * For ≤6 filaments, tries all permutations of all non-empty subsets. - * For >6 filaments, uses a greedy build that adds one filament at a time - * and stops when no further addition improves the score. + * Uses the variable-length optimizer for every enhanced-color run. It may + * omit unhelpful filaments and, when enabled, repeat a filament to create + * a useful extra color transition. * * @returns Optimal filament ordering (may be a subset of the input) and optimizer result */ @@ -857,38 +818,26 @@ function findBestFilamentOrder( layerHeight: number, firstLayerHeight: number, optimizerOptions?: Partial, - maxHeight?: number + maxHeight?: number, + allowRepeatedSwaps: boolean = false ): { sortedFilaments: Filament[]; result?: OptimizerResult } { if (filaments.length <= 1) { return { sortedFilaments: [...filaments] }; } - // Use advanced optimizer if options provided - if (optimizerOptions) { - return findBestFilamentOrderWithOptimizer( - filaments, - imageSwatches, - layerHeight, - firstLayerHeight, - optimizerOptions, - maxHeight - ); - } - - // Legacy implementation - return { - sortedFilaments: findBestFilamentOrderLegacy( - filaments, - imageSwatches, - layerHeight, - firstLayerHeight, - maxHeight - ), - }; + return findBestFilamentOrderWithOptimizer( + filaments, + imageSwatches, + layerHeight, + firstLayerHeight, + optimizerOptions ?? {}, + maxHeight, + allowRepeatedSwaps + ); } /** - * Advanced optimizer path using simulated annealing / genetic algorithm + * Advanced optimizer path using the shared variable-length sequence search. */ function findBestFilamentOrderWithOptimizer( filaments: Filament[], @@ -896,7 +845,8 @@ function findBestFilamentOrderWithOptimizer( layerHeight: number, firstLayerHeight: number, optimizerOptions: Partial, - maxHeight?: number + maxHeight?: number, + allowRepeatedSwaps: boolean = false ): { sortedFilaments: Filament[]; result: OptimizerResult } { // Spatial weighting has already been folded into swatch counts by the caller. const imageTargets = clusterImageColors(imageSwatches, 32, 5.0); @@ -916,7 +866,10 @@ function findBestFilamentOrderWithOptimizer( })); // Run optimizer - const result = optimizeFilamentOrder(scaledFilaments, context, optimizerOptions); + const result = optimizeFilamentOrder(scaledFilaments, context, { + ...optimizerOptions, + allowRepeatedSwaps, + }); // Map back to original filaments (unscaled TDs) const sortedFilaments = result.order.map((sf) => @@ -926,244 +879,6 @@ function findBestFilamentOrderWithOptimizer( return { sortedFilaments, result }; } -/** - * Legacy optimizer path (exhaustive for ≤6, greedy for >6) - */ -function findBestFilamentOrderLegacy( - filaments: Filament[], - imageSwatches: Array<{ hex: string; count?: number }>, - layerHeight: number, - firstLayerHeight: number, - maxHeight?: number -): Filament[] { - if (filaments.length <= 1) return [...filaments]; - - // Cluster image colors into weighted representative targets - const imageTargets = clusterImageColors(imageSwatches, 32, 5.0); - if (imageTargets.length === 0) return [...filaments]; - - // Apply frontlit TD scale - const scaledFilaments = filaments.map((f) => ({ - ...f, - td: f.td * FRONTLIT_TD_SCALE, - })); - - if (filaments.length <= 6) { - // Exhaustive search — try all permutations of all non-empty subsets - // For N=6: sum of k! * C(6,k) for k=1..6 = 1957 total permutations - const subsets = nonEmptySubsets(scaledFilaments); - let bestScore = Infinity; - let bestPerm = scaledFilaments; - - for (const subset of subsets) { - const perms = permutations(subset); - for (const perm of perms) { - const palette = buildAchievableColorPalette( - perm, - layerHeight, - firstLayerHeight, - maxHeight - ); - const score = scoreSequenceAgainstImage(palette, imageTargets); - if (score < bestScore) { - bestScore = score; - bestPerm = perm; - } - } - } - - // Return the original filaments in the best ordering (unscaled TDs) - return bestPerm.map((sf) => filaments.find((f) => f.id === sf.id)!); - } - - // Greedy heuristic for large sets: - // Build the sequence one filament at a time, stopping when no addition helps. - // Try each filament as a possible starting point. - const allStarts = scaledFilaments.map((f, idx) => ({ f, idx })); - let globalBestSequence: typeof scaledFilaments = []; - let globalBestScore = Infinity; - - for (const { f: startFilament } of allStarts) { - const remaining = scaledFilaments.filter((sf) => sf.id !== startFilament.id); - const sequence = [startFilament]; - - let palette = buildAchievableColorPalette( - sequence, - layerHeight, - firstLayerHeight, - maxHeight - ); - let currentScore = scoreSequenceAgainstImage(palette, imageTargets); - const pool = [...remaining]; - - while (pool.length > 0) { - let bestIdx = -1; - let bestScore = currentScore; - - for (let i = 0; i < pool.length; i++) { - const candidate = [...sequence, pool[i]]; - const candidatePalette = buildAchievableColorPalette( - candidate, - layerHeight, - firstLayerHeight, - maxHeight - ); - const candidateScore = scoreSequenceAgainstImage(candidatePalette, imageTargets); - if (candidateScore < bestScore) { - bestScore = candidateScore; - bestIdx = i; - } - } - - // Stop if no filament improves the score - if (bestIdx < 0 || currentScore - bestScore < 0.5) break; - - sequence.push(pool.splice(bestIdx, 1)[0]); - palette = buildAchievableColorPalette( - sequence, - layerHeight, - firstLayerHeight, - maxHeight - ); - currentScore = bestScore; - } - - if (currentScore < globalBestScore) { - globalBestScore = currentScore; - globalBestSequence = [...sequence]; - } - } - - return globalBestSequence.map((sf) => filaments.find((f) => f.id === sf.id)!); -} - -// ============================================================================= -// REPEATED SWAPS — SEQUENCE EXPANSION -// ============================================================================= - -/** - * Build an expanded filament sequence that allows filaments to repeat. - * - * Uses a greedy approach: starting from the base ordering, repeatedly - * tries inserting each available filament at the top of the stack. - * Stops when no insertion improves the palette coverage, or when - * a maximum sequence length is reached. - * - * Note: candidates are drawn from ALL original filaments, not just those - * in the base ordering — a filament omitted from the base ordering might - * still be useful as a blending layer. - * - * @param baseFilaments - The initial filament ordering (already optimized, may be a subset) - * @param allFilaments - The full set of available filaments - * @param imageSwatches - Target colors from the image - * @param layerHeight - Physical layer height - * @param firstLayerHeight - First layer height - * @returns Expanded filament sequence with potential repeats - */ -function buildRepeatedSwapSequence( - baseFilaments: Filament[], - allFilaments: Filament[], - imageSwatches: Array<{ hex: string; count?: number }>, - layerHeight: number, - firstLayerHeight: number, - maxHeight?: number -): Filament[] { - if (baseFilaments.length === 0) return []; - - // Cluster image colors into weighted representative targets - const imageTargets = clusterImageColors(imageSwatches, 32, 5.0); - if (imageTargets.length === 0) return [...baseFilaments]; - - // Start with the base sequence - let currentSequence = baseFilaments.map((f) => ({ - ...f, - td: f.td * FRONTLIT_TD_SCALE, - })); - - let currentPalette = buildAchievableColorPalette( - currentSequence, - layerHeight, - firstLayerHeight, - maxHeight - ); - let currentScore = scoreSequenceAgainstImage(currentPalette, imageTargets); - - // Use ALL filaments as insertion candidates (scaled) - const candidates = allFilaments.map((f) => ({ - ...f, - td: f.td * FRONTLIT_TD_SCALE, - })); - - // Maximum number of extra swaps to try (avoid runaway sequences) - const MAX_EXTRA_SWAPS = Math.min(4, allFilaments.length); - // Minimum improvement threshold — stop if gains are diminishing - // The scorer is normalized by target weight, so this is a fractional delta. - const MIN_IMPROVEMENT = 0.001; - - for (let iter = 0; iter < MAX_EXTRA_SWAPS; iter++) { - let bestCandidate: (typeof candidates)[0] | null = null; - let bestInsertPos = -1; - let bestScore = currentScore; - - for (const candidate of candidates) { - // Try inserting at every position in the sequence (not just append). - // Inserting earlier lets later filaments blend on top naturally, - // potentially reusing existing transitions rather than creating - // expensive new ones. - // Position 0 = new foundation, position len = append at top. - // Skip if it would create consecutive identical filaments. - for (let pos = 1; pos <= currentSequence.length; pos++) { - // Skip consecutive duplicates - if (pos > 0 && currentSequence[pos - 1].id === candidate.id) continue; - if (pos < currentSequence.length && currentSequence[pos].id === candidate.id) - continue; - - const trial = [ - ...currentSequence.slice(0, pos), - candidate, - ...currentSequence.slice(pos), - ]; - const trialPalette = buildAchievableColorPalette( - trial, - layerHeight, - firstLayerHeight, - maxHeight - ); - const trialScore = scoreSequenceAgainstImage(trialPalette, imageTargets); - - if (trialScore < bestScore) { - bestScore = trialScore; - bestCandidate = candidate; - bestInsertPos = pos; - } - } - } - - if (!bestCandidate || bestInsertPos < 0 || currentScore - bestScore < MIN_IMPROVEMENT) { - break; // No meaningful improvement - } - - currentSequence = [ - ...currentSequence.slice(0, bestInsertPos), - bestCandidate, - ...currentSequence.slice(bestInsertPos), - ]; - currentPalette = buildAchievableColorPalette( - currentSequence, - layerHeight, - firstLayerHeight, - maxHeight - ); - currentScore = bestScore; - } - - // Map back to original (unscaled) filaments, preserving sequence with repeats - return currentSequence.map((sf) => { - const orig = allFilaments.find((f) => f.id === sf.id)!; - return { ...orig }; // Return copies with original TD - }); -} - // ============================================================================= // MAIN AUTO-PAINT ALGORITHM // ============================================================================= @@ -1246,23 +961,13 @@ export function generateAutoLayers( layerHeight, firstLayerHeight, optimizerOptions, - maxHeight + maxHeight, + allowRepeatedSwaps ); sortedFilaments = orderingResult.sortedFilaments; optimizerResult = orderingResult.result; - // If repeated swaps are also enabled, expand the sequence - if (allowRepeatedSwaps) { - sortedFilaments = buildRepeatedSwapSequence( - sortedFilaments, - filaments, - imageSwatches, - layerHeight, - firstLayerHeight, - maxHeight - ); - } } else { // Standard: sort by luminance (dark to light) sortedFilaments = [...filaments].sort((a, b) => { diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index fdc8cc2..904d2c0 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -1,10 +1,11 @@ /** * Advanced Filament Order Optimizer * - * Implements sophisticated optimization algorithms to find the best filament ordering - * for multi-material lithophanes. Supports: - * - Simulated Annealing: Probabilistic global optimization with temperature scheduling - * - Genetic Algorithm: Population-based evolutionary optimization + * Finds the best printable filament sequence for multi-material lithophanes. + * Supports: + * - Exhaustive ordered-subset search for small profiles + * - Beam search for medium profiles + * - Variable-length simulated annealing for large profiles * - Deterministic Seeding: Reproducible results for A/B testing * - Result Caching: Skip redundant computations */ @@ -22,13 +23,15 @@ import { export interface OptimizerOptions { algorithm: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'; + allowRepeatedSwaps?: boolean; seed?: number; // For deterministic results maxIterations?: number; // Algorithm-specific iteration limit temperature?: number; // Initial temperature for SA coolingRate?: number; // Temperature decay for SA - populationSize?: number; // Population size for GA - mutationRate?: number; // Mutation probability for GA - eliteCount?: number; // Number of elite individuals to preserve in GA + populationSize?: number; // Retained for persisted optimizer compatibility + mutationRate?: number; // Retained for persisted optimizer compatibility + eliteCount?: number; // Retained for persisted optimizer compatibility + beamWidth?: number; // Number of partial stacks kept by beam search cachingEnabled?: boolean; // Enable result caching } @@ -120,12 +123,14 @@ const globalCache = new OptimizerCache(); function tuningFingerprint(options: OptimizerOptions) { return { + allowRepeatedSwaps: options.allowRepeatedSwaps ?? false, maxIterations: options.maxIterations ?? null, temperature: options.temperature ?? null, coolingRate: options.coolingRate ?? null, populationSize: options.populationSize ?? null, mutationRate: options.mutationRate ?? null, eliteCount: options.eliteCount ?? null, + beamWidth: options.beamWidth ?? null, }; } @@ -196,12 +201,104 @@ export function scoreFilamentSequence(filaments: Filament[], context: ScoringCon } // ============================================================================ -// Exhaustive Search (Optimal but slow for >8 filaments) +// Variable-length sequence helpers +// ============================================================================ + +const MAX_EXHAUSTIVE_FILAMENTS = 6; +const AUTO_BEAM_MAX_FILAMENTS = 12; +const MAX_EXTRA_REPEATS = 4; +const DEFAULT_BEAM_WIDTH = 100; + +type SequenceScorer = (filaments: Filament[]) => number; +type ResolvedOptimizerAlgorithm = + | 'exhaustive' + | 'beam' + | 'simulated-annealing' + | 'genetic'; + +interface ScoredSequence { + order: Filament[]; + score: number; +} + +function sequenceKey(sequence: Filament[]): string { + return sequence.map((filament) => filament.id).join('|'); +} + +function hasConsecutiveDuplicates(sequence: Filament[]): boolean { + return sequence.some((filament, index) => index > 0 && filament.id === sequence[index - 1].id); +} + +function hasDuplicateIds(sequence: Filament[]): boolean { + return new Set(sequence.map((filament) => filament.id)).size !== sequence.length; +} + +function isValidSequence(sequence: Filament[], allowRepeatedSwaps: boolean): boolean { + return ( + sequence.length > 0 && + !hasConsecutiveDuplicates(sequence) && + (allowRepeatedSwaps || !hasDuplicateIds(sequence)) + ); +} + +function maxSequenceLength(filaments: Filament[], allowRepeatedSwaps: boolean): number { + return filaments.length + (allowRepeatedSwaps ? MAX_EXTRA_REPEATS : 0); +} + +function isBetterCandidate( + candidate: ScoredSequence, + current: ScoredSequence | null +): boolean { + if (!current) return true; + if (candidate.score !== current.score) return candidate.score < current.score; + return sequenceKey(candidate.order) < sequenceKey(current.order); +} + +function expandWithRepeatedFilaments( + initial: ScoredSequence, + filaments: Filament[], + scoreSequence: SequenceScorer +): { best: ScoredSequence; iterations: number } { + const maxLength = maxSequenceLength(filaments, true); + let best = initial; + let iterations = 0; + + while (best.order.length < maxLength) { + let next: ScoredSequence | null = null; + + for (const filament of filaments) { + for (let position = 0; position <= best.order.length; position++) { + const candidateOrder = [ + ...best.order.slice(0, position), + filament, + ...best.order.slice(position), + ]; + if (!isValidSequence(candidateOrder, true)) continue; + + const candidate = { + order: candidateOrder, + score: scoreSequence(candidateOrder), + }; + iterations++; + if (isBetterCandidate(candidate, next)) next = candidate; + } + } + + if (!next || next.score >= best.score) break; + best = next; + } + + return { best, iterations }; +} + +// ============================================================================ +// Exhaustive Search (all ordered non-empty subsets, up to six filaments) // ============================================================================ function optimizeExhaustive( filaments: Filament[], - scoreSequence: (filaments: Filament[]) => number + scoreSequence: SequenceScorer, + options: OptimizerOptions ): OptimizerResult { if (filaments.length === 0) { return { @@ -212,43 +309,115 @@ function optimizeExhaustive( }; } - if (filaments.length === 1) { + const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + let best: ScoredSequence | null = null; + let iterations = 0; + + const visit = (sequence: Filament[], remaining: Filament[]): void => { + if (sequence.length > 0) { + const candidate = { order: sequence, score: scoreSequence(sequence) }; + iterations++; + if (isBetterCandidate(candidate, best)) best = candidate; + } + + for (let index = 0; index < remaining.length; index++) { + visit( + [...sequence, remaining[index]], + [...remaining.slice(0, index), ...remaining.slice(index + 1)] + ); + } + }; + + visit([], filaments); + const baseBest = best ?? { order: [filaments[0]], score: scoreSequence([filaments[0]]) }; + + if (!allowRepeatedSwaps) { return { - order: [filaments[0]], - score: scoreSequence(filaments), - iterations: 1, + order: baseBest.order, + score: baseBest.score, + iterations, converged: true, }; } - let bestOrder = filaments; - let bestScore = scoreSequence(filaments); + const expanded = expandWithRepeatedFilaments(baseBest, filaments, scoreSequence); + return { + order: expanded.best.order, + score: expanded.best.score, + iterations: iterations + expanded.iterations, + converged: true, + }; +} + +// ============================================================================ +// Beam Search (deterministic variable-length search for medium profiles) +// ============================================================================ + +function optimizeBeamSearch( + filaments: Filament[], + scoreSequence: SequenceScorer, + options: OptimizerOptions +): OptimizerResult { + if (filaments.length <= 1) { + return optimizeExhaustive(filaments, scoreSequence, options); + } + + const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); + const beamWidth = options.beamWidth ?? DEFAULT_BEAM_WIDTH; let iterations = 0; + let best: ScoredSequence | null = null; - // Generate all permutations - const permute = (arr: Filament[], start = 0): void => { - if (start === arr.length - 1) { - iterations++; - const score = scoreSequence(arr); - if (score < bestScore) { - bestScore = score; - bestOrder = [...arr]; + let beam = filaments.map((filament) => { + const candidate = { order: [filament], score: scoreSequence([filament]) }; + iterations++; + if (isBetterCandidate(candidate, best)) best = candidate; + return candidate; + }); + beam.sort((left, right) => + left.score === right.score + ? sequenceKey(left.order).localeCompare(sequenceKey(right.order)) + : left.score - right.score + ); + beam = beam.slice(0, beamWidth); + + for (let depth = 1; depth < maximumLength && beam.length > 0; depth++) { + const candidates = new Map(); + + for (const state of beam) { + for (const filament of filaments) { + if (state.order.at(-1)?.id === filament.id) continue; + if ( + !allowRepeatedSwaps && + state.order.some((existing) => existing.id === filament.id) + ) { + continue; + } + + const order = [...state.order, filament]; + const key = sequenceKey(order); + if (candidates.has(key)) continue; + + const candidate = { order, score: scoreSequence(order) }; + candidates.set(key, candidate); + iterations++; + if (isBetterCandidate(candidate, best)) best = candidate; } - return; } - for (let i = start; i < arr.length; i++) { - [arr[start], arr[i]] = [arr[i], arr[start]]; - permute(arr, start + 1); - [arr[start], arr[i]] = [arr[i], arr[start]]; - } - }; - - permute([...filaments]); + beam = [...candidates.values()] + .sort((left, right) => + left.score === right.score + ? sequenceKey(left.order).localeCompare(sequenceKey(right.order)) + : left.score - right.score + ) + .slice(0, beamWidth); + } + const bestSequence = best ?? { order: [filaments[0]], score: scoreSequence([filaments[0]]) }; return { - order: bestOrder, - score: bestScore, + order: bestSequence.order, + score: bestSequence.score, iterations, converged: true, }; @@ -264,22 +433,106 @@ function optimizeExhaustive( * SA is a probabilistic technique that can escape local minima by accepting * worse solutions with probability exp(-ΔE/T), where T decreases over time. */ +function randomInitialSequence( + filaments: Filament[], + allowRepeatedSwaps: boolean, + rng: SeededRandom +): Filament[] { + const shuffled = rng.shuffle(filaments); + const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); + const initialLength = rng.nextInt(1, Math.min(filaments.length, maximumLength) + 1); + return shuffled.slice(0, initialLength); +} + +function sequenceEquals(left: Filament[], right: Filament[]): boolean { + return ( + left.length === right.length && + left.every((filament, index) => filament.id === right[index].id) + ); +} + +function buildVariableLengthNeighbor( + sequence: Filament[], + filaments: Filament[], + allowRepeatedSwaps: boolean, + rng: SeededRandom +): Filament[] { + const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); + const availableMoves: Array<'swap' | 'relocate' | 'insert' | 'remove' | 'replace'> = []; + + if (sequence.length > 1) { + availableMoves.push('swap', 'relocate', 'remove'); + } + if (sequence.length < maximumLength) availableMoves.push('insert'); + availableMoves.push('replace'); + + for (let attempt = 0; attempt < 12; attempt++) { + const move = availableMoves[rng.nextInt(0, availableMoves.length)]; + const candidate = [...sequence]; + + if (move === 'swap' && candidate.length > 1) { + const first = rng.nextInt(0, candidate.length); + let second = rng.nextInt(0, candidate.length - 1); + if (second >= first) second++; + [candidate[first], candidate[second]] = [candidate[second], candidate[first]]; + } else if (move === 'relocate' && candidate.length > 1) { + const from = rng.nextInt(0, candidate.length); + const [moved] = candidate.splice(from, 1); + candidate.splice(rng.nextInt(0, candidate.length + 1), 0, moved); + } else if (move === 'insert' && candidate.length < maximumLength) { + const eligible = allowRepeatedSwaps + ? filaments + : filaments.filter( + (filament) => !candidate.some((existing) => existing.id === filament.id) + ); + if (eligible.length === 0) continue; + candidate.splice( + rng.nextInt(0, candidate.length + 1), + 0, + eligible[rng.nextInt(0, eligible.length)] + ); + } else if (move === 'remove' && candidate.length > 1) { + candidate.splice(rng.nextInt(0, candidate.length), 1); + } else if (move === 'replace') { + const position = rng.nextInt(0, candidate.length); + const eligible = filaments.filter((filament) => { + if (filament.id === candidate[position].id) return false; + return ( + allowRepeatedSwaps || + !candidate.some( + (existing, index) => index !== position && existing.id === filament.id + ) + ); + }); + if (eligible.length === 0) continue; + candidate[position] = eligible[rng.nextInt(0, eligible.length)]; + } + + if (isValidSequence(candidate, allowRepeatedSwaps) && !sequenceEquals(candidate, sequence)) { + return candidate; + } + } + + return sequence; +} + function optimizeSimulatedAnnealing( filaments: Filament[], - scoreSequence: (filaments: Filament[]) => number, + scoreSequence: SequenceScorer, options: OptimizerOptions ): OptimizerResult { if (filaments.length <= 1) { - return optimizeExhaustive(filaments, scoreSequence); + return optimizeExhaustive(filaments, scoreSequence, options); } + const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; const rng = new SeededRandom(options.seed); const maxIterations = options.maxIterations ?? Math.max(1000, filaments.length * 100); const initialTemp = options.temperature ?? 10.0; const coolingRate = options.coolingRate ?? 0.995; const minTemp = 0.01; - let currentOrder = rng.shuffle(filaments); + let currentOrder = randomInitialSequence(filaments, allowRepeatedSwaps, rng); let currentScore = scoreSequence(currentOrder); let bestOrder = [...currentOrder]; let bestScore = currentScore; @@ -289,12 +542,16 @@ function optimizeSimulatedAnnealing( while (iterations < maxIterations && temperature > minTemp) { iterations++; - // Generate neighbor by swapping two random filaments - const newOrder = [...currentOrder]; - const i = rng.nextInt(0, newOrder.length); - let j = rng.nextInt(0, newOrder.length - 1); - if (j >= i) j++; - [newOrder[i], newOrder[j]] = [newOrder[j], newOrder[i]]; + const newOrder = buildVariableLengthNeighbor( + currentOrder, + filaments, + allowRepeatedSwaps, + rng + ); + if (sequenceEquals(newOrder, currentOrder)) { + temperature *= coolingRate; + continue; + } const newScore = scoreSequence(newOrder); const deltaE = newScore - currentScore; @@ -327,139 +584,42 @@ function optimizeSimulatedAnnealing( } // ============================================================================ -// Genetic Algorithm (Great for large search spaces) +// Genetic compatibility route // ============================================================================ /** - * Genetic Algorithm optimizer with elitism and tournament selection. - * - * Maintains a population of candidate solutions, evolves them through - * selection, crossover, and mutation. + * The persisted Genetic option currently routes to the variable-length + * annealer. Fixed-length crossover cannot represent subset or repeat moves. */ function optimizeGenetic( filaments: Filament[], - scoreSequence: (filaments: Filament[]) => number, + scoreSequence: SequenceScorer, options: OptimizerOptions ): OptimizerResult { - if (filaments.length <= 1) { - return optimizeExhaustive(filaments, scoreSequence); - } - - const rng = new SeededRandom(options.seed); - const populationSize = options.populationSize ?? Math.max(50, filaments.length * 10); - const maxGenerations = options.maxIterations ?? 100; - const mutationRate = options.mutationRate ?? 0.1; - const eliteCount = options.eliteCount ?? Math.max(2, Math.floor(populationSize * 0.1)); - - // Initialize population with random orderings - let population: Array<{ order: Filament[]; score: number }> = []; - for (let i = 0; i < populationSize; i++) { - const order = rng.shuffle(filaments); - const score = scoreSequence(order); - population.push({ order, score }); - } - - let bestEver = { ...population[0] }; - let generations = 0; - let stagnantGenerations = 0; - const maxStagnant = 20; - - while (generations < maxGenerations && stagnantGenerations < maxStagnant) { - generations++; - - // Sort by score (lower is better) - population.sort((a, b) => a.score - b.score); - - // Check for improvement - if (population[0].score < bestEver.score) { - bestEver = { order: [...population[0].order], score: population[0].score }; - stagnantGenerations = 0; - } else { - stagnantGenerations++; - } - - // Elitism: preserve best individuals - const nextGeneration = population.slice(0, eliteCount).map((ind) => ({ - order: [...ind.order], - score: ind.score, - })); - - // Generate offspring - while (nextGeneration.length < populationSize) { - // Tournament selection: pick 3 random, choose best - const parent1 = tournamentSelect(population, 3, rng); - const parent2 = tournamentSelect(population, 3, rng); - - // Order crossover (OX) - const child = orderCrossover(parent1.order, parent2.order, rng); - - // Mutation: swap two positions with probability - if (rng.next() < mutationRate) { - const i = rng.nextInt(0, child.length); - const j = rng.nextInt(0, child.length); - [child[i], child[j]] = [child[j], child[i]]; - } - - const score = scoreSequence(child); - nextGeneration.push({ order: child, score }); - } - - population = nextGeneration; - } - - return { - order: bestEver.order, - score: bestEver.score, - iterations: generations, - converged: stagnantGenerations >= maxStagnant, - }; -} - -/** - * Tournament selection: pick k random individuals, return best one - */ -function tournamentSelect( - population: Array<{ order: Filament[]; score: number }>, - tournamentSize: number, - rng: SeededRandom -): { order: Filament[]; score: number } { - let best = population[rng.nextInt(0, population.length)]; - - for (let i = 1; i < tournamentSize; i++) { - const candidate = population[rng.nextInt(0, population.length)]; - if (candidate.score < best.score) { - best = candidate; - } - } - - return { order: [...best.order], score: best.score }; + // Genetic crossover assumes a fixed permutation. Until a variable-length + // crossover proves competitive, keep the persisted UI choice but route it + // through the variable-length annealer with a slightly larger budget. + return optimizeSimulatedAnnealing(filaments, scoreSequence, { + ...options, + maxIterations: options.maxIterations ?? Math.max(1500, filaments.length * 150), + }); } -/** - * Order crossover (OX): preserves relative order from both parents - */ -function orderCrossover(parent1: Filament[], parent2: Filament[], rng: SeededRandom): Filament[] { - const length = parent1.length; - const start = rng.nextInt(0, length); - const end = rng.nextInt(start + 1, length + 1); - - // Copy segment from parent1 - const child: (Filament | null)[] = new Array(length).fill(null); - for (let i = start; i < end; i++) { - child[i] = parent1[i]; +function resolveAlgorithm( + requested: OptimizerOptions['algorithm'], + filamentCount: number +): ResolvedOptimizerAlgorithm { + if (requested === 'auto') { + if (filamentCount <= MAX_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; + if (filamentCount <= AUTO_BEAM_MAX_FILAMENTS) return 'beam'; + return 'simulated-annealing'; } - // Fill remaining from parent2, preserving order - const remaining = parent2.filter((f) => !child.includes(f)); - let remainingIdx = 0; - - for (let i = 0; i < length; i++) { - if (child[i] === null) { - child[i] = remaining[remainingIdx++]; - } + if (requested === 'exhaustive' && filamentCount > MAX_EXHAUSTIVE_FILAMENTS) { + return filamentCount <= AUTO_BEAM_MAX_FILAMENTS ? 'beam' : 'simulated-annealing'; } - return child as Filament[]; + return requested; } // ============================================================================ @@ -485,17 +645,7 @@ export function optimizeFilamentOrder( ...options, }; - // Auto-select algorithm based on problem size (before cache check) - let algorithm = opts.algorithm; - if (algorithm === 'auto') { - if (filaments.length <= 6) { - algorithm = 'exhaustive'; - } else if (filaments.length <= 10) { - algorithm = 'simulated-annealing'; - } else { - algorithm = 'genetic'; - } - } + const algorithm = resolveAlgorithm(opts.algorithm, filaments.length); const defaultSeedInput = canonicalOptimizerInput(filaments, context, algorithm, opts); const seed = opts.seed ?? stableHash32(defaultSeedInput); @@ -514,7 +664,10 @@ export function optimizeFilamentOrder( switch (algorithm) { case 'exhaustive': - result = optimizeExhaustive(filaments, scoreSequence); + result = optimizeExhaustive(filaments, scoreSequence, opts); + break; + case 'beam': + result = optimizeBeamSearch(filaments, scoreSequence, opts); break; case 'simulated-annealing': result = optimizeSimulatedAnnealing(filaments, scoreSequence, opts); @@ -527,7 +680,8 @@ export function optimizeFilamentOrder( } // Tag the result with the resolved algorithm - result.resolvedAlgorithm = algorithm; + result.resolvedAlgorithm = + algorithm === 'genetic' ? 'variable-length-sa' : algorithm; if (opts.cachingEnabled) { globalCache.set(cacheKey, result); diff --git a/tests/assets/auto-paint-goldens.json b/tests/assets/auto-paint-goldens.json index 2ecbba8..ca837d7 100644 --- a/tests/assets/auto-paint-goldens.json +++ b/tests/assets/auto-paint-goldens.json @@ -673,154 +673,162 @@ }, "Current 8 Colors / logo-png / enhanced=true / repeats=false": { "filamentOrder": [ - "plvjtmc", + "vsn9q6u", + "98z555k", + "upcjpfe", "azwg1yp", "xyyxysq", - "w8cncoa", - "upcjpfe", - "p9c63ms", - "vsn9q6u", - "98z555k" + "w8cncoa" ], "transitionZones": [ { - "filamentId": "plvjtmc", + "filamentId": "vsn9q6u", "startHeight": 0, - "endHeight": 0.16, - "idealThickness": 0.16, - "actualThickness": 0.16 + "endHeight": 0.49400000000000005, + "idealThickness": 0.49400000000000005, + "actualThickness": 0.49400000000000005 + }, + { + "filamentId": "98z555k", + "startHeight": 0.49400000000000005, + "endHeight": 0.767, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "upcjpfe", + "startHeight": 0.767, + "endHeight": 1.11, + "idealThickness": 0.343, + "actualThickness": 0.343 }, { "filamentId": "azwg1yp", - "startHeight": 0.16, - "endHeight": 0.356, + "startHeight": 1.11, + "endHeight": 1.306, "idealThickness": 0.19599999999999998, "actualThickness": 0.19599999999999998 }, { "filamentId": "xyyxysq", - "startHeight": 0.356, - "endHeight": 0.713, + "startHeight": 1.306, + "endHeight": 1.663, "idealThickness": 0.357, "actualThickness": 0.357 }, { "filamentId": "w8cncoa", - "startHeight": 0.713, - "endHeight": 1.154, + "startHeight": 1.663, + "endHeight": 2.104, "idealThickness": 0.44099999999999995, "actualThickness": 0.44099999999999995 - }, - { - "filamentId": "upcjpfe", - "startHeight": 1.154, - "endHeight": 1.4969999999999999, - "idealThickness": 0.343, - "actualThickness": 0.343 - }, - { - "filamentId": "p9c63ms", - "startHeight": 1.4969999999999999, - "endHeight": 1.9169999999999998, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.42000000000000004 - }, - { - "filamentId": "vsn9q6u", - "startHeight": 1.9169999999999998, - "endHeight": 2.183, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.26599999999999996 - }, - { - "filamentId": "98z555k", - "startHeight": 2.183, - "endHeight": 2.456, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.27299999999999996 } ], - "totalHeight": 2.456, + "totalHeight": 2.104, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ - "plvjtmc", + "98z555k", + "w8cncoa", "azwg1yp", "xyyxysq", "w8cncoa", "upcjpfe", - "p9c63ms", - "w8cncoa", "vsn9q6u", - "98z555k" + "98z555k", + "plvjtmc", + "azwg1yp", + "98z555k", + "vsn9q6u" ], "transitionZones": [ { - "filamentId": "plvjtmc", + "filamentId": "98z555k", "startHeight": 0, - "endHeight": 0.16, - "idealThickness": 0.16, - "actualThickness": 0.16 + "endHeight": 0.507, + "idealThickness": 0.507, + "actualThickness": 0.507 + }, + { + "filamentId": "w8cncoa", + "startHeight": 0.507, + "endHeight": 0.948, + "idealThickness": 0.44099999999999995, + "actualThickness": 0.44099999999999995 }, { "filamentId": "azwg1yp", - "startHeight": 0.16, - "endHeight": 0.356, + "startHeight": 0.948, + "endHeight": 1.144, "idealThickness": 0.19599999999999998, "actualThickness": 0.19599999999999998 }, { "filamentId": "xyyxysq", - "startHeight": 0.356, - "endHeight": 0.713, + "startHeight": 1.144, + "endHeight": 1.501, "idealThickness": 0.357, "actualThickness": 0.357 }, { "filamentId": "w8cncoa", - "startHeight": 0.713, - "endHeight": 1.154, + "startHeight": 1.501, + "endHeight": 1.9419999999999997, "idealThickness": 0.44099999999999995, "actualThickness": 0.44099999999999995 }, { "filamentId": "upcjpfe", - "startHeight": 1.154, - "endHeight": 1.4969999999999999, + "startHeight": 1.9419999999999997, + "endHeight": 2.2849999999999997, "idealThickness": 0.343, "actualThickness": 0.343 }, { - "filamentId": "p9c63ms", - "startHeight": 1.4969999999999999, - "endHeight": 1.9169999999999998, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.42000000000000004 + "filamentId": "vsn9q6u", + "startHeight": 2.2849999999999997, + "endHeight": 2.5509999999999997, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 }, { - "filamentId": "w8cncoa", - "startHeight": 1.9169999999999998, - "endHeight": 2.3579999999999997, - "idealThickness": 0.44099999999999995, - "actualThickness": 0.44099999999999995 + "filamentId": "98z555k", + "startHeight": 2.5509999999999997, + "endHeight": 2.824, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.27299999999999996 }, { - "filamentId": "vsn9q6u", - "startHeight": 2.3579999999999997, - "endHeight": 2.6239999999999997, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.26599999999999996 + "filamentId": "plvjtmc", + "startHeight": 2.824, + "endHeight": 2.904, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "azwg1yp", + "startHeight": 2.904, + "endHeight": 3.1, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.19599999999999998 }, { "filamentId": "98z555k", - "startHeight": 2.6239999999999997, - "endHeight": 2.897, + "startHeight": 3.1, + "endHeight": 3.373, "idealThickness": 0.27299999999999996, "actualThickness": 0.27299999999999996 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 3.373, + "endHeight": 3.6390000000000002, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 } ], - "totalHeight": 2.897, + "totalHeight": 3.6390000000000002, "compressionRatio": 1 }, "Current 8 Colors / large-jpeg / enhanced=false / repeats=false": { @@ -972,9 +980,7 @@ "upcjpfe", "plvjtmc", "p9c63ms", - "azwg1yp", "w8cncoa", - "98z555k", "xyyxysq", "vsn9q6u" ], @@ -1000,55 +1006,45 @@ "idealThickness": 0.42000000000000004, "actualThickness": 0.42000000000000004 }, - { - "filamentId": "azwg1yp", - "startHeight": 1.137, - "endHeight": 1.333, - "idealThickness": 0.19599999999999998, - "actualThickness": 0.19599999999999998 - }, { "filamentId": "w8cncoa", - "startHeight": 1.333, - "endHeight": 1.774, + "startHeight": 1.137, + "endHeight": 1.5779999999999998, "idealThickness": 0.44099999999999995, "actualThickness": 0.44099999999999995 }, - { - "filamentId": "98z555k", - "startHeight": 1.774, - "endHeight": 2.047, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.27299999999999996 - }, { "filamentId": "xyyxysq", - "startHeight": 2.047, - "endHeight": 2.404, + "startHeight": 1.5779999999999998, + "endHeight": 1.9349999999999998, "idealThickness": 0.357, "actualThickness": 0.357 }, { "filamentId": "vsn9q6u", - "startHeight": 2.404, - "endHeight": 2.67, + "startHeight": 1.9349999999999998, + "endHeight": 2.2009999999999996, "idealThickness": 0.26599999999999996, "actualThickness": 0.26599999999999996 } ], - "totalHeight": 2.67, + "totalHeight": 2.2009999999999996, "compressionRatio": 1 }, "Current 8 Colors / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ "vsn9q6u", "xyyxysq", - "w8cncoa", "upcjpfe", - "98z555k", "plvjtmc", "p9c63ms", - "azwg1yp" + "upcjpfe", + "98z555k", + "plvjtmc", + "azwg1yp", + "vsn9q6u", + "plvjtmc", + "p9c63ms" ], "transitionZones": [ { @@ -1066,49 +1062,77 @@ "actualThickness": 0.357 }, { - "filamentId": "w8cncoa", + "filamentId": "upcjpfe", "startHeight": 0.851, - "endHeight": 1.2919999999999998, - "idealThickness": 0.44099999999999995, - "actualThickness": 0.44099999999999995 + "endHeight": 1.194, + "idealThickness": 0.343, + "actualThickness": 0.343 + }, + { + "filamentId": "plvjtmc", + "startHeight": 1.194, + "endHeight": 1.274, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "p9c63ms", + "startHeight": 1.274, + "endHeight": 1.694, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 }, { "filamentId": "upcjpfe", - "startHeight": 1.2919999999999998, - "endHeight": 1.6349999999999998, + "startHeight": 1.694, + "endHeight": 2.037, "idealThickness": 0.343, "actualThickness": 0.343 }, { "filamentId": "98z555k", - "startHeight": 1.6349999999999998, - "endHeight": 1.9079999999999997, + "startHeight": 2.037, + "endHeight": 2.31, "idealThickness": 0.27299999999999996, "actualThickness": 0.27299999999999996 }, { "filamentId": "plvjtmc", - "startHeight": 1.9079999999999997, - "endHeight": 1.9879999999999998, + "startHeight": 2.31, + "endHeight": 2.39, "idealThickness": 0.08, "actualThickness": 0.08 }, - { - "filamentId": "p9c63ms", - "startHeight": 1.9879999999999998, - "endHeight": 2.408, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.42000000000000004 - }, { "filamentId": "azwg1yp", - "startHeight": 2.408, - "endHeight": 2.604, + "startHeight": 2.39, + "endHeight": 2.5860000000000003, "idealThickness": 0.19599999999999998, "actualThickness": 0.19599999999999998 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 2.5860000000000003, + "endHeight": 2.8520000000000003, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.26599999999999996 + }, + { + "filamentId": "plvjtmc", + "startHeight": 2.8520000000000003, + "endHeight": 2.9320000000000004, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "p9c63ms", + "startHeight": 2.9320000000000004, + "endHeight": 3.3520000000000003, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.42000000000000004 } ], - "totalHeight": 2.604, + "totalHeight": 3.3520000000000003, "compressionRatio": 1 } } diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts index 5153d65..8b97442 100644 --- a/tests/autoPaint.test.ts +++ b/tests/autoPaint.test.ts @@ -151,6 +151,46 @@ test('auto-paint slice data stays synchronized and uses print-layer heights', as ); }); +test('enhanced repeated-swap search keeps the printable red-to-pink transition', async () => { + const { autoPaintToSliceHeights, generateAutoLayers, hexToRgb } = + await loadAutoPaintModule(); + const layerHeight = 0.08; + const firstLayerHeight = 0.16; + const result = generateAutoLayers( + [ + { id: 'red', color: '#ff0000', td: 1.2 }, + { id: 'white', color: '#ffffff', td: 1.2 }, + ], + [ + { hex: '#ff0000', count: 20 }, + { hex: '#ff8080', count: 80 }, + ], + layerHeight, + firstLayerHeight, + undefined, + true, + true, + { algorithm: 'exhaustive', seed: 4 } + ); + const slices = autoPaintToSliceHeights(result, layerHeight, firstLayerHeight); + + assert.ok( + result.filamentOrder.includes('red') && result.filamentOrder.includes('white'), + 'the optimized stack should include red under white' + ); + assert.ok( + result.filamentOrder.every((id, index) => index === 0 || id !== result.filamentOrder[index - 1]), + 'the optimizer must never emit adjacent duplicate swaps' + ); + assert.ok( + slices.virtualSwatches.some((swatch) => { + const { r, g, b } = hexToRgb(swatch.hex); + return r > 180 && g > 40 && g < 220 && b > 40 && b < 220; + }), + 'a thin white transition over red should produce a pink printable swatch' + ); +}); + test('auto-paint slice data never returns more than 500 layers', async () => { const { autoPaintToSliceHeights } = await loadAutoPaintModule(); const tallResult = { diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index 2fd634f..bf6c954 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -171,3 +171,116 @@ test('the shared scorer evaluates the compressed stack when Max Height is set', ); assert.equal(result.score, scoreFilamentSequence(result.order, compressedContext)); }); + +test('exhaustive search evaluates ordered subsets and drops a strictly worse filament', async () => { + const { optimizeFilamentOrder } = await loadOptimizerModule(); + const candidates = [ + { id: 'black', color: '#000000', td: 0.8 }, + { id: 'white', color: '#ffffff', td: 1.2 }, + { id: 'near-white', color: '#fefefe', td: 1.2 }, + ]; + const target = { + imageColors: [{ L: 100, a: 0, b: 0, weight: 1 }], + layerHeight: 0.08, + firstLayerHeight: 0.16, + }; + + const result = optimizeFilamentOrder(candidates, target, { + algorithm: 'exhaustive', + cachingEnabled: false, + }); + + assert.equal(result.iterations, 15, 'three filaments have 15 ordered non-empty subsets'); + assert.deepEqual(result.order.map((filament) => filament.id), ['white']); +}); + +test('repeats can close the RGB color path to reach the missing magenta blend', async () => { + const { optimizeFilamentOrder } = await loadOptimizerModule(); + const primaries = [ + { id: 'red', color: '#ff0000', td: 1.2 }, + { id: 'green', color: '#00ff00', td: 1.2 }, + { id: 'blue', color: '#0000ff', td: 1.2 }, + ]; + const spectrumTargets = { + imageColors: [ + { L: 53, a: 80, b: 67, weight: 0.05 }, + { L: 88, a: -86, b: 83, weight: 0.05 }, + { L: 32, a: 79, b: -108, weight: 0.05 }, + { L: 97, a: -22, b: 94, weight: 0.28 }, + { L: 91, a: -48, b: -14, weight: 0.28 }, + { L: 60, a: 98, b: -61, weight: 0.29 }, + ], + layerHeight: 0.08, + firstLayerHeight: 0.16, + }; + + const result = optimizeFilamentOrder(primaries, spectrumTargets, { + algorithm: 'exhaustive', + allowRepeatedSwaps: true, + cachingEnabled: false, + }); + + const ids = result.order.map((filament) => filament.id); + assert.ok( + new Set(ids).size < ids.length, + 'closing the RGB path should repeat one primary for the magenta transition' + ); + assert.ok( + ids.every((id, index) => index === 0 || id !== ids[index - 1]), + 'repeated stacks must not contain adjacent duplicate filaments' + ); + assert.ok(ids.length <= primaries.length + 4); +}); + +test('variable-length optimizers preserve sequence safety invariants', async (t) => { + const { optimizeFilamentOrder } = await loadOptimizerModule(); + const algorithms = ['exhaustive', 'simulated-annealing', 'genetic', 'auto'] as const; + + for (const allowRepeatedSwaps of [false, true]) { + for (const algorithm of algorithms) { + await t.test(`${algorithm} / repeats=${allowRepeatedSwaps}`, () => { + const result = optimizeFilamentOrder(filaments, context, { + algorithm, + allowRepeatedSwaps, + seed: 20260621, + maxIterations: 40, + cachingEnabled: false, + }); + const ids = result.order.map((filament) => filament.id); + + assert.ok(ids.length >= 1); + assert.ok(ids.length <= filaments.length + (allowRepeatedSwaps ? 4 : 0)); + assert.ok(ids.every((id, index) => index === 0 || id !== ids[index - 1])); + if (!allowRepeatedSwaps) { + assert.equal(new Set(ids).size, ids.length); + } + }); + } + } +}); + +test('auto uses beam search for medium profiles and protects exhaustive search above six', async () => { + const { optimizeFilamentOrder } = await loadOptimizerModule(); + const mediumProfile = [ + ...filaments, + { id: 'green', color: '#42a85f', td: 1.6 }, + { id: 'yellow', color: '#e7cd38', td: 1.7 }, + { id: 'purple', color: '#7545a8', td: 1.9 }, + ]; + + const auto = optimizeFilamentOrder(mediumProfile, context, { + algorithm: 'auto', + seed: 7, + cachingEnabled: false, + }); + const guardedExhaustive = optimizeFilamentOrder(mediumProfile, context, { + algorithm: 'exhaustive', + seed: 7, + cachingEnabled: false, + }); + + assert.equal(auto.resolvedAlgorithm, 'beam'); + assert.equal(guardedExhaustive.resolvedAlgorithm, 'beam'); + assert.ok(auto.order.length <= mediumProfile.length); + assert.equal(new Set(auto.order.map((filament) => filament.id)).size, auto.order.length); +}); From d685832faffd1640c68b1af5d388083976a61cb4 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 21 Jun 2026 17:00:54 +0300 Subject: [PATCH 07/31] Improve auto-paint progress and 3MF exports --- CHANGELOG.md | 3 ++ docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 12 ++++---- src/components/AutoPaintTab.tsx | 7 ++++- src/components/ThreeDControls.tsx | 2 ++ src/docs/3d-mode.md | 2 ++ src/hooks/useAutoPaintWorker.ts | 30 +++++++++++++++++-- src/lib/export3mf.ts | 9 ++++-- src/lib/optimizer.ts | 43 ++++++++++++++++++++++++++ src/workers/autoPaint.worker.ts | 48 ++++++++++++++++++++++++++---- tests/autoPaintWorker.test.ts | 10 +++++++ tests/export3mf.test.ts | 25 ++++++++++++++++ tests/optimizer.test.ts | 25 ++++++++++++++++ 12 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 tests/autoPaintWorker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66836d4..a9e4729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint regression baseline** - Added focused layer-invariant coverage, per-algorithm seeded determinism checks, and 24 seeded stack snapshots across the 2-, 4-, and 8-filament profiles, both image fixtures, Enhanced matching states, and repeated-swap states. - **Auto-paint benchmark harness** - Added an on-demand JSON benchmark that measures palette and preview-realized color error, coverage, stack cost, compression impact, runtime, and optimizer iterations across the saved fixture profiles. +- **Auto-paint optimization progress** - Enhanced matching now reports an approximate completion percentage while its background search is running. ### Changed @@ -22,6 +23,8 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint layer cap** - Corrected the slice-data safety limit so exceptionally tall auto-paint stacks stop at 500 layers rather than returning 501. +- **Desktop 3MF export reliability** - 3MF model XML now goes directly into the archive instead of being read back through `FileReader`, avoiding desktop WebView `NotReadableError` failures during export. + ## v3.1.0 - 2026-06-18 ### Added diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md index e03ab76..afb434c 100644 --- a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md +++ b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md @@ -345,15 +345,15 @@ values and re-map internally (decision log §4). Goal: fix F9 ergonomics. Independent of phases 3-4 but nicer after them (beam search has natural progress). -- [ ] **5.1** Optimizer accepts `onProgress(iteration, total, bestScore)`; worker +- [x] **5.1** Optimizer accepts `onProgress(iteration, total, bestScore)`; worker throttles (~10 Hz) `postMessage({ type: 'progress', id, ... })`; final message keeps the current shape (`{ id, result }`) for compatibility. -- [ ] **5.2** Hook surfaces `progress` in `UseAutoPaintWorkerResult`; AutoPaintTab +- [x] **5.2** Hook surfaces `progress` in `UseAutoPaintWorkerResult`; AutoPaintTab shows it next to the spinner ("Optimizing… 43%"). -- [ ] **5.3** Keep terminate-based cancel; optionally keep a warm worker between - requests and cancel via `id` checks instead of terminate (measure startup cost - first — only do this if it's noticeable). -- [ ] **5.4 Tests**: progress monotonic 0→1 (mirror `tests/algorithms-progress.test.ts` +- [x] **5.3** Kept terminate-based cancellation. A warm worker was not added because + its startup cost is not currently noticeable enough to justify a more complex + cancellation path. +- [x] **5.4 Tests**: progress monotonic 0→1 (mirror `tests/algorithms-progress.test.ts` pattern); stale progress messages (old `id`) ignored. Risk: low. diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index a8dae08..2e5739d 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -77,6 +77,7 @@ interface AutoPaintTabProps { autoPaintResult?: AutoPaintResult; autoPaintSliceData?: AutoPaintSliceData; isComputing?: boolean; + progress?: number; error?: string; calibrationLayerHeight: number; setCalibrationLayerHeight: (v: number) => void; @@ -139,6 +140,7 @@ export default function AutoPaintTab({ autoPaintResult, autoPaintSliceData, isComputing = false, + progress = 0, error, calibrationLayerHeight, filteredCount, @@ -513,7 +515,10 @@ export default function AutoPaintTab({ {isComputing && (
- Optimizing filament order... + + Optimizing filament order... + {progress > 0 ? ` ${Math.round(progress * 100)}%` : ''} +
)} {error && !isComputing && ( diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index e296561..5078c80 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -209,6 +209,7 @@ export default function ThreeDControls({ const { autoPaintResult, isComputing: isAutoPaintComputing, + progress: autoPaintProgress, error: autoPaintError, } = useAutoPaintWorker({ paintMode, @@ -459,6 +460,7 @@ export default function ThreeDControls({ autoPaintResult={autoPaintResult} autoPaintSliceData={autoPaintSliceData} isComputing={isAutoPaintComputing} + progress={autoPaintProgress} error={autoPaintError} calibrationLayerHeight={calibrationLayerHeight} setCalibrationLayerHeight={setCalibrationLayerHeight} diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index a26e5ea..024324f 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -80,6 +80,8 @@ Optional controls appear with enhanced matching: - **Line width** should roughly match the printer line or nozzle width used for dither dots. - **Optimizer Settings** let you choose **Algorithm**, **Region priority**, and an optional **Seed**. +While Kromacut is optimizing a filament order, the panel shows an approximate completion percentage. Starting a new calculation cancels the older one, so the percentage always belongs to the current settings. + ## Flat Paint **Flat Paint (flat face-down print)** builds a uniform-thickness slab instead of a stepped relief. Every printed layer has the full model footprint: diff --git a/src/hooks/useAutoPaintWorker.ts b/src/hooks/useAutoPaintWorker.ts index 689249f..2db6027 100644 --- a/src/hooks/useAutoPaintWorker.ts +++ b/src/hooks/useAutoPaintWorker.ts @@ -13,7 +13,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AutoPaintResult } from '../lib/autoPaint'; import type { Filament, Swatch } from '../types'; -import type { AutoPaintWorkerRequest, AutoPaintWorkerResponse } from '../workers/autoPaint.worker'; +import type { + AutoPaintWorkerProgress, + AutoPaintWorkerRequest, + AutoPaintWorkerResponse, +} from '../workers/autoPaint.worker'; export interface UseAutoPaintWorkerOptions { paintMode: 'manual' | 'autopaint'; @@ -32,11 +36,22 @@ export interface UseAutoPaintWorkerOptions { export interface UseAutoPaintWorkerResult { autoPaintResult: AutoPaintResult | undefined; isComputing: boolean; + progress: number; error?: string; } let nextRequestId = 1; +export function isCurrentAutoPaintWorkerResponse(responseId: number, activeRequestId: number): boolean { + return responseId === activeRequestId; +} + +function isAutoPaintWorkerProgress( + response: AutoPaintWorkerResponse +): response is AutoPaintWorkerProgress { + return 'type' in response && response.type === 'progress'; +} + function useStableValueByKey(value: T, key: string): T { const stableRef = useRef<{ key: string; value: T } | null>(null); if (!stableRef.current || stableRef.current.key !== key) { @@ -62,6 +77,7 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain const [autoPaintResult, setAutoPaintResult] = useState(undefined); const [isComputing, setIsComputing] = useState(false); + const [progress, setProgress] = useState(0); const [error, setError] = useState(undefined); const workerRef = useRef(null); @@ -86,6 +102,7 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain activeRequestIdRef.current = 0; setIsComputing(false); + setProgress(nextError ? 0 : 1); setError(nextError); if (nextError) { @@ -150,7 +167,12 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain workerRef.current.onmessage = (e: MessageEvent) => { const resp = e.data; - if (resp.id !== activeRequestIdRef.current) return; + if (!isCurrentAutoPaintWorkerResponse(resp.id, activeRequestIdRef.current)) return; + + if (isAutoPaintWorkerProgress(resp)) { + setProgress((current) => Math.max(current, resp.progress)); + return; + } if (resp.error) { finishRequest(resp.id, resp.error); @@ -190,6 +212,7 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain cancelWorker(); setAutoPaintResult(undefined); setIsComputing(false); + setProgress(0); setError(undefined); return; } @@ -201,6 +224,7 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain activeRequestIdRef.current = id; setAutoPaintResult(undefined); setIsComputing(true); + setProgress(0); setError(undefined); debounceTimerRef.current = setTimeout(() => { @@ -259,5 +283,5 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain finishRequest, ]); - return { autoPaintResult, isComputing, error }; + return { autoPaintResult, isComputing, progress, error }; } diff --git a/src/lib/export3mf.ts b/src/lib/export3mf.ts index a14185f..308ee54 100644 --- a/src/lib/export3mf.ts +++ b/src/lib/export3mf.ts @@ -634,9 +634,12 @@ export async function exportObjectTo3MFBlob( flushXmlChunk(); - const finalBlob = new Blob(xmlParts, { type: 'text/xml' }); - - zip.folder('3D')?.file('3dmodel.model', finalBlob); + // JSZip reads Blob inputs through FileReader. In the Tauri webview that + // extra read can fail with NotReadableError even though this XML was just + // generated in memory. Passing text lets JSZip encode it directly. + const modelXml = xmlParts.join(''); + xmlParts.length = 0; + zip.folder('3D')?.file('3dmodel.model', modelXml); // Generate Metadata/model_settings.config // This is required for Bambu Studio / Orca Slicer / Creality Print to correctly identify diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index 904d2c0..b3281ae 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -33,6 +33,7 @@ export interface OptimizerOptions { eliteCount?: number; // Retained for persisted optimizer compatibility beamWidth?: number; // Number of partial stacks kept by beam search cachingEnabled?: boolean; // Enable result caching + onProgress?: (iteration: number, total: number, bestScore: number) => void; } export interface OptimizerResult { @@ -254,6 +255,33 @@ function isBetterCandidate( return sequenceKey(candidate.order) < sequenceKey(current.order); } +function reportProgress( + options: OptimizerOptions, + iteration: number, + total: number, + bestScore: number +) { + options.onProgress?.(Math.min(iteration, total), Math.max(total, 1), bestScore); +} + +function orderedSubsetCount(filamentCount: number): number { + let total = 0; + let permutations = 1; + for (let length = 1; length <= filamentCount; length++) { + permutations *= filamentCount - length + 1; + total += permutations; + } + return total; +} + +function repeatedInsertionUpperBound(filamentCount: number): number { + let total = 0; + for (let extra = 0; extra < MAX_EXTRA_REPEATS; extra++) { + total += filamentCount * (filamentCount + extra + 1); + } + return total; +} + function expandWithRepeatedFilaments( initial: ScoredSequence, filaments: Filament[], @@ -310,14 +338,19 @@ function optimizeExhaustive( } const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + const totalIterations = + orderedSubsetCount(filaments.length) + + (allowRepeatedSwaps ? repeatedInsertionUpperBound(filaments.length) : 0); let best: ScoredSequence | null = null; let iterations = 0; + reportProgress(options, 0, totalIterations, Infinity); const visit = (sequence: Filament[], remaining: Filament[]): void => { if (sequence.length > 0) { const candidate = { order: sequence, score: scoreSequence(sequence) }; iterations++; if (isBetterCandidate(candidate, best)) best = candidate; + reportProgress(options, iterations, totalIterations, best?.score ?? Infinity); } for (let index = 0; index < remaining.length; index++) { @@ -365,13 +398,17 @@ function optimizeBeamSearch( const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); const beamWidth = options.beamWidth ?? DEFAULT_BEAM_WIDTH; + const totalIterations = + filaments.length + (maximumLength - 1) * beamWidth * filaments.length; let iterations = 0; let best: ScoredSequence | null = null; + reportProgress(options, 0, totalIterations, Infinity); let beam = filaments.map((filament) => { const candidate = { order: [filament], score: scoreSequence([filament]) }; iterations++; if (isBetterCandidate(candidate, best)) best = candidate; + reportProgress(options, iterations, totalIterations, best?.score ?? Infinity); return candidate; }); beam.sort((left, right) => @@ -402,6 +439,7 @@ function optimizeBeamSearch( candidates.set(key, candidate); iterations++; if (isBetterCandidate(candidate, best)) best = candidate; + reportProgress(options, iterations, totalIterations, best?.score ?? Infinity); } } @@ -538,6 +576,7 @@ function optimizeSimulatedAnnealing( let bestScore = currentScore; let temperature = initialTemp; let iterations = 0; + reportProgress(options, 0, maxIterations, bestScore); while (iterations < maxIterations && temperature > minTemp) { iterations++; @@ -550,6 +589,7 @@ function optimizeSimulatedAnnealing( ); if (sequenceEquals(newOrder, currentOrder)) { temperature *= coolingRate; + reportProgress(options, iterations, maxIterations, bestScore); continue; } @@ -570,6 +610,7 @@ function optimizeSimulatedAnnealing( } temperature *= coolingRate; + reportProgress(options, iterations, maxIterations, bestScore); } // Convergence check: did we stabilize? @@ -655,6 +696,7 @@ export function optimizeFilamentOrder( if (opts.cachingEnabled) { const cached = globalCache.get(cacheKey); if (cached) { + reportProgress(opts, 1, 1, cached.score); return { ...cached, cacheHit: true }; } } @@ -682,6 +724,7 @@ export function optimizeFilamentOrder( // Tag the result with the resolved algorithm result.resolvedAlgorithm = algorithm === 'genetic' ? 'variable-length-sa' : algorithm; + reportProgress(opts, result.iterations, result.iterations, result.score); if (opts.cachingEnabled) { globalCache.set(cacheKey, result); diff --git a/src/workers/autoPaint.worker.ts b/src/workers/autoPaint.worker.ts index 8b91d0a..165c5bf 100644 --- a/src/workers/autoPaint.worker.ts +++ b/src/workers/autoPaint.worker.ts @@ -11,6 +11,8 @@ import type { Filament } from '../types'; import type { OptimizerOptions } from '../lib/optimizer'; import type { AutoPaintResult } from '../lib/autoPaint'; +type WorkerOptimizerOptions = Omit; + export interface AutoPaintWorkerRequest { id: number; filaments: Filament[]; @@ -20,17 +22,49 @@ export interface AutoPaintWorkerRequest { maxHeight?: number; enhancedColorMatch?: boolean; allowRepeatedSwaps?: boolean; - optimizerOptions?: Partial; + optimizerOptions?: Partial; } -export interface AutoPaintWorkerResponse { +export interface AutoPaintWorkerResult { id: number; result?: AutoPaintResult; error?: string; } +export interface AutoPaintWorkerProgress { + type: 'progress'; + id: number; + progress: number; + iteration: number; + total: number; + bestScore: number; +} + +export type AutoPaintWorkerResponse = AutoPaintWorkerResult | AutoPaintWorkerProgress; + self.onmessage = (e: MessageEvent) => { const req = e.data; + const PROGRESS_INTERVAL_MS = 100; + let lastProgress = 0; + let lastProgressAt = -Infinity; + + const reportProgress = (iteration: number, total: number, bestScore: number) => { + const progress = Math.max(lastProgress, Math.min(1, total > 0 ? iteration / total : 0)); + const now = performance.now(); + if (progress < 1 && now - lastProgressAt < PROGRESS_INTERVAL_MS) return; + + lastProgress = progress; + lastProgressAt = now; + const response: AutoPaintWorkerProgress = { + type: 'progress', + id: req.id, + progress, + iteration, + total, + bestScore, + }; + self.postMessage(response); + }; try { const result = generateAutoLayers( @@ -41,13 +75,17 @@ self.onmessage = (e: MessageEvent) => { req.maxHeight, req.enhancedColorMatch, req.allowRepeatedSwaps, - req.optimizerOptions + { + ...req.optimizerOptions, + onProgress: reportProgress, + } ); - const response: AutoPaintWorkerResponse = { id: req.id, result }; + reportProgress(1, 1, result.optimizerMetadata?.score ?? Infinity); + const response: AutoPaintWorkerResult = { id: req.id, result }; self.postMessage(response); } catch (err) { - const response: AutoPaintWorkerResponse = { + const response: AutoPaintWorkerResult = { id: req.id, error: err instanceof Error ? err.message : String(err), }; diff --git a/tests/autoPaintWorker.test.ts b/tests/autoPaintWorker.test.ts new file mode 100644 index 0000000..964b451 --- /dev/null +++ b/tests/autoPaintWorker.test.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isCurrentAutoPaintWorkerResponse } from '../src/hooks/useAutoPaintWorker.ts'; + +test('auto-paint worker ignores progress and result messages from stale requests', () => { + assert.equal(isCurrentAutoPaintWorkerResponse(7, 7), true); + assert.equal(isCurrentAutoPaintWorkerResponse(6, 7), false); + assert.equal(isCurrentAutoPaintWorkerResponse(8, 7), false); +}); diff --git a/tests/export3mf.test.ts b/tests/export3mf.test.ts index e02c31a..17f7953 100644 --- a/tests/export3mf.test.ts +++ b/tests/export3mf.test.ts @@ -1406,6 +1406,31 @@ test('3MF export keeps generated meshes as separate layer objects', async () => ); }); +test('3MF export does not require FileReader to read generated model XML', async () => { + const { exportObjectTo3MFBlob } = await loadExport3mfModule(); + const previousFileReader = globalThis.FileReader; + + class RejectingFileReader { + error = new DOMException('Generated blobs must not be re-read', 'NotReadableError'); + onerror: ((event: { target: RejectingFileReader }) => void) | null = null; + + readAsArrayBuffer() { + this.onerror?.({ target: this }); + } + } + + globalThis.FileReader = RejectingFileReader as unknown as typeof FileReader; + try { + const root = new THREE.Group(); + root.add(createLayerMesh(createSharedCubeGeometry(), 0xff0000)); + + const blob = await exportObjectTo3MFBlob(root); + assert.ok(blob.size > 0); + } finally { + globalThis.FileReader = previousFileReader; + } +}); + test('exports include preview-hidden layers with their original filament colors', async () => { const root = new THREE.Group(); const first = createLayerMesh(createSharedCubeGeometry(), 0xff0000); diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index bf6c954..270d2a0 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -69,6 +69,31 @@ test('each optimizer produces the same result for the same seed', async (t) => { } }); +test('optimizer progress is monotonic and completes for every algorithm', async (t) => { + const { optimizeFilamentOrder } = await loadOptimizerModule(); + const algorithms = ['exhaustive', 'simulated-annealing', 'genetic', 'auto'] as const; + + for (const algorithm of algorithms) { + await t.test(algorithm, () => { + const samples: number[] = []; + optimizeFilamentOrder(filaments, context, { + algorithm, + seed: 42, + maxIterations: 30, + cachingEnabled: false, + onProgress: (iteration, total) => samples.push(total > 0 ? iteration / total : 0), + }); + + assert.ok(samples.length > 0); + assert.equal(samples.at(-1), 1); + assert.ok( + samples.every((sample, index) => index === 0 || sample >= samples[index - 1]), + 'progress must never move backwards' + ); + }); + } +}); + function withoutCacheState(result: T): Omit { const outcome = { ...result }; delete outcome.cacheHit; From 46a666208f80bc4d3f3e79062a2feaa113edb946 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 21 Jun 2026 21:37:25 +0300 Subject: [PATCH 08/31] Harden auto-paint physics and 3MF export --- CHANGELOG.md | 3 +- README.md | 2 +- docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 24 ++- src/docs/3d-mode.md | 2 + src/lib/autoPaint.ts | 294 ++++++++++++++++++----------- src/lib/export3mf.ts | 59 +++++- src/lib/optimizer.ts | 7 +- tests/autoPaint.test.ts | 82 ++++++++ tests/export3mf.test.ts | 9 +- tests/optimizer.test.ts | 23 +++ 10 files changed, 367 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e4729..4e311c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint optimizer objective** - Enhanced color matching now evaluates the same zone-compressed, layer-snapped color-to-height path used by the printable preview. All optimizer algorithms share that scorer, including Max Height constraints, so selected filament orders better match the finished model. Repeated optical calculations are memoized during searches. - **Auto-paint sequence search** - Enhanced matching can now omit filaments that do not improve the printable stack and can natively add up to four non-adjacent repeated swaps when they create useful blends. Auto uses exact subset search through six filaments, deterministic beam search through twelve, and variable-length annealing beyond that. +- **Calibrated Auto-paint previews** - Calibrated filaments now use their measured red, green, and blue TD values when simulating blends, while the scalar working TD continues to control layer-zone thickness. ### Fixed @@ -23,7 +24,7 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint layer cap** - Corrected the slice-data safety limit so exceptionally tall auto-paint stacks stop at 500 layers rather than returning 501. -- **Desktop 3MF export reliability** - 3MF model XML now goes directly into the archive instead of being read back through `FileReader`, avoiding desktop WebView `NotReadableError` failures during export. +- **Desktop 3MF export reliability** - 3MF model XML now streams into the archive in bounded chunks, avoiding desktop WebView `FileReader` `NotReadableError` failures and `RangeError: Invalid string length` on large exports. ## v3.1.0 - 2026-06-18 diff --git a/README.md b/README.md index a7d6e7a..a651ae5 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Each filament row in the Auto-paint tab includes a **calibration wizard** to hel 4. **Automatic TD calculation** — The wizard performs exponential regression on your measurements to compute the optimal TD value with a confidence score (High/Medium/Low/Very Low). 5. **Save profile** — Keep calibrated filaments in a reusable profile for future projects. -Calibrated filaments display a confidence badge next to their TD value. Higher confidence = more accurate optical simulation = better print results. +Calibrated filaments display a confidence badge next to their TD value. Higher confidence = more accurate optical simulation = better print results. Auto-paint also uses the calibrated red, green, and blue TD values for preview blending, while the working TD continues to set layer-zone thickness. ### Advanced Optimizer diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md index afb434c..b66174a 100644 --- a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md +++ b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md @@ -364,23 +364,21 @@ Goal: close F8 where measurement proves it helps. Each item independent; ship on with harness evidence. These change preview colors for existing projects — CHANGELOG each. -- [ ] **6a Per-channel TD blending where calibration exists**: `blendColors` uses +- [x] **6a Per-channel TD blending where calibration exists**: `blendColors` uses `calibration.td: [r,g,b]` (per-channel transmission, gamma-space — consistent with how calibration measures) when present; scalar `tdSingleValue` fallback unchanged for uncalibrated filaments. Touches `blendColors` call sites in `autoPaint.ts` only; `Filament` already carries `calibration`. -- [ ] **6b Compression-aware backgrounds**: carry the actual blended end-color of zone - _i−1_ forward as zone _i_'s background in `buildAchievableColorPalette` and - `autoPaintToSliceHeights` (today: pure color, `autoPaint.ts:656-663, 1499-1506`). - Makes compressed previews honest and lets the (unified) scorer see compression - damage. Optionally: when `compressionRatio < 0.9`, re-score the top-k candidate - orders under compression and pick the best (bounded cost). -- [ ] **6c CIEDE2000** behind a metric constant for scoring + clustering distances - (~3-4× distance cost; affordable at ≤32 targets). Adopt only if harness shows - end-to-end improvement on saturated fixtures. -- [ ] **6d FRONTLIT_TD_SCALE**: leave at 0.1 for now; add a calibration-wizard-derived - frontlit factor as a future item (requires new measurement flow — out of scope). -- [ ] **6e Cleanup**: delete dead `luminanceToHeight` (`autoPaint.ts:1551`); update the +- [ ] **6b Compression-aware backgrounds**: implemented behind + `USE_COMPRESSION_AWARE_BACKGROUNDS`, but not enabled. The harness regressed the + 8-color large-image case from ΔE 11.58 to 13.26 (14.5%), beyond the 5% tolerance. + Revisit only with a better compression model or measured compressed-stack data. +- [x] **6c CIEDE2000 evaluation**: implemented behind `OPTIMIZER_DISTANCE_METRIC` for + scoring and clustering, but retained CIE76. The harness put CIEDE2000's two + 8-filament runs at 4.1–4.7 s, over the 2 s budget, so it did not earn adoption. +- [x] **6d FRONTLIT_TD_SCALE**: deliberately retained at 0.1. A calibrated front-lit + factor needs a new measurement workflow and remains out of scope. +- [x] **6e Cleanup**: deleted dead `luminanceToHeight`; updated the stale module doc header (`autoPaint.ts:1-17`). Risk: medium (visual shifts); mitigated by gating + harness + CHANGELOG. diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index 024324f..897fe8f 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -82,6 +82,8 @@ Optional controls appear with enhanced matching: While Kromacut is optimizing a filament order, the panel shows an approximate completion percentage. Starting a new calculation cancels the older one, so the percentage always belongs to the current settings. +When a filament has been calibrated, Auto-paint also uses its measured red, green, and blue TD values to preview color transitions. The normal working TD still controls layer-zone thickness, so calibration makes the color model more faithful without changing your print-height settings. + ## Flat Paint **Flat Paint (flat face-down print)** builds a uniform-thickness slab instead of a stepped relief. Every printed layer has the full model footprint: diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 36000da..717651e 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -1,19 +1,11 @@ /** - * Auto-Paint Algorithm for Filament Painting (HueForge-style lithophanes) + * Auto-paint optical model and printable color-stack planner. * - * This module implements a physically-accurate optical simulation for - * multi-filament lithophane printing using the Beer-Lambert law. + * This module models multi-filament prints with Beer-Lambert blending, then + * turns the selected filament stack into layer zones and preview colors. * - * Key concepts: - * 1. TRANSITION ZONES: Each filament needs enough vertical space to fully - * transition from the previous color to its pure color. - * 2. CUMULATIVE HEIGHT: Total height = sum of all transition zones. - * 3. COMPRESSION: If user sets a max height below the ideal, zones are compressed. - * 4. LUMINANCE MAPPING: Image pixel brightness maps to position within zones. - * 5. ENHANCED COLOR MATCHING: Optimizes filament ordering by evaluating all - * permutations for best color reproduction (DeltaE-based). - * 6. REPEATED SWAPS: Allows filaments to appear multiple times in the stack - * to create intermediate blended colors (e.g., thin white over red = pink). + * It keeps the optimizer and preview on the same model: optional calibrated + * per-channel TDs and printable layer sampling are shared by both paths. */ import type { Filament } from '../types'; @@ -23,7 +15,7 @@ import { type OptimizerResult, type ScoringContext, } from './optimizer'; -import { computeProfileConfidence } from './calibration'; +import { computeProfileConfidence, type CalibrationRgb } from './calibration'; export { LAYER_ACTIVATION_EPSILON } from './layerActivation'; @@ -46,11 +38,14 @@ export interface WeightedLab extends Lab { weight: number; } +type ColorDistanceMetric = 'cie76' | 'ciede2000'; + /** A transition zone between two filaments */ export interface TransitionZone { filamentId: string; filamentColor: string; filamentTd: number; // Transmission Distance of this filament + filamentTdChannels?: CalibrationRgb; // Calibrated R, G, B TDs when available startHeight: number; // mm from Z=0 endHeight: number; // mm from Z=0 idealThickness: number; // Uncompressed zone thickness @@ -209,36 +204,105 @@ export function getLuminance(color: RGB): number { * * @param backgroundColor - The color of the existing stack * @param filamentColor - The color of the filament being added - * @param filamentTD - Transmission Distance of the filament (mm) + * @param filamentTD - Scalar working TD or calibrated R, G, B TDs (mm) * @param layerThickness - How thick the filament layer is (mm) * @returns The resulting blended color */ export function blendColors( backgroundColor: RGB, filamentColor: RGB, - filamentTD: number, + filamentTD: number | CalibrationRgb, layerThickness: number ): RGB { - // Prevent division by zero or invalid TD - if (filamentTD <= 0 || layerThickness <= 0) { + if (layerThickness <= 0) { return filamentColor; } - // Beer-Lambert law: transmission = 10^(-thickness/TD) - // At thickness == TD, transmission = 10^(-1) = 0.1 (10%) - const transmission = Math.pow(0.1, layerThickness / filamentTD); + const channelTd: CalibrationRgb = + typeof filamentTD === 'number' ? [filamentTD, filamentTD, filamentTD] : filamentTD; + if (channelTd.some((td) => !Number.isFinite(td) || td <= 0)) { + return filamentColor; + } - // Opacity is the inverse of transmission - const opacity = 1 - transmission; + const blendChannel = (background: number, filament: number, td: number) => { + // Beer-Lambert law: transmission = 10^(-thickness/TD). + // At thickness == TD, transmission = 10^(-1) = 0.1 (10%). + const transmission = Math.pow(0.1, layerThickness / td); + return filament * (1 - transmission) + background * transmission; + }; - // Linear interpolation (simple RGB mixing) return { - r: filamentColor.r * opacity + backgroundColor.r * transmission, - g: filamentColor.g * opacity + backgroundColor.g * transmission, - b: filamentColor.b * opacity + backgroundColor.b * transmission, + r: blendChannel(backgroundColor.r, filamentColor.r, channelTd[0]), + g: blendChannel(backgroundColor.g, filamentColor.g, channelTd[1]), + b: blendChannel(backgroundColor.b, filamentColor.b, channelTd[2]), }; } +/** Calculate Delta E (CIEDE2000) directly from Lab values. */ +export function deltaE2000Lab(lab1: Lab, lab2: Lab): number { + const chroma1 = Math.hypot(lab1.a, lab1.b); + const chroma2 = Math.hypot(lab2.a, lab2.b); + const averageChroma = (chroma1 + chroma2) / 2; + const g = 0.5 * (1 - Math.sqrt(averageChroma ** 7 / (averageChroma ** 7 + 25 ** 7))); + const a1 = (1 + g) * lab1.a; + const a2 = (1 + g) * lab2.a; + const adjustedChroma1 = Math.hypot(a1, lab1.b); + const adjustedChroma2 = Math.hypot(a2, lab2.b); + const hue = (a: number, b: number) => (Math.atan2(b, a) * 180 / Math.PI + 360) % 360; + const hue1 = hue(a1, lab1.b); + const hue2 = hue(a2, lab2.b); + const deltaL = lab2.L - lab1.L; + const deltaChroma = adjustedChroma2 - adjustedChroma1; + const hueDifference = + adjustedChroma1 * adjustedChroma2 === 0 + ? 0 + : Math.abs(hue2 - hue1) <= 180 + ? hue2 - hue1 + : hue2 <= hue1 + ? hue2 - hue1 + 360 + : hue2 - hue1 - 360; + const deltaHue = + 2 * Math.sqrt(adjustedChroma1 * adjustedChroma2) * Math.sin((hueDifference / 2) * Math.PI / 180); + const meanL = (lab1.L + lab2.L) / 2; + const meanChroma = (adjustedChroma1 + adjustedChroma2) / 2; + const meanHue = + adjustedChroma1 * adjustedChroma2 === 0 + ? hue1 + hue2 + : Math.abs(hue1 - hue2) <= 180 + ? (hue1 + hue2) / 2 + : hue1 + hue2 < 360 + ? (hue1 + hue2 + 360) / 2 + : (hue1 + hue2 - 360) / 2; + const hueWeight = + 1 - + 0.17 * Math.cos((meanHue - 30) * Math.PI / 180) + + 0.24 * Math.cos(2 * meanHue * Math.PI / 180) + + 0.32 * Math.cos((3 * meanHue + 6) * Math.PI / 180) - + 0.2 * Math.cos((4 * meanHue - 63) * Math.PI / 180); + const lightnessScale = 1 + 0.015 * (meanL - 50) ** 2 / Math.sqrt(20 + (meanL - 50) ** 2); + const chromaScale = 1 + 0.045 * meanChroma; + const hueScale = 1 + 0.015 * meanChroma * hueWeight; + const rotation = + -2 * + Math.sqrt(meanChroma ** 7 / (meanChroma ** 7 + 25 ** 7)) * + Math.sin((60 * Math.exp(-(((meanHue - 275) / 25) ** 2))) * Math.PI / 180); + + return Math.sqrt( + (deltaL / lightnessScale) ** 2 + + (deltaChroma / chromaScale) ** 2 + + (deltaHue / hueScale) ** 2 + + rotation * (deltaChroma / chromaScale) * (deltaHue / hueScale) + ); +} + +const OPTIMIZER_DISTANCE_METRIC: ColorDistanceMetric = 'cie76'; + +function optimizerColorDistance(left: Lab, right: Lab): number { + return OPTIMIZER_DISTANCE_METRIC === 'ciede2000' + ? deltaE2000Lab(left, right) + : deltaELab(left, right); +} + /** * Calculate the opacity (how opaque) a filament layer is at a given thickness. * @@ -268,6 +332,37 @@ const DELTA_E_THRESHOLD = 2.3; // "Just noticeable difference" * Scale user-entered TD values down for internal simulation. */ const FRONTLIT_TD_SCALE = 0.1; +const USE_CALIBRATED_CHANNEL_TD = true; +const USE_COMPRESSION_AWARE_BACKGROUNDS = false; + +type AutoPaintFilament = Pick; + +function calibratedTdChannels(filament: AutoPaintFilament): CalibrationRgb | undefined { + if (!USE_CALIBRATED_CHANNEL_TD) return undefined; + const channels = filament.calibration?.td; + if (!channels || channels.some((td) => !Number.isFinite(td) || td <= 0)) { + return undefined; + } + return channels; +} + +function scaleFilamentForFrontlight(filament: Filament): Filament { + const scaledTd = filament.td * FRONTLIT_TD_SCALE; + if (!filament.calibration) { + return { ...filament, td: scaledTd }; + } + + const calibration = filament.calibration; + return { + ...filament, + td: scaledTd, + calibration: { + ...calibration, + td: calibration.td.map((td) => td * FRONTLIT_TD_SCALE) as CalibrationRgb, + tdSingleValue: calibration.tdSingleValue * FRONTLIT_TD_SCALE, + }, + }; +} /** * Simulate adding filament layers until the blended color matches the @@ -336,7 +431,7 @@ export function calculateTransitionThickness( * @returns Object with ideal height and zone breakdown */ export function calculateIdealHeight( - sortedFilaments: Array<{ id: string; color: string; td: number }>, + sortedFilaments: AutoPaintFilament[], layerHeight: number, baseThickness: number = 0.6, transitionThicknessCache?: Map @@ -363,6 +458,7 @@ export function calculateIdealHeight( filamentId: firstFilament.id, filamentColor: firstFilament.color, filamentTd: firstFilament.td, + filamentTdChannels: calibratedTdChannels(firstFilament), startHeight: 0, endHeight: foundationThickness, idealThickness: foundationThickness, @@ -401,6 +497,7 @@ export function calculateIdealHeight( filamentId: filament.id, filamentColor: filament.color, filamentTd: filament.td, + filamentTdChannels: calibratedTdChannels(filament), startHeight: currentHeight, endHeight: currentHeight + transitionThickness, idealThickness: transitionThickness, @@ -457,6 +554,37 @@ export function compressZones( return { compressedZones, compressionRatio }; } +function buildZoneBackgrounds(zones: TransitionZone[]): RGB[] { + if (zones.length === 0) return []; + + const backgrounds: RGB[] = []; + let previousEndColor = hexToRgb(zones[0].filamentColor); + + for (let index = 0; index < zones.length; index++) { + const zone = zones[index]; + const filamentColor = hexToRgb(zone.filamentColor); + const backgroundColor = + index === 0 + ? filamentColor + : USE_COMPRESSION_AWARE_BACKGROUNDS + ? previousEndColor + : hexToRgb(zones[index - 1].filamentColor); + backgrounds.push(backgroundColor); + + previousEndColor = + index === 0 + ? filamentColor + : blendColors( + backgroundColor, + filamentColor, + zone.filamentTdChannels ?? zone.filamentTd, + zone.actualThickness + ); + } + + return backgrounds; +} + // ============================================================================= // IMAGE COLOR ANALYSIS // ============================================================================= @@ -505,24 +633,21 @@ export function clusterImageColors( totalCount: number; }> = []; - const thresholdSq = threshold * threshold; - for (const item of items) { // Find nearest existing cluster let bestIdx = -1; - let bestDeSq = Infinity; + let bestDistance = Infinity; for (let ci = 0; ci < clusters.length; ci++) { const c = clusters[ci]; - const deSq = - (item.lab.L - c.L) ** 2 + (item.lab.a - c.a) ** 2 + (item.lab.b - c.b) ** 2; - if (deSq < bestDeSq) { - bestDeSq = deSq; + const distance = optimizerColorDistance(item.lab, c); + if (distance < bestDistance) { + bestDistance = distance; bestIdx = ci; } } - if (bestIdx >= 0 && bestDeSq < thresholdSq) { + if (bestIdx >= 0 && bestDistance < threshold) { // Merge into existing cluster (weighted centroid update) const c = clusters[bestIdx]; const total = c.totalCount + item.count; @@ -585,7 +710,7 @@ export function clusterImageColors( * @returns Array of achievable { height, lab, rgb } at each layer step */ export function buildAchievableColorPalette( - sequence: Array<{ id: string; color: string; td: number }>, + sequence: AutoPaintFilament[], layerHeight: number, firstLayerHeight: number, maxHeight?: number, @@ -595,7 +720,7 @@ export function buildAchievableColorPalette( // Calculate zones for this sequence const { zones } = calculateIdealHeight( - sequence.map((f) => ({ id: f.id, color: f.color, td: f.td })), + sequence, layerHeight, Math.max(firstLayerHeight, layerHeight), transitionThicknessCache @@ -607,6 +732,7 @@ export function buildAchievableColorPalette( maxHeight === undefined ? zones : compressZones(zones, maxHeight).compressedZones; const totalHeight = activeZones[activeZones.length - 1].endHeight; const palette: Array<{ height: number; lab: Lab; rgb: RGB }> = []; + const zoneBackgrounds = buildZoneBackgrounds(activeZones); let currentZ = 0; let layerIndex = 0; @@ -643,11 +769,10 @@ export function buildAchievableColorPalette( if (activeZoneIndex === 0) { blendedColor = filamentColor; } else { - const bgColor = hexToRgb(activeZones[activeZoneIndex - 1].filamentColor); blendedColor = blendColors( - bgColor, + zoneBackgrounds[activeZoneIndex], filamentColor, - zone.filamentTd, + zone.filamentTdChannels ?? zone.filamentTd, thicknessInCurrentZone ); } @@ -709,11 +834,7 @@ export function scoreSequenceAgainstImage( let bestIdx = 0; for (let ri = 0; ri < paletteEntries.length; ri++) { const entry = paletteEntries[ri]; - const de = Math.sqrt( - (entry.lab.L - target.L) ** 2 + - (entry.lab.a - target.a) ** 2 + - (entry.lab.b - target.b) ** 2 - ); + const de = optimizerColorDistance(entry.lab, target); if (de < minDE) { minDE = de; bestHeight = entry.height; @@ -744,11 +865,11 @@ export function scoreSequenceAgainstImage( const projectedL = start.lab.L + t * dL; const projectedA = start.lab.a + t * da; const projectedB = start.lab.b + t * db; - const projectedDistance = Math.sqrt( - (target.L - projectedL) ** 2 + - (target.a - projectedA) ** 2 + - (target.b - projectedB) ** 2 - ); + const projectedDistance = optimizerColorDistance(target, { + L: projectedL, + a: projectedA, + b: projectedB, + }); if (projectedDistance < minDE) { minDE = projectedDistance; bestHeight = start.height + t * (end.height - start.height); @@ -758,11 +879,7 @@ export function scoreSequenceAgainstImage( const mappedIdx = paletteEntries.findIndex((entry) => entry.height >= bestHeight); bestIdx = mappedIdx >= 0 ? mappedIdx : paletteEntries.length - 1; const mappedColor = paletteEntries[bestIdx].lab; - const mappedDeltaE = Math.sqrt( - (mappedColor.L - target.L) ** 2 + - (mappedColor.a - target.a) ** 2 + - (mappedColor.b - target.b) ** 2 - ); + const mappedDeltaE = optimizerColorDistance(mappedColor, target); weightedDeltaE += mappedDeltaE * target.weight; bestMatchHeights.push(bestHeight); @@ -860,10 +977,7 @@ function findBestFilamentOrderWithOptimizer( }; // Apply frontlit TD scale - const scaledFilaments = filaments.map((f) => ({ - ...f, - td: f.td * FRONTLIT_TD_SCALE, - })); + const scaledFilaments = filaments.map(scaleFilamentForFrontlight); // Run optimizer const result = optimizeFilamentOrder(scaledFilaments, context, { @@ -980,14 +1094,11 @@ export function generateAutoLayers( const filamentOrder = sortedFilaments.map((f) => f.id); // Apply frontlit TD scale for internal simulation - const scaledFilaments = sortedFilaments.map((f) => ({ - ...f, - td: f.td * FRONTLIT_TD_SCALE, - })); + const scaledFilaments = sortedFilaments.map(scaleFilamentForFrontlight); // --- STEP 3: CALCULATE IDEAL HEIGHT WITH TRANSITION ZONES --- const { idealHeight, zones } = calculateIdealHeight( - scaledFilaments.map((f) => ({ id: f.id, color: f.color, td: f.td })), + scaledFilaments, layerHeight, Math.max(firstLayerHeight, layerHeight) ); @@ -1109,6 +1220,7 @@ export function autoPaintToSliceHeights( const colorOrder: number[] = []; const zones = result.transitionZones; + const zoneBackgrounds = buildZoneBackgrounds(zones); // Generate layers at each layerHeight increment from 0 to totalHeight. // For each layer, simulate the Beer-Lambert blended color at that Z. @@ -1150,11 +1262,10 @@ export function autoPaintToSliceHeights( if (activeZoneIndex === 0) { blendedColor = filamentColor; } else { - const bgColor = hexToRgb(zones[activeZoneIndex - 1].filamentColor); blendedColor = blendColors( - bgColor, + zoneBackgrounds[activeZoneIndex], filamentColor, - zone.filamentTd, + zone.filamentTdChannels ?? zone.filamentTd, thicknessInCurrentZone ); } @@ -1181,53 +1292,6 @@ export function autoPaintToSliceHeights( }; } -// ============================================================================= -// LUMINANCE-TO-HEIGHT MAPPING -// ============================================================================= - -/** - * Map a pixel's luminance to a target height within the transition zones. - * - * This is the key function that determines how image brightness translates - * to physical height in the 3D model. - * - * The mapping works as follows: - * - Darkest pixels (luminance = 0) → minimum height (base layer only) - * - Lightest pixels (luminance = 1) → maximum height (all layers) - * - Mid-tones → proportional position within the transition zones - * - * @param normalizedLuminance - Pixel luminance normalized to 0-1 - * @param transitionZones - The computed transition zones - * @param totalHeight - Total model height - * @param firstLayerHeight - First layer height - * @returns Target height in mm - */ -export function luminanceToHeight( - normalizedLuminance: number, - transitionZones: TransitionZone[], - totalHeight: number, - firstLayerHeight: number -): number { - if (transitionZones.length === 0) { - return firstLayerHeight; - } - - // Base height (darkest pixels get at least the foundation) - const baseHeight = transitionZones[0].endHeight; - - if (normalizedLuminance <= 0) { - return baseHeight; - } - - if (normalizedLuminance >= 1) { - return totalHeight; - } - - // Linear interpolation from base to total height - // This gives a smooth gradient where brightness = height - return baseHeight + normalizedLuminance * (totalHeight - baseHeight); -} - // ============================================================================= // CONFIDENCE SCORING // ============================================================================= diff --git a/src/lib/export3mf.ts b/src/lib/export3mf.ts index 308ee54..09ae70f 100644 --- a/src/lib/export3mf.ts +++ b/src/lib/export3mf.ts @@ -23,6 +23,54 @@ type ExportGeometrySource = { itemSize?: number; }; +function utf8ByteLength(value: string): number { + let length = 0; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 0x80) { + length += 1; + } else if (code < 0x800) { + length += 2; + } else if ( + code >= 0xd800 && + code <= 0xdbff && + index + 1 < value.length && + value.charCodeAt(index + 1) >= 0xdc00 && + value.charCodeAt(index + 1) <= 0xdfff + ) { + length += 4; + index++; + } else { + // This also matches TextEncoder's replacement behavior for an + // unpaired UTF-16 surrogate. + length += 3; + } + } + return length; +} + +/** + * Encodes XML chunks straight into one JSZip-supported byte buffer. This + * avoids both Blob/FileReader reads in desktop WebViews and Array.join's + * string-size limit without making temporary copies of every XML chunk. + */ +function encodeXmlChunks(chunks: string[]): Uint8Array { + const byteLength = chunks.reduce((total, chunk) => total + utf8ByteLength(chunk), 0); + const output = new Uint8Array(byteLength); + const encoder = new TextEncoder(); + let offset = 0; + + for (const chunk of chunks) { + const { read, written } = encoder.encodeInto(chunk, output.subarray(offset)); + if (read !== chunk.length) { + throw new Error('Could not encode complete 3MF model XML'); + } + offset += written; + } + + return output; +} + /** * Meshes tagged with the same `userData.kromacutExportGroup` key are merged * into a single 3MF object (used by Flat Paint to export one object per @@ -634,12 +682,11 @@ export async function exportObjectTo3MFBlob( flushXmlChunk(); - // JSZip reads Blob inputs through FileReader. In the Tauri webview that - // extra read can fail with NotReadableError even though this XML was just - // generated in memory. Passing text lets JSZip encode it directly. - const modelXml = xmlParts.join(''); - xmlParts.length = 0; - zip.folder('3D')?.file('3dmodel.model', modelXml); + // JSZip accepts Uint8Array directly. Encoding into it chunk-by-chunk + // avoids both the Tauri WebView FileReader failure for Blobs and + // Array.join's string-size limit for large models. + const modelXmlBytes = encodeXmlChunks(xmlParts.splice(0)); + zip.folder('3D')?.file('3dmodel.model', modelXmlBytes, { binary: true }); // Generate Metadata/model_settings.config // This is required for Bambu Studio / Orca Slicer / Creality Print to correctly identify diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index b3281ae..6754c57 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -147,6 +147,7 @@ function canonicalOptimizerInput( id: filament.id, color: filament.color, td: filament.td, + calibrationTd: filament.calibration?.td ?? null, })), clusters: context.imageColors.map((color) => ({ L: color.L, @@ -181,7 +182,11 @@ export function createSequenceScorer(context: ScoringContext): (filaments: Filam return (filaments) => { if (filaments.length === 0) return Infinity; - const sequenceKey = filaments.map((filament) => `${filament.id}:${filament.color}:${filament.td}`).join('|'); + const sequenceKey = filaments + .map((filament) => + [filament.id, filament.color, filament.td, ...(filament.calibration?.td ?? [])].join(':') + ) + .join('|'); let palette = paletteCache.get(sequenceKey); if (!palette) { palette = buildAchievableColorPalette( diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts index 8b97442..be07d9e 100644 --- a/tests/autoPaint.test.ts +++ b/tests/autoPaint.test.ts @@ -39,6 +39,16 @@ function assertAlmostEqual(actual: number, expected: number, message?: string) { ); } +test('CIEDE2000 distance matches the published reference pair', async () => { + const { deltaE2000Lab } = await loadAutoPaintModule(); + const distance = deltaE2000Lab( + { L: 50, a: 2.6772, b: -79.7751 }, + { L: 50, a: 0, b: -82.7485 } + ); + + assert.ok(Math.abs(distance - 2.0425) < 0.0001); +}); + test('transition thickness stays printable and respects its TD cap', async () => { const { calculateTransitionThickness, hexToRgb } = await loadAutoPaintModule(); const layerHeight = 0.1; @@ -66,6 +76,78 @@ test('transition thickness stays printable and respects its TD cap', async () => ); }); +test('calibrated per-channel TDs tint blends without changing scalar TD behavior', async () => { + const { blendColors } = await loadAutoPaintModule(); + const background = { r: 0, g: 0, b: 0 }; + const filament = { r: 200, g: 200, b: 200 }; + + const scalar = blendColors(background, filament, 1, 0.25); + const equalChannels = blendColors(background, filament, [1, 1, 1], 0.25); + const calibrated = blendColors(background, filament, [0.5, 1, 2], 0.25); + + assert.deepEqual(equalChannels, scalar, 'equal channel TDs must preserve legacy scalar blending'); + assert.ok( + calibrated.r > calibrated.g && calibrated.g > calibrated.b, + 'shorter channel TDs must become opaque sooner' + ); +}); + +test('calibrated channel TDs flow through generated auto-paint preview slices', async () => { + const { autoPaintToSliceHeights, generateAutoLayers, hexToRgb } = + await loadAutoPaintModule(); + const baseFilaments = [ + { id: 'black', color: '#000000', td: 1 }, + { id: 'white', color: '#ffffff', td: 16 }, + ]; + const calibratedFilaments = [ + baseFilaments[0], + { + ...baseFilaments[1], + calibration: { + color: '#ffffff', + measurements: [], + td: [8, 16, 32] as [number, number, number], + tdSingleValue: 16, + confidence: 1, + calibrationDate: '2026-01-01T00:00:00.000Z', + }, + }, + ]; + const swatches = [ + { hex: '#000000', count: 1 }, + { hex: '#ffffff', count: 1 }, + ]; + const scalarResult = generateAutoLayers(baseFilaments, swatches, 0.1, 0.2, undefined, false); + const calibratedResult = generateAutoLayers( + calibratedFilaments, + swatches, + 0.1, + 0.2, + undefined, + false + ); + const scalarSlices = autoPaintToSliceHeights(scalarResult, 0.1, 0.2); + const calibratedSlices = autoPaintToSliceHeights(calibratedResult, 0.1, 0.2); + + assert.deepEqual( + calibratedSlices.colorSliceHeights, + scalarSlices.colorSliceHeights, + 'zone thickness remains governed by the scalar working TD' + ); + const whiteLayer = calibratedSlices.filamentSwatches.findIndex((swatch) => swatch.hex === '#ffffff'); + assert.ok(whiteLayer >= 0, 'the calibrated filament should contribute preview layers'); + + const scalarColor = hexToRgb(scalarSlices.virtualSwatches[whiteLayer].hex); + const calibratedColor = hexToRgb(calibratedSlices.virtualSwatches[whiteLayer].hex); + assert.equal(scalarColor.r, scalarColor.g, 'the scalar white blend remains neutral'); + assert.equal(scalarColor.g, scalarColor.b, 'the scalar white blend remains neutral'); + assert.notEqual( + calibratedColor.r, + calibratedColor.b, + 'calibrated channel TDs produce their measured color bias' + ); +}); + test('ideal-height zones include a foundation and remain contiguous when compressed', async () => { const { calculateIdealHeight, compressZones } = await loadAutoPaintModule(); const layerHeight = 0.1; diff --git a/tests/export3mf.test.ts b/tests/export3mf.test.ts index 17f7953..e737fa0 100644 --- a/tests/export3mf.test.ts +++ b/tests/export3mf.test.ts @@ -1406,7 +1406,7 @@ test('3MF export keeps generated meshes as separate layer objects', async () => ); }); -test('3MF export does not require FileReader to read generated model XML', async () => { +test('3MF export streams generated model XML without FileReader', async () => { const { exportObjectTo3MFBlob } = await loadExport3mfModule(); const previousFileReader = globalThis.FileReader; @@ -1431,6 +1431,13 @@ test('3MF export does not require FileReader to read generated model XML', async } }); +test('3MF export keeps generated model XML chunked for large models', () => { + const source = readFileSync(resolve(process.cwd(), 'src/lib/export3mf.ts'), 'utf8'); + + assert.doesNotMatch(source, /xmlParts\.join\(/); + assert.match(source, /encodeXmlChunks\(xmlParts\.splice\(0\)\)/); +}); + test('exports include preview-hidden layers with their original filament colors', async () => { const root = new THREE.Group(); const first = createLayerMesh(createSharedCubeGeometry(), 0xff0000); diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index 270d2a0..8ecbbb8 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -145,6 +145,29 @@ test('cache keys include all weighted clusters and optimizer tuning', async () = }); assert.equal(changedTemperature.cacheHit, undefined, 'a changed temperature must miss cache'); assert.equal(getOptimizerCacheStats().size, 3); + + const calibratedFilaments = filaments.map((filament, index) => + index === 2 + ? { + ...filament, + calibration: { + color: filament.color, + measurements: [], + td: [1.2, 1.8, 2.4] as [number, number, number], + tdSingleValue: filament.td, + confidence: 1, + calibrationDate: '2026-01-01T00:00:00.000Z', + }, + } + : filament + ); + const changedCalibration = optimizeFilamentOrder(calibratedFilaments, manyClusters, options); + assert.equal( + changedCalibration.cacheHit, + undefined, + 'changed per-channel calibration TDs must miss cache' + ); + assert.equal(getOptimizerCacheStats().size, 4); }); test('default optimizer seeds are stable and cacheable', async () => { From 109a417e97c7c14ef7883ddaa55d5e29363a9934 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 21 Jun 2026 21:38:48 +0300 Subject: [PATCH 09/31] Keep auto-paint plan local --- .gitignore | 1 + docs/AUTOPAINT_IMPROVEMENT_PLAN.md | 424 ----------------------------- 2 files changed, 1 insertion(+), 424 deletions(-) delete mode 100644 docs/AUTOPAINT_IMPROVEMENT_PLAN.md diff --git a/.gitignore b/.gitignore index 4cbec2b..8f9935e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ test-results/ .todo .claude +docs/AUTOPAINT_IMPROVEMENT_PLAN.md # Tauri / Rust build artifacts src-tauri/target/ diff --git a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md b/docs/AUTOPAINT_IMPROVEMENT_PLAN.md deleted file mode 100644 index b66174a..0000000 --- a/docs/AUTOPAINT_IMPROVEMENT_PLAN.md +++ /dev/null @@ -1,424 +0,0 @@ -# Auto-Paint Algorithm Improvement Plan - -> Status: **PLANNED — no implementation started.** -> Source: code review of the Auto-paint pipeline (2026-06-11). All file/line references -> are against `develop` at commit `6b55485`. - -This document records verified findings about the current Auto-paint algorithms and -turns them into a phased, testable improvement plan. Each phase is independently -shippable and ordered so that correctness fixes land before behavioral changes, and -measurement lands before everything. - ---- - -## 1. Invariants that must hold in every phase - -These come from product constraints and existing tests. Any phase that breaks one is -not shippable. - -- **Result schema stability**: `AutoPaintResult` and `autoPaintToSliceHeights` output - shapes are unchanged. Export (3MF/STL), persisted state (`ThreeDControlsStateShape`), - and saved profiles all consume these. -- **Printability**: foundation zone always present; slice heights are exact - `layerHeight` multiples (first = `max(firstLayerHeight, layerHeight)`); ≤500 layers - (`autoPaint.ts:1516`); no zero-thickness zones. -- **Layer snapping**: per-pixel snapping behavior in `ThreeDView.tsx` untouched unless a - phase explicitly targets it. -- **Determinism**: same inputs + same seed → byte-identical result. (Phase 3 makes the - no-seed case deterministic too; it must not break the seeded case.) -- **Worker responsiveness**: algorithm work stays in `autoPaint.worker.ts`; the main - thread never runs the optimizer. -- **User data compatibility**: persisted `optimizerAlgorithm` values - (`auto | exhaustive | simulated-annealing | genetic`), saved profiles (`.kfil`/`.kapp`), - and calibration data (`CalibrationResult`) keep their meaning. -- **Existing test suites stay green**: `tests/export3mf.test.ts` (manifoldness, color - collapse, seeded auto-paint regression stacks) and `tests/e2e/kromacut-flow.spec.ts`. - ---- - -## 2. Verified findings - -### F1 — Legacy enhanced path is unreachable in production - -`useAutoPaintWorker.ts:203-206` always sends `optimizerOptions` (at minimum -`{ algorithm }`), and `findBestFilamentOrder` (`autoPaint.ts:835-843`) routes to the -advanced optimizer whenever options are present. The legacy subset-aware search -(`findBestFilamentOrderLegacy`, `autoPaint.ts:974-1063`) only runs from tests and -`debugAutoPaint`. Production therefore always uses the weaker scorer (F2) and never -does subset selection (F3). - -### F2 — Advanced optimizer scores a different physical model than what gets built - -`scoreFilamentOrder` → `findBestAchievableColor` → `simulateStackAtHeight` -(`optimizer.ts:166-238`): - -- Transition thickness budget is `prevFilament.td * 3` (`optimizer.ts:226`) — keyed to - the **previous** filament's TD. The real zone model sizes zones by the **incoming** - filament's TD and ΔE convergence (`calculateTransitionThickness`, - `autoPaint.ts:284-325`). -- Because that budget is independent of color distance, the advanced score **cannot - see transition cost** (a yellow→purple order is not penalized for its expensive - transition). -- Samples 20 uniform heights; ignores foundation opacity, layer grid, and compression. -- Objective is pure weighted mean ΔE — it lacks the legacy scorer's height-spread, - layer-count (0.5/layer), and transition-waste (1.5/entry) penalties - (`scoreSequenceAgainstImage`, `autoPaint.ts:734-806`). -- Minor: `findBestAchievableColor`'s sampled height range includes the foundation TD - that `simulateStackAtHeight` never consumes (top samples are duplicates). - -The legacy scorer builds its palette via `calculateIdealHeight` — the same model that -`generateAutoLayers` builds geometry with and `autoPaintToSliceHeights` previews with. -The optimizer optimizes objective A; the build pipeline realizes model B. - -### F3 — Subset selection lost - -All three advanced algorithms permute the **full** filament set. Legacy evaluated all -non-empty subsets for ≤6 filaments (1,956 evaluations at N=6) and stopped greedy -addition when no filament helped for >6. Today nothing can drop a harmful filament; -repeated swaps can only add occurrences. - -### F4 — Region weighting is spatially blind and its mode detection is inverted - -- Swatches lose pixel positions at extraction (`useSwatches.ts:62-109` — pure color - histogram). -- `applyRegionWeightHeuristic` (`autoPaint.ts:868-924`) reduces the full W×H weight map - to mean+variance, then boosts clusters by **luminance band**, never by location. -- Numerically verified (replicated `generateCenterWeightedMapSimple` / - `generateEdgeWeightedMapSimple` + normalization): center maps have variance - ≈0.055-0.067 → classified `isHighContrast=true` → applies the **edge** adjustment; - edge maps on 1:1/4:3 images have variance ≈0.045-0.048 → applies the **center** - adjustment. The modes are swapped on square images and identical (both "edge") at - 16:9. Behavior depends on aspect ratio, never on image content. -- `generateAutoLayers:1267-1282` allocates the full Float32Array map per request - (~45 MB for the 3888×2916 fixture) only to compute two scalars. -- The repeated-swaps path ignores region weights entirely - (`buildRepeatedSwapSequence` clusters at `autoPaint.ts:1098` without them). - -### F5 — Optimizer cache returns stale results across region modes - -`OptimizerCache.getCacheKey` (`optimizer.ts:101-114`) hashes the first 20 cluster -colors but **not their weights**, and not `regionWeights` or SA/GA tuning params. -Region weighting only changes weights → identical key → with an explicit seed, -switching Region priority returns the cached previous-mode order (UI shows "Cache -hit"). - -### F6 — Non-deterministic by default - -Without a user seed, SA/GA seed from `Date.now()` (`optimizer.ts:525`): same image + -filaments produce different orders run to run. Caching is also disabled in that case. - -### F7 — Repeated swaps are a second, conflicting optimization - -`buildRepeatedSwapSequence` (`autoPaint.ts:1088-1185`): greedy insert-only; position -loop starts at 1 (`:1137`) so the foundation can never change; capped at 4 insertions; -scored with the **legacy** objective on top of a base order chosen by the **advanced** -objective; no remove/relocate moves. - -### F8 — Physics gaps shared by both paths - -- `blendColors` (`autoPaint.ts:217-241`) lerps in gamma sRGB with a **scalar** - transmission. Calibration already fits **per-channel TD** (`CalibrationResult.td: -[r,g,b]`, `calibration.ts:283-322`) that auto-paint never reads — only - `tdSingleValue`. -- `FRONTLIT_TD_SCALE = 0.1` (`autoPaint.ts:271`) is a hard-coded global fudge. -- Zone _i_ blends over the **pure** color of filament _i−1_ - (`buildAchievableColorPalette:656-663`, `autoPaintToSliceHeights:1499-1506`). Valid - pre-compression (zones are sized for ΔE convergence), wrong after `compressZones` — - compressed previews look cleaner than the print will, and ordering is never re-scored - under compression. -- ΔE is CIE76, which over-penalizes chroma differences in saturated regions. -- Note: gamma-space lerp is self-consistent with how calibration _measures_ - (`predictWorkingBlendRgb`, `calibration.ts:128-140` fits `tdSingleValue` against the - same gamma-space model). Any change to the blend model must move together with - calibration fitting or it invalidates calibrated TDs. - -### F9 — Worker protocol: no progress, terminate-only cancel - -`autoPaint.worker.ts` is single-shot request/response. Exhaustive at 8 filaments ≈ -40,320 permutations × ~670 stack sims each with zero progress feedback. The hook -terminates + recreates the worker per input change (`useAutoPaintWorker.ts:177-219`), -paying worker startup each time. - -### F10 — Minor issues - -- `luminanceToHeight` (`autoPaint.ts:1551`) is dead code (no callers). -- `scoreSequenceAgainstImage` multiplies weighted ΔE by `imageTargets.length` - (`autoPaint.ts:777`), so fixed penalty constants change relative strength with - cluster count. -- SA neighbor allows `i === j` (wasted iterations) (`optimizer.ts:333-335`). -- Zone caps (`td * 0.7`) produce off-grid zone boundaries (hidden by per-pixel re-snap). -- UI "Exhaustive (≤8 filaments)" implies subset search; it is full-set permutations - only. Hook silently downgrades exhaustive >8 to `auto` - (`useAutoPaintWorker.ts:189-192`), duplicating a guard in `ThreeDControls.tsx:130-134`. -- `tests/`: **no unit tests exist for `autoPaint.ts` or `optimizer.ts` internals.** - Coverage is indirect (export3mf seeded stacks, e2e flow). - ---- - -## 3. The plan - -Phases are ordered: measurement → low-risk correctness → objective unification → -search-space unification → polish → gated physics. Do not reorder Phase 4 before -Phase 3 (widening the search around a misaligned objective optimizes the wrong thing -harder). - -### Phase 0 — Test baseline + benchmark harness (prerequisite for everything) - -Goal: pin current behavior and make quality measurable before changing anything. - -- [x] **0.1 Unit tests for `autoPaint.ts` pure functions** (new - `tests/autoPaint.test.ts`): - - `calculateTransitionThickness`: returns ≥ `layerHeight`; respects `0.7×TD` cap; - early-exit for near-identical colors. - - `compressZones`: zones tile `[0, H]` contiguously (each `startHeight` equals - previous `endHeight`); ratio honored; no-op when under max. - - `calculateIdealHeight`: foundation = `max(baseThickness, td×1.3)`; zone count = - filament count. - - `autoPaintToSliceHeights`: every slice = `layerHeight` (first = - `max(firstLayerHeight, layerHeight)`); ≤500 layers; `virtualSwatches`, - `filamentSwatches`, `colorSliceHeights`, `colorOrder` lengths agree. -- [x] **0.2 Seeded golden snapshots** of `generateAutoLayers` for the - 2/4/8-color `.kapp` profiles × both fixture images (`tests/assets`), with - enhanced on/off and repeated swaps on/off: assert `filamentOrder`, zone - boundaries (±1e-6), `totalHeight`, `compressionRatio`. These get **re-baselined - deliberately** in Phases 3/4 — their job is making behavior changes visible, - not frozen. -- [x] **0.3 Determinism tests**: per algorithm, same seed twice → deep-equal result. -- [x] **0.4 Benchmark harness** (`tests/benchmark/autoPaintBench.ts`, runnable via a - package script, not part of CI gating initially). Per image × profile × algorithm × - seed, emit JSON with: - - Weighted mean ΔE (report **both** CIE76 and CIEDE2000) from clustered targets to - nearest achievable palette color; weighted P95. - - Coverage@2.3 and @5.0 (fraction of image weight within JND thresholds). - - **End-to-end realized error**: run the ThreeDView polyline mapping (port of - `ThreeDView.tsx:753-1002`, reuse the port in `tests/export3mf.test.ts:~700-768`) - over fixture pixels; report pixel-weighted ΔE of mapped vs original. *This is the - primary metric — it measures the whole pipeline, not the scorer's opinion of itself.* - - Structure: total height, layer count, sequence length, wasted-layer fraction, - compression ratio under a fixed `maxHeight` scenario. - - Cost: wall time, evaluations, iterations. - - Stability: cross-seed rank agreement for SA/GA. -- [x] **0.5 Acceptance rule for later phases** (documented in the harness README): - end-to-end realized ΔE improves on average across fixtures and regresses on no - fixture beyond tolerance (suggest 5%), within cost budgets (≤2 s for 8 filaments). - -Risk: none (additive). Estimated scope: tests only. - -### Phase 1 — Region weighting made spatially correct; delete the heuristic - -Goal: per-color weights actually reflect where colors sit in the image. Fixes F4. - -- [x] **1.1** Extend swatch extraction (`useSwatches.ts`) to accumulate, per color, in - the same tile pass that builds the histogram: `centerWeight` and `edgeWeight` - (sum of the geometric per-pixel weight functions evaluated inline — no - Float32Array maps materialized). Both modes computed in one pass so switching - modes never rescans the image. Swatch entries gain optional fields; existing - consumers unaffected. -- [x] **1.2** Thread the chosen mode's weighted count through - `useAutoPaintWorker` → worker request → `generateAutoLayers`: when mode ≠ - `uniform`, use the weighted count as `count` input to `clusterImageColors` - (which already weights by count). Keep raw count for display. -- [x] **1.3** Delete `applyRegionWeightHeuristic` (`autoPaint.ts:868-924`) and the - per-request map generation in `generateAutoLayers:1267-1282`. Remove - `regionWeights` from `OptimizerOptions`/`ScoringContext` (or keep as deprecated - no-op field if persisted anywhere — verify; current persistence stores only the - mode string, so removal should be safe). -- [x] **1.4** Repeated-swaps path uses the same weighted targets (fixes the - inconsistency at `autoPaint.ts:1098`). -- [x] **1.5 Tests**: synthetic fixture (red center disc on blue border): - - `center` mode must rank red clusters above blue; `edge` mode the reverse. - - Mode `uniform` byte-identical to pre-change output (golden snapshot). - - No `Float32Array(width*height)` allocation in the worker path (can assert via - absence of the code path / memory not practical — code-level assertion). - -Risk: low-medium (plumbing). User-visible: center/edge modes start doing what their -labels say (today they are swapped or no-ops — treat as bug fix, note in CHANGELOG). - -### Phase 2 — Cache correctness + determinism by default - -Goal: fix F5, F6. Small, independent, immediately shippable. - -- [x] **2.1** Cache key includes cluster **weights**, full cluster set (not first 20), - and all algorithm-relevant options (temperature, cooling, population, mutation, - elite, maxIterations). Simplest robust form: hash a canonical JSON of - `{filaments, clusters(L,a,b,weight), layerHeight, firstLayerHeight, algorithm, - seed, tuning}`. -- [x] **2.2** Default seed = stable 32-bit hash of the same canonical inputs instead of - `Date.now()` (`optimizer.ts:525`). User-provided seed still overrides. This makes - every run reproducible and cacheable; remove the `hasExplicitSeed` cache gating. -- [x] **2.3 Tests**: same inputs, no seed, twice → identical result. Toggling region - mode with a fixed seed → different cache entries (regression test for F5). - Changing only `temperature` → cache miss. - -Risk: low. User-visible: results stop changing between identical runs (improvement); -"Cache hit" indicator becomes trustworthy. - -### Phase 3 — Unify the objective (one scorer, zone-accurate) - -Goal: the optimizer optimizes the same model the pipeline builds. Fixes F2; partially -F8 (scoring side of the pure-background issue can be folded in here or deferred to -Phase 6b). - -- [x] **3.1** Move/share the palette builder: expose `buildAchievableColorPalette` + - `scoreSequenceAgainstImage` (or a thin `scoreSequence(sequence, context)` - wrapper) for use by `optimizer.ts`. `ScoringContext` carries the weighted Lab - targets as today. -- [x] **3.2** Replace `scoreFilamentOrder`/`findBestAchievableColor`/ - `simulateStackAtHeight` with the unified scorer in exhaustive, SA, and GA. -- [x] **3.3 Performance work so per-eval cost stays acceptable**: - - Memoize `calculateTransitionThickness(bgColorHex, filamentId)` per optimizer run — - pair space ≤ N², eliminates the dominant inner loop. - - Memoize palette per unique sequence string (SA revisits neighbors). - - Fix the score-scale fragility while touching it: normalize by total target weight - instead of multiplying by `imageTargets.length` (`autoPaint.ts:777`), and rescale - the structural penalty constants to match (calibrate against Phase 0 harness so - rankings on fixtures are preserved or improved — this is a tuning task, use the - harness). -- [x] **3.4** SA neighbor: redraw `j` until `j ≠ i` (F10). -- [x] **3.5 Tests**: property test — the score the optimizer reports for its chosen - order equals `scoreSequence` of that order under the build model (was untrue - before). Re-baseline Phase 0 golden snapshots; harness must satisfy the Phase 0.5 - acceptance rule. Budget: ≤2 s for 8 filaments (exhaustive falls back per - existing UI guard). - Validated on the development machine with `npm run benchmark:autopaint` against - Phase 2 commit `966c13a`: Auto-mode average realized ΔE improved from 34.75 to - 30.23, every fixture held or improved, and the slowest 8-filament Auto run was - 42 ms. - -Risk: medium — orders will change for users (expected: improvement, verified by -harness). Determinism preserved (seeded). Schema unchanged. - -### Phase 4 — Variable-length search space (subsets + repeats, natively) - -Goal: one search over sequences, restoring subset selection (F3) and integrating -repeats (F7). Replaces `buildRepeatedSwapSequence`. - -Search space: sequences of filament occurrences, length 1..(N + 4), no consecutive -duplicates, repeats allowed **only when** `allowRepeatedSwaps` is on (otherwise -sequences are permutations of subsets). 500-layer guard enforced via scoring penalty + -hard cap. - -- [x] **4.1** `exhaustive` (≤6 filaments): all permutations of all non-empty subsets - (1,956 evals at N=6 — legacy semantics restored) under the unified scorer. When - repeats are on, extend with the bounded insertion expansion as a post-pass _of - the same scorer_ (cheap and already consistent), or fold repeats into beam - search (4.2) and route there. Update UI label/threshold honestly (≤6, not ≤8; - keep the existing >threshold downgrade-to-auto behavior in one place — remove - the duplicate guard, F10). -- [x] **4.2** **Beam search** (new internal algorithm, used by `auto` for 7-12 - filaments): build sequences bottom-up; at each depth keep top-K (K≈100, tunable - via harness) partial stacks scored with the unified scorer; candidate extensions - = any non-duplicate filament occurrence + "stop" (subset selection falls out - naturally). Deterministic, anytime, trivially reports progress (depth/maxDepth). -- [x] **4.3** **Variable-length SA** (replaces permutation SA; used by - `simulated-annealing` and by `auto` for >12): moves = swap(i,j), relocate(i→j), - insert(filament, pos) [only if repeats allowed or filament unused], - remove(pos) [if length > 1], replace(pos, filament). Seeded move selection; - geometric cooling as today. -- [x] **4.4** GA (`genetic` UI value): keep permutation GA over the full set initially - but wrap with subset-aware repair, or route `genetic` to variable-length SA with - a different default budget if GA quality on the harness is not competitive. - Decide on harness data, not in advance. -- [x] **4.5** Delete `buildRepeatedSwapSequence`; `generateAutoLayers` passes - `allowRepeatedSwaps` into optimizer options instead - (`autoPaint.ts:1307-1316`). -- [x] **4.6 Tests**: printability invariants (foundation exists; no consecutive - duplicate filament ids; sequence length caps; result schema unchanged; slice snapping - unchanged); per-seed determinism for each algorithm; harness non-regression per - Phase 0.5; specific scenario tests: - - A filament strictly worse than every alternative (e.g., a near-duplicate hue with - worse TD) gets dropped (subset regression — impossible today). - - Thin-white-over-red produces a pink intermediate when repeats are on (repeated-swap - regression). - - `allowRepeatedSwaps=false` → no filament appears twice. - Validated on the development machine with `npm run benchmark:autopaint` against - Phase 3 commit `bc1d155`: Auto-mode average realized ΔE improved from 30.23 to - 29.96, every fixture held or improved, and the slowest 8-filament Auto run was - 331 ms. - -Risk: medium-high (largest behavioral change; biggest quality upside). UI: **no new -dropdown entries** — `auto/exhaustive/simulated-annealing/genetic` keep their persisted -values and re-map internally (decision log §4). - -### Phase 5 — Worker progress + lifecycle polish - -Goal: fix F9 ergonomics. Independent of phases 3-4 but nicer after them (beam search -has natural progress). - -- [x] **5.1** Optimizer accepts `onProgress(iteration, total, bestScore)`; worker - throttles (~10 Hz) `postMessage({ type: 'progress', id, ... })`; final message - keeps the current shape (`{ id, result }`) for compatibility. -- [x] **5.2** Hook surfaces `progress` in `UseAutoPaintWorkerResult`; AutoPaintTab - shows it next to the spinner ("Optimizing… 43%"). -- [x] **5.3** Kept terminate-based cancellation. A warm worker was not added because - its startup cost is not currently noticeable enough to justify a more complex - cancellation path. -- [x] **5.4 Tests**: progress monotonic 0→1 (mirror `tests/algorithms-progress.test.ts` - pattern); stale progress messages (old `id`) ignored. - -Risk: low. - -### Phase 6 — Physics upgrades (each gated behind a constant + harness validation) - -Goal: close F8 where measurement proves it helps. Each item independent; ship only -with harness evidence. These change preview colors for existing projects — CHANGELOG -each. - -- [x] **6a Per-channel TD blending where calibration exists**: `blendColors` uses - `calibration.td: [r,g,b]` (per-channel transmission, gamma-space — consistent - with how calibration measures) when present; scalar `tdSingleValue` fallback - unchanged for uncalibrated filaments. Touches `blendColors` call sites in - `autoPaint.ts` only; `Filament` already carries `calibration`. -- [ ] **6b Compression-aware backgrounds**: implemented behind - `USE_COMPRESSION_AWARE_BACKGROUNDS`, but not enabled. The harness regressed the - 8-color large-image case from ΔE 11.58 to 13.26 (14.5%), beyond the 5% tolerance. - Revisit only with a better compression model or measured compressed-stack data. -- [x] **6c CIEDE2000 evaluation**: implemented behind `OPTIMIZER_DISTANCE_METRIC` for - scoring and clustering, but retained CIE76. The harness put CIEDE2000's two - 8-filament runs at 4.1–4.7 s, over the 2 s budget, so it did not earn adoption. -- [x] **6d FRONTLIT_TD_SCALE**: deliberately retained at 0.1. A calibrated front-lit - factor needs a new measurement workflow and remains out of scope. -- [x] **6e Cleanup**: deleted dead `luminanceToHeight`; updated the - stale module doc header (`autoPaint.ts:1-17`). - -Risk: medium (visual shifts); mitigated by gating + harness + CHANGELOG. - ---- - -## 4. Decision log - -- **No new UI algorithm choices.** Persisted `optimizerAlgorithm` values keep their - names; internals re-map (`auto` → subset-exhaustive ≤6 / beam 7-12 / var-length SA - above; `exhaustive` → subset-exhaustive with honest ≤6 cap; `simulated-annealing` → - variable-length SA; `genetic` → keep or alias per Phase 4.4 harness data). Beam - search is an `auto` implementation detail, not a dropdown entry. Rationale: choices - are speed/quality trade-offs users already understand; renaming breaks saved - profiles/state. -- **Rejected: DP / shortest-path over discretized color states.** Reachable-color - state space (Lab × height with path-dependent blending) discretizes poorly; beam - search captures the same "build bottom-up, prune" idea without state-explosion risk. -- **Rejected (for now): variable-length genomes in GA.** Crossover design is fiddly; - at ≤16 filaments variable-length SA + beam cover the space. Revisit only if harness - shows SA stuck. -- **Gamma-space blending stays** (not switched to linear-light): calibration fits TDs - against the same gamma-space model, so the pair is self-consistent; changing it - would invalidate users' calibrated TDs (F8 note). Per-channel TD (6a) improves hue - realism without breaking that consistency. -- **`buildRepeatedSwapSequence` is deleted in Phase 4**, not improved in place — its - objective-mixing is structural (F7). - -## 5. Quick reference: finding → phase - -| Finding | Fixed in | -| ----------------------------------------- | ------------------- | -| F1 legacy path dead / weak scorer in prod | Phase 3 (+4) | -| F2 misaligned objective | Phase 3 | -| F3 subset selection lost | Phase 4 | -| F4 region weighting blind/inverted | Phase 1 | -| F5 stale cache across modes | Phase 2 | -| F6 non-deterministic default | Phase 2 | -| F7 repeated swaps bolt-on | Phase 4 | -| F8 physics gaps | Phase 6 (a-d) | -| F9 worker progress | Phase 5 | -| F10 minor issues | Phases 3.4, 4.1, 6e | -| No unit tests / no benchmark | Phase 0 | From 9d766cd0616d1a92891ecfb7d3bb867b58c8f307 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 22 Jun 2026 21:15:28 +0300 Subject: [PATCH 10/31] Allow explicit exhaustive auto-paint search --- src/components/AutoPaintTab.tsx | 8 ++------ src/components/ThreeDControls.tsx | 6 ------ src/lib/optimizer.ts | 10 +++------- tests/optimizer.test.ts | 11 ++++++++--- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 2e5739d..60029a1 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -683,12 +683,8 @@ export default function AutoPaintTab({ Auto (smart selection) - 6} - > - Exhaustive (≤6 filaments) + + Exhaustive (recommended ≤6) { - if (optimizerAlgorithm === 'exhaustive' && filaments.length > 6) { - setOptimizerAlgorithm('auto'); - } - }, [filaments.length, optimizerAlgorithm]); - const handleEnhancedColorMatchChange = useCallback((v: boolean) => { setEnhancedColorMatch(v); if (!v) { diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index 6754c57..36124e3 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -210,7 +210,7 @@ export function scoreFilamentSequence(filaments: Filament[], context: ScoringCon // Variable-length sequence helpers // ============================================================================ -const MAX_EXHAUSTIVE_FILAMENTS = 6; +const MAX_AUTO_EXHAUSTIVE_FILAMENTS = 6; const AUTO_BEAM_MAX_FILAMENTS = 12; const MAX_EXTRA_REPEATS = 4; const DEFAULT_BEAM_WIDTH = 100; @@ -325,7 +325,7 @@ function expandWithRepeatedFilaments( } // ============================================================================ -// Exhaustive Search (all ordered non-empty subsets, up to six filaments) +// Exhaustive Search (all ordered non-empty subsets) // ============================================================================ function optimizeExhaustive( @@ -656,15 +656,11 @@ function resolveAlgorithm( filamentCount: number ): ResolvedOptimizerAlgorithm { if (requested === 'auto') { - if (filamentCount <= MAX_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; + if (filamentCount <= MAX_AUTO_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; if (filamentCount <= AUTO_BEAM_MAX_FILAMENTS) return 'beam'; return 'simulated-annealing'; } - if (requested === 'exhaustive' && filamentCount > MAX_EXHAUSTIVE_FILAMENTS) { - return filamentCount <= AUTO_BEAM_MAX_FILAMENTS ? 'beam' : 'simulated-annealing'; - } - return requested; } diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index 8ecbbb8..132f039 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -307,7 +307,7 @@ test('variable-length optimizers preserve sequence safety invariants', async (t) } }); -test('auto uses beam search for medium profiles and protects exhaustive search above six', async () => { +test('auto uses beam search for medium profiles while explicit exhaustive remains exact', async () => { const { optimizeFilamentOrder } = await loadOptimizerModule(); const mediumProfile = [ ...filaments, @@ -321,14 +321,19 @@ test('auto uses beam search for medium profiles and protects exhaustive search a seed: 7, cachingEnabled: false, }); - const guardedExhaustive = optimizeFilamentOrder(mediumProfile, context, { + const exhaustive = optimizeFilamentOrder(mediumProfile, context, { algorithm: 'exhaustive', seed: 7, cachingEnabled: false, }); assert.equal(auto.resolvedAlgorithm, 'beam'); - assert.equal(guardedExhaustive.resolvedAlgorithm, 'beam'); + assert.equal(exhaustive.resolvedAlgorithm, 'exhaustive'); + assert.equal( + exhaustive.iterations, + 13_699, + 'seven filaments have 13,699 ordered non-empty subsets' + ); assert.ok(auto.order.length <= mediumProfile.length); assert.equal(new Set(auto.order.map((filament) => filament.id)).size, auto.order.length); }); From 30ecd49e57546f345d0a166c943980d041dbed80 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 22 Jun 2026 21:52:26 +0300 Subject: [PATCH 11/31] Add Reddit community links --- CHANGELOG.md | 1 + README.md | 2 +- src/assets/discord.svg | 1 + src/assets/github.svg | 1 + src/assets/reddit.svg | 1 + src/components/Header.tsx | 39 +++++++++++++++++++++++++++++++++------ 6 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 src/assets/discord.svg create mode 100644 src/assets/github.svg create mode 100644 src/assets/reddit.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e311c3..d93196b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Added +- **Reddit community links** - Added r/kromacut links to the app header and README, and use branded Discord, Reddit, and GitHub icons in the header toolbar. - **Auto-paint regression baseline** - Added focused layer-invariant coverage, per-algorithm seeded determinism checks, and 24 seeded stack snapshots across the 2-, 4-, and 8-filament profiles, both image fixtures, Enhanced matching states, and repeated-swap states. - **Auto-paint benchmark harness** - Added an on-demand JSON benchmark that measures palette and preview-realized color error, coverage, stack cost, compression impact, runtime, and optimizer iterations across the saved fixture profiles. - **Auto-paint optimization progress** - Enhanced matching now reports an approximate completion percentage while its background search is running. diff --git a/README.md b/README.md index a651ae5..f4c6a56 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Kromacut -[![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white)](https://www.patreon.com/cw/vycdev) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/nU63sFMcnX) [![YouTube](https://img.shields.io/badge/YouTube-@vycdev-red?logo=youtube&logoColor=white)](https://www.youtube.com/@vycdev) [![Release](https://img.shields.io/github/v/release/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) [![Repo size](https://img.shields.io/github/repo-size/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut) [![Total downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/total?label=total%20downloads&cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases) [![Latest downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/latest/total?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) +[![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white)](https://www.patreon.com/cw/vycdev) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/nU63sFMcnX) [![Reddit](https://img.shields.io/badge/Reddit-r%2Fkromacut-FF4500?logo=reddit&logoColor=white)](https://www.reddit.com/r/kromacut/) [![YouTube](https://img.shields.io/badge/YouTube-@vycdev-red?logo=youtube&logoColor=white)](https://www.youtube.com/@vycdev) [![Release](https://img.shields.io/github/v/release/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) [![Repo size](https://img.shields.io/github/repo-size/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut) [![Total downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/total?label=total%20downloads&cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases) [![Latest downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/latest/total?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) Open-source HueForge-style tool for converting images into stacked, color-layered 3D prints. diff --git a/src/assets/discord.svg b/src/assets/discord.svg new file mode 100644 index 0000000..9d7796b --- /dev/null +++ b/src/assets/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/src/assets/github.svg b/src/assets/github.svg new file mode 100644 index 0000000..538ec5b --- /dev/null +++ b/src/assets/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/src/assets/reddit.svg b/src/assets/reddit.svg new file mode 100644 index 0000000..ea03883 --- /dev/null +++ b/src/assets/reddit.svg @@ -0,0 +1 @@ +Reddit \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6cdcd5c..50a6a93 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -7,12 +7,10 @@ import { CheckCircle2, Download, Image, - Github, Heart, Loader2, Moon, Sun, - MessageCircle, RefreshCw, Settings, X, @@ -41,6 +39,9 @@ import { subscribeToUpdateCheckOnStartup, } from '@/lib/updatePreferences'; import logo from '../assets/logo.png'; +import discordIcon from '../assets/discord.svg'; +import githubIcon from '../assets/github.svg'; +import redditIcon from '../assets/reddit.svg'; interface Props { onLoadTest: () => void; @@ -197,6 +198,24 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT Load TD Test + @@ -225,7 +246,9 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT aria-label="GitHub" title="GitHub" > - + + + GitHub @@ -268,7 +291,10 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT onClick={(event) => event.stopPropagation()} >
-

+

Settings

+ Height dithering + +
- )} + {heightDithering && enhancedColorMatch && ( +
+ + { + setLocalDitherLineWidth(e.target.value); + }} + onBlur={() => { + let val = parseFloat(localDitherLineWidth); + if (isNaN(val)) { + setLocalDitherLineWidth( + ditherLineWidth.toString() + ); + return; + } + val = Math.max(0.1, Math.min(2, val)); + setDitherLineWidth(val); + setLocalDitherLineWidth(val.toString()); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }} + className="w-20 h-7 text-xs" + /> + + mm + + +
+ )} + )} @@ -664,7 +796,7 @@ export default function AutoPaintTab({
@@ -680,22 +812,40 @@ export default function AutoPaintTab({ - - Fast - - - Balanced (recommended) - - - Thorough (slower) - + {OPTIMIZER_TIERS.map((tier) => ( + + {tier.label} + + ))}
+
+
+ + +
+

+ {optimizerTierDescription} + {optimizerAlgorithm === 'exact' && maxRepeatedSwaps > 0 && ( + + {' '} + The base order is exact; up to { + maxRepeatedSwaps + }{' '} + repeated swaps are refined heuristically. + + )} +

+
@@ -723,10 +873,49 @@ export default function AutoPaintTab({
+
+ + +
+

+ Higher opacity retains a longer physical color ramp, improving + color resolution at the cost of height, swaps, and runtime. +

@@ -996,14 +1185,18 @@ export default function AutoPaintTab({ ) : ( )} - {isNextBestComputing ? 'Finding suggestion...' : 'Suggest next filament'} + {isNextBestComputing + ? 'Finding suggestion...' + : 'Suggest next filament'} {nextBestResult?.candidate && (
{nextBestResult.candidate.hex.toUpperCase()} @@ -1014,7 +1207,9 @@ export default function AutoPaintTab({ > Est. ΔE{' '} - +{nextBestResult.candidate.improvementPct.toFixed(1)}% + + + {nextBestResult.candidate.improvementPct.toFixed(1)} + %
@@ -1049,7 +1244,10 @@ export default function AutoPaintTab({ className="w-full h-7 text-xs mt-0.5" onClick={() => { suggestionCountRef.current += 1; - const nn = String(suggestionCountRef.current).padStart(2, '0'); + const nn = String(suggestionCountRef.current).padStart( + 2, + '0' + ); addFilamentWithProps({ color: nextBestResult.candidate!.hex, td: nextBestResult.candidate!.td, diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index f81c678..4367f7a 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -16,7 +16,12 @@ import { useProfileManager } from '../hooks/useProfileManager'; import { useColorSlicing } from '../hooks/useColorSlicing'; import { useSwapPlan } from '../hooks/useSwapPlan'; import { useAutoPaintWorker } from '../hooks/useAutoPaintWorker'; -import type { Swatch, ThreeDControlsStateShape } from '../types'; +import type { + AutoPaintRepeatLimit, + AutoPaintTransitionOpacity, + Swatch, + ThreeDControlsStateShape, +} from '../types'; import PrintSettingsCard from './PrintSettingsCard'; import PrintInstructions from './PrintInstructions'; import AutoPaintTab from './AutoPaintTab'; @@ -111,13 +116,20 @@ export default function ThreeDControls({ const [paintMode, setPaintMode] = useState<'manual' | 'autopaint'>(initialPaintMode); const [autoPaintMaxHeight, setAutoPaintMaxHeight] = useState(undefined); const [enhancedColorMatch, setEnhancedColorMatch] = useState(persisted?.enhancedColorMatch ?? false); - const [allowRepeatedSwaps, setAllowRepeatedSwaps] = useState(persisted?.allowRepeatedSwaps ?? false); + const [maxRepeatedSwaps, setMaxRepeatedSwaps] = useState( + persisted?.maxRepeatedSwaps ?? (persisted?.allowRepeatedSwaps ? 4 : 0) + ); + const [transitionOpacity, setTransitionOpacity] = useState( + persisted?.transitionOpacity ?? 0.9 + ); const [heightDithering, setHeightDithering] = useState(persisted?.heightDithering ?? false); const [ditherLineWidth, setDitherLineWidth] = useState(persisted?.ditherLineWidth ?? 0.42); const [flatPaint, setFlatPaint] = useState(initialFlatPaint); // --- Optimizer Options --- - const [optimizerAlgorithm, setOptimizerAlgorithm] = useState<'fast' | 'balanced' | 'thorough'>( + const [optimizerAlgorithm, setOptimizerAlgorithm] = useState< + 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact' + >( persisted?.optimizerAlgorithm ?? 'balanced' ); const [optimizerSeed, setOptimizerSeed] = useState( @@ -130,7 +142,6 @@ export default function ThreeDControls({ const handleEnhancedColorMatchChange = useCallback((v: boolean) => { setEnhancedColorMatch(v); if (!v) { - setAllowRepeatedSwaps(false); setHeightDithering(false); } }, []); @@ -155,7 +166,8 @@ export default function ThreeDControls({ paintMode, filaments, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, heightDithering, ditherLineWidth, flatPaint, @@ -165,7 +177,7 @@ export default function ThreeDControls({ smoothMeshing, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); + }, [paintMode, filaments, enhancedColorMatch, maxRepeatedSwaps, transitionOpacity, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); useEffect(() => { savePrintSettingsToStorage({ layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing }); @@ -213,11 +225,13 @@ export default function ThreeDControls({ slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, optimizerAlgorithm, optimizerSeed, regionWeightingMode, }); + const autoPaintProgressPercent = Math.round(Math.max(0, Math.min(1, autoPaintProgress)) * 100); const autoPaintSliceData = useMemo(() => { if (!autoPaintResult) return undefined; @@ -310,7 +324,8 @@ export default function ThreeDControls({ filaments, paintMode, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, heightDithering, ditherLineWidth, flatPaint, @@ -352,7 +367,8 @@ export default function ThreeDControls({ filaments, paintMode, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, heightDithering, ditherLineWidth, flatPaint, @@ -378,7 +394,7 @@ export default function ThreeDControls({ {isAutoPaintComputing ? ( <> - Computing... + Computing... {autoPaintProgressPercent}% ) : ( <> @@ -462,8 +478,10 @@ export default function ThreeDControls({ imageSwatches={filtered} enhancedColorMatch={enhancedColorMatch} setEnhancedColorMatch={handleEnhancedColorMatchChange} - allowRepeatedSwaps={allowRepeatedSwaps} - setAllowRepeatedSwaps={setAllowRepeatedSwaps} + maxRepeatedSwaps={maxRepeatedSwaps} + setMaxRepeatedSwaps={setMaxRepeatedSwaps} + transitionOpacity={transitionOpacity} + setTransitionOpacity={setTransitionOpacity} heightDithering={heightDithering} setHeightDithering={setHeightDithering} ditherLineWidth={ditherLineWidth} diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index 656a480..540405d 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -75,11 +75,14 @@ Enable **Enhanced color matching** when filament order matters and you want Krom Optional controls appear with enhanced matching: -- **Allow repeated filament swaps** lets the same filament appear more than once. +- **Extra repeated swaps** chooses whether a filament may reappear, and lets you allow 2, 4, 6, 8, or 12 extra occurrences. More repeats can create useful blend paths but expand the search space. +- **Transition detail** chooses the opacity endpoint for each physical color transition: Compact stops at 80% opacity, Detailed at 90%, and Maximum at 95%. Higher settings create taller stacks with more printable intermediate colors. - **Height dithering** uses printable height dots to smooth tonal transitions. - **Line width** should roughly match the printer line or nozzle width used for dither dots. - **Optimizer Settings** let you choose **Algorithm**, **Region priority**, and an optional **Seed**. +Enhanced matching scores the palette that is already visible in 2D mode; it does not reduce that palette again. For detailed work, prepare the image in 2D first (for example, K-means with a weight of 128 and an Auto palette of 64 or 128 colors), then switch to Auto-paint. This keeps the 2D palette decision explicit, but more source colors make every optimizer tier slower. + While Kromacut is optimizing a filament order, the panel shows an approximate completion percentage. Starting a new calculation cancels the older one, so the percentage always belongs to the current settings. When a filament has been calibrated, Auto-paint uses its measured red, green, and blue TD values for both transition colors and transition thickness. Calibration can therefore change the generated stack height and swap plan as well as the preview color, making the print model more faithful to the measured filament. @@ -102,16 +105,23 @@ Flat Paint and **Smooth Meshing** are mutually exclusive. Turning one on turns t | Setting | Meaning | | --------------- | ------------------------------------------------------------ | -| Algorithm | Auto, Exhaustive, Simulated Annealing, or Genetic Algorithm. | +| Algorithm | Fast, Balanced, Thorough, Deep, or Exact base order. | | Region priority | Uniform, Center-weighted, or Edge-weighted matching. | | Seed (optional) | Overrides the automatic stable seed for an intentional comparison. | -Use **Auto (smart selection)** unless you have a reason to compare algorithms. -Auto uses exact ordered-subset search through 6 filaments, beam search from 7 to 12, -then variable-length simulated annealing for larger profiles. With Enhanced color -matching, Kromacut can omit filaments that do not improve the printable stack. When -Repeated swaps is enabled, it can also add up to four non-adjacent repeated filament -occurrences when they improve the blend path. +Start with **Balanced**. It uses a full deterministic beam search and is the best +general-purpose choice. **Fast** uses a narrower beam for a quicker preview. +**Thorough** adds deeper multi-start refinement, while **Deep** widens the beam and +spends substantially more time exploring alternatives. Each higher tier keeps the +best result from the tier below for the same seed. + +**Exact base order** checks every possible no-repeat filament order. It checks 109,600 +orders at eight filaments and 986,409 at nine, so larger profiles can take a long time. +The search stays in the background worker and you can start another search to cancel it. +When **Extra repeated swaps** is above Off, Exact still proves the base order but treats +repeated occurrences as a separate refined search. Enhanced matching can omit filaments that do +not improve the printable stack and add the selected number of non-adjacent repeated occurrences +when they improve the blend path. **Region priority** changes which source colors the optimizer values most: **Center-weighted** gives more importance to colors that occur near the middle of the image, while **Edge-weighted** favors colors nearer its outer edges. It does not crop or change the image itself. diff --git a/src/hooks/useAutoPaintWorker.ts b/src/hooks/useAutoPaintWorker.ts index 509c897..fb07d8b 100644 --- a/src/hooks/useAutoPaintWorker.ts +++ b/src/hooks/useAutoPaintWorker.ts @@ -12,7 +12,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AutoPaintResult } from '../lib/autoPaint'; -import type { Filament, Swatch } from '../types'; +import type { + AutoPaintRepeatLimit, + AutoPaintTransitionOpacity, + Filament, + Swatch, +} from '../types'; import type { AutoPaintWorkerProgress, AutoPaintWorkerRequest, @@ -27,8 +32,9 @@ export interface UseAutoPaintWorkerOptions { slicerFirstLayerHeight: number; autoPaintMaxHeight?: number; enhancedColorMatch: boolean; - allowRepeatedSwaps: boolean; - optimizerAlgorithm: 'fast' | 'balanced' | 'thorough'; + maxRepeatedSwaps: AutoPaintRepeatLimit; + transitionOpacity: AutoPaintTransitionOpacity; + optimizerAlgorithm: 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; optimizerSeed?: number; regionWeightingMode: 'uniform' | 'center' | 'edge'; } @@ -69,7 +75,8 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, optimizerAlgorithm, optimizerSeed, regionWeightingMode, @@ -238,9 +245,11 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain firstLayerHeight: slicerFirstLayerHeight, maxHeight: autoPaintMaxHeight, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, optimizerOptions: { algorithm: optimizerAlgorithm, + maxExtraRepeats: maxRepeatedSwaps, + transitionOpacity, ...(optimizerSeed !== undefined && { seed: optimizerSeed }), }, }; @@ -271,7 +280,8 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, optimizerAlgorithm, optimizerSeed, regionWeightingMode, diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 59dca1f..78c4b04 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -93,7 +93,7 @@ export interface AutoPaintResult { }; // Optimizer metadata (for advanced optimizer only) optimizerMetadata?: { - algorithm: string; // resolved: 'exhaustive' | 'beam' | 'simulated-annealing' | 'balanced-hybrid' + algorithm: string; // concrete tier path, such as 'beam', 'deep-hybrid', or 'exact-base' score: number; // Quality score achieved iterations: number; // Iterations performed converged: boolean; // Whether algorithm converged @@ -370,6 +370,24 @@ export function getOpacity( */ const DELTA_E_THRESHOLD = 2.3; // "Just noticeable difference" +/** Legacy compact transition endpoint: 80% opacity. The UI explicitly defaults to Detailed (90%). */ +export const DEFAULT_TRANSITION_OPACITY = 0.8; + +function normalizeTransitionOpacity(targetOpacity: number | undefined): number { + if (!Number.isFinite(targetOpacity)) return DEFAULT_TRANSITION_OPACITY; + return Math.max(0.5, Math.min(0.99, targetOpacity!)); +} + +function transitionThicknessMultiplier(targetOpacity: number): number { + // Keep the old compact 0.7×TD endpoint byte-for-byte stable. The detailed + // and maximum presets deliberately align with the familiar 1×TD and + // 1.3×TD (about 95%) optical landmarks. + if (Math.abs(targetOpacity - 0.8) < 1e-9) return 0.7; + if (Math.abs(targetOpacity - 0.9) < 1e-9) return 1; + if (Math.abs(targetOpacity - 0.95) < 1e-9) return 1.3; + return -Math.log10(1 - targetOpacity); +} + /** * Frontlit prints behave optically like a much shorter effective TD. * Scale user-entered TD values down for internal simulation. @@ -421,7 +439,8 @@ export function calculateTransitionThickness( backgroundColor: RGB, filamentColor: RGB, filamentTD: number | CalibrationRgb, - layerHeight: number + layerHeight: number, + targetOpacity: number = DEFAULT_TRANSITION_OPACITY ): number { // Early exit if colors are already close if (deltaE(backgroundColor, filamentColor) < DELTA_E_THRESHOLD) { @@ -436,14 +455,15 @@ export function calculateTransitionThickness( return layerHeight; } - // The cap determines the absolute maximum transition thickness. - // At 0.7×TD, opacity ≈ 80%. At 1×TD, opacity ≈ 90%. - // For transitions between adjacent colors in a sorted stack, - // DeltaE convergence typically fires well before this cap. - // We use 0.7×TD — if the color hasn't converged by ~80% opacity, - // additional thickness gives diminishing visual returns. - const OPACITY_CAP = 0.7; - const maxThickness = Math.max(layerHeight, Math.max(...channelTds) * OPACITY_CAP); + // The requested opacity determines the absolute maximum transition + // thickness. Perceptual convergence can still complete the transition + // earlier when the blended result is already close to the target color. + const resolvedOpacity = normalizeTransitionOpacity(targetOpacity); + const opacityThicknessMultiplier = transitionThicknessMultiplier(resolvedOpacity); + const maxThickness = Math.max( + layerHeight, + Math.max(...channelTds) * opacityThicknessMultiplier + ); // Simulate adding layers until color converges or we hit the cap while (thickness < maxThickness) { @@ -455,8 +475,9 @@ export function calculateTransitionThickness( break; } - // Also stop if opacity is already very high — diminishing returns - if (getOpacity(filamentTD, thickness) > 0.85) { + // Stop at the selected opacity endpoint when perceptual convergence + // has not already completed the transition. + if (getOpacity(filamentTD, thickness) >= resolvedOpacity) { break; } } @@ -481,7 +502,8 @@ export function calculateIdealHeight( sortedFilaments: AutoPaintFilament[], layerHeight: number, baseThickness: number = 0.6, - transitionThicknessCache?: Map + transitionThicknessCache?: Map, + transitionOpacity: number = DEFAULT_TRANSITION_OPACITY ): { idealHeight: number; zones: TransitionZone[] } { if (sortedFilaments.length === 0) { return { idealHeight: baseThickness, zones: [] }; @@ -533,6 +555,7 @@ export function calculateIdealHeight( : transitionTd ), layerHeight, + transitionOpacity, ].join(':'); let transitionThickness = transitionThicknessCache?.get(transitionKey); if (transitionThickness === undefined) { @@ -540,7 +563,8 @@ export function calculateIdealHeight( currentBackgroundColor, filamentRgb, transitionTd, - layerHeight + layerHeight, + transitionOpacity ); transitionThicknessCache?.set(transitionKey, transitionThickness); } @@ -964,6 +988,7 @@ export function buildAchievableColorPalette( layerHeight: number, firstLayerHeight: number, maxHeight?: number, + transitionOpacity: number = DEFAULT_TRANSITION_OPACITY, transitionThicknessCache?: Map ): Array<{ height: number; lab: Lab; rgb: RGB }> { if (sequence.length === 0) return []; @@ -973,7 +998,8 @@ export function buildAchievableColorPalette( sequence, layerHeight, Math.max(firstLayerHeight, layerHeight), - transitionThicknessCache + transitionThicknessCache, + transitionOpacity ); if (zones.length === 0) return []; @@ -1100,6 +1126,10 @@ export function weightedErrorPercentile( const REALIZED_ERROR_TAIL_WEIGHT = 0.5; /** Percentile used for the realized-error tail term. */ const REALIZED_ERROR_TAIL_PERCENTILE = 0.95; +/** Detail coverage: targets within this realized error are treated as retained. */ +const DETAIL_COVERAGE_DE = 6; +/** Prefer stacks that retain more weighted source-color detail. */ +const DETAIL_COVERAGE_PENALTY = 8; /** A printable palette entry counts as "used" if a target lands within this ΔE00. */ const USEFUL_PALETTE_MATCH_DE = 8; @@ -1138,6 +1168,7 @@ export function scoreSequenceAgainstImage( const errorSamples: Array<{ value: number; weight: number }> = []; const bestMatchHeights: number[] = []; const usedPaletteEntries = new Set(); + let detailCoveredWeight = 0; for (const entry of mapped) { const realizedDeltaE = realizedColorError(entry.mappedLab, entry.target); @@ -1146,6 +1177,7 @@ export function scoreSequenceAgainstImage( totalWeight += weight; errorSamples.push({ value: realizedDeltaE, weight }); bestMatchHeights.push(entry.projectedHeight); + if (realizedDeltaE <= DETAIL_COVERAGE_DE) detailCoveredWeight += weight; // Mark this palette entry as useful if its printable color is a decent match. if (realizedDeltaE < USEFUL_PALETTE_MATCH_DE) usedPaletteEntries.add(entry.paletteIndex); } @@ -1155,6 +1187,7 @@ export function scoreSequenceAgainstImage( const weightedMean = weightedErrorSum / totalWeight; const weightedTail = weightedErrorPercentile(errorSamples, REALIZED_ERROR_TAIL_PERCENTILE); let score = weightedMean + REALIZED_ERROR_TAIL_WEIGHT * weightedTail; + score += (1 - detailCoveredWeight / totalWeight) * DETAIL_COVERAGE_PENALTY; // 2. Height spread penalty: penalize when distinct image colors // collapse to the same height (leading to flat surfaces). @@ -1220,6 +1253,26 @@ function findBestFilamentOrder( /** * Advanced optimizer path using the shared variable-length sequence search. */ +/** + * Convert the already-processed 2D palette into weighted optimizer targets + * without applying another palette-reduction pass. + */ +export function buildOptimizerImageTargets( + imageSwatches: Array<{ hex: string; count?: number }> +): WeightedLab[] { + const totalWeight = imageSwatches.reduce( + (total, swatch) => total + Math.max(0, swatch.count ?? 1), + 0 + ); + + return imageSwatches + .map((swatch) => ({ + ...rgbToLab(hexToRgb(swatch.hex)), + weight: Math.max(0, swatch.count ?? 1) / Math.max(1, totalWeight), + })) + .filter((target) => target.weight > 0); +} + function findBestFilamentOrderWithOptimizer( filaments: Filament[], imageSwatches: Array<{ hex: string; count?: number }>, @@ -1229,8 +1282,7 @@ function findBestFilamentOrderWithOptimizer( maxHeight?: number, allowRepeatedSwaps: boolean = false ): { sortedFilaments: Filament[]; result: OptimizerResult } { - // Spatial weighting has already been folded into swatch counts by the caller. - const imageTargets = clusterImageColors(imageSwatches, 32, 5.0); + const imageTargets = buildOptimizerImageTargets(imageSwatches); // Build scoring context const context: ScoringContext = { @@ -1238,6 +1290,7 @@ function findBestFilamentOrderWithOptimizer( layerHeight, firstLayerHeight, maxHeight, + transitionOpacity: optimizerOptions.transitionOpacity, }; // Apply frontlit TD scale @@ -1362,7 +1415,9 @@ export function generateAutoLayers( const { idealHeight, zones } = calculateIdealHeight( scaledFilaments, layerHeight, - Math.max(firstLayerHeight, layerHeight) + Math.max(firstLayerHeight, layerHeight), + undefined, + optimizerOptions?.transitionOpacity ); // --- STEP 4: APPLY COMPRESSION ON THE PRINTABLE HEIGHT GRID --- diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index f43bbc4..627d8fe 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -22,7 +22,7 @@ import { // ============================================================================ /** User-facing optimizer effort tiers (persisted and shown in the UI). */ -export type OptimizerTier = 'fast' | 'balanced' | 'thorough'; +export type OptimizerTier = 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; /** * Values the optimizer accepts: the user-facing tiers plus explicit concrete @@ -40,13 +40,16 @@ export function normalizeOptimizerTier(value: string | undefined | null): Optimi case 'fast': case 'balanced': case 'thorough': + case 'deep': + case 'exact': return value; - case 'exhaustive': // explicit exact search → closest tier is Thorough + case 'exhaustive': // explicit exact search → Exact base order + return 'exact'; case 'genetic': // legacy "max effort" option - return 'thorough'; + return 'deep'; case 'beam': - return 'fast'; - // 'auto', 'simulated-annealing', anything unknown → the smart default + return 'balanced'; + // 'auto', 'simulated-annealing', anything unknown → the recommended default default: return 'balanced'; } @@ -54,7 +57,12 @@ export function normalizeOptimizerTier(value: string | undefined | null): Optimi export interface OptimizerOptions { algorithm: OptimizerAlgorithm; + /** Legacy compatibility flag; new callers should set maxExtraRepeats. */ allowRepeatedSwaps?: boolean; + /** Maximum extra non-adjacent filament occurrences (0–12). */ + maxExtraRepeats?: number; + /** Transition opacity target used by the shared printable-palette scorer. */ + transitionOpacity?: number; seed?: number; // For deterministic results maxIterations?: number; // Algorithm-specific iteration limit temperature?: number; // Initial temperature for SA @@ -81,6 +89,7 @@ export interface ScoringContext { layerHeight: number; firstLayerHeight: number; maxHeight?: number; + transitionOpacity?: number; } // ============================================================================ @@ -156,6 +165,8 @@ const globalCache = new OptimizerCache(); function tuningFingerprint(options: OptimizerOptions) { return { allowRepeatedSwaps: options.allowRepeatedSwaps ?? false, + maxExtraRepeats: options.maxExtraRepeats ?? null, + transitionOpacity: options.transitionOpacity ?? null, maxIterations: options.maxIterations ?? null, temperature: options.temperature ?? null, coolingRate: options.coolingRate ?? null, @@ -189,6 +200,7 @@ function canonicalOptimizerInput( layerHeight: context.layerHeight, firstLayerHeight: context.firstLayerHeight, maxHeight: context.maxHeight ?? null, + transitionOpacity: context.transitionOpacity ?? null, algorithm, seed: seed ?? null, tuning: tuningFingerprint(options), @@ -225,6 +237,7 @@ export function createSequenceScorer(context: ScoringContext): (filaments: Filam context.layerHeight, context.firstLayerHeight, context.maxHeight, + context.transitionOpacity, transitionThicknessCache ); paletteCache.set(sequenceKey, palette); @@ -241,28 +254,51 @@ export function scoreFilamentSequence(filaments: Filament[], context: ScoringCon // Variable-length sequence helpers // ============================================================================ -const MAX_AUTO_EXHAUSTIVE_FILAMENTS = 6; -const AUTO_BEAM_MAX_FILAMENTS = 12; -const MAX_EXTRA_REPEATS = 4; -const DEFAULT_BEAM_WIDTH = 100; +const MAX_EXTRA_REPEATS = 12; +const LEGACY_EXTRA_REPEATS = 4; +const FAST_BEAM_WIDTH = 25; +const BALANCED_BEAM_WIDTH = 100; +const THOROUGH_BEAM_WIDTH = 100; +const DEEP_BEAM_WIDTH = 250; type SequenceScorer = (filaments: Filament[]) => number; type ResolvedOptimizerAlgorithm = | 'exhaustive' | 'beam' | 'simulated-annealing' - | 'balanced-hybrid'; + | 'narrow-beam' + | 'thorough-hybrid' + | 'deep-hybrid' + | 'exact-base'; const SA_MIN_TEMPERATURE = 0.01; const SA_INITIAL_TEMPERATURE = 10.0; const SA_DEFAULT_COOLING_RATE = 0.995; -// The balanced/thorough multi-start refines around good seeds, so it anneals at -// a lower temperature scaled to the CIEDE2000 objective (scores ~5–30), where +// The hybrid tiers refine around good seeds at a temperature scaled to the +// CIEDE2000 objective (scores ~5–30), where // the legacy SA temperature of 10 would just wander away from the seed. const SA_HYBRID_TEMPERATURE = 2.0; -// Thorough seeds the hybrid with the exact no-repeat optimum when the ordered -// subset space is small enough to enumerate quickly (≈7 filaments → 13,699). -const THOROUGH_EXACT_SUBSET_LIMIT = 20000; + +interface HybridSearchPlan { + beamWidth: number; + restarts: number; + minimumIterationsPerRestart: number; + iterationsPerFilament: number; +} + +const THOROUGH_PLAN: HybridSearchPlan = { + beamWidth: THOROUGH_BEAM_WIDTH, + restarts: 12, + minimumIterationsPerRestart: 1_500, + iterationsPerFilament: 250, +}; + +const DEEP_PLAN: HybridSearchPlan = { + beamWidth: DEEP_BEAM_WIDTH, + restarts: 32, + minimumIterationsPerRestart: 4_000, + iterationsPerFilament: 500, +}; interface ScoredSequence { order: Filament[]; @@ -289,8 +325,15 @@ function isValidSequence(sequence: Filament[], allowRepeatedSwaps: boolean): boo ); } -function maxSequenceLength(filaments: Filament[], allowRepeatedSwaps: boolean): number { - return filaments.length + (allowRepeatedSwaps ? MAX_EXTRA_REPEATS : 0); +function resolveMaxExtraRepeats(options: OptimizerOptions): number { + if (Number.isFinite(options.maxExtraRepeats)) { + return Math.max(0, Math.min(MAX_EXTRA_REPEATS, Math.floor(options.maxExtraRepeats!))); + } + return options.allowRepeatedSwaps ? LEGACY_EXTRA_REPEATS : 0; +} + +function maxSequenceLength(filaments: Filament[], maxExtraRepeats: number): number { + return filaments.length + maxExtraRepeats; } function isBetterCandidate( @@ -311,6 +354,22 @@ function reportProgress( options.onProgress?.(Math.min(iteration, total), Math.max(total, 1), bestScore); } +function withProgressSpan( + options: OptimizerOptions, + start: number, + end: number +): OptimizerOptions { + if (!options.onProgress) return { ...options, onProgress: undefined }; + + return { + ...options, + onProgress: (iteration, total, bestScore) => { + const phaseProgress = total > 0 ? Math.min(1, Math.max(0, iteration / total)) : 1; + options.onProgress?.(start + (end - start) * phaseProgress, 1, bestScore); + }, + }; +} + function orderedSubsetCount(filamentCount: number): number { let total = 0; let permutations = 1; @@ -321,9 +380,14 @@ function orderedSubsetCount(filamentCount: number): number { return total; } -function repeatedInsertionUpperBound(filamentCount: number): number { +/** Number of no-repeat base sequences an Exact search would evaluate. */ +export function getExactBaseOrderCount(filamentCount: number): number { + return filamentCount > 0 ? orderedSubsetCount(filamentCount) : 0; +} + +function repeatedInsertionUpperBound(filamentCount: number, maxExtraRepeats: number): number { let total = 0; - for (let extra = 0; extra < MAX_EXTRA_REPEATS; extra++) { + for (let extra = 0; extra < maxExtraRepeats; extra++) { total += filamentCount * (filamentCount + extra + 1); } return total; @@ -332,9 +396,10 @@ function repeatedInsertionUpperBound(filamentCount: number): number { function expandWithRepeatedFilaments( initial: ScoredSequence, filaments: Filament[], - scoreSequence: SequenceScorer + scoreSequence: SequenceScorer, + maxExtraRepeats: number ): { best: ScoredSequence; iterations: number } { - const maxLength = maxSequenceLength(filaments, true); + const maxLength = maxSequenceLength(filaments, maxExtraRepeats); let best = initial; let iterations = 0; @@ -384,10 +449,11 @@ function optimizeExhaustive( }; } - const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + const maxExtraRepeats = resolveMaxExtraRepeats(options); + const allowRepeatedSwaps = maxExtraRepeats > 0; const totalIterations = orderedSubsetCount(filaments.length) + - (allowRepeatedSwaps ? repeatedInsertionUpperBound(filaments.length) : 0); + (allowRepeatedSwaps ? repeatedInsertionUpperBound(filaments.length, maxExtraRepeats) : 0); let best: ScoredSequence | null = null; let iterations = 0; reportProgress(options, 0, totalIterations, Infinity); @@ -420,7 +486,12 @@ function optimizeExhaustive( }; } - const expanded = expandWithRepeatedFilaments(baseBest, filaments, scoreSequence); + const expanded = expandWithRepeatedFilaments( + baseBest, + filaments, + scoreSequence, + maxExtraRepeats + ); return { order: expanded.best.order, score: expanded.best.score, @@ -442,9 +513,10 @@ function optimizeBeamSearch( return optimizeExhaustive(filaments, scoreSequence, options); } - const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; - const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); - const beamWidth = options.beamWidth ?? DEFAULT_BEAM_WIDTH; + const maxExtraRepeats = resolveMaxExtraRepeats(options); + const allowRepeatedSwaps = maxExtraRepeats > 0; + const maximumLength = maxSequenceLength(filaments, maxExtraRepeats); + const beamWidth = options.beamWidth ?? BALANCED_BEAM_WIDTH; const totalIterations = filaments.length + (maximumLength - 1) * beamWidth * filaments.length; let iterations = 0; @@ -520,11 +592,11 @@ function optimizeBeamSearch( */ function randomInitialSequence( filaments: Filament[], - allowRepeatedSwaps: boolean, + maxExtraRepeats: number, rng: SeededRandom ): Filament[] { const shuffled = rng.shuffle(filaments); - const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); + const maximumLength = maxSequenceLength(filaments, maxExtraRepeats); const initialLength = rng.nextInt(1, Math.min(filaments.length, maximumLength) + 1); return shuffled.slice(0, initialLength); } @@ -539,10 +611,11 @@ function sequenceEquals(left: Filament[], right: Filament[]): boolean { function buildVariableLengthNeighbor( sequence: Filament[], filaments: Filament[], - allowRepeatedSwaps: boolean, + maxExtraRepeats: number, rng: SeededRandom ): Filament[] { - const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); + const allowRepeatedSwaps = maxExtraRepeats > 0; + const maximumLength = maxSequenceLength(filaments, maxExtraRepeats); const availableMoves: Array<'swap' | 'relocate' | 'insert' | 'remove' | 'replace'> = []; if (sequence.length > 1) { @@ -610,7 +683,7 @@ function runAnneal( initialOrder: Filament[], filaments: Filament[], scoreSequence: SequenceScorer, - allowRepeatedSwaps: boolean, + maxExtraRepeats: number, rng: SeededRandom, maxIterations: number, initialTemperature: number, @@ -629,7 +702,7 @@ function runAnneal( const newOrder = buildVariableLengthNeighbor( currentOrder, filaments, - allowRepeatedSwaps, + maxExtraRepeats, rng ); if (sequenceEquals(newOrder, currentOrder)) { @@ -669,13 +742,13 @@ function optimizeSimulatedAnnealing( return optimizeExhaustive(filaments, scoreSequence, options); } - const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + const maxExtraRepeats = resolveMaxExtraRepeats(options); const rng = new SeededRandom(options.seed); const maxIterations = options.maxIterations ?? Math.max(1000, filaments.length * 100); const initialTemp = options.temperature ?? SA_INITIAL_TEMPERATURE; const coolingRate = options.coolingRate ?? SA_DEFAULT_COOLING_RATE; - const initialOrder = randomInitialSequence(filaments, allowRepeatedSwaps, rng); + const initialOrder = randomInitialSequence(filaments, maxExtraRepeats, rng); reportProgress(options, 0, maxIterations, scoreSequence(initialOrder)); let reported = 0; @@ -683,7 +756,7 @@ function optimizeSimulatedAnnealing( initialOrder, filaments, scoreSequence, - allowRepeatedSwaps, + maxExtraRepeats, rng, maxIterations, initialTemp, @@ -703,29 +776,26 @@ function optimizeSimulatedAnnealing( } /** - * Balanced / Thorough hybrid: take beam search's deterministic best as a seed, - * then run several deterministic annealing restarts (the first refines the beam - * seed, the rest explore seeded-random starts) and keep the global best. - * - * The budget is a fixed iteration count, not a wall-clock deadline, so results - * stay reproducible across machines (goldens, caching, A/B seeds depend on it). + * Refine a deterministic beam result with seeded multi-start annealing. A + * supplied baseline is retained throughout, so a deeper tier can never return + * a worse result than the tier it extends. */ -function optimizeBalanced( +function optimizeHybrid( filaments: Filament[], scoreSequence: SequenceScorer, options: OptimizerOptions, - thorough: boolean + plan: HybridSearchPlan, + baseline?: OptimizerResult ): OptimizerResult { if (filaments.length <= 1) { return optimizeExhaustive(filaments, scoreSequence, options); } - const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + const maxExtraRepeats = resolveMaxExtraRepeats(options); const rng = new SeededRandom(options.seed); - const restarts = thorough ? 12 : 5; const iterationsPerRestart = options.maxIterations ?? - Math.max(thorough ? 1500 : 600, filaments.length * (thorough ? 250 : 120)); + Math.max(plan.minimumIterationsPerRestart, filaments.length * plan.iterationsPerFilament); const initialTemp = options.temperature ?? SA_HYBRID_TEMPERATURE; // Cool from the initial temperature to the floor across one restart so each // restart spends its whole budget refining instead of freezing early. @@ -733,38 +803,37 @@ function optimizeBalanced( options.coolingRate ?? Math.pow(SA_MIN_TEMPERATURE / initialTemp, 1 / Math.max(1, iterationsPerRestart)); - // 1. Seeds. Beam is always cheap and strong. Thorough additionally enumerates - // the exact no-repeat optimum when the subset space is small enough, then - // lets the hybrid explore repeats around it — so Thorough ≥ exact ≥ beam. - const beam = optimizeBeamSearch(filaments, scoreSequence, { ...options, onProgress: undefined }); + // Beam provides a strong deterministic seed before the broader local search. + const beam = optimizeBeamSearch(filaments, scoreSequence, { + ...options, + beamWidth: options.beamWidth ?? plan.beamWidth, + onProgress: undefined, + }); let best: ScoredSequence = { order: beam.order, score: beam.score }; let iterations = beam.iterations; - if (thorough && orderedSubsetCount(filaments.length) <= THOROUGH_EXACT_SUBSET_LIMIT) { - const exact = optimizeExhaustive(filaments, scoreSequence, { ...options, onProgress: undefined }); - iterations += exact.iterations; - if (isBetterCandidate({ order: exact.order, score: exact.score }, best)) { - best = { order: exact.order, score: exact.score }; - } + if (baseline && isBetterCandidate({ order: baseline.order, score: baseline.score }, best)) { + best = { order: baseline.order, score: baseline.score }; } + iterations += baseline?.iterations ?? 0; - const total = iterations + restarts * iterationsPerRestart; + const total = iterations + plan.restarts * iterationsPerRestart; let reported = iterations; reportProgress(options, reported, total, best.score); - // 2. Multi-start annealing: restart 0 refines the best seed; the rest explore - // seeded-random starts. Keep the global best across all restarts. - for (let restart = 0; restart < restarts; restart++) { + // Restart 0 refines the strongest retained candidate; the rest explore + // seeded-random starts. Keep the global best across all restarts. + for (let restart = 0; restart < plan.restarts; restart++) { const initialOrder = restart === 0 ? best.order - : randomInitialSequence(filaments, allowRepeatedSwaps, rng); + : randomInitialSequence(filaments, maxExtraRepeats, rng); const outcome = runAnneal( initialOrder, filaments, scoreSequence, - allowRepeatedSwaps, + maxExtraRepeats, rng, iterationsPerRestart, initialTemp, @@ -789,30 +858,84 @@ function optimizeBalanced( }; } +/** Deep is Thorough plus a wider, much larger hybrid pass. */ +function optimizeDeep( + filaments: Filament[], + scoreSequence: SequenceScorer, + options: OptimizerOptions +): OptimizerResult { + const thorough = optimizeHybrid( + filaments, + scoreSequence, + withProgressSpan(options, 0, 0.2), + THOROUGH_PLAN + ); + return optimizeHybrid( + filaments, + scoreSequence, + withProgressSpan(options, 0.2, 1), + DEEP_PLAN, + thorough + ); +} + +/** + * Exact base-order search. Repeated swaps remain a separate larger space, so + * when they are enabled retain Deep's repeat-aware result alongside exact base + * enumeration and its greedy repeat refinement. + */ +function optimizeExactBase( + filaments: Filament[], + scoreSequence: SequenceScorer, + options: OptimizerOptions +): OptimizerResult { + if (resolveMaxExtraRepeats(options) === 0) { + return optimizeExhaustive(filaments, scoreSequence, options); + } + + const deep = optimizeDeep(filaments, scoreSequence, withProgressSpan(options, 0, 0.4)); + const exact = optimizeExhaustive( + filaments, + scoreSequence, + withProgressSpan(options, 0.4, 1) + ); + const exactCandidate = { order: exact.order, score: exact.score }; + const deepCandidate = { order: deep.order, score: deep.score }; + const best = isBetterCandidate(deepCandidate, exactCandidate) ? deepCandidate : exactCandidate; + + return { + order: best.order, + score: best.score, + iterations: deep.iterations + exact.iterations, + converged: true, + }; +} + function resolveAlgorithm( requested: OptimizerAlgorithm, filamentCount: number ): ResolvedOptimizerAlgorithm { + if (filamentCount <= 1) return 'exhaustive'; + switch (requested) { // Explicit concrete searches pass through unchanged. case 'exhaustive': case 'beam': case 'simulated-annealing': return requested; - // Fast: exact when cheap, else the deterministic beam baseline. + // Fast uses a deliberately narrow beam for rapid previews. case 'fast': - if (filamentCount <= MAX_AUTO_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; - if (filamentCount <= AUTO_BEAM_MAX_FILAMENTS) return 'beam'; - return 'simulated-annealing'; - // Balanced (default): exact for tiny profiles, beam-seeded multi-start otherwise. + return 'narrow-beam'; + // Balanced is the full deterministic beam baseline. case 'balanced': - if (filamentCount <= MAX_AUTO_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; - return 'balanced-hybrid'; - // Thorough: exact for tiny profiles, otherwise an exact-seeded larger - // hybrid (the hybrid itself enumerates when feasible — see optimizeBalanced). + return 'beam'; + // Thorough and Deep are progressively broader deterministic hybrids. case 'thorough': - if (filamentCount <= MAX_AUTO_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; - return 'balanced-hybrid'; + return 'thorough-hybrid'; + case 'deep': + return 'deep-hybrid'; + case 'exact': + return 'exact-base'; } } @@ -840,12 +963,11 @@ export function optimizeFilamentOrder( }; const resolved = resolveAlgorithm(opts.algorithm, filaments.length); - const thorough = opts.algorithm === 'thorough'; - // Key on the requested tier, not the resolved concrete: 'balanced' and - // 'thorough' both resolve to 'balanced-hybrid' but run different budgets and - // must not share a cache entry or default seed. - const defaultSeedInput = canonicalOptimizerInput(filaments, context, opts.algorithm, opts); + // Tiers share the same automatic seed so higher effort builds directly on + // comparable deterministic search paths. The cache key still includes the + // requested tier because their budgets and outputs can differ. + const defaultSeedInput = canonicalOptimizerInput(filaments, context, 'tier-comparison', opts); const seed = opts.seed ?? stableHash32(defaultSeedInput); opts.seed = seed; const cacheKey = canonicalOptimizerInput(filaments, context, opts.algorithm, opts, seed); @@ -871,8 +993,20 @@ export function optimizeFilamentOrder( case 'simulated-annealing': result = optimizeSimulatedAnnealing(filaments, scoreSequence, opts); break; - case 'balanced-hybrid': - result = optimizeBalanced(filaments, scoreSequence, opts, thorough); + case 'narrow-beam': + result = optimizeBeamSearch(filaments, scoreSequence, { + ...opts, + beamWidth: opts.beamWidth ?? FAST_BEAM_WIDTH, + }); + break; + case 'thorough-hybrid': + result = optimizeHybrid(filaments, scoreSequence, opts, THOROUGH_PLAN); + break; + case 'deep-hybrid': + result = optimizeDeep(filaments, scoreSequence, opts); + break; + case 'exact-base': + result = optimizeExactBase(filaments, scoreSequence, opts); break; } diff --git a/src/types/index.ts b/src/types/index.ts index 4ceb954..99cac53 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,12 @@ import type { AutoPaintResult } from '../lib/autoPaint'; import type { CalibrationResult } from '../lib/calibration'; +export const AUTO_PAINT_REPEAT_LIMITS = [0, 2, 4, 6, 8, 12] as const; +export type AutoPaintRepeatLimit = (typeof AUTO_PAINT_REPEAT_LIMITS)[number]; + +export const AUTO_PAINT_TRANSITION_OPACITIES = [0.8, 0.9, 0.95] as const; +export type AutoPaintTransitionOpacity = (typeof AUTO_PAINT_TRANSITION_OPACITIES)[number]; + export type Swatch = { hex: string; a: number; @@ -43,13 +49,18 @@ export interface ThreeDControlsStateShape { paintMode: 'manual' | 'autopaint'; // Enhanced color matching options enhancedColorMatch?: boolean; + /** Legacy persisted value. Migrate to maxRepeatedSwaps when loading. */ allowRepeatedSwaps?: boolean; + /** Maximum extra non-adjacent filament occurrences the optimizer may add. */ + maxRepeatedSwaps?: AutoPaintRepeatLimit; + /** Target transition opacity used to create the printable color ramp. */ + transitionOpacity?: AutoPaintTransitionOpacity; heightDithering?: boolean; ditherLineWidth?: number; /** Flat Paint: build a flat, face-down slab (auto-paint only) */ flatPaint?: boolean; // Optimizer options (effort tier; legacy values migrate on load) - optimizerAlgorithm?: 'fast' | 'balanced' | 'thorough'; + optimizerAlgorithm?: 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; optimizerSeed?: number; regionWeightingMode?: 'uniform' | 'center' | 'edge'; // Auto-paint computed state (only used when paintMode is 'autopaint') diff --git a/src/workers/autoPaint.worker.ts b/src/workers/autoPaint.worker.ts index 165c5bf..2f2c142 100644 --- a/src/workers/autoPaint.worker.ts +++ b/src/workers/autoPaint.worker.ts @@ -7,7 +7,7 @@ */ import { generateAutoLayers } from '../lib/autoPaint'; -import type { Filament } from '../types'; +import type { AutoPaintRepeatLimit, Filament } from '../types'; import type { OptimizerOptions } from '../lib/optimizer'; import type { AutoPaintResult } from '../lib/autoPaint'; @@ -21,7 +21,7 @@ export interface AutoPaintWorkerRequest { firstLayerHeight: number; maxHeight?: number; enhancedColorMatch?: boolean; - allowRepeatedSwaps?: boolean; + maxRepeatedSwaps?: AutoPaintRepeatLimit; optimizerOptions?: Partial; } @@ -74,7 +74,7 @@ self.onmessage = (e: MessageEvent) => { req.firstLayerHeight, req.maxHeight, req.enhancedColorMatch, - req.allowRepeatedSwaps, + (req.maxRepeatedSwaps ?? 0) > 0, { ...req.optimizerOptions, onProgress: reportProgress, diff --git a/tests/assets/auto-paint-goldens.json b/tests/assets/auto-paint-goldens.json index 87b554f..914f056 100644 --- a/tests/assets/auto-paint-goldens.json +++ b/tests/assets/auto-paint-goldens.json @@ -321,42 +321,74 @@ }, "GH#27 / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ - "xud8mzr", "plvjtmc", "h8zuocq", - "y202l1e" + "y202l1e", + "plvjtmc", + "y202l1e", + "xud8mzr", + "y202l1e", + "plvjtmc" ], "transitionZones": [ { - "filamentId": "xud8mzr", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.56, - "idealThickness": 0.4810000000000001, - "actualThickness": 0.56 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "h8zuocq", + "startHeight": 0.16, + "endHeight": 0.48, + "idealThickness": 0.245, + "actualThickness": 0.32 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.48, + "endHeight": 0.8, + "idealThickness": 0.329, + "actualThickness": 0.32 }, { "filamentId": "plvjtmc", - "startHeight": 0.56, - "endHeight": 0.64, + "startHeight": 0.8, + "endHeight": 0.88, "idealThickness": 0.08, "actualThickness": 0.08 }, { - "filamentId": "h8zuocq", - "startHeight": 0.64, - "endHeight": 0.88, - "idealThickness": 0.245, + "filamentId": "y202l1e", + "startHeight": 0.88, + "endHeight": 1.2, + "idealThickness": 0.329, + "actualThickness": 0.32 + }, + { + "filamentId": "xud8mzr", + "startHeight": 1.2, + "endHeight": 1.44, + "idealThickness": 0.259, "actualThickness": 0.24 }, { "filamentId": "y202l1e", - "startHeight": 0.88, - "endHeight": 1.2, + "startHeight": 1.44, + "endHeight": 1.76, "idealThickness": 0.329, "actualThickness": 0.32 + }, + { + "filamentId": "plvjtmc", + "startHeight": 1.76, + "endHeight": 1.84, + "idealThickness": 0.08, + "actualThickness": 0.08 } ], - "totalHeight": 1.2, + "totalHeight": 1.84, "compressionRatio": 1 }, "GH#27 / large-jpeg / enhanced=false / repeats=false": { @@ -443,8 +475,8 @@ "filamentOrder": [ "plvjtmc", "h8zuocq", - "xud8mzr", - "y202l1e" + "y202l1e", + "xud8mzr" ], "transitionZones": [ { @@ -462,18 +494,18 @@ "actualThickness": 0.32 }, { - "filamentId": "xud8mzr", + "filamentId": "y202l1e", "startHeight": 0.48, - "endHeight": 0.72, - "idealThickness": 0.24, - "actualThickness": 0.24 + "endHeight": 0.8, + "idealThickness": 0.329, + "actualThickness": 0.32 }, { - "filamentId": "y202l1e", - "startHeight": 0.72, + "filamentId": "xud8mzr", + "startHeight": 0.8, "endHeight": 1.04, - "idealThickness": 0.329, - "actualThickness": 0.32 + "idealThickness": 0.259, + "actualThickness": 0.24 } ], "totalHeight": 1.04, @@ -481,74 +513,74 @@ }, "GH#27 / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ - "h8zuocq", - "plvjtmc", "xud8mzr", "h8zuocq", "plvjtmc", + "y202l1e", + "plvjtmc", "h8zuocq", - "xud8mzr", - "y202l1e" + "plvjtmc", + "xud8mzr" ], "transitionZones": [ { - "filamentId": "h8zuocq", + "filamentId": "xud8mzr", "startHeight": 0, - "endHeight": 0.48, - "idealThickness": 0.45500000000000007, - "actualThickness": 0.48 - }, - { - "filamentId": "plvjtmc", - "startHeight": 0.48, "endHeight": 0.56, - "idealThickness": 0.08, - "actualThickness": 0.08 + "idealThickness": 0.4810000000000001, + "actualThickness": 0.56 }, { - "filamentId": "xud8mzr", + "filamentId": "h8zuocq", "startHeight": 0.56, "endHeight": 0.8, - "idealThickness": 0.259, + "idealThickness": 0.24, "actualThickness": 0.24 }, { - "filamentId": "h8zuocq", + "filamentId": "plvjtmc", "startHeight": 0.8, - "endHeight": 1.04, - "idealThickness": 0.24, - "actualThickness": 0.24 + "endHeight": 0.88, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.88, + "endHeight": 1.2, + "idealThickness": 0.329, + "actualThickness": 0.32 }, { "filamentId": "plvjtmc", - "startHeight": 1.04, - "endHeight": 1.12, + "startHeight": 1.2, + "endHeight": 1.28, "idealThickness": 0.08, "actualThickness": 0.08 }, { "filamentId": "h8zuocq", - "startHeight": 1.12, - "endHeight": 1.36, + "startHeight": 1.28, + "endHeight": 1.52, "idealThickness": 0.245, "actualThickness": 0.24 }, { - "filamentId": "xud8mzr", - "startHeight": 1.36, + "filamentId": "plvjtmc", + "startHeight": 1.52, "endHeight": 1.6, - "idealThickness": 0.24, - "actualThickness": 0.24 + "idealThickness": 0.08, + "actualThickness": 0.08 }, { - "filamentId": "y202l1e", + "filamentId": "xud8mzr", "startHeight": 1.6, - "endHeight": 2, - "idealThickness": 0.329, - "actualThickness": 0.4 + "endHeight": 1.84, + "idealThickness": 0.259, + "actualThickness": 0.24 } ], - "totalHeight": 2, + "totalHeight": 1.84, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=false / repeats=false": { @@ -697,104 +729,105 @@ }, "Current 8 Colors / logo-png / enhanced=true / repeats=false": { "filamentOrder": [ - "98z555k", - "vsn9q6u", "plvjtmc", + "98z555k", + "upcjpfe", "azwg1yp", "xyyxysq", - "upcjpfe", - "w8cncoa" + "w8cncoa", + "p9c63ms", + "vsn9q6u" ], "transitionZones": [ { - "filamentId": "98z555k", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.56, - "idealThickness": 0.507, - "actualThickness": 0.56 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { - "filamentId": "vsn9q6u", - "startHeight": 0.56, - "endHeight": 0.8, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.24 + "filamentId": "98z555k", + "startHeight": 0.16, + "endHeight": 0.48, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.32 }, { - "filamentId": "plvjtmc", - "startHeight": 0.8, - "endHeight": 0.88, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "upcjpfe", + "startHeight": 0.48, + "endHeight": 0.8, + "idealThickness": 0.343, + "actualThickness": 0.32 }, { "filamentId": "azwg1yp", - "startHeight": 0.88, - "endHeight": 1.12, + "startHeight": 0.8, + "endHeight": 1.04, "idealThickness": 0.19599999999999998, "actualThickness": 0.24 }, { "filamentId": "xyyxysq", - "startHeight": 1.12, - "endHeight": 1.44, + "startHeight": 1.04, + "endHeight": 1.36, "idealThickness": 0.357, "actualThickness": 0.32 }, - { - "filamentId": "upcjpfe", - "startHeight": 1.44, - "endHeight": 1.76, - "idealThickness": 0.343, - "actualThickness": 0.32 - }, { "filamentId": "w8cncoa", - "startHeight": 1.76, - "endHeight": 2.24, + "startHeight": 1.36, + "endHeight": 1.84, "idealThickness": 0.44099999999999995, "actualThickness": 0.48 + }, + { + "filamentId": "p9c63ms", + "startHeight": 1.84, + "endHeight": 2.24, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.4 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 2.24, + "endHeight": 2.48, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.24 } ], - "totalHeight": 2.24, + "totalHeight": 2.48, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ + "98z555k", "vsn9q6u", - "plvjtmc", - "azwg1yp", "upcjpfe", - "vsn9q6u", "azwg1yp", "xyyxysq", "upcjpfe", "w8cncoa", "vsn9q6u", - "98z555k", - "vsn9q6u" + "plvjtmc", + "azwg1yp", + "upcjpfe", + "azwg1yp" ], "transitionZones": [ { - "filamentId": "vsn9q6u", + "filamentId": "98z555k", "startHeight": 0, "endHeight": 0.56, - "idealThickness": 0.49400000000000005, + "idealThickness": 0.507, "actualThickness": 0.56 }, { - "filamentId": "plvjtmc", + "filamentId": "vsn9q6u", "startHeight": 0.56, - "endHeight": 0.64, - "idealThickness": 0.08, - "actualThickness": 0.08 - }, - { - "filamentId": "azwg1yp", - "startHeight": 0.64, "endHeight": 0.8, - "idealThickness": 0.19599999999999998, - "actualThickness": 0.16 + "idealThickness": 0.26599999999999996, + "actualThickness": 0.24 }, { "filamentId": "upcjpfe", @@ -803,61 +836,68 @@ "idealThickness": 0.343, "actualThickness": 0.32 }, - { - "filamentId": "vsn9q6u", - "startHeight": 1.12, - "endHeight": 1.44, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 - }, { "filamentId": "azwg1yp", - "startHeight": 1.44, - "endHeight": 1.6, + "startHeight": 1.12, + "endHeight": 1.36, "idealThickness": 0.19599999999999998, - "actualThickness": 0.16 + "actualThickness": 0.24 }, { "filamentId": "xyyxysq", - "startHeight": 1.6, - "endHeight": 2, + "startHeight": 1.36, + "endHeight": 1.68, "idealThickness": 0.357, - "actualThickness": 0.4 + "actualThickness": 0.32 }, { "filamentId": "upcjpfe", - "startHeight": 2, - "endHeight": 2.32, + "startHeight": 1.68, + "endHeight": 2.08, "idealThickness": 0.343, - "actualThickness": 0.32 + "actualThickness": 0.4 }, { "filamentId": "w8cncoa", - "startHeight": 2.32, - "endHeight": 2.72, + "startHeight": 2.08, + "endHeight": 2.48, "idealThickness": 0.44099999999999995, "actualThickness": 0.4 }, { "filamentId": "vsn9q6u", + "startHeight": 2.48, + "endHeight": 2.72, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.24 + }, + { + "filamentId": "plvjtmc", "startHeight": 2.72, + "endHeight": 2.8, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "azwg1yp", + "startHeight": 2.8, "endHeight": 3.04, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 + "idealThickness": 0.19599999999999998, + "actualThickness": 0.24 }, { - "filamentId": "98z555k", + "filamentId": "upcjpfe", "startHeight": 3.04, - "endHeight": 3.28, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.24 + "endHeight": 3.36, + "idealThickness": 0.343, + "actualThickness": 0.32 }, { - "filamentId": "vsn9q6u", - "startHeight": 3.28, + "filamentId": "azwg1yp", + "startHeight": 3.36, "endHeight": 3.6, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 + "idealThickness": 0.19599999999999998, + "actualThickness": 0.24 } ], "totalHeight": 3.6, @@ -1010,12 +1050,12 @@ "Current 8 Colors / large-jpeg / enhanced=true / repeats=false": { "filamentOrder": [ "w8cncoa", - "p9c63ms", - "xyyxysq", - "vsn9q6u", + "azwg1yp", "plvjtmc", - "98z555k", - "upcjpfe" + "vsn9q6u", + "xyyxysq", + "upcjpfe", + "98z555k" ], "transitionZones": [ { @@ -1026,145 +1066,153 @@ "actualThickness": 0.88 }, { - "filamentId": "p9c63ms", + "filamentId": "azwg1yp", "startHeight": 0.88, - "endHeight": 1.28, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.4 + "endHeight": 1.04, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.16 }, { - "filamentId": "xyyxysq", - "startHeight": 1.28, - "endHeight": 1.6, - "idealThickness": 0.357, - "actualThickness": 0.32 + "filamentId": "plvjtmc", + "startHeight": 1.04, + "endHeight": 1.12, + "idealThickness": 0.08, + "actualThickness": 0.08 }, { "filamentId": "vsn9q6u", - "startHeight": 1.6, - "endHeight": 1.92, + "startHeight": 1.12, + "endHeight": 1.44, "idealThickness": 0.26599999999999996, "actualThickness": 0.32 }, { - "filamentId": "plvjtmc", - "startHeight": 1.92, - "endHeight": 2, - "idealThickness": 0.08, - "actualThickness": 0.08 - }, - { - "filamentId": "98z555k", - "startHeight": 2, - "endHeight": 2.24, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.24 + "filamentId": "xyyxysq", + "startHeight": 1.44, + "endHeight": 1.76, + "idealThickness": 0.357, + "actualThickness": 0.32 }, { "filamentId": "upcjpfe", - "startHeight": 2.24, - "endHeight": 2.56, + "startHeight": 1.76, + "endHeight": 2.08, "idealThickness": 0.343, "actualThickness": 0.32 + }, + { + "filamentId": "98z555k", + "startHeight": 2.08, + "endHeight": 2.4, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.32 } ], - "totalHeight": 2.56, + "totalHeight": 2.4, "compressionRatio": 1 }, "Current 8 Colors / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ + "azwg1yp", + "upcjpfe", "vsn9q6u", "plvjtmc", - "upcjpfe", "98z555k", "upcjpfe", + "vsn9q6u", + "xyyxysq", + "vsn9q6u", "p9c63ms", "98z555k", - "upcjpfe", - "plvjtmc", - "vsn9q6u", - "xyyxysq" + "upcjpfe" ], "transitionZones": [ { - "filamentId": "vsn9q6u", + "filamentId": "azwg1yp", "startHeight": 0, - "endHeight": 0.56, - "idealThickness": 0.49400000000000005, - "actualThickness": 0.56 - }, - { - "filamentId": "plvjtmc", - "startHeight": 0.56, - "endHeight": 0.64, - "idealThickness": 0.08, - "actualThickness": 0.08 + "endHeight": 0.4, + "idealThickness": 0.364, + "actualThickness": 0.4 }, { "filamentId": "upcjpfe", - "startHeight": 0.64, - "endHeight": 0.96, + "startHeight": 0.4, + "endHeight": 0.72, "idealThickness": 0.343, "actualThickness": 0.32 }, + { + "filamentId": "vsn9q6u", + "startHeight": 0.72, + "endHeight": 1.04, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.32 + }, + { + "filamentId": "plvjtmc", + "startHeight": 1.04, + "endHeight": 1.12, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, { "filamentId": "98z555k", - "startHeight": 0.96, - "endHeight": 1.2, + "startHeight": 1.12, + "endHeight": 1.36, "idealThickness": 0.27299999999999996, "actualThickness": 0.24 }, { "filamentId": "upcjpfe", - "startHeight": 1.2, - "endHeight": 1.6, + "startHeight": 1.36, + "endHeight": 1.68, "idealThickness": 0.343, - "actualThickness": 0.4 + "actualThickness": 0.32 }, { - "filamentId": "p9c63ms", - "startHeight": 1.6, + "filamentId": "vsn9q6u", + "startHeight": 1.68, "endHeight": 2, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.4 + "idealThickness": 0.26599999999999996, + "actualThickness": 0.32 }, { - "filamentId": "98z555k", + "filamentId": "xyyxysq", "startHeight": 2, - "endHeight": 2.24, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.24 + "endHeight": 2.32, + "idealThickness": 0.357, + "actualThickness": 0.32 }, { - "filamentId": "upcjpfe", - "startHeight": 2.24, - "endHeight": 2.64, - "idealThickness": 0.343, - "actualThickness": 0.4 + "filamentId": "vsn9q6u", + "startHeight": 2.32, + "endHeight": 2.56, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.24 }, { - "filamentId": "plvjtmc", - "startHeight": 2.64, - "endHeight": 2.72, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "p9c63ms", + "startHeight": 2.56, + "endHeight": 3.04, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.48 }, { - "filamentId": "vsn9q6u", - "startHeight": 2.72, - "endHeight": 2.96, - "idealThickness": 0.26599999999999996, + "filamentId": "98z555k", + "startHeight": 3.04, + "endHeight": 3.28, + "idealThickness": 0.27299999999999996, "actualThickness": 0.24 }, { - "filamentId": "xyyxysq", - "startHeight": 2.96, - "endHeight": 3.28, - "idealThickness": 0.357, + "filamentId": "upcjpfe", + "startHeight": 3.28, + "endHeight": 3.6, + "idealThickness": 0.343, "actualThickness": 0.32 } ], - "totalHeight": 3.28, + "totalHeight": 3.6, "compressionRatio": 1 } } diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts index 9c5a192..e80e449 100644 --- a/tests/autoPaint.test.ts +++ b/tests/autoPaint.test.ts @@ -49,19 +49,52 @@ test('CIEDE2000 distance matches the published reference pair', async () => { assert.ok(Math.abs(distance - 2.0425) < 0.0001); }); -test('transition thickness stays printable and respects its TD cap', async () => { +test('enhanced matching keeps every color from the processed 2D palette', async () => { + const { buildOptimizerImageTargets } = await loadAutoPaintModule(); + const targets = buildOptimizerImageTargets([ + { hex: '#ff0000', count: 6 }, + { hex: '#00ff00', count: 3 }, + { hex: '#0000ff', count: 1 }, + ]); + + assert.equal(targets.length, 3, 'the optimizer must not perform a second color reduction'); + assertAlmostEqual(targets.reduce((sum, target) => sum + target.weight, 0), 1); + assertAlmostEqual(targets[0].weight, 0.6); + assertAlmostEqual(targets[1].weight, 0.3); + assertAlmostEqual(targets[2].weight, 0.1); +}); + +test('transition thickness follows the selected Beer-Lambert opacity endpoint', async () => { const { calculateTransitionThickness, hexToRgb } = await loadAutoPaintModule(); const layerHeight = 0.1; const td = 1; - const thickness = calculateTransitionThickness( + const compact = calculateTransitionThickness( hexToRgb('#000000'), hexToRgb('#ffffff'), td, - layerHeight + layerHeight, + 0.8 + ); + const detailed = calculateTransitionThickness( + hexToRgb('#000000'), + hexToRgb('#ffffff'), + td, + layerHeight, + 0.9 + ); + const maximum = calculateTransitionThickness( + hexToRgb('#000000'), + hexToRgb('#ffffff'), + td, + layerHeight, + 0.95 ); - assert.ok(thickness >= layerHeight, 'a transition must contain at least one layer'); - assert.ok(thickness <= td * 0.7 + EPSILON, 'a transition must not exceed the TD cap'); + assert.ok(compact >= layerHeight, 'a transition must contain at least one layer'); + assert.ok(compact <= 0.7 * td + EPSILON); + assert.ok(detailed <= td + EPSILON); + assert.ok(maximum <= 1.3 * td + EPSILON); + assert.ok(compact <= detailed && detailed <= maximum, 'detail modes must not shorten the ramp'); const nearIdenticalThickness = calculateTransitionThickness( hexToRgb('#112233'), @@ -98,7 +131,7 @@ test('calibrated channel TDs determine transition thickness', async () => { ); assert.ok( calibratedThickness <= 2 * 0.7 + EPSILON, - 'the calibrated transition must respect the slowest-channel TD cap' + 'the calibrated transition must respect the default slowest-channel TD cap' ); }); diff --git a/tests/benchmark/autoPaintBench.ts b/tests/benchmark/autoPaintBench.ts index 3adbb15..06a1284 100644 --- a/tests/benchmark/autoPaintBench.ts +++ b/tests/benchmark/autoPaintBench.ts @@ -5,7 +5,7 @@ import { createServer } from 'vite'; import { autoPaintGoldenScenarios } from '../autoPaintGoldenFixtures.ts'; type AutoPaintModule = typeof import('../../src/lib/autoPaint.ts'); -type Algorithm = 'fast' | 'balanced' | 'thorough'; +type Algorithm = 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; type Lab = { L: number; a: number; b: number }; type WeightedLab = Lab & { weight: number }; type Sample = { value: number; weight: number }; @@ -88,7 +88,7 @@ const output: unknown[] = []; for (const scenario of autoPaintGoldenScenarios().filter( (scenario) => scenario.enhancedColorMatch && scenario.allowRepeatedSwaps )) { - const algorithms: Algorithm[] = ['fast', 'balanced', 'thorough']; + const algorithms: Algorithm[] = ['fast', 'balanced', 'thorough', 'deep', 'exact']; // Measure against the raw image colors (ground truth), not the optimizer's // clustered targets, so the benchmark is an independent yardstick. const targets: WeightedLab[] = scenario.imageSwatches.map((swatch) => { diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index 4696c94..b92031b 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -49,7 +49,15 @@ async function loadViteModule(modulePath: string): Promise { test('each optimizer produces the same result for the same seed', async (t) => { const { optimizeFilamentOrder } = await loadOptimizerModule(); - const algorithms = ['exhaustive', 'simulated-annealing', 'fast', 'balanced', 'thorough'] as const; + const algorithms = [ + 'exhaustive', + 'simulated-annealing', + 'fast', + 'balanced', + 'thorough', + 'deep', + 'exact', + ] as const; for (const algorithm of algorithms) { await t.test(algorithm, () => { @@ -71,7 +79,15 @@ test('each optimizer produces the same result for the same seed', async (t) => { test('optimizer progress is monotonic and completes for every algorithm', async (t) => { const { optimizeFilamentOrder } = await loadOptimizerModule(); - const algorithms = ['exhaustive', 'simulated-annealing', 'fast', 'balanced', 'thorough'] as const; + const algorithms = [ + 'exhaustive', + 'simulated-annealing', + 'fast', + 'balanced', + 'thorough', + 'deep', + 'exact', + ] as const; for (const algorithm of algorithms) { await t.test(algorithm, () => { @@ -92,6 +108,25 @@ test('optimizer progress is monotonic and completes for every algorithm', async ); }); } + + const exactRepeatSamples: number[] = []; + optimizeFilamentOrder(filaments, context, { + algorithm: 'exact', + allowRepeatedSwaps: true, + seed: 42, + maxIterations: 20, + cachingEnabled: false, + onProgress: (iteration, total) => + exactRepeatSamples.push(total > 0 ? iteration / total : 0), + }); + assert.ok(exactRepeatSamples.length > 2, 'exact repeat refinement must report intermediate progress'); + assert.equal(exactRepeatSamples.at(-1), 1); + assert.ok( + exactRepeatSamples.every( + (sample, index) => index === 0 || sample >= exactRepeatSamples[index - 1] + ), + 'exact repeat refinement progress must never move backwards' + ); }); function withoutCacheState(result: T): Omit { @@ -281,7 +316,15 @@ test('repeats can close the RGB color path to reach the missing magenta blend', test('variable-length optimizers preserve sequence safety invariants', async (t) => { const { optimizeFilamentOrder } = await loadOptimizerModule(); - const algorithms = ['exhaustive', 'simulated-annealing', 'fast', 'balanced', 'thorough'] as const; + const algorithms = [ + 'exhaustive', + 'simulated-annealing', + 'fast', + 'balanced', + 'thorough', + 'deep', + 'exact', + ] as const; for (const allowRepeatedSwaps of [false, true]) { for (const algorithm of algorithms) { @@ -306,7 +349,27 @@ test('variable-length optimizers preserve sequence safety invariants', async (t) } }); -test('fast uses beam search for medium profiles while explicit exhaustive remains exact', async () => { +test('maxExtraRepeats supports the full user-facing repeat-limit range', async () => { + const { optimizeFilamentOrder } = await loadOptimizerModule(); + + for (const maxExtraRepeats of [0, 2, 4, 6, 8, 12]) { + const result = optimizeFilamentOrder(filaments, context, { + algorithm: 'balanced', + maxExtraRepeats, + seed: 20260624, + cachingEnabled: false, + }); + const ids = result.order.map((filament) => filament.id); + + assert.ok(ids.length <= filaments.length + maxExtraRepeats); + assert.ok(ids.every((id, index) => index === 0 || id !== ids[index - 1])); + if (maxExtraRepeats === 0) { + assert.equal(new Set(ids).size, ids.length, 'Off must prohibit all repeated filaments'); + } + } +}); + +test('fast uses a narrow beam while explicit exhaustive remains exact', async () => { const { optimizeFilamentOrder } = await loadOptimizerModule(); const mediumProfile = [ ...filaments, @@ -326,7 +389,7 @@ test('fast uses beam search for medium profiles while explicit exhaustive remain cachingEnabled: false, }); - assert.equal(fast.resolvedAlgorithm, 'beam'); + assert.equal(fast.resolvedAlgorithm, 'narrow-beam'); assert.equal(exhaustive.resolvedAlgorithm, 'exhaustive'); assert.equal( exhaustive.iterations, @@ -337,8 +400,12 @@ test('fast uses beam search for medium profiles while explicit exhaustive remain assert.equal(new Set(fast.order.map((filament) => filament.id)).size, fast.order.length); }); -test('balanced hybrid is deterministic, valid, and never worse than fast on a medium profile', async () => { - const { optimizeFilamentOrder } = await loadOptimizerModule(); +test('effort tiers are deterministic and preserve the previous tier best result', async () => { + const { + getExactBaseOrderCount, + normalizeOptimizerTier, + optimizeFilamentOrder, + } = await loadOptimizerModule(); const mediumProfile = [ ...filaments, { id: 'green', color: '#42a85f', td: 1.6 }, @@ -346,24 +413,62 @@ test('balanced hybrid is deterministic, valid, and never worse than fast on a me { id: 'purple', color: '#7545a8', td: 1.9 }, ]; - const options = { algorithm: 'balanced' as const, seed: 7, cachingEnabled: false }; - const first = optimizeFilamentOrder(mediumProfile, context, options); - const second = optimizeFilamentOrder(mediumProfile, context, options); + const options = { seed: 7, cachingEnabled: false, maxIterations: 30 }; const fast = optimizeFilamentOrder(mediumProfile, context, { + ...options, algorithm: 'fast', - seed: 7, - cachingEnabled: false, + }); + const balanced = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'balanced', + }); + const thorough = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'thorough', + }); + const deep = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'deep', + }); + const exact = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'exact', + }); + const deepAgain = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'deep', }); - assert.equal(first.resolvedAlgorithm, 'balanced-hybrid'); - assert.deepEqual(second, first, 'same seed must reproduce the same hybrid result'); + assert.equal(fast.resolvedAlgorithm, 'narrow-beam'); + assert.equal(balanced.resolvedAlgorithm, 'beam'); + assert.equal(thorough.resolvedAlgorithm, 'thorough-hybrid'); + assert.equal(deep.resolvedAlgorithm, 'deep-hybrid'); + assert.equal(exact.resolvedAlgorithm, 'exact-base'); + assert.deepEqual(deepAgain, deep, 'same seed must reproduce the Deep tier'); - const ids = first.order.map((filament) => filament.id); + const ids = deep.order.map((filament) => filament.id); assert.ok(ids.every((id, index) => index === 0 || id !== ids[index - 1]), 'no adjacent duplicates'); assert.equal(new Set(ids).size, ids.length, 'no repeats without allowRepeatedSwaps'); assert.ok( - first.score <= fast.score + 1e-9, - `balanced (${first.score}) must not be worse than fast (${fast.score})` + balanced.score <= fast.score + 1e-9, + `balanced (${balanced.score}) must not be worse than fast (${fast.score})` ); + assert.ok( + thorough.score <= balanced.score + 1e-9, + `thorough (${thorough.score}) must not be worse than balanced (${balanced.score})` + ); + assert.ok( + deep.score <= thorough.score + 1e-9, + `deep (${deep.score}) must not be worse than thorough (${thorough.score})` + ); + assert.ok( + exact.score <= deep.score + 1e-9, + `exact (${exact.score}) must not be worse than deep (${deep.score})` + ); + + assert.equal(getExactBaseOrderCount(8), 109_600); + assert.equal(getExactBaseOrderCount(9), 986_409); + assert.equal(normalizeOptimizerTier('exhaustive'), 'exact'); + assert.equal(normalizeOptimizerTier('genetic'), 'deep'); }); From 67ea4485805269d23503853aee67c8f1566080fc Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 24 Jun 2026 21:37:33 +0300 Subject: [PATCH 22/31] Add auto-paint detail controls and optimizer tiers Replace the repeated-swaps toggle with an Off/2/4/6/8/12 selector and add a transition-detail selector (80/90/95% opacity) so stack height vs color resolution is an explicit trade-off. Expand the optimizer menu to five effort tiers (Fast/Balanced/Thorough/Deep/Exact base order) and score sequences against the already-processed palette with an added detail- coverage term. Polish the panel UI: a determinate progress bar, fixed-width label column so controls align, a quality/speed meter under the algorithm picker, and nesting the enhanced-matching sub-options under their gate toggle. --- CHANGELOG.md | 4 +- README.md | 37 ++- src/App.tsx | 39 ++- src/components/AutoPaintTab.tsx | 400 ++++++++++++++++------ src/components/ThreeDControls.tsx | 42 ++- src/docs/3d-mode.md | 26 +- src/hooks/useAutoPaintWorker.ts | 22 +- src/lib/autoPaint.ts | 91 ++++- src/lib/optimizer.ts | 296 ++++++++++++----- src/types/index.ts | 13 +- src/workers/autoPaint.worker.ts | 6 +- tests/assets/auto-paint-goldens.json | 480 +++++++++++++++------------ tests/autoPaint.test.ts | 45 ++- tests/benchmark/autoPaintBench.ts | 4 +- tests/optimizer.test.ts | 139 +++++++- 15 files changed, 1152 insertions(+), 492 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c9949..ced4cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,13 @@ All notable changes to Kromacut are documented in this file. - **Calibration image sampler** - The sampler now shows a circular brush over the exact image area it averages, plus a marker for the last captured sample, making it easier to avoid patch edges and glare. - **Auto-paint optimizer objective** - Enhanced color matching now evaluates the same zone-compressed, layer-snapped color-to-height path used by the printable preview. All optimizer algorithms share that scorer, including Max Height constraints, so selected filament orders better match the finished model. Repeated optical calculations are memoized during searches. -- **Auto-paint sequence search** - Enhanced matching can now omit filaments that do not improve the printable stack and can natively add up to four non-adjacent repeated swaps when they create useful blends. Auto uses exact subset search through six filaments, deterministic beam search through twelve, and variable-length annealing beyond that. +- **Auto-paint detail controls** - Enhanced matching now scores every color from the already-processed 2D palette instead of applying a hidden second color reduction. Repeated swaps are now a selector with Off, 2, 4, 6, 8, and 12 extra occurrences; transition detail similarly exposes 80%, 90%, and 95% Beer-Lambert opacity endpoints so quality, stack height, and runtime are an intentional trade-off. Existing repeated-swap settings migrate to four extra occurrences. - **Calibrated Auto-paint model** - Calibrated filaments now use their measured red, green, and blue TD values when simulating blends and calculating transition-zone thickness, so generated stack heights and swap plans reflect the measured optical model. - **Auto-paint transition compositing** - Each filament transition now starts from the preceding transition's actual blended end color, including after Max Height compression, instead of assuming a pure previous-filament color. - **Auto-paint optical blending** - Beer-Lambert color mixing now happens in linear-light sRGB before returning display colors, replacing gamma-space interpolation with a more physically coherent light model. - **Filament calibration model** - Calibration now fits both working and RGB-channel TD values using Auto-paint's linear-light optical model. Recalibrate profiles created with earlier releases before using them for new prints. - **Auto-paint optimizer metric** - The optimizer now scores realized print error in CIEDE2000 (ΔE2000) instead of CIE76, and adds a weighted p95 tail term so a few rare but conspicuous colors are not sacrificed to lower the average. Nearest-color and Lab-polyline projection stay in Euclidean CIE76, where they are geometrically valid and fast. Selected filament orders change for some Enhanced color-match profiles. -- **Auto-paint optimizer tiers** - Replaced the algorithm menu (Auto / Exhaustive / Simulated Annealing / Genetic) with three intent-named effort tiers: **Fast** (deterministic beam search), **Balanced** (the new default — beam-seeded deterministic multi-start local search that often finds a shorter, equal-or-better stack), and **Thorough** (exact enumeration for small profiles, otherwise an exact-seeded larger hybrid). Every tier is fully reproducible from its seed, and higher tiers never score worse than lower ones. Saved profiles migrate automatically: Exhaustive and Genetic become Thorough, Auto and Simulated Annealing become Balanced. The "Genetic" option — which only ever ran annealing — has been removed. +- **Auto-paint optimizer tiers** - Enhanced matching now offers five reproducible effort tiers: **Fast** (narrow beam preview), **Balanced** (the full-beam default), **Thorough** (deeper hybrid refinement), **Deep** (a wider, much larger hybrid search), and **Exact base order** (all no-repeat orders). Each higher tier preserves the best result from the preceding tier for the same seed. Exact remains available for every profile and reports its candidate count; with repeated swaps it remains exact for the base order while refining repeats separately. Saved values migrate automatically: legacy Exhaustive becomes Exact, Genetic becomes Deep, and Auto or Simulated Annealing becomes Balanced. ### Fixed diff --git a/README.md b/README.md index b221319..dacf53d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Open-source HueForge-style tool for converting images into stacked, color-layere Kromacut is a browser-first app that converts images into multi-color lithophane 3D prints. It offers two powerful workflows: -**Auto-paint mode** — Define your actual filaments (color + Transmission Distance), and Kromacut automatically computes optimal layer stacks using physically accurate Beer-Lambert optical blending. Features include a calibration wizard, advanced optimizer (simulated annealing/genetic algorithms), and region weighting for spatial priority. +**Auto-paint mode** — Define your actual filaments (color + Transmission Distance), and Kromacut automatically computes optimal layer stacks using physically accurate Beer-Lambert optical blending. Features include a calibration wizard, five deterministic optimizer effort tiers, and region weighting for spatial priority. **Manual mode** — Reduce images to a small palette, manually tweak per-color layer heights and ordering, and fine-tune every aspect of the stack with complete control. @@ -45,7 +45,7 @@ Another minimal test you can try yourself in the app header: the Transmission Di - Plain-text 3D print instructions that describe layer heights and exact layers where filament swaps are required. - Copy-to-clipboard button for the print instructions (produces a clean, copyable plain-text plan). - **Filament calibration wizard** — Accurately determine Transmission Distance (TD) values through measured samples with confidence scoring. -- **Advanced optimizer** — Simulated annealing and genetic algorithms find optimal filament ordering for complex multi-color prints. +- **Advanced optimizer** — Five deterministic effort tiers search for optimal filament ordering, from quick previews to exact base-order enumeration. - **Region weighting** — Prioritize accuracy in specific image areas (center, edges) during auto-paint optimization. ## Notable implementation details @@ -66,7 +66,7 @@ Another minimal test you can try yourself in the app header: the Transmission Di 3. Click **Add Filament** and configure your filaments: - Use the **calibration wizard** (calibrate icon) to measure accurate TD values, or - Enter TD values manually. -4. Enable **Enhanced color matching** for optimal results (auto-selects best algorithm). +4. Enable **Enhanced color matching** for optimal results; start with the **Balanced** optimizer tier. 5. (Optional) Set **Region weighting** to Center or Edge to prioritize important areas. 6. Use the **layer-by-layer preview slider** to verify transitions. 7. Export via **Download STL** or **Download 3MF** and follow the print plan. @@ -113,7 +113,7 @@ Auto-paint is an automated layer-generation mode that replaces the manual palett - **Filaments**: Each filament has a hex color and a **Transmission Distance (TD)** value (in mm). TD describes how translucent the filament is — at a thickness equal to TD, only ~10% of light passes through. Dark/opaque filaments have low TD (e.g. 0.5 mm); light/translucent filaments have high TD (e.g. 6+ mm). When you add a filament without specifying a TD, Kromacut estimates one from the color's luminance and saturation. - **Beer-Lambert optical blending**: The algorithm simulates how light transmits through stacked filament layers using the Beer-Lambert law: `transmission = 10^(-thickness / TD)`. It blends in linear-light sRGB before converting back for display and color matching, which better models the color you see from thin semi-transparent layers. -- **Transition zones**: Each filament in the stack needs enough vertical space to visually transition from the color below it to its own pure color. The algorithm simulates adding layers one at a time until the blended color converges (DeltaE < 2.3 — the "just noticeable difference" threshold) or opacity exceeds 85%. The result is a set of transition zones, each with a start height, end height, and the filament used. +- **Transition zones**: Each filament in the stack needs enough vertical space to visually transition from the color below it to its own pure color. The algorithm simulates adding layers one at a time until the blended color converges (DeltaE < 2.3 — the "just noticeable difference" threshold) or reaches the selected opacity endpoint. **Compact** uses 80% opacity, **Detailed** uses 90% (one TD), and **Maximum** uses 95% (the same endpoint as the foundation layer). Higher endpoints retain more printable intermediate colors but create taller stacks. - **Luminance-to-height mapping**: Once the transition zones are computed, each pixel's brightness is mapped to a target height in the model. Dark pixels get the minimum height (base layer only), bright pixels get the full height (all layers), and mid-tones fall proportionally in between. This produces the characteristic lithophane-style relief where image brightness = model thickness. ### How it works (step by step) @@ -138,14 +138,20 @@ Calibrated filaments display a confidence badge next to their TD value. Higher c ### Advanced Optimizer -When **Enhanced color matching** is enabled, Kromacut uses advanced optimization algorithms to find the best filament order: +When **Enhanced color matching** is enabled, choose an effort tier for the filament-order search: -| Algorithm | When Used | Description | +| Tier | Search | Best use | |---|---|---| -| **Exhaustive search** | Up to 6 filaments | Evaluates every ordered, non-empty subset to guarantee the best available stack. | -| **Beam search** | Auto, 7-12 filaments | Keeps the strongest partial stacks as it grows them, letting Auto omit unhelpful filaments without an expensive full enumeration. | -| **Variable-length annealing** | Auto, 13+ filaments; manual Simulated Annealing or Genetic | Searches swaps, moves, insertions, removals, and replacements. The Genetic setting currently uses this search with a larger budget. | -| **Auto** | Default | Chooses exhaustive search, beam search, or variable-length annealing from the filament count. | +| **Fast** | Narrow deterministic beam search. | A quick preview while tuning settings. | +| **Balanced** | Full deterministic beam search. | Recommended default for most prints. | +| **Thorough** | Full beam plus deeper multi-start refinement. | A stronger search without a long wait. | +| **Deep** | Wider beam plus substantially more deterministic refinement. | Difficult images when Thorough does not improve the stack. | +| **Exact base order** | Enumerates every no-repeat base order. | A guaranteed best base order; the candidate count grows very quickly with each filament. | + +Each higher tier retains the best result from the tier below for the same seed. Exact +base order checks 109,600 sequences at eight filaments and 986,409 at nine, so use it +deliberately on larger profiles. With extra repeated swaps enabled, its base order is exact +but repeated occurrences are still refined heuristically. The optimizer displays metadata after generation: - **Algorithm used** — Which method was selected @@ -174,14 +180,17 @@ Region weighting is most useful when filament budget is limited and you want the |---|---| | **Max Height** | Constrains the total model height (mm). When set below the auto-calculated ideal, zones are uniformly compressed. Leave blank or click `Auto` for the physics-derived default. | | **Enhanced color matching** | Optimizes the printable filament sequence rather than simply sorting by luminance. It may omit filaments that do not improve the result and evaluates the actual preview color-to-height path. | -| **Allow repeated filament swaps** | (Requires Enhanced color matching) Lets the optimizer use a filament more than once, up to four extra occurrences, when that creates a useful color transition. For example, a thin white layer over red can create pink. | +| **Extra repeated swaps** | (Requires Enhanced color matching) Lets the optimizer use a filament more than once. Choose Off, 2, 4, 6, 8, or 12 additional occurrences; more repeats may create useful blend paths but substantially expand the search. | | **Height dithering** | (Requires Enhanced color matching) Applies block-aware Floyd-Steinberg error diffusion to the quantized height map. Instead of sharp stair-steps between layer heights, dithering produces a stippled gradient that simulates intermediate heights, resulting in smoother tonal transitions in the print. Edge pixels between different heights are protected from dithering to avoid staircase artifacts. | | **Flat Paint (flat face-down print)** | Builds a uniform-thickness slab printed image-side down instead of a stepped relief. Each pixel column's layer order is reversed so the artwork sits against the build plate (already mirrored — don't mirror in the slicer) under a transparent carrier layer, and the back is filled with the foundation filament so every layer has the full footprint. The result has a smooth, glass-flat face — great for bookmarks and coasters. Requires a multi-material printer (AMS/toolchanger); export as 3MF, which contains one object per filament plus the clear carrier object. Flat Paint and Smooth Meshing toggle each other off because flat prints always use the full-footprint slab layout. | | **Dither line width** | (Requires Height dithering) Controls the minimum dot size for the dither pattern in mm. This should roughly match your printer's line/nozzle width so dither dots are actually printable. Default: `0.42 mm`. | -| **Optimizer algorithm** | Choose Auto (recommended), Exhaustive (up to 6 filaments), Simulated Annealing, or Genetic. Auto uses exhaustive search through 6 filaments, beam search through 12, then variable-length annealing. | +| **Optimizer algorithm** | Choose Fast for a preview, Balanced for the recommended full beam, Thorough or Deep for more search effort, or Exact base order when you want every no-repeat base stack checked. | +| **Transition detail** | Chooses where a color transition stops: Compact at 80% opacity, Detailed at 90%, or Maximum at 95%. Higher detail preserves a longer physical color ramp, increasing model height, swaps, and runtime. | | **Optimizer seed** | Leave blank for an automatic stable seed, or enter a number for a specific repeatable run. | | **Region weighting** | Prioritize specific image regions: Uniform (equal), Center (image middle), or Edge (outer image border). Helps focus quality budget on important areas. | +Enhanced matching uses every color in the palette currently produced by 2D mode; it does not silently reduce that palette again. For detail-critical work, first prepare the image in 2D with K-means (for example, weight 128) and the Auto palette set to the number of colors you want to preserve. More 2D colors improve the target representation but make the optimizer proportionally slower. + ### Filament profiles Auto-paint configurations (filament lists) can be saved, loaded, and shared as **profiles**: @@ -228,7 +237,7 @@ Click **Add to filaments** to insert the suggestion directly into the filament l 2. Switch to the **Auto-paint** tab (inside the 3D controls panel). 3. Click **Add Filament** and configure each filament's color and TD to match your real filament stock. - **Tip:** Use the calibration wizard (calibrate icon on each row) to measure accurate TD values. -4. (Optional) Enable **Enhanced color matching** for better results with complex images. The optimizer will automatically select the best algorithm (exhaustive, simulated annealing, or genetic) based on your filament count. +4. (Optional) Enable **Enhanced color matching** for better results with complex images. Start with the **Balanced** optimizer tier, then use Thorough, Deep, or Exact base order when you want to spend more search effort. 5. (Optional) Set **Region weighting** to Center or Edge to prioritize accuracy in visually important areas. 6. The 3D preview updates automatically. Adjust **Max Height** if the model is too tall. 7. Use the **layer-by-layer preview slider** at the bottom of the 3D view to verify color transitions. @@ -253,7 +262,7 @@ Transmission Distance (TD) is the concept HueForge uses to produce perceptual in - **Automatic Beer-Lambert blending** — Kromacut simulates light transmission through stacked filament layers using physically accurate optical models. - **Filament-based workflow** — Define your actual filaments (color + TD values) and let the algorithm compute optimal layer stacks automatically. - **Calibration wizard** — Measure accurate TD values from physical test prints for each filament. -- **Advanced optimizer** — Simulated annealing and genetic algorithms find the best filament ordering for complex images. +- **Advanced optimizer** — Deterministic effort tiers search for the best filament ordering for complex images, from quick beam previews to exact base-order enumeration. - **Transition zones** — Automatically calculated vertical zones where each filament blends from the color below to its own pure color. See the [Auto-paint section](#auto-paint) above for full details. diff --git a/src/App.tsx b/src/App.tsx index 9d22a48..00641b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,12 @@ import React, { useEffect, useRef, useState } from 'react'; import ThreeDControls from './components/ThreeDControls'; -import type { ThreeDControlsStateShape } from './types'; +import { + AUTO_PAINT_REPEAT_LIMITS, + AUTO_PAINT_TRANSITION_OPACITIES, + type AutoPaintRepeatLimit, + type AutoPaintTransitionOpacity, + type ThreeDControlsStateShape, +} from './types'; import ThreeDView from './components/ThreeDView'; import logo from './assets/logo.png'; import tdTestImg from './assets/tdTest.png'; @@ -79,11 +85,26 @@ type AutoPaintPersisted = Pick< | 'regionWeightingMode' | 'enhancedColorMatch' | 'allowRepeatedSwaps' + | 'maxRepeatedSwaps' + | 'transitionOpacity' | 'heightDithering' | 'ditherLineWidth' | 'flatPaint' >; +function isOneOf(value: unknown, values: T): value is T[number] { + return typeof value === 'number' && values.includes(value as T[number]); +} + +function normalizeRepeatLimit(value: unknown, legacyEnabled: unknown): AutoPaintRepeatLimit { + if (isOneOf(value, AUTO_PAINT_REPEAT_LIMITS)) return value; + return legacyEnabled === true ? 4 : 0; +} + +function normalizeTransitionOpacity(value: unknown): AutoPaintTransitionOpacity { + return isOneOf(value, AUTO_PAINT_TRANSITION_OPACITIES) ? value : 0.9; +} + const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { try { const raw = localStorage.getItem(AUTOPAINT_STORAGE_KEY); @@ -105,7 +126,11 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { optimizerSeed: parsed.optimizerSeed, regionWeightingMode: parsed.regionWeightingMode, enhancedColorMatch: parsed.enhancedColorMatch ?? false, - allowRepeatedSwaps: parsed.allowRepeatedSwaps ?? false, + maxRepeatedSwaps: normalizeRepeatLimit( + parsed.maxRepeatedSwaps, + parsed.allowRepeatedSwaps + ), + transitionOpacity: normalizeTransitionOpacity(parsed.transitionOpacity), heightDithering: parsed.heightDithering ?? false, ditherLineWidth: parsed.ditherLineWidth, flatPaint: parsed.flatPaint ?? false, @@ -243,7 +268,9 @@ function App(): React.ReactElement | null { regionWeightingMode: autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode, enhancedColorMatch: autopaintHydrated.enhancedColorMatch ?? prev.enhancedColorMatch, - allowRepeatedSwaps: autopaintHydrated.allowRepeatedSwaps ?? prev.allowRepeatedSwaps, + maxRepeatedSwaps: + autopaintHydrated.maxRepeatedSwaps ?? prev.maxRepeatedSwaps, + transitionOpacity: autopaintHydrated.transitionOpacity ?? prev.transitionOpacity, heightDithering: autopaintHydrated.heightDithering ?? prev.heightDithering, ditherLineWidth: autopaintHydrated.ditherLineWidth ?? prev.ditherLineWidth, flatPaint: autopaintHydrated.flatPaint ?? prev.flatPaint, @@ -262,7 +289,8 @@ function App(): React.ReactElement | null { optimizerSeed: threeDState.optimizerSeed, regionWeightingMode: threeDState.regionWeightingMode, enhancedColorMatch: threeDState.enhancedColorMatch, - allowRepeatedSwaps: threeDState.allowRepeatedSwaps, + maxRepeatedSwaps: threeDState.maxRepeatedSwaps, + transitionOpacity: threeDState.transitionOpacity, heightDithering: threeDState.heightDithering, ditherLineWidth: threeDState.ditherLineWidth, flatPaint: threeDState.flatPaint, @@ -274,7 +302,8 @@ function App(): React.ReactElement | null { threeDState.optimizerSeed, threeDState.regionWeightingMode, threeDState.enhancedColorMatch, - threeDState.allowRepeatedSwaps, + threeDState.maxRepeatedSwaps, + threeDState.transitionOpacity, threeDState.heightDithering, threeDState.ditherLineWidth, threeDState.flatPaint, diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 04c1293..862c9bb 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -27,13 +27,83 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { TabsContent } from '@/components/ui/tabs'; import type { AutoPaintResult, TransitionZone } from '../lib/autoPaint'; import type { AutoPaintProfile } from '../lib/profileManager'; -import type { Filament, Swatch } from '../types'; +import type { AutoPaintRepeatLimit, AutoPaintTransitionOpacity, Filament, Swatch } from '../types'; import type { CalibrationResult } from '../lib/calibration'; +import { getExactBaseOrderCount } from '../lib/optimizer'; import FilamentRow from './FilamentRow'; import { FilamentCalibrationWizard } from './FilamentCalibrationWizard'; import { getConfidenceLabel, getConfidenceColor } from '../lib/calibration'; import { useNextBestColorWorker } from '../hooks/useNextBestColorWorker'; +type OptimizerTierValue = 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; + +interface OptimizerTierMeta { + value: OptimizerTierValue; + label: string; + blurb: string; + /** Relative output quality, 1–5, for the inline meter. */ + quality: number; + /** Relative speed, 1–5, for the inline meter (5 = fastest). */ + speed: number; +} + +const OPTIMIZER_TIERS: readonly OptimizerTierMeta[] = [ + { + value: 'fast', + label: 'Fast', + blurb: 'Narrow beam search for a quick preview.', + quality: 2, + speed: 5, + }, + { + value: 'balanced', + label: 'Balanced', + blurb: 'Full deterministic beam search; the recommended default.', + quality: 3, + speed: 4, + }, + { + value: 'thorough', + label: 'Thorough', + blurb: 'Full beam plus deeper multi-start refinement.', + quality: 4, + speed: 3, + }, + { + value: 'deep', + label: 'Deep', + blurb: 'Wide beam plus a much broader deterministic search.', + quality: 5, + speed: 2, + }, + { + value: 'exact', + label: 'Exact base order', + blurb: 'Enumerates every no-repeat base order.', + quality: 5, + speed: 1, + }, +]; + +/** Small 5-segment bar used to convey relative speed / quality of a tier. */ +function TierMeter({ label, value }: { label: string; value: number }): React.ReactElement { + return ( + + {label} + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + + + ); +} + interface AutoPaintSliceData { virtualSwatches: Swatch[]; colorSliceHeights: number[]; @@ -89,8 +159,10 @@ interface AutoPaintTabProps { // Enhanced matching options enhancedColorMatch: boolean; setEnhancedColorMatch: (v: boolean) => void; - allowRepeatedSwaps: boolean; - setAllowRepeatedSwaps: (v: boolean) => void; + maxRepeatedSwaps: AutoPaintRepeatLimit; + setMaxRepeatedSwaps: (v: AutoPaintRepeatLimit) => void; + transitionOpacity: AutoPaintTransitionOpacity; + setTransitionOpacity: (v: AutoPaintTransitionOpacity) => void; heightDithering: boolean; setHeightDithering: (v: boolean) => void; ditherLineWidth: number; @@ -101,8 +173,8 @@ interface AutoPaintTabProps { setFlatPaint: (v: boolean) => void; // Optimizer options - optimizerAlgorithm: 'fast' | 'balanced' | 'thorough'; - setOptimizerAlgorithm: (v: 'fast' | 'balanced' | 'thorough') => void; + optimizerAlgorithm: 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; + setOptimizerAlgorithm: (v: 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact') => void; optimizerSeed: number | undefined; setOptimizerSeed: (v: number | undefined) => void; regionWeightingMode: 'uniform' | 'center' | 'edge'; @@ -147,8 +219,10 @@ export default function AutoPaintTab({ imageSwatches, enhancedColorMatch, setEnhancedColorMatch, - allowRepeatedSwaps, - setAllowRepeatedSwaps, + maxRepeatedSwaps, + setMaxRepeatedSwaps, + transitionOpacity, + setTransitionOpacity, heightDithering, setHeightDithering, ditherLineWidth, @@ -180,6 +254,17 @@ export default function AutoPaintTab({ const [localOptimizerSeed, setLocalOptimizerSeed] = React.useState( optimizerSeed?.toString() ?? '' ); + const exactBaseOrderCount = getExactBaseOrderCount(filaments.length); + const activeTier = + OPTIMIZER_TIERS.find((tier) => tier.value === optimizerAlgorithm) ?? OPTIMIZER_TIERS[1]; + const optimizerTierDescription = + optimizerAlgorithm === 'exact' + ? `Checks all ${exactBaseOrderCount.toLocaleString()} no-repeat base orders.${ + filaments.length > 8 + ? ' Large profiles can take a long time; start another search to cancel.' + : '' + }` + : activeTier.blurb; // Calibration wizard state const [calibratingFilamentId, setCalibratingFilamentId] = React.useState(null); @@ -513,12 +598,24 @@ export default function AutoPaintTab({
)} {isComputing && ( -
- - - Optimizing filament order... - {progress > 0 ? ` ${Math.round(progress * 100)}%` : ''} - +
+
+ + + Optimizing filament order… + + + {Math.round(progress * 100)}% + +
+
+
+
)} {error && !isComputing && ( @@ -545,82 +642,117 @@ export default function AutoPaintTab({ onCheckedChange={setEnhancedColorMatch} />
-
-
)} @@ -664,7 +796,7 @@ export default function AutoPaintTab({
@@ -680,22 +812,40 @@ export default function AutoPaintTab({ - - Fast - - - Balanced (recommended) - - - Thorough (slower) - + {OPTIMIZER_TIERS.map((tier) => ( + + {tier.label} + + ))}
+
+
+ + +
+

+ {optimizerTierDescription} + {optimizerAlgorithm === 'exact' && maxRepeatedSwaps > 0 && ( + + {' '} + The base order is exact; up to { + maxRepeatedSwaps + }{' '} + repeated swaps are refined heuristically. + + )} +

+
@@ -723,10 +873,49 @@ export default function AutoPaintTab({
+
+ + +
+

+ Higher opacity retains a longer physical color ramp, improving + color resolution at the cost of height, swaps, and runtime. +

@@ -996,14 +1185,18 @@ export default function AutoPaintTab({ ) : ( )} - {isNextBestComputing ? 'Finding suggestion...' : 'Suggest next filament'} + {isNextBestComputing + ? 'Finding suggestion...' + : 'Suggest next filament'} {nextBestResult?.candidate && (
{nextBestResult.candidate.hex.toUpperCase()} @@ -1014,7 +1207,9 @@ export default function AutoPaintTab({ > Est. ΔE{' '} - +{nextBestResult.candidate.improvementPct.toFixed(1)}% + + + {nextBestResult.candidate.improvementPct.toFixed(1)} + %
@@ -1049,7 +1244,10 @@ export default function AutoPaintTab({ className="w-full h-7 text-xs mt-0.5" onClick={() => { suggestionCountRef.current += 1; - const nn = String(suggestionCountRef.current).padStart(2, '0'); + const nn = String(suggestionCountRef.current).padStart( + 2, + '0' + ); addFilamentWithProps({ color: nextBestResult.candidate!.hex, td: nextBestResult.candidate!.td, diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index f81c678..4367f7a 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -16,7 +16,12 @@ import { useProfileManager } from '../hooks/useProfileManager'; import { useColorSlicing } from '../hooks/useColorSlicing'; import { useSwapPlan } from '../hooks/useSwapPlan'; import { useAutoPaintWorker } from '../hooks/useAutoPaintWorker'; -import type { Swatch, ThreeDControlsStateShape } from '../types'; +import type { + AutoPaintRepeatLimit, + AutoPaintTransitionOpacity, + Swatch, + ThreeDControlsStateShape, +} from '../types'; import PrintSettingsCard from './PrintSettingsCard'; import PrintInstructions from './PrintInstructions'; import AutoPaintTab from './AutoPaintTab'; @@ -111,13 +116,20 @@ export default function ThreeDControls({ const [paintMode, setPaintMode] = useState<'manual' | 'autopaint'>(initialPaintMode); const [autoPaintMaxHeight, setAutoPaintMaxHeight] = useState(undefined); const [enhancedColorMatch, setEnhancedColorMatch] = useState(persisted?.enhancedColorMatch ?? false); - const [allowRepeatedSwaps, setAllowRepeatedSwaps] = useState(persisted?.allowRepeatedSwaps ?? false); + const [maxRepeatedSwaps, setMaxRepeatedSwaps] = useState( + persisted?.maxRepeatedSwaps ?? (persisted?.allowRepeatedSwaps ? 4 : 0) + ); + const [transitionOpacity, setTransitionOpacity] = useState( + persisted?.transitionOpacity ?? 0.9 + ); const [heightDithering, setHeightDithering] = useState(persisted?.heightDithering ?? false); const [ditherLineWidth, setDitherLineWidth] = useState(persisted?.ditherLineWidth ?? 0.42); const [flatPaint, setFlatPaint] = useState(initialFlatPaint); // --- Optimizer Options --- - const [optimizerAlgorithm, setOptimizerAlgorithm] = useState<'fast' | 'balanced' | 'thorough'>( + const [optimizerAlgorithm, setOptimizerAlgorithm] = useState< + 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact' + >( persisted?.optimizerAlgorithm ?? 'balanced' ); const [optimizerSeed, setOptimizerSeed] = useState( @@ -130,7 +142,6 @@ export default function ThreeDControls({ const handleEnhancedColorMatchChange = useCallback((v: boolean) => { setEnhancedColorMatch(v); if (!v) { - setAllowRepeatedSwaps(false); setHeightDithering(false); } }, []); @@ -155,7 +166,8 @@ export default function ThreeDControls({ paintMode, filaments, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, heightDithering, ditherLineWidth, flatPaint, @@ -165,7 +177,7 @@ export default function ThreeDControls({ smoothMeshing, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); + }, [paintMode, filaments, enhancedColorMatch, maxRepeatedSwaps, transitionOpacity, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); useEffect(() => { savePrintSettingsToStorage({ layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing }); @@ -213,11 +225,13 @@ export default function ThreeDControls({ slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, optimizerAlgorithm, optimizerSeed, regionWeightingMode, }); + const autoPaintProgressPercent = Math.round(Math.max(0, Math.min(1, autoPaintProgress)) * 100); const autoPaintSliceData = useMemo(() => { if (!autoPaintResult) return undefined; @@ -310,7 +324,8 @@ export default function ThreeDControls({ filaments, paintMode, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, heightDithering, ditherLineWidth, flatPaint, @@ -352,7 +367,8 @@ export default function ThreeDControls({ filaments, paintMode, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, heightDithering, ditherLineWidth, flatPaint, @@ -378,7 +394,7 @@ export default function ThreeDControls({ {isAutoPaintComputing ? ( <> - Computing... + Computing... {autoPaintProgressPercent}% ) : ( <> @@ -462,8 +478,10 @@ export default function ThreeDControls({ imageSwatches={filtered} enhancedColorMatch={enhancedColorMatch} setEnhancedColorMatch={handleEnhancedColorMatchChange} - allowRepeatedSwaps={allowRepeatedSwaps} - setAllowRepeatedSwaps={setAllowRepeatedSwaps} + maxRepeatedSwaps={maxRepeatedSwaps} + setMaxRepeatedSwaps={setMaxRepeatedSwaps} + transitionOpacity={transitionOpacity} + setTransitionOpacity={setTransitionOpacity} heightDithering={heightDithering} setHeightDithering={setHeightDithering} ditherLineWidth={ditherLineWidth} diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index 656a480..540405d 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -75,11 +75,14 @@ Enable **Enhanced color matching** when filament order matters and you want Krom Optional controls appear with enhanced matching: -- **Allow repeated filament swaps** lets the same filament appear more than once. +- **Extra repeated swaps** chooses whether a filament may reappear, and lets you allow 2, 4, 6, 8, or 12 extra occurrences. More repeats can create useful blend paths but expand the search space. +- **Transition detail** chooses the opacity endpoint for each physical color transition: Compact stops at 80% opacity, Detailed at 90%, and Maximum at 95%. Higher settings create taller stacks with more printable intermediate colors. - **Height dithering** uses printable height dots to smooth tonal transitions. - **Line width** should roughly match the printer line or nozzle width used for dither dots. - **Optimizer Settings** let you choose **Algorithm**, **Region priority**, and an optional **Seed**. +Enhanced matching scores the palette that is already visible in 2D mode; it does not reduce that palette again. For detailed work, prepare the image in 2D first (for example, K-means with a weight of 128 and an Auto palette of 64 or 128 colors), then switch to Auto-paint. This keeps the 2D palette decision explicit, but more source colors make every optimizer tier slower. + While Kromacut is optimizing a filament order, the panel shows an approximate completion percentage. Starting a new calculation cancels the older one, so the percentage always belongs to the current settings. When a filament has been calibrated, Auto-paint uses its measured red, green, and blue TD values for both transition colors and transition thickness. Calibration can therefore change the generated stack height and swap plan as well as the preview color, making the print model more faithful to the measured filament. @@ -102,16 +105,23 @@ Flat Paint and **Smooth Meshing** are mutually exclusive. Turning one on turns t | Setting | Meaning | | --------------- | ------------------------------------------------------------ | -| Algorithm | Auto, Exhaustive, Simulated Annealing, or Genetic Algorithm. | +| Algorithm | Fast, Balanced, Thorough, Deep, or Exact base order. | | Region priority | Uniform, Center-weighted, or Edge-weighted matching. | | Seed (optional) | Overrides the automatic stable seed for an intentional comparison. | -Use **Auto (smart selection)** unless you have a reason to compare algorithms. -Auto uses exact ordered-subset search through 6 filaments, beam search from 7 to 12, -then variable-length simulated annealing for larger profiles. With Enhanced color -matching, Kromacut can omit filaments that do not improve the printable stack. When -Repeated swaps is enabled, it can also add up to four non-adjacent repeated filament -occurrences when they improve the blend path. +Start with **Balanced**. It uses a full deterministic beam search and is the best +general-purpose choice. **Fast** uses a narrower beam for a quicker preview. +**Thorough** adds deeper multi-start refinement, while **Deep** widens the beam and +spends substantially more time exploring alternatives. Each higher tier keeps the +best result from the tier below for the same seed. + +**Exact base order** checks every possible no-repeat filament order. It checks 109,600 +orders at eight filaments and 986,409 at nine, so larger profiles can take a long time. +The search stays in the background worker and you can start another search to cancel it. +When **Extra repeated swaps** is above Off, Exact still proves the base order but treats +repeated occurrences as a separate refined search. Enhanced matching can omit filaments that do +not improve the printable stack and add the selected number of non-adjacent repeated occurrences +when they improve the blend path. **Region priority** changes which source colors the optimizer values most: **Center-weighted** gives more importance to colors that occur near the middle of the image, while **Edge-weighted** favors colors nearer its outer edges. It does not crop or change the image itself. diff --git a/src/hooks/useAutoPaintWorker.ts b/src/hooks/useAutoPaintWorker.ts index 509c897..fb07d8b 100644 --- a/src/hooks/useAutoPaintWorker.ts +++ b/src/hooks/useAutoPaintWorker.ts @@ -12,7 +12,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AutoPaintResult } from '../lib/autoPaint'; -import type { Filament, Swatch } from '../types'; +import type { + AutoPaintRepeatLimit, + AutoPaintTransitionOpacity, + Filament, + Swatch, +} from '../types'; import type { AutoPaintWorkerProgress, AutoPaintWorkerRequest, @@ -27,8 +32,9 @@ export interface UseAutoPaintWorkerOptions { slicerFirstLayerHeight: number; autoPaintMaxHeight?: number; enhancedColorMatch: boolean; - allowRepeatedSwaps: boolean; - optimizerAlgorithm: 'fast' | 'balanced' | 'thorough'; + maxRepeatedSwaps: AutoPaintRepeatLimit; + transitionOpacity: AutoPaintTransitionOpacity; + optimizerAlgorithm: 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; optimizerSeed?: number; regionWeightingMode: 'uniform' | 'center' | 'edge'; } @@ -69,7 +75,8 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, optimizerAlgorithm, optimizerSeed, regionWeightingMode, @@ -238,9 +245,11 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain firstLayerHeight: slicerFirstLayerHeight, maxHeight: autoPaintMaxHeight, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, optimizerOptions: { algorithm: optimizerAlgorithm, + maxExtraRepeats: maxRepeatedSwaps, + transitionOpacity, ...(optimizerSeed !== undefined && { seed: optimizerSeed }), }, }; @@ -271,7 +280,8 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, - allowRepeatedSwaps, + maxRepeatedSwaps, + transitionOpacity, optimizerAlgorithm, optimizerSeed, regionWeightingMode, diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 59dca1f..78c4b04 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -93,7 +93,7 @@ export interface AutoPaintResult { }; // Optimizer metadata (for advanced optimizer only) optimizerMetadata?: { - algorithm: string; // resolved: 'exhaustive' | 'beam' | 'simulated-annealing' | 'balanced-hybrid' + algorithm: string; // concrete tier path, such as 'beam', 'deep-hybrid', or 'exact-base' score: number; // Quality score achieved iterations: number; // Iterations performed converged: boolean; // Whether algorithm converged @@ -370,6 +370,24 @@ export function getOpacity( */ const DELTA_E_THRESHOLD = 2.3; // "Just noticeable difference" +/** Legacy compact transition endpoint: 80% opacity. The UI explicitly defaults to Detailed (90%). */ +export const DEFAULT_TRANSITION_OPACITY = 0.8; + +function normalizeTransitionOpacity(targetOpacity: number | undefined): number { + if (!Number.isFinite(targetOpacity)) return DEFAULT_TRANSITION_OPACITY; + return Math.max(0.5, Math.min(0.99, targetOpacity!)); +} + +function transitionThicknessMultiplier(targetOpacity: number): number { + // Keep the old compact 0.7×TD endpoint byte-for-byte stable. The detailed + // and maximum presets deliberately align with the familiar 1×TD and + // 1.3×TD (about 95%) optical landmarks. + if (Math.abs(targetOpacity - 0.8) < 1e-9) return 0.7; + if (Math.abs(targetOpacity - 0.9) < 1e-9) return 1; + if (Math.abs(targetOpacity - 0.95) < 1e-9) return 1.3; + return -Math.log10(1 - targetOpacity); +} + /** * Frontlit prints behave optically like a much shorter effective TD. * Scale user-entered TD values down for internal simulation. @@ -421,7 +439,8 @@ export function calculateTransitionThickness( backgroundColor: RGB, filamentColor: RGB, filamentTD: number | CalibrationRgb, - layerHeight: number + layerHeight: number, + targetOpacity: number = DEFAULT_TRANSITION_OPACITY ): number { // Early exit if colors are already close if (deltaE(backgroundColor, filamentColor) < DELTA_E_THRESHOLD) { @@ -436,14 +455,15 @@ export function calculateTransitionThickness( return layerHeight; } - // The cap determines the absolute maximum transition thickness. - // At 0.7×TD, opacity ≈ 80%. At 1×TD, opacity ≈ 90%. - // For transitions between adjacent colors in a sorted stack, - // DeltaE convergence typically fires well before this cap. - // We use 0.7×TD — if the color hasn't converged by ~80% opacity, - // additional thickness gives diminishing visual returns. - const OPACITY_CAP = 0.7; - const maxThickness = Math.max(layerHeight, Math.max(...channelTds) * OPACITY_CAP); + // The requested opacity determines the absolute maximum transition + // thickness. Perceptual convergence can still complete the transition + // earlier when the blended result is already close to the target color. + const resolvedOpacity = normalizeTransitionOpacity(targetOpacity); + const opacityThicknessMultiplier = transitionThicknessMultiplier(resolvedOpacity); + const maxThickness = Math.max( + layerHeight, + Math.max(...channelTds) * opacityThicknessMultiplier + ); // Simulate adding layers until color converges or we hit the cap while (thickness < maxThickness) { @@ -455,8 +475,9 @@ export function calculateTransitionThickness( break; } - // Also stop if opacity is already very high — diminishing returns - if (getOpacity(filamentTD, thickness) > 0.85) { + // Stop at the selected opacity endpoint when perceptual convergence + // has not already completed the transition. + if (getOpacity(filamentTD, thickness) >= resolvedOpacity) { break; } } @@ -481,7 +502,8 @@ export function calculateIdealHeight( sortedFilaments: AutoPaintFilament[], layerHeight: number, baseThickness: number = 0.6, - transitionThicknessCache?: Map + transitionThicknessCache?: Map, + transitionOpacity: number = DEFAULT_TRANSITION_OPACITY ): { idealHeight: number; zones: TransitionZone[] } { if (sortedFilaments.length === 0) { return { idealHeight: baseThickness, zones: [] }; @@ -533,6 +555,7 @@ export function calculateIdealHeight( : transitionTd ), layerHeight, + transitionOpacity, ].join(':'); let transitionThickness = transitionThicknessCache?.get(transitionKey); if (transitionThickness === undefined) { @@ -540,7 +563,8 @@ export function calculateIdealHeight( currentBackgroundColor, filamentRgb, transitionTd, - layerHeight + layerHeight, + transitionOpacity ); transitionThicknessCache?.set(transitionKey, transitionThickness); } @@ -964,6 +988,7 @@ export function buildAchievableColorPalette( layerHeight: number, firstLayerHeight: number, maxHeight?: number, + transitionOpacity: number = DEFAULT_TRANSITION_OPACITY, transitionThicknessCache?: Map ): Array<{ height: number; lab: Lab; rgb: RGB }> { if (sequence.length === 0) return []; @@ -973,7 +998,8 @@ export function buildAchievableColorPalette( sequence, layerHeight, Math.max(firstLayerHeight, layerHeight), - transitionThicknessCache + transitionThicknessCache, + transitionOpacity ); if (zones.length === 0) return []; @@ -1100,6 +1126,10 @@ export function weightedErrorPercentile( const REALIZED_ERROR_TAIL_WEIGHT = 0.5; /** Percentile used for the realized-error tail term. */ const REALIZED_ERROR_TAIL_PERCENTILE = 0.95; +/** Detail coverage: targets within this realized error are treated as retained. */ +const DETAIL_COVERAGE_DE = 6; +/** Prefer stacks that retain more weighted source-color detail. */ +const DETAIL_COVERAGE_PENALTY = 8; /** A printable palette entry counts as "used" if a target lands within this ΔE00. */ const USEFUL_PALETTE_MATCH_DE = 8; @@ -1138,6 +1168,7 @@ export function scoreSequenceAgainstImage( const errorSamples: Array<{ value: number; weight: number }> = []; const bestMatchHeights: number[] = []; const usedPaletteEntries = new Set(); + let detailCoveredWeight = 0; for (const entry of mapped) { const realizedDeltaE = realizedColorError(entry.mappedLab, entry.target); @@ -1146,6 +1177,7 @@ export function scoreSequenceAgainstImage( totalWeight += weight; errorSamples.push({ value: realizedDeltaE, weight }); bestMatchHeights.push(entry.projectedHeight); + if (realizedDeltaE <= DETAIL_COVERAGE_DE) detailCoveredWeight += weight; // Mark this palette entry as useful if its printable color is a decent match. if (realizedDeltaE < USEFUL_PALETTE_MATCH_DE) usedPaletteEntries.add(entry.paletteIndex); } @@ -1155,6 +1187,7 @@ export function scoreSequenceAgainstImage( const weightedMean = weightedErrorSum / totalWeight; const weightedTail = weightedErrorPercentile(errorSamples, REALIZED_ERROR_TAIL_PERCENTILE); let score = weightedMean + REALIZED_ERROR_TAIL_WEIGHT * weightedTail; + score += (1 - detailCoveredWeight / totalWeight) * DETAIL_COVERAGE_PENALTY; // 2. Height spread penalty: penalize when distinct image colors // collapse to the same height (leading to flat surfaces). @@ -1220,6 +1253,26 @@ function findBestFilamentOrder( /** * Advanced optimizer path using the shared variable-length sequence search. */ +/** + * Convert the already-processed 2D palette into weighted optimizer targets + * without applying another palette-reduction pass. + */ +export function buildOptimizerImageTargets( + imageSwatches: Array<{ hex: string; count?: number }> +): WeightedLab[] { + const totalWeight = imageSwatches.reduce( + (total, swatch) => total + Math.max(0, swatch.count ?? 1), + 0 + ); + + return imageSwatches + .map((swatch) => ({ + ...rgbToLab(hexToRgb(swatch.hex)), + weight: Math.max(0, swatch.count ?? 1) / Math.max(1, totalWeight), + })) + .filter((target) => target.weight > 0); +} + function findBestFilamentOrderWithOptimizer( filaments: Filament[], imageSwatches: Array<{ hex: string; count?: number }>, @@ -1229,8 +1282,7 @@ function findBestFilamentOrderWithOptimizer( maxHeight?: number, allowRepeatedSwaps: boolean = false ): { sortedFilaments: Filament[]; result: OptimizerResult } { - // Spatial weighting has already been folded into swatch counts by the caller. - const imageTargets = clusterImageColors(imageSwatches, 32, 5.0); + const imageTargets = buildOptimizerImageTargets(imageSwatches); // Build scoring context const context: ScoringContext = { @@ -1238,6 +1290,7 @@ function findBestFilamentOrderWithOptimizer( layerHeight, firstLayerHeight, maxHeight, + transitionOpacity: optimizerOptions.transitionOpacity, }; // Apply frontlit TD scale @@ -1362,7 +1415,9 @@ export function generateAutoLayers( const { idealHeight, zones } = calculateIdealHeight( scaledFilaments, layerHeight, - Math.max(firstLayerHeight, layerHeight) + Math.max(firstLayerHeight, layerHeight), + undefined, + optimizerOptions?.transitionOpacity ); // --- STEP 4: APPLY COMPRESSION ON THE PRINTABLE HEIGHT GRID --- diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index f43bbc4..627d8fe 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -22,7 +22,7 @@ import { // ============================================================================ /** User-facing optimizer effort tiers (persisted and shown in the UI). */ -export type OptimizerTier = 'fast' | 'balanced' | 'thorough'; +export type OptimizerTier = 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; /** * Values the optimizer accepts: the user-facing tiers plus explicit concrete @@ -40,13 +40,16 @@ export function normalizeOptimizerTier(value: string | undefined | null): Optimi case 'fast': case 'balanced': case 'thorough': + case 'deep': + case 'exact': return value; - case 'exhaustive': // explicit exact search → closest tier is Thorough + case 'exhaustive': // explicit exact search → Exact base order + return 'exact'; case 'genetic': // legacy "max effort" option - return 'thorough'; + return 'deep'; case 'beam': - return 'fast'; - // 'auto', 'simulated-annealing', anything unknown → the smart default + return 'balanced'; + // 'auto', 'simulated-annealing', anything unknown → the recommended default default: return 'balanced'; } @@ -54,7 +57,12 @@ export function normalizeOptimizerTier(value: string | undefined | null): Optimi export interface OptimizerOptions { algorithm: OptimizerAlgorithm; + /** Legacy compatibility flag; new callers should set maxExtraRepeats. */ allowRepeatedSwaps?: boolean; + /** Maximum extra non-adjacent filament occurrences (0–12). */ + maxExtraRepeats?: number; + /** Transition opacity target used by the shared printable-palette scorer. */ + transitionOpacity?: number; seed?: number; // For deterministic results maxIterations?: number; // Algorithm-specific iteration limit temperature?: number; // Initial temperature for SA @@ -81,6 +89,7 @@ export interface ScoringContext { layerHeight: number; firstLayerHeight: number; maxHeight?: number; + transitionOpacity?: number; } // ============================================================================ @@ -156,6 +165,8 @@ const globalCache = new OptimizerCache(); function tuningFingerprint(options: OptimizerOptions) { return { allowRepeatedSwaps: options.allowRepeatedSwaps ?? false, + maxExtraRepeats: options.maxExtraRepeats ?? null, + transitionOpacity: options.transitionOpacity ?? null, maxIterations: options.maxIterations ?? null, temperature: options.temperature ?? null, coolingRate: options.coolingRate ?? null, @@ -189,6 +200,7 @@ function canonicalOptimizerInput( layerHeight: context.layerHeight, firstLayerHeight: context.firstLayerHeight, maxHeight: context.maxHeight ?? null, + transitionOpacity: context.transitionOpacity ?? null, algorithm, seed: seed ?? null, tuning: tuningFingerprint(options), @@ -225,6 +237,7 @@ export function createSequenceScorer(context: ScoringContext): (filaments: Filam context.layerHeight, context.firstLayerHeight, context.maxHeight, + context.transitionOpacity, transitionThicknessCache ); paletteCache.set(sequenceKey, palette); @@ -241,28 +254,51 @@ export function scoreFilamentSequence(filaments: Filament[], context: ScoringCon // Variable-length sequence helpers // ============================================================================ -const MAX_AUTO_EXHAUSTIVE_FILAMENTS = 6; -const AUTO_BEAM_MAX_FILAMENTS = 12; -const MAX_EXTRA_REPEATS = 4; -const DEFAULT_BEAM_WIDTH = 100; +const MAX_EXTRA_REPEATS = 12; +const LEGACY_EXTRA_REPEATS = 4; +const FAST_BEAM_WIDTH = 25; +const BALANCED_BEAM_WIDTH = 100; +const THOROUGH_BEAM_WIDTH = 100; +const DEEP_BEAM_WIDTH = 250; type SequenceScorer = (filaments: Filament[]) => number; type ResolvedOptimizerAlgorithm = | 'exhaustive' | 'beam' | 'simulated-annealing' - | 'balanced-hybrid'; + | 'narrow-beam' + | 'thorough-hybrid' + | 'deep-hybrid' + | 'exact-base'; const SA_MIN_TEMPERATURE = 0.01; const SA_INITIAL_TEMPERATURE = 10.0; const SA_DEFAULT_COOLING_RATE = 0.995; -// The balanced/thorough multi-start refines around good seeds, so it anneals at -// a lower temperature scaled to the CIEDE2000 objective (scores ~5–30), where +// The hybrid tiers refine around good seeds at a temperature scaled to the +// CIEDE2000 objective (scores ~5–30), where // the legacy SA temperature of 10 would just wander away from the seed. const SA_HYBRID_TEMPERATURE = 2.0; -// Thorough seeds the hybrid with the exact no-repeat optimum when the ordered -// subset space is small enough to enumerate quickly (≈7 filaments → 13,699). -const THOROUGH_EXACT_SUBSET_LIMIT = 20000; + +interface HybridSearchPlan { + beamWidth: number; + restarts: number; + minimumIterationsPerRestart: number; + iterationsPerFilament: number; +} + +const THOROUGH_PLAN: HybridSearchPlan = { + beamWidth: THOROUGH_BEAM_WIDTH, + restarts: 12, + minimumIterationsPerRestart: 1_500, + iterationsPerFilament: 250, +}; + +const DEEP_PLAN: HybridSearchPlan = { + beamWidth: DEEP_BEAM_WIDTH, + restarts: 32, + minimumIterationsPerRestart: 4_000, + iterationsPerFilament: 500, +}; interface ScoredSequence { order: Filament[]; @@ -289,8 +325,15 @@ function isValidSequence(sequence: Filament[], allowRepeatedSwaps: boolean): boo ); } -function maxSequenceLength(filaments: Filament[], allowRepeatedSwaps: boolean): number { - return filaments.length + (allowRepeatedSwaps ? MAX_EXTRA_REPEATS : 0); +function resolveMaxExtraRepeats(options: OptimizerOptions): number { + if (Number.isFinite(options.maxExtraRepeats)) { + return Math.max(0, Math.min(MAX_EXTRA_REPEATS, Math.floor(options.maxExtraRepeats!))); + } + return options.allowRepeatedSwaps ? LEGACY_EXTRA_REPEATS : 0; +} + +function maxSequenceLength(filaments: Filament[], maxExtraRepeats: number): number { + return filaments.length + maxExtraRepeats; } function isBetterCandidate( @@ -311,6 +354,22 @@ function reportProgress( options.onProgress?.(Math.min(iteration, total), Math.max(total, 1), bestScore); } +function withProgressSpan( + options: OptimizerOptions, + start: number, + end: number +): OptimizerOptions { + if (!options.onProgress) return { ...options, onProgress: undefined }; + + return { + ...options, + onProgress: (iteration, total, bestScore) => { + const phaseProgress = total > 0 ? Math.min(1, Math.max(0, iteration / total)) : 1; + options.onProgress?.(start + (end - start) * phaseProgress, 1, bestScore); + }, + }; +} + function orderedSubsetCount(filamentCount: number): number { let total = 0; let permutations = 1; @@ -321,9 +380,14 @@ function orderedSubsetCount(filamentCount: number): number { return total; } -function repeatedInsertionUpperBound(filamentCount: number): number { +/** Number of no-repeat base sequences an Exact search would evaluate. */ +export function getExactBaseOrderCount(filamentCount: number): number { + return filamentCount > 0 ? orderedSubsetCount(filamentCount) : 0; +} + +function repeatedInsertionUpperBound(filamentCount: number, maxExtraRepeats: number): number { let total = 0; - for (let extra = 0; extra < MAX_EXTRA_REPEATS; extra++) { + for (let extra = 0; extra < maxExtraRepeats; extra++) { total += filamentCount * (filamentCount + extra + 1); } return total; @@ -332,9 +396,10 @@ function repeatedInsertionUpperBound(filamentCount: number): number { function expandWithRepeatedFilaments( initial: ScoredSequence, filaments: Filament[], - scoreSequence: SequenceScorer + scoreSequence: SequenceScorer, + maxExtraRepeats: number ): { best: ScoredSequence; iterations: number } { - const maxLength = maxSequenceLength(filaments, true); + const maxLength = maxSequenceLength(filaments, maxExtraRepeats); let best = initial; let iterations = 0; @@ -384,10 +449,11 @@ function optimizeExhaustive( }; } - const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + const maxExtraRepeats = resolveMaxExtraRepeats(options); + const allowRepeatedSwaps = maxExtraRepeats > 0; const totalIterations = orderedSubsetCount(filaments.length) + - (allowRepeatedSwaps ? repeatedInsertionUpperBound(filaments.length) : 0); + (allowRepeatedSwaps ? repeatedInsertionUpperBound(filaments.length, maxExtraRepeats) : 0); let best: ScoredSequence | null = null; let iterations = 0; reportProgress(options, 0, totalIterations, Infinity); @@ -420,7 +486,12 @@ function optimizeExhaustive( }; } - const expanded = expandWithRepeatedFilaments(baseBest, filaments, scoreSequence); + const expanded = expandWithRepeatedFilaments( + baseBest, + filaments, + scoreSequence, + maxExtraRepeats + ); return { order: expanded.best.order, score: expanded.best.score, @@ -442,9 +513,10 @@ function optimizeBeamSearch( return optimizeExhaustive(filaments, scoreSequence, options); } - const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; - const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); - const beamWidth = options.beamWidth ?? DEFAULT_BEAM_WIDTH; + const maxExtraRepeats = resolveMaxExtraRepeats(options); + const allowRepeatedSwaps = maxExtraRepeats > 0; + const maximumLength = maxSequenceLength(filaments, maxExtraRepeats); + const beamWidth = options.beamWidth ?? BALANCED_BEAM_WIDTH; const totalIterations = filaments.length + (maximumLength - 1) * beamWidth * filaments.length; let iterations = 0; @@ -520,11 +592,11 @@ function optimizeBeamSearch( */ function randomInitialSequence( filaments: Filament[], - allowRepeatedSwaps: boolean, + maxExtraRepeats: number, rng: SeededRandom ): Filament[] { const shuffled = rng.shuffle(filaments); - const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); + const maximumLength = maxSequenceLength(filaments, maxExtraRepeats); const initialLength = rng.nextInt(1, Math.min(filaments.length, maximumLength) + 1); return shuffled.slice(0, initialLength); } @@ -539,10 +611,11 @@ function sequenceEquals(left: Filament[], right: Filament[]): boolean { function buildVariableLengthNeighbor( sequence: Filament[], filaments: Filament[], - allowRepeatedSwaps: boolean, + maxExtraRepeats: number, rng: SeededRandom ): Filament[] { - const maximumLength = maxSequenceLength(filaments, allowRepeatedSwaps); + const allowRepeatedSwaps = maxExtraRepeats > 0; + const maximumLength = maxSequenceLength(filaments, maxExtraRepeats); const availableMoves: Array<'swap' | 'relocate' | 'insert' | 'remove' | 'replace'> = []; if (sequence.length > 1) { @@ -610,7 +683,7 @@ function runAnneal( initialOrder: Filament[], filaments: Filament[], scoreSequence: SequenceScorer, - allowRepeatedSwaps: boolean, + maxExtraRepeats: number, rng: SeededRandom, maxIterations: number, initialTemperature: number, @@ -629,7 +702,7 @@ function runAnneal( const newOrder = buildVariableLengthNeighbor( currentOrder, filaments, - allowRepeatedSwaps, + maxExtraRepeats, rng ); if (sequenceEquals(newOrder, currentOrder)) { @@ -669,13 +742,13 @@ function optimizeSimulatedAnnealing( return optimizeExhaustive(filaments, scoreSequence, options); } - const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + const maxExtraRepeats = resolveMaxExtraRepeats(options); const rng = new SeededRandom(options.seed); const maxIterations = options.maxIterations ?? Math.max(1000, filaments.length * 100); const initialTemp = options.temperature ?? SA_INITIAL_TEMPERATURE; const coolingRate = options.coolingRate ?? SA_DEFAULT_COOLING_RATE; - const initialOrder = randomInitialSequence(filaments, allowRepeatedSwaps, rng); + const initialOrder = randomInitialSequence(filaments, maxExtraRepeats, rng); reportProgress(options, 0, maxIterations, scoreSequence(initialOrder)); let reported = 0; @@ -683,7 +756,7 @@ function optimizeSimulatedAnnealing( initialOrder, filaments, scoreSequence, - allowRepeatedSwaps, + maxExtraRepeats, rng, maxIterations, initialTemp, @@ -703,29 +776,26 @@ function optimizeSimulatedAnnealing( } /** - * Balanced / Thorough hybrid: take beam search's deterministic best as a seed, - * then run several deterministic annealing restarts (the first refines the beam - * seed, the rest explore seeded-random starts) and keep the global best. - * - * The budget is a fixed iteration count, not a wall-clock deadline, so results - * stay reproducible across machines (goldens, caching, A/B seeds depend on it). + * Refine a deterministic beam result with seeded multi-start annealing. A + * supplied baseline is retained throughout, so a deeper tier can never return + * a worse result than the tier it extends. */ -function optimizeBalanced( +function optimizeHybrid( filaments: Filament[], scoreSequence: SequenceScorer, options: OptimizerOptions, - thorough: boolean + plan: HybridSearchPlan, + baseline?: OptimizerResult ): OptimizerResult { if (filaments.length <= 1) { return optimizeExhaustive(filaments, scoreSequence, options); } - const allowRepeatedSwaps = options.allowRepeatedSwaps ?? false; + const maxExtraRepeats = resolveMaxExtraRepeats(options); const rng = new SeededRandom(options.seed); - const restarts = thorough ? 12 : 5; const iterationsPerRestart = options.maxIterations ?? - Math.max(thorough ? 1500 : 600, filaments.length * (thorough ? 250 : 120)); + Math.max(plan.minimumIterationsPerRestart, filaments.length * plan.iterationsPerFilament); const initialTemp = options.temperature ?? SA_HYBRID_TEMPERATURE; // Cool from the initial temperature to the floor across one restart so each // restart spends its whole budget refining instead of freezing early. @@ -733,38 +803,37 @@ function optimizeBalanced( options.coolingRate ?? Math.pow(SA_MIN_TEMPERATURE / initialTemp, 1 / Math.max(1, iterationsPerRestart)); - // 1. Seeds. Beam is always cheap and strong. Thorough additionally enumerates - // the exact no-repeat optimum when the subset space is small enough, then - // lets the hybrid explore repeats around it — so Thorough ≥ exact ≥ beam. - const beam = optimizeBeamSearch(filaments, scoreSequence, { ...options, onProgress: undefined }); + // Beam provides a strong deterministic seed before the broader local search. + const beam = optimizeBeamSearch(filaments, scoreSequence, { + ...options, + beamWidth: options.beamWidth ?? plan.beamWidth, + onProgress: undefined, + }); let best: ScoredSequence = { order: beam.order, score: beam.score }; let iterations = beam.iterations; - if (thorough && orderedSubsetCount(filaments.length) <= THOROUGH_EXACT_SUBSET_LIMIT) { - const exact = optimizeExhaustive(filaments, scoreSequence, { ...options, onProgress: undefined }); - iterations += exact.iterations; - if (isBetterCandidate({ order: exact.order, score: exact.score }, best)) { - best = { order: exact.order, score: exact.score }; - } + if (baseline && isBetterCandidate({ order: baseline.order, score: baseline.score }, best)) { + best = { order: baseline.order, score: baseline.score }; } + iterations += baseline?.iterations ?? 0; - const total = iterations + restarts * iterationsPerRestart; + const total = iterations + plan.restarts * iterationsPerRestart; let reported = iterations; reportProgress(options, reported, total, best.score); - // 2. Multi-start annealing: restart 0 refines the best seed; the rest explore - // seeded-random starts. Keep the global best across all restarts. - for (let restart = 0; restart < restarts; restart++) { + // Restart 0 refines the strongest retained candidate; the rest explore + // seeded-random starts. Keep the global best across all restarts. + for (let restart = 0; restart < plan.restarts; restart++) { const initialOrder = restart === 0 ? best.order - : randomInitialSequence(filaments, allowRepeatedSwaps, rng); + : randomInitialSequence(filaments, maxExtraRepeats, rng); const outcome = runAnneal( initialOrder, filaments, scoreSequence, - allowRepeatedSwaps, + maxExtraRepeats, rng, iterationsPerRestart, initialTemp, @@ -789,30 +858,84 @@ function optimizeBalanced( }; } +/** Deep is Thorough plus a wider, much larger hybrid pass. */ +function optimizeDeep( + filaments: Filament[], + scoreSequence: SequenceScorer, + options: OptimizerOptions +): OptimizerResult { + const thorough = optimizeHybrid( + filaments, + scoreSequence, + withProgressSpan(options, 0, 0.2), + THOROUGH_PLAN + ); + return optimizeHybrid( + filaments, + scoreSequence, + withProgressSpan(options, 0.2, 1), + DEEP_PLAN, + thorough + ); +} + +/** + * Exact base-order search. Repeated swaps remain a separate larger space, so + * when they are enabled retain Deep's repeat-aware result alongside exact base + * enumeration and its greedy repeat refinement. + */ +function optimizeExactBase( + filaments: Filament[], + scoreSequence: SequenceScorer, + options: OptimizerOptions +): OptimizerResult { + if (resolveMaxExtraRepeats(options) === 0) { + return optimizeExhaustive(filaments, scoreSequence, options); + } + + const deep = optimizeDeep(filaments, scoreSequence, withProgressSpan(options, 0, 0.4)); + const exact = optimizeExhaustive( + filaments, + scoreSequence, + withProgressSpan(options, 0.4, 1) + ); + const exactCandidate = { order: exact.order, score: exact.score }; + const deepCandidate = { order: deep.order, score: deep.score }; + const best = isBetterCandidate(deepCandidate, exactCandidate) ? deepCandidate : exactCandidate; + + return { + order: best.order, + score: best.score, + iterations: deep.iterations + exact.iterations, + converged: true, + }; +} + function resolveAlgorithm( requested: OptimizerAlgorithm, filamentCount: number ): ResolvedOptimizerAlgorithm { + if (filamentCount <= 1) return 'exhaustive'; + switch (requested) { // Explicit concrete searches pass through unchanged. case 'exhaustive': case 'beam': case 'simulated-annealing': return requested; - // Fast: exact when cheap, else the deterministic beam baseline. + // Fast uses a deliberately narrow beam for rapid previews. case 'fast': - if (filamentCount <= MAX_AUTO_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; - if (filamentCount <= AUTO_BEAM_MAX_FILAMENTS) return 'beam'; - return 'simulated-annealing'; - // Balanced (default): exact for tiny profiles, beam-seeded multi-start otherwise. + return 'narrow-beam'; + // Balanced is the full deterministic beam baseline. case 'balanced': - if (filamentCount <= MAX_AUTO_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; - return 'balanced-hybrid'; - // Thorough: exact for tiny profiles, otherwise an exact-seeded larger - // hybrid (the hybrid itself enumerates when feasible — see optimizeBalanced). + return 'beam'; + // Thorough and Deep are progressively broader deterministic hybrids. case 'thorough': - if (filamentCount <= MAX_AUTO_EXHAUSTIVE_FILAMENTS) return 'exhaustive'; - return 'balanced-hybrid'; + return 'thorough-hybrid'; + case 'deep': + return 'deep-hybrid'; + case 'exact': + return 'exact-base'; } } @@ -840,12 +963,11 @@ export function optimizeFilamentOrder( }; const resolved = resolveAlgorithm(opts.algorithm, filaments.length); - const thorough = opts.algorithm === 'thorough'; - // Key on the requested tier, not the resolved concrete: 'balanced' and - // 'thorough' both resolve to 'balanced-hybrid' but run different budgets and - // must not share a cache entry or default seed. - const defaultSeedInput = canonicalOptimizerInput(filaments, context, opts.algorithm, opts); + // Tiers share the same automatic seed so higher effort builds directly on + // comparable deterministic search paths. The cache key still includes the + // requested tier because their budgets and outputs can differ. + const defaultSeedInput = canonicalOptimizerInput(filaments, context, 'tier-comparison', opts); const seed = opts.seed ?? stableHash32(defaultSeedInput); opts.seed = seed; const cacheKey = canonicalOptimizerInput(filaments, context, opts.algorithm, opts, seed); @@ -871,8 +993,20 @@ export function optimizeFilamentOrder( case 'simulated-annealing': result = optimizeSimulatedAnnealing(filaments, scoreSequence, opts); break; - case 'balanced-hybrid': - result = optimizeBalanced(filaments, scoreSequence, opts, thorough); + case 'narrow-beam': + result = optimizeBeamSearch(filaments, scoreSequence, { + ...opts, + beamWidth: opts.beamWidth ?? FAST_BEAM_WIDTH, + }); + break; + case 'thorough-hybrid': + result = optimizeHybrid(filaments, scoreSequence, opts, THOROUGH_PLAN); + break; + case 'deep-hybrid': + result = optimizeDeep(filaments, scoreSequence, opts); + break; + case 'exact-base': + result = optimizeExactBase(filaments, scoreSequence, opts); break; } diff --git a/src/types/index.ts b/src/types/index.ts index 4ceb954..99cac53 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,12 @@ import type { AutoPaintResult } from '../lib/autoPaint'; import type { CalibrationResult } from '../lib/calibration'; +export const AUTO_PAINT_REPEAT_LIMITS = [0, 2, 4, 6, 8, 12] as const; +export type AutoPaintRepeatLimit = (typeof AUTO_PAINT_REPEAT_LIMITS)[number]; + +export const AUTO_PAINT_TRANSITION_OPACITIES = [0.8, 0.9, 0.95] as const; +export type AutoPaintTransitionOpacity = (typeof AUTO_PAINT_TRANSITION_OPACITIES)[number]; + export type Swatch = { hex: string; a: number; @@ -43,13 +49,18 @@ export interface ThreeDControlsStateShape { paintMode: 'manual' | 'autopaint'; // Enhanced color matching options enhancedColorMatch?: boolean; + /** Legacy persisted value. Migrate to maxRepeatedSwaps when loading. */ allowRepeatedSwaps?: boolean; + /** Maximum extra non-adjacent filament occurrences the optimizer may add. */ + maxRepeatedSwaps?: AutoPaintRepeatLimit; + /** Target transition opacity used to create the printable color ramp. */ + transitionOpacity?: AutoPaintTransitionOpacity; heightDithering?: boolean; ditherLineWidth?: number; /** Flat Paint: build a flat, face-down slab (auto-paint only) */ flatPaint?: boolean; // Optimizer options (effort tier; legacy values migrate on load) - optimizerAlgorithm?: 'fast' | 'balanced' | 'thorough'; + optimizerAlgorithm?: 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; optimizerSeed?: number; regionWeightingMode?: 'uniform' | 'center' | 'edge'; // Auto-paint computed state (only used when paintMode is 'autopaint') diff --git a/src/workers/autoPaint.worker.ts b/src/workers/autoPaint.worker.ts index 165c5bf..2f2c142 100644 --- a/src/workers/autoPaint.worker.ts +++ b/src/workers/autoPaint.worker.ts @@ -7,7 +7,7 @@ */ import { generateAutoLayers } from '../lib/autoPaint'; -import type { Filament } from '../types'; +import type { AutoPaintRepeatLimit, Filament } from '../types'; import type { OptimizerOptions } from '../lib/optimizer'; import type { AutoPaintResult } from '../lib/autoPaint'; @@ -21,7 +21,7 @@ export interface AutoPaintWorkerRequest { firstLayerHeight: number; maxHeight?: number; enhancedColorMatch?: boolean; - allowRepeatedSwaps?: boolean; + maxRepeatedSwaps?: AutoPaintRepeatLimit; optimizerOptions?: Partial; } @@ -74,7 +74,7 @@ self.onmessage = (e: MessageEvent) => { req.firstLayerHeight, req.maxHeight, req.enhancedColorMatch, - req.allowRepeatedSwaps, + (req.maxRepeatedSwaps ?? 0) > 0, { ...req.optimizerOptions, onProgress: reportProgress, diff --git a/tests/assets/auto-paint-goldens.json b/tests/assets/auto-paint-goldens.json index 87b554f..914f056 100644 --- a/tests/assets/auto-paint-goldens.json +++ b/tests/assets/auto-paint-goldens.json @@ -321,42 +321,74 @@ }, "GH#27 / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ - "xud8mzr", "plvjtmc", "h8zuocq", - "y202l1e" + "y202l1e", + "plvjtmc", + "y202l1e", + "xud8mzr", + "y202l1e", + "plvjtmc" ], "transitionZones": [ { - "filamentId": "xud8mzr", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.56, - "idealThickness": 0.4810000000000001, - "actualThickness": 0.56 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, + { + "filamentId": "h8zuocq", + "startHeight": 0.16, + "endHeight": 0.48, + "idealThickness": 0.245, + "actualThickness": 0.32 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.48, + "endHeight": 0.8, + "idealThickness": 0.329, + "actualThickness": 0.32 }, { "filamentId": "plvjtmc", - "startHeight": 0.56, - "endHeight": 0.64, + "startHeight": 0.8, + "endHeight": 0.88, "idealThickness": 0.08, "actualThickness": 0.08 }, { - "filamentId": "h8zuocq", - "startHeight": 0.64, - "endHeight": 0.88, - "idealThickness": 0.245, + "filamentId": "y202l1e", + "startHeight": 0.88, + "endHeight": 1.2, + "idealThickness": 0.329, + "actualThickness": 0.32 + }, + { + "filamentId": "xud8mzr", + "startHeight": 1.2, + "endHeight": 1.44, + "idealThickness": 0.259, "actualThickness": 0.24 }, { "filamentId": "y202l1e", - "startHeight": 0.88, - "endHeight": 1.2, + "startHeight": 1.44, + "endHeight": 1.76, "idealThickness": 0.329, "actualThickness": 0.32 + }, + { + "filamentId": "plvjtmc", + "startHeight": 1.76, + "endHeight": 1.84, + "idealThickness": 0.08, + "actualThickness": 0.08 } ], - "totalHeight": 1.2, + "totalHeight": 1.84, "compressionRatio": 1 }, "GH#27 / large-jpeg / enhanced=false / repeats=false": { @@ -443,8 +475,8 @@ "filamentOrder": [ "plvjtmc", "h8zuocq", - "xud8mzr", - "y202l1e" + "y202l1e", + "xud8mzr" ], "transitionZones": [ { @@ -462,18 +494,18 @@ "actualThickness": 0.32 }, { - "filamentId": "xud8mzr", + "filamentId": "y202l1e", "startHeight": 0.48, - "endHeight": 0.72, - "idealThickness": 0.24, - "actualThickness": 0.24 + "endHeight": 0.8, + "idealThickness": 0.329, + "actualThickness": 0.32 }, { - "filamentId": "y202l1e", - "startHeight": 0.72, + "filamentId": "xud8mzr", + "startHeight": 0.8, "endHeight": 1.04, - "idealThickness": 0.329, - "actualThickness": 0.32 + "idealThickness": 0.259, + "actualThickness": 0.24 } ], "totalHeight": 1.04, @@ -481,74 +513,74 @@ }, "GH#27 / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ - "h8zuocq", - "plvjtmc", "xud8mzr", "h8zuocq", "plvjtmc", + "y202l1e", + "plvjtmc", "h8zuocq", - "xud8mzr", - "y202l1e" + "plvjtmc", + "xud8mzr" ], "transitionZones": [ { - "filamentId": "h8zuocq", + "filamentId": "xud8mzr", "startHeight": 0, - "endHeight": 0.48, - "idealThickness": 0.45500000000000007, - "actualThickness": 0.48 - }, - { - "filamentId": "plvjtmc", - "startHeight": 0.48, "endHeight": 0.56, - "idealThickness": 0.08, - "actualThickness": 0.08 + "idealThickness": 0.4810000000000001, + "actualThickness": 0.56 }, { - "filamentId": "xud8mzr", + "filamentId": "h8zuocq", "startHeight": 0.56, "endHeight": 0.8, - "idealThickness": 0.259, + "idealThickness": 0.24, "actualThickness": 0.24 }, { - "filamentId": "h8zuocq", + "filamentId": "plvjtmc", "startHeight": 0.8, - "endHeight": 1.04, - "idealThickness": 0.24, - "actualThickness": 0.24 + "endHeight": 0.88, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "y202l1e", + "startHeight": 0.88, + "endHeight": 1.2, + "idealThickness": 0.329, + "actualThickness": 0.32 }, { "filamentId": "plvjtmc", - "startHeight": 1.04, - "endHeight": 1.12, + "startHeight": 1.2, + "endHeight": 1.28, "idealThickness": 0.08, "actualThickness": 0.08 }, { "filamentId": "h8zuocq", - "startHeight": 1.12, - "endHeight": 1.36, + "startHeight": 1.28, + "endHeight": 1.52, "idealThickness": 0.245, "actualThickness": 0.24 }, { - "filamentId": "xud8mzr", - "startHeight": 1.36, + "filamentId": "plvjtmc", + "startHeight": 1.52, "endHeight": 1.6, - "idealThickness": 0.24, - "actualThickness": 0.24 + "idealThickness": 0.08, + "actualThickness": 0.08 }, { - "filamentId": "y202l1e", + "filamentId": "xud8mzr", "startHeight": 1.6, - "endHeight": 2, - "idealThickness": 0.329, - "actualThickness": 0.4 + "endHeight": 1.84, + "idealThickness": 0.259, + "actualThickness": 0.24 } ], - "totalHeight": 2, + "totalHeight": 1.84, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=false / repeats=false": { @@ -697,104 +729,105 @@ }, "Current 8 Colors / logo-png / enhanced=true / repeats=false": { "filamentOrder": [ - "98z555k", - "vsn9q6u", "plvjtmc", + "98z555k", + "upcjpfe", "azwg1yp", "xyyxysq", - "upcjpfe", - "w8cncoa" + "w8cncoa", + "p9c63ms", + "vsn9q6u" ], "transitionZones": [ { - "filamentId": "98z555k", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.56, - "idealThickness": 0.507, - "actualThickness": 0.56 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { - "filamentId": "vsn9q6u", - "startHeight": 0.56, - "endHeight": 0.8, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.24 + "filamentId": "98z555k", + "startHeight": 0.16, + "endHeight": 0.48, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.32 }, { - "filamentId": "plvjtmc", - "startHeight": 0.8, - "endHeight": 0.88, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "upcjpfe", + "startHeight": 0.48, + "endHeight": 0.8, + "idealThickness": 0.343, + "actualThickness": 0.32 }, { "filamentId": "azwg1yp", - "startHeight": 0.88, - "endHeight": 1.12, + "startHeight": 0.8, + "endHeight": 1.04, "idealThickness": 0.19599999999999998, "actualThickness": 0.24 }, { "filamentId": "xyyxysq", - "startHeight": 1.12, - "endHeight": 1.44, + "startHeight": 1.04, + "endHeight": 1.36, "idealThickness": 0.357, "actualThickness": 0.32 }, - { - "filamentId": "upcjpfe", - "startHeight": 1.44, - "endHeight": 1.76, - "idealThickness": 0.343, - "actualThickness": 0.32 - }, { "filamentId": "w8cncoa", - "startHeight": 1.76, - "endHeight": 2.24, + "startHeight": 1.36, + "endHeight": 1.84, "idealThickness": 0.44099999999999995, "actualThickness": 0.48 + }, + { + "filamentId": "p9c63ms", + "startHeight": 1.84, + "endHeight": 2.24, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.4 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 2.24, + "endHeight": 2.48, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.24 } ], - "totalHeight": 2.24, + "totalHeight": 2.48, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ + "98z555k", "vsn9q6u", - "plvjtmc", - "azwg1yp", "upcjpfe", - "vsn9q6u", "azwg1yp", "xyyxysq", "upcjpfe", "w8cncoa", "vsn9q6u", - "98z555k", - "vsn9q6u" + "plvjtmc", + "azwg1yp", + "upcjpfe", + "azwg1yp" ], "transitionZones": [ { - "filamentId": "vsn9q6u", + "filamentId": "98z555k", "startHeight": 0, "endHeight": 0.56, - "idealThickness": 0.49400000000000005, + "idealThickness": 0.507, "actualThickness": 0.56 }, { - "filamentId": "plvjtmc", + "filamentId": "vsn9q6u", "startHeight": 0.56, - "endHeight": 0.64, - "idealThickness": 0.08, - "actualThickness": 0.08 - }, - { - "filamentId": "azwg1yp", - "startHeight": 0.64, "endHeight": 0.8, - "idealThickness": 0.19599999999999998, - "actualThickness": 0.16 + "idealThickness": 0.26599999999999996, + "actualThickness": 0.24 }, { "filamentId": "upcjpfe", @@ -803,61 +836,68 @@ "idealThickness": 0.343, "actualThickness": 0.32 }, - { - "filamentId": "vsn9q6u", - "startHeight": 1.12, - "endHeight": 1.44, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 - }, { "filamentId": "azwg1yp", - "startHeight": 1.44, - "endHeight": 1.6, + "startHeight": 1.12, + "endHeight": 1.36, "idealThickness": 0.19599999999999998, - "actualThickness": 0.16 + "actualThickness": 0.24 }, { "filamentId": "xyyxysq", - "startHeight": 1.6, - "endHeight": 2, + "startHeight": 1.36, + "endHeight": 1.68, "idealThickness": 0.357, - "actualThickness": 0.4 + "actualThickness": 0.32 }, { "filamentId": "upcjpfe", - "startHeight": 2, - "endHeight": 2.32, + "startHeight": 1.68, + "endHeight": 2.08, "idealThickness": 0.343, - "actualThickness": 0.32 + "actualThickness": 0.4 }, { "filamentId": "w8cncoa", - "startHeight": 2.32, - "endHeight": 2.72, + "startHeight": 2.08, + "endHeight": 2.48, "idealThickness": 0.44099999999999995, "actualThickness": 0.4 }, { "filamentId": "vsn9q6u", + "startHeight": 2.48, + "endHeight": 2.72, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.24 + }, + { + "filamentId": "plvjtmc", "startHeight": 2.72, + "endHeight": 2.8, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "azwg1yp", + "startHeight": 2.8, "endHeight": 3.04, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 + "idealThickness": 0.19599999999999998, + "actualThickness": 0.24 }, { - "filamentId": "98z555k", + "filamentId": "upcjpfe", "startHeight": 3.04, - "endHeight": 3.28, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.24 + "endHeight": 3.36, + "idealThickness": 0.343, + "actualThickness": 0.32 }, { - "filamentId": "vsn9q6u", - "startHeight": 3.28, + "filamentId": "azwg1yp", + "startHeight": 3.36, "endHeight": 3.6, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 + "idealThickness": 0.19599999999999998, + "actualThickness": 0.24 } ], "totalHeight": 3.6, @@ -1010,12 +1050,12 @@ "Current 8 Colors / large-jpeg / enhanced=true / repeats=false": { "filamentOrder": [ "w8cncoa", - "p9c63ms", - "xyyxysq", - "vsn9q6u", + "azwg1yp", "plvjtmc", - "98z555k", - "upcjpfe" + "vsn9q6u", + "xyyxysq", + "upcjpfe", + "98z555k" ], "transitionZones": [ { @@ -1026,145 +1066,153 @@ "actualThickness": 0.88 }, { - "filamentId": "p9c63ms", + "filamentId": "azwg1yp", "startHeight": 0.88, - "endHeight": 1.28, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.4 + "endHeight": 1.04, + "idealThickness": 0.19599999999999998, + "actualThickness": 0.16 }, { - "filamentId": "xyyxysq", - "startHeight": 1.28, - "endHeight": 1.6, - "idealThickness": 0.357, - "actualThickness": 0.32 + "filamentId": "plvjtmc", + "startHeight": 1.04, + "endHeight": 1.12, + "idealThickness": 0.08, + "actualThickness": 0.08 }, { "filamentId": "vsn9q6u", - "startHeight": 1.6, - "endHeight": 1.92, + "startHeight": 1.12, + "endHeight": 1.44, "idealThickness": 0.26599999999999996, "actualThickness": 0.32 }, { - "filamentId": "plvjtmc", - "startHeight": 1.92, - "endHeight": 2, - "idealThickness": 0.08, - "actualThickness": 0.08 - }, - { - "filamentId": "98z555k", - "startHeight": 2, - "endHeight": 2.24, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.24 + "filamentId": "xyyxysq", + "startHeight": 1.44, + "endHeight": 1.76, + "idealThickness": 0.357, + "actualThickness": 0.32 }, { "filamentId": "upcjpfe", - "startHeight": 2.24, - "endHeight": 2.56, + "startHeight": 1.76, + "endHeight": 2.08, "idealThickness": 0.343, "actualThickness": 0.32 + }, + { + "filamentId": "98z555k", + "startHeight": 2.08, + "endHeight": 2.4, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.32 } ], - "totalHeight": 2.56, + "totalHeight": 2.4, "compressionRatio": 1 }, "Current 8 Colors / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ + "azwg1yp", + "upcjpfe", "vsn9q6u", "plvjtmc", - "upcjpfe", "98z555k", "upcjpfe", + "vsn9q6u", + "xyyxysq", + "vsn9q6u", "p9c63ms", "98z555k", - "upcjpfe", - "plvjtmc", - "vsn9q6u", - "xyyxysq" + "upcjpfe" ], "transitionZones": [ { - "filamentId": "vsn9q6u", + "filamentId": "azwg1yp", "startHeight": 0, - "endHeight": 0.56, - "idealThickness": 0.49400000000000005, - "actualThickness": 0.56 - }, - { - "filamentId": "plvjtmc", - "startHeight": 0.56, - "endHeight": 0.64, - "idealThickness": 0.08, - "actualThickness": 0.08 + "endHeight": 0.4, + "idealThickness": 0.364, + "actualThickness": 0.4 }, { "filamentId": "upcjpfe", - "startHeight": 0.64, - "endHeight": 0.96, + "startHeight": 0.4, + "endHeight": 0.72, "idealThickness": 0.343, "actualThickness": 0.32 }, + { + "filamentId": "vsn9q6u", + "startHeight": 0.72, + "endHeight": 1.04, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.32 + }, + { + "filamentId": "plvjtmc", + "startHeight": 1.04, + "endHeight": 1.12, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, { "filamentId": "98z555k", - "startHeight": 0.96, - "endHeight": 1.2, + "startHeight": 1.12, + "endHeight": 1.36, "idealThickness": 0.27299999999999996, "actualThickness": 0.24 }, { "filamentId": "upcjpfe", - "startHeight": 1.2, - "endHeight": 1.6, + "startHeight": 1.36, + "endHeight": 1.68, "idealThickness": 0.343, - "actualThickness": 0.4 + "actualThickness": 0.32 }, { - "filamentId": "p9c63ms", - "startHeight": 1.6, + "filamentId": "vsn9q6u", + "startHeight": 1.68, "endHeight": 2, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.4 + "idealThickness": 0.26599999999999996, + "actualThickness": 0.32 }, { - "filamentId": "98z555k", + "filamentId": "xyyxysq", "startHeight": 2, - "endHeight": 2.24, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.24 + "endHeight": 2.32, + "idealThickness": 0.357, + "actualThickness": 0.32 }, { - "filamentId": "upcjpfe", - "startHeight": 2.24, - "endHeight": 2.64, - "idealThickness": 0.343, - "actualThickness": 0.4 + "filamentId": "vsn9q6u", + "startHeight": 2.32, + "endHeight": 2.56, + "idealThickness": 0.26599999999999996, + "actualThickness": 0.24 }, { - "filamentId": "plvjtmc", - "startHeight": 2.64, - "endHeight": 2.72, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "p9c63ms", + "startHeight": 2.56, + "endHeight": 3.04, + "idealThickness": 0.42000000000000004, + "actualThickness": 0.48 }, { - "filamentId": "vsn9q6u", - "startHeight": 2.72, - "endHeight": 2.96, - "idealThickness": 0.26599999999999996, + "filamentId": "98z555k", + "startHeight": 3.04, + "endHeight": 3.28, + "idealThickness": 0.27299999999999996, "actualThickness": 0.24 }, { - "filamentId": "xyyxysq", - "startHeight": 2.96, - "endHeight": 3.28, - "idealThickness": 0.357, + "filamentId": "upcjpfe", + "startHeight": 3.28, + "endHeight": 3.6, + "idealThickness": 0.343, "actualThickness": 0.32 } ], - "totalHeight": 3.28, + "totalHeight": 3.6, "compressionRatio": 1 } } diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts index 9c5a192..e80e449 100644 --- a/tests/autoPaint.test.ts +++ b/tests/autoPaint.test.ts @@ -49,19 +49,52 @@ test('CIEDE2000 distance matches the published reference pair', async () => { assert.ok(Math.abs(distance - 2.0425) < 0.0001); }); -test('transition thickness stays printable and respects its TD cap', async () => { +test('enhanced matching keeps every color from the processed 2D palette', async () => { + const { buildOptimizerImageTargets } = await loadAutoPaintModule(); + const targets = buildOptimizerImageTargets([ + { hex: '#ff0000', count: 6 }, + { hex: '#00ff00', count: 3 }, + { hex: '#0000ff', count: 1 }, + ]); + + assert.equal(targets.length, 3, 'the optimizer must not perform a second color reduction'); + assertAlmostEqual(targets.reduce((sum, target) => sum + target.weight, 0), 1); + assertAlmostEqual(targets[0].weight, 0.6); + assertAlmostEqual(targets[1].weight, 0.3); + assertAlmostEqual(targets[2].weight, 0.1); +}); + +test('transition thickness follows the selected Beer-Lambert opacity endpoint', async () => { const { calculateTransitionThickness, hexToRgb } = await loadAutoPaintModule(); const layerHeight = 0.1; const td = 1; - const thickness = calculateTransitionThickness( + const compact = calculateTransitionThickness( hexToRgb('#000000'), hexToRgb('#ffffff'), td, - layerHeight + layerHeight, + 0.8 + ); + const detailed = calculateTransitionThickness( + hexToRgb('#000000'), + hexToRgb('#ffffff'), + td, + layerHeight, + 0.9 + ); + const maximum = calculateTransitionThickness( + hexToRgb('#000000'), + hexToRgb('#ffffff'), + td, + layerHeight, + 0.95 ); - assert.ok(thickness >= layerHeight, 'a transition must contain at least one layer'); - assert.ok(thickness <= td * 0.7 + EPSILON, 'a transition must not exceed the TD cap'); + assert.ok(compact >= layerHeight, 'a transition must contain at least one layer'); + assert.ok(compact <= 0.7 * td + EPSILON); + assert.ok(detailed <= td + EPSILON); + assert.ok(maximum <= 1.3 * td + EPSILON); + assert.ok(compact <= detailed && detailed <= maximum, 'detail modes must not shorten the ramp'); const nearIdenticalThickness = calculateTransitionThickness( hexToRgb('#112233'), @@ -98,7 +131,7 @@ test('calibrated channel TDs determine transition thickness', async () => { ); assert.ok( calibratedThickness <= 2 * 0.7 + EPSILON, - 'the calibrated transition must respect the slowest-channel TD cap' + 'the calibrated transition must respect the default slowest-channel TD cap' ); }); diff --git a/tests/benchmark/autoPaintBench.ts b/tests/benchmark/autoPaintBench.ts index 3adbb15..06a1284 100644 --- a/tests/benchmark/autoPaintBench.ts +++ b/tests/benchmark/autoPaintBench.ts @@ -5,7 +5,7 @@ import { createServer } from 'vite'; import { autoPaintGoldenScenarios } from '../autoPaintGoldenFixtures.ts'; type AutoPaintModule = typeof import('../../src/lib/autoPaint.ts'); -type Algorithm = 'fast' | 'balanced' | 'thorough'; +type Algorithm = 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; type Lab = { L: number; a: number; b: number }; type WeightedLab = Lab & { weight: number }; type Sample = { value: number; weight: number }; @@ -88,7 +88,7 @@ const output: unknown[] = []; for (const scenario of autoPaintGoldenScenarios().filter( (scenario) => scenario.enhancedColorMatch && scenario.allowRepeatedSwaps )) { - const algorithms: Algorithm[] = ['fast', 'balanced', 'thorough']; + const algorithms: Algorithm[] = ['fast', 'balanced', 'thorough', 'deep', 'exact']; // Measure against the raw image colors (ground truth), not the optimizer's // clustered targets, so the benchmark is an independent yardstick. const targets: WeightedLab[] = scenario.imageSwatches.map((swatch) => { diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index 4696c94..b92031b 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -49,7 +49,15 @@ async function loadViteModule(modulePath: string): Promise { test('each optimizer produces the same result for the same seed', async (t) => { const { optimizeFilamentOrder } = await loadOptimizerModule(); - const algorithms = ['exhaustive', 'simulated-annealing', 'fast', 'balanced', 'thorough'] as const; + const algorithms = [ + 'exhaustive', + 'simulated-annealing', + 'fast', + 'balanced', + 'thorough', + 'deep', + 'exact', + ] as const; for (const algorithm of algorithms) { await t.test(algorithm, () => { @@ -71,7 +79,15 @@ test('each optimizer produces the same result for the same seed', async (t) => { test('optimizer progress is monotonic and completes for every algorithm', async (t) => { const { optimizeFilamentOrder } = await loadOptimizerModule(); - const algorithms = ['exhaustive', 'simulated-annealing', 'fast', 'balanced', 'thorough'] as const; + const algorithms = [ + 'exhaustive', + 'simulated-annealing', + 'fast', + 'balanced', + 'thorough', + 'deep', + 'exact', + ] as const; for (const algorithm of algorithms) { await t.test(algorithm, () => { @@ -92,6 +108,25 @@ test('optimizer progress is monotonic and completes for every algorithm', async ); }); } + + const exactRepeatSamples: number[] = []; + optimizeFilamentOrder(filaments, context, { + algorithm: 'exact', + allowRepeatedSwaps: true, + seed: 42, + maxIterations: 20, + cachingEnabled: false, + onProgress: (iteration, total) => + exactRepeatSamples.push(total > 0 ? iteration / total : 0), + }); + assert.ok(exactRepeatSamples.length > 2, 'exact repeat refinement must report intermediate progress'); + assert.equal(exactRepeatSamples.at(-1), 1); + assert.ok( + exactRepeatSamples.every( + (sample, index) => index === 0 || sample >= exactRepeatSamples[index - 1] + ), + 'exact repeat refinement progress must never move backwards' + ); }); function withoutCacheState(result: T): Omit { @@ -281,7 +316,15 @@ test('repeats can close the RGB color path to reach the missing magenta blend', test('variable-length optimizers preserve sequence safety invariants', async (t) => { const { optimizeFilamentOrder } = await loadOptimizerModule(); - const algorithms = ['exhaustive', 'simulated-annealing', 'fast', 'balanced', 'thorough'] as const; + const algorithms = [ + 'exhaustive', + 'simulated-annealing', + 'fast', + 'balanced', + 'thorough', + 'deep', + 'exact', + ] as const; for (const allowRepeatedSwaps of [false, true]) { for (const algorithm of algorithms) { @@ -306,7 +349,27 @@ test('variable-length optimizers preserve sequence safety invariants', async (t) } }); -test('fast uses beam search for medium profiles while explicit exhaustive remains exact', async () => { +test('maxExtraRepeats supports the full user-facing repeat-limit range', async () => { + const { optimizeFilamentOrder } = await loadOptimizerModule(); + + for (const maxExtraRepeats of [0, 2, 4, 6, 8, 12]) { + const result = optimizeFilamentOrder(filaments, context, { + algorithm: 'balanced', + maxExtraRepeats, + seed: 20260624, + cachingEnabled: false, + }); + const ids = result.order.map((filament) => filament.id); + + assert.ok(ids.length <= filaments.length + maxExtraRepeats); + assert.ok(ids.every((id, index) => index === 0 || id !== ids[index - 1])); + if (maxExtraRepeats === 0) { + assert.equal(new Set(ids).size, ids.length, 'Off must prohibit all repeated filaments'); + } + } +}); + +test('fast uses a narrow beam while explicit exhaustive remains exact', async () => { const { optimizeFilamentOrder } = await loadOptimizerModule(); const mediumProfile = [ ...filaments, @@ -326,7 +389,7 @@ test('fast uses beam search for medium profiles while explicit exhaustive remain cachingEnabled: false, }); - assert.equal(fast.resolvedAlgorithm, 'beam'); + assert.equal(fast.resolvedAlgorithm, 'narrow-beam'); assert.equal(exhaustive.resolvedAlgorithm, 'exhaustive'); assert.equal( exhaustive.iterations, @@ -337,8 +400,12 @@ test('fast uses beam search for medium profiles while explicit exhaustive remain assert.equal(new Set(fast.order.map((filament) => filament.id)).size, fast.order.length); }); -test('balanced hybrid is deterministic, valid, and never worse than fast on a medium profile', async () => { - const { optimizeFilamentOrder } = await loadOptimizerModule(); +test('effort tiers are deterministic and preserve the previous tier best result', async () => { + const { + getExactBaseOrderCount, + normalizeOptimizerTier, + optimizeFilamentOrder, + } = await loadOptimizerModule(); const mediumProfile = [ ...filaments, { id: 'green', color: '#42a85f', td: 1.6 }, @@ -346,24 +413,62 @@ test('balanced hybrid is deterministic, valid, and never worse than fast on a me { id: 'purple', color: '#7545a8', td: 1.9 }, ]; - const options = { algorithm: 'balanced' as const, seed: 7, cachingEnabled: false }; - const first = optimizeFilamentOrder(mediumProfile, context, options); - const second = optimizeFilamentOrder(mediumProfile, context, options); + const options = { seed: 7, cachingEnabled: false, maxIterations: 30 }; const fast = optimizeFilamentOrder(mediumProfile, context, { + ...options, algorithm: 'fast', - seed: 7, - cachingEnabled: false, + }); + const balanced = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'balanced', + }); + const thorough = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'thorough', + }); + const deep = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'deep', + }); + const exact = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'exact', + }); + const deepAgain = optimizeFilamentOrder(mediumProfile, context, { + ...options, + algorithm: 'deep', }); - assert.equal(first.resolvedAlgorithm, 'balanced-hybrid'); - assert.deepEqual(second, first, 'same seed must reproduce the same hybrid result'); + assert.equal(fast.resolvedAlgorithm, 'narrow-beam'); + assert.equal(balanced.resolvedAlgorithm, 'beam'); + assert.equal(thorough.resolvedAlgorithm, 'thorough-hybrid'); + assert.equal(deep.resolvedAlgorithm, 'deep-hybrid'); + assert.equal(exact.resolvedAlgorithm, 'exact-base'); + assert.deepEqual(deepAgain, deep, 'same seed must reproduce the Deep tier'); - const ids = first.order.map((filament) => filament.id); + const ids = deep.order.map((filament) => filament.id); assert.ok(ids.every((id, index) => index === 0 || id !== ids[index - 1]), 'no adjacent duplicates'); assert.equal(new Set(ids).size, ids.length, 'no repeats without allowRepeatedSwaps'); assert.ok( - first.score <= fast.score + 1e-9, - `balanced (${first.score}) must not be worse than fast (${fast.score})` + balanced.score <= fast.score + 1e-9, + `balanced (${balanced.score}) must not be worse than fast (${fast.score})` ); + assert.ok( + thorough.score <= balanced.score + 1e-9, + `thorough (${thorough.score}) must not be worse than balanced (${balanced.score})` + ); + assert.ok( + deep.score <= thorough.score + 1e-9, + `deep (${deep.score}) must not be worse than thorough (${thorough.score})` + ); + assert.ok( + exact.score <= deep.score + 1e-9, + `exact (${exact.score}) must not be worse than deep (${deep.score})` + ); + + assert.equal(getExactBaseOrderCount(8), 109_600); + assert.equal(getExactBaseOrderCount(9), 986_409); + assert.equal(normalizeOptimizerTier('exhaustive'), 'exact'); + assert.equal(normalizeOptimizerTier('genetic'), 'deep'); }); From e58a0d66c0bbe09dfedd9d03065eccff28977e61 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 24 Jun 2026 22:07:34 +0300 Subject: [PATCH 23/31] Use Stucki kernel for height dithering Diffuse height-quantization error with the 12-neighbor, error-conserving Stucki kernel instead of Floyd-Steinberg, spreading error over a wider area for smoother tonal gradients and fewer directional artifacts. Block-aware dot sizing, serpentine scan, and edge protection are unchanged. --- CHANGELOG.md | 1 + README.md | 2 +- src/components/ThreeDView.tsx | 45 ++++++++++++++++++++++------------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ced4cb3..8e76dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to Kromacut are documented in this file. - **Calibration image sampler** - The sampler now shows a circular brush over the exact image area it averages, plus a marker for the last captured sample, making it easier to avoid patch edges and glare. - **Auto-paint optimizer objective** - Enhanced color matching now evaluates the same zone-compressed, layer-snapped color-to-height path used by the printable preview. All optimizer algorithms share that scorer, including Max Height constraints, so selected filament orders better match the finished model. Repeated optical calculations are memoized during searches. - **Auto-paint detail controls** - Enhanced matching now scores every color from the already-processed 2D palette instead of applying a hidden second color reduction. Repeated swaps are now a selector with Off, 2, 4, 6, 8, and 12 extra occurrences; transition detail similarly exposes 80%, 90%, and 95% Beer-Lambert opacity endpoints so quality, stack height, and runtime are an intentional trade-off. Existing repeated-swap settings migrate to four extra occurrences. +- **Height dithering kernel** - Height dithering now diffuses quantization error with the Stucki kernel (12 neighbors, error-conserving) instead of Floyd-Steinberg, spreading error over a wider area for smoother tonal gradients and finer apparent detail. Block-aware dot sizing and edge protection are unchanged. - **Calibrated Auto-paint model** - Calibrated filaments now use their measured red, green, and blue TD values when simulating blends and calculating transition-zone thickness, so generated stack heights and swap plans reflect the measured optical model. - **Auto-paint transition compositing** - Each filament transition now starts from the preceding transition's actual blended end color, including after Max Height compression, instead of assuming a pure previous-filament color. - **Auto-paint optical blending** - Beer-Lambert color mixing now happens in linear-light sRGB before returning display colors, replacing gamma-space interpolation with a more physically coherent light model. diff --git a/README.md b/README.md index dacf53d..8c7c649 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Region weighting is most useful when filament budget is limited and you want the | **Max Height** | Constrains the total model height (mm). When set below the auto-calculated ideal, zones are uniformly compressed. Leave blank or click `Auto` for the physics-derived default. | | **Enhanced color matching** | Optimizes the printable filament sequence rather than simply sorting by luminance. It may omit filaments that do not improve the result and evaluates the actual preview color-to-height path. | | **Extra repeated swaps** | (Requires Enhanced color matching) Lets the optimizer use a filament more than once. Choose Off, 2, 4, 6, 8, or 12 additional occurrences; more repeats may create useful blend paths but substantially expand the search. | -| **Height dithering** | (Requires Enhanced color matching) Applies block-aware Floyd-Steinberg error diffusion to the quantized height map. Instead of sharp stair-steps between layer heights, dithering produces a stippled gradient that simulates intermediate heights, resulting in smoother tonal transitions in the print. Edge pixels between different heights are protected from dithering to avoid staircase artifacts. | +| **Height dithering** | (Requires Enhanced color matching) Applies block-aware Stucki error diffusion to the quantized height map. Instead of sharp stair-steps between layer heights, dithering produces a stippled gradient that simulates intermediate heights, resulting in smoother tonal transitions in the print. Edge pixels between different heights are protected from dithering to avoid staircase artifacts. | | **Flat Paint (flat face-down print)** | Builds a uniform-thickness slab printed image-side down instead of a stepped relief. Each pixel column's layer order is reversed so the artwork sits against the build plate (already mirrored — don't mirror in the slicer) under a transparent carrier layer, and the back is filled with the foundation filament so every layer has the full footprint. The result has a smooth, glass-flat face — great for bookmarks and coasters. Requires a multi-material printer (AMS/toolchanger); export as 3MF, which contains one object per filament plus the clear carrier object. Flat Paint and Smooth Meshing toggle each other off because flat prints always use the full-footprint slab layout. | | **Dither line width** | (Requires Height dithering) Controls the minimum dot size for the dither pattern in mm. This should roughly match your printer's line/nozzle width so dither dots are actually printable. Default: `0.42 mm`. | | **Optimizer algorithm** | Choose Fast for a preview, Balanced for the recommended full beam, Thorough or Deep for more search effort, or Exact base order when you want every no-repeat base stack checked. | diff --git a/src/components/ThreeDView.tsx b/src/components/ThreeDView.tsx index 20fca07..0665f57 100644 --- a/src/components/ThreeDView.tsx +++ b/src/components/ThreeDView.tsx @@ -40,7 +40,7 @@ interface ThreeDViewProps { autoPaintTotalHeight?: number; // Total model height when auto-paint is enabled autoPaintFilamentOrder?: string[]; // Filament IDs in order (for cache invalidation) enhancedColorMatch?: boolean; // Use color-distance mapping instead of luminance - heightDithering?: boolean; // Floyd-Steinberg error diffusion on height map + heightDithering?: boolean; // Stucki error diffusion on height map ditherLineWidth?: number; // Minimum dot size in mm for dithering smoothMeshing?: boolean; // Smooth connected boundaries using welded grid topology isOrtho?: boolean; @@ -1004,7 +1004,7 @@ export default function ThreeDView({ // --- Pass 2: Quantize heights (with optional dithering) --- // The continuous height map has sub-layer precision, but // the 3D model must use discrete layer heights. When - // heightDithering is ON, block-aware Floyd-Steinberg error + // heightDithering is ON, block-aware Stucki error // diffusion produces dots sized to the printer's line width // so the dither pattern is actually printable. Edges // between different quantized heights are protected from @@ -1058,7 +1058,7 @@ export default function ThreeDView({ } } - // --- Step 2c: Block-aware Floyd-Steinberg --- + // --- Step 2c: Block-aware Stucki error diffusion --- // The block size ensures dither dots are at least as // wide as the printer's line width (in pixels). const blockSize = Math.max(1, Math.round(ditherLineWidth / pixelSize)); @@ -1086,7 +1086,25 @@ export default function ThreeDView({ if (blockCnt[bi] > 0) blockAvg[bi] /= blockCnt[bi]; } - // Dither at block level + // Dither at block level using the Stucki kernel: a + // wider, error-conserving diffusion than Floyd- + // Steinberg for smoother tone and finer detail. + // Offsets are scan-relative (dx along the serpentine + // direction, dy downward); weights are pre-divided by 42. + const STUCKI_KERNEL: ReadonlyArray = [ + [1, 0, 8 / 42], + [2, 0, 4 / 42], + [-2, 1, 2 / 42], + [-1, 1, 4 / 42], + [0, 1, 8 / 42], + [1, 1, 4 / 42], + [2, 1, 2 / 42], + [-2, 2, 1 / 42], + [-1, 2, 2 / 42], + [0, 2, 4 / 42], + [1, 2, 2 / 42], + [2, 2, 1 / 42], + ]; const errBuf = new Float64Array(bW * bH); const blockSnapped = new Float32Array(bW * bH); @@ -1125,18 +1143,13 @@ export default function ThreeDView({ if (!blockHasEdge[bi]) { const err = blockAvg[bi] + errBuf[bi] - snapped; - const xFwd = ltr ? bx + 1 : bx - 1; - const xDiagFwd = ltr ? bx + 1 : bx - 1; - const xDiagBack = ltr ? bx - 1 : bx + 1; - - if (xFwd >= 0 && xFwd < bW) - errBuf[by * bW + xFwd] += err * (7 / 16); - if (by + 1 < bH) { - if (xDiagBack >= 0 && xDiagBack < bW) - errBuf[(by + 1) * bW + xDiagBack] += err * (3 / 16); - errBuf[(by + 1) * bW + bx] += err * (5 / 16); - if (xDiagFwd >= 0 && xDiagFwd < bW) - errBuf[(by + 1) * bW + xDiagFwd] += err * (1 / 16); + const dir = ltr ? 1 : -1; + for (let k = 0; k < STUCKI_KERNEL.length; k++) { + const [dx, dy, weight] = STUCKI_KERNEL[k]; + const tx = bx + dir * dx; + const ty = by + dy; + if (tx < 0 || tx >= bW || ty >= bH) continue; + errBuf[ty * bW + tx] += err * weight; } } } From f3449dc6f9f0c957e023aab32e966243d7e016fb Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 25 Jun 2026 10:34:17 +0300 Subject: [PATCH 24/31] Score auto-paint with the preview's printable-color mapping The optimizer scored each image color by projecting onto the raw per-layer Lab polyline, which could land a target on a one-layer transition sliver -- a color no pixel is ever assigned -- and then optimize that fiction (e.g. treating a purple target as rendering blue). Collapse consecutive same-color layers into flat-zone nodes the way the 3D preview does, so the objective scores the colors the model actually builds. All goldens and realized-error budgets pass unchanged. --- src/lib/autoPaint.ts | 121 ++++++++++++++++++++++++++++++++----------- 1 file changed, 92 insertions(+), 29 deletions(-) diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 78c4b04..bfb8c1a 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -1032,10 +1032,14 @@ export interface MappedTarget { } /** - * Map each weighted image target onto the printable palette the way the preview - * does: find the closest point on the palette's Lab polyline (Euclidean - * projection), then snap to the printable layer at that height. Shared by the - * optimizer objective and the benchmark so the two cannot drift. + * Map each weighted image target onto the printable palette exactly the way the + * 3D preview does, so the optimizer scores the colors the model actually shows. + * + * The preview collapses consecutive same-color layers into flat-zone nodes and + * projects each pixel onto that node/transition polyline. Scoring against the + * raw per-layer polyline instead let the optimizer land a target on a one-layer + * transition sliver — a color no pixel is ever assigned — and optimize that + * fiction. Mirroring the collapse keeps the objective and the build consistent. */ export function mapTargetsToPrintablePalette( palette: Array<{ height: number; lab: Lab; rgb: RGB }>, @@ -1043,59 +1047,118 @@ export function mapTargetsToPrintablePalette( ): MappedTarget[] { if (palette.length === 0) return []; + // Collapse consecutive near-identical layers into flat-zone nodes (ΔE<0.5), + // matching ThreeDView. Each node keeps its height range and an averaged Lab. + const COLLAPSE_DE_SQ = 0.25; // 0.5^2 + const nodes: Array<{ lab: Lab; minHeight: number; maxHeight: number; paletteIndex: number }> = + []; + let runStart = 0; + for (let i = 1; i <= palette.length; i++) { + let split = i === palette.length; + if (!split) { + const ref = palette[runStart].lab; + const cur = palette[i].lab; + const deSq = (cur.L - ref.L) ** 2 + (cur.a - ref.a) ** 2 + (cur.b - ref.b) ** 2; + split = deSq >= COLLAPSE_DE_SQ; + } + if (split) { + let sL = 0; + let sa = 0; + let sb = 0; + for (let j = runStart; j < i; j++) { + sL += palette[j].lab.L; + sa += palette[j].lab.a; + sb += palette[j].lab.b; + } + const n = i - runStart; + nodes.push({ + lab: { L: sL / n, a: sa / n, b: sb / n }, + minHeight: palette[runStart].height, + maxHeight: palette[i - 1].height, + paletteIndex: runStart, + }); + runStart = i; + } + } + + // Transition segments connect the end of one flat zone to the start of the + // next, tracing the blend path through the printable layers between them. + const segments = nodes.slice(0, -1).map((A, ni) => { + const B = nodes[ni + 1]; + return { + aL: A.lab.L, + aa: A.lab.a, + ab: A.lab.b, + dL: B.lab.L - A.lab.L, + da: B.lab.a - A.lab.a, + db: B.lab.b - A.lab.b, + hStart: A.maxHeight, + hEnd: B.minHeight, + }; + }); + return imageTargets.map((target) => { let minDistance = Infinity; - let bestHeight = palette[0].height; + let nodeMatch = 0; + let onSegment = false; + let segmentHeight = nodes[0].minHeight; - // Nearest printable color seeds the projection threshold. - for (let ri = 0; ri < palette.length; ri++) { - const de = optimizerColorDistance(palette[ri].lab, target); + // Nearest flat-zone node by color. + for (let ni = 0; ni < nodes.length; ni++) { + const de = optimizerColorDistance(nodes[ni].lab, target); if (de < minDistance) { minDistance = de; - bestHeight = palette[ri].height; - if (de < 0.5) break; + nodeMatch = ni; + onSegment = false; } } - // Refine against the closest point on each polyline segment. - for (let ri = 0; ri < palette.length - 1; ri++) { - const start = palette[ri]; - const end = palette[ri + 1]; - const dL = end.lab.L - start.lab.L; - const da = end.lab.a - start.lab.a; - const db = end.lab.b - start.lab.b; - const lengthSquared = dL * dL + da * da + db * db; + // Refine against the closest point on each transition segment. + for (const seg of segments) { + const lengthSquared = seg.dL * seg.dL + seg.da * seg.da + seg.db * seg.db; if (lengthSquared < 0.01) continue; - const t = Math.max( 0, Math.min( 1, - ((target.L - start.lab.L) * dL + - (target.a - start.lab.a) * da + - (target.b - start.lab.b) * db) / + ((target.L - seg.aL) * seg.dL + + (target.a - seg.aa) * seg.da + + (target.b - seg.ab) * seg.db) / lengthSquared ) ); const projectedDistance = optimizerColorDistance(target, { - L: start.lab.L + t * dL, - a: start.lab.a + t * da, - b: start.lab.b + t * db, + L: seg.aL + t * seg.dL, + a: seg.aa + t * seg.da, + b: seg.ab + t * seg.db, }); if (projectedDistance < minDistance) { minDistance = projectedDistance; - bestHeight = start.height + t * (end.height - start.height); + onSegment = true; + segmentHeight = seg.hStart + t * (seg.hEnd - seg.hStart); } } - const mappedIdx = palette.findIndex((entry) => entry.height >= bestHeight); - const paletteIndex = mappedIdx >= 0 ? mappedIdx : palette.length - 1; + if (!onSegment) { + // Flat-zone match: the printed surface is this filament's solid + // color across the whole zone (sub-position within it is relief). + const node = nodes[nodeMatch]; + return { + target, + paletteIndex: node.paletteIndex, + mappedLab: node.lab, + projectedHeight: (node.minHeight + node.maxHeight) / 2, + }; + } + // Transition match: the printed color is the layer at that height. + const mappedIdx = palette.findIndex((entry) => entry.height >= segmentHeight); + const paletteIndex = mappedIdx >= 0 ? mappedIdx : palette.length - 1; return { target, paletteIndex, mappedLab: palette[paletteIndex].lab, - projectedHeight: bestHeight, + projectedHeight: segmentHeight, }; }); } From 5b1b2680f2dea46372ab18a403082ed5753af4bf Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 25 Jun 2026 11:30:07 +0300 Subject: [PATCH 25/31] Add Preserve color separation auto-paint mode Assign each distinct image color to a distinct printable color so perceptibly different colors are never collapsed onto one flat surface, keeping gradient variation at a small per-color accuracy cost. The injective, weight-ordered assignment runs in the shared printable-color mapper used by both the optimizer score and the 3D preview, so the optimized order and the built model stay consistent. Opt-in toggle under Enhanced color matching, off by default; existing behavior, goldens, and quality budgets are unchanged. --- CHANGELOG.md | 1 + src/App.tsx | 7 +++ src/components/AutoPaintTab.tsx | 28 ++++++++++++ src/components/ThreeDControls.tsx | 9 +++- src/components/ThreeDView.tsx | 51 ++++++++++++++++++++-- src/hooks/useAutoPaintWorker.ts | 4 ++ src/lib/autoPaint.ts | 72 +++++++++++++++++++++++++++++-- src/lib/optimizer.ts | 15 ++++++- src/types/index.ts | 2 + 9 files changed, 180 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e76dda..4d188fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Added +- **Preserve color separation (Auto-paint)** - New opt-in toggle (Enhanced color matching) that assigns every distinct image color to a distinct printable color, so perceptibly different colors are never collapsed onto one flat surface — gradients keep their variation. It trades a small amount of per-color accuracy for that separation and is fully reachable whenever the stack exposes at least as many distinct printable colors as the image has (raise the height if not). The optimizer scores and the 3D preview build through the same shared mapper, so they stay consistent. - **Reddit community links** - Added r/kromacut links to the app header and README, and use branded Discord, Reddit, and GitHub icons in the header toolbar. - **Auto-paint regression baseline** - Added focused layer-invariant coverage, per-algorithm seeded determinism checks, and 24 seeded stack snapshots across the 2-, 4-, and 8-filament profiles, both image fixtures, Enhanced matching states, and repeated-swap states. - **Auto-paint benchmark harness** - Added an on-demand JSON benchmark that measures realized print error (CIEDE2000 mean, p95, and coverage) for both the uncompressed and the Max Height-compressed stack, plus the achievable palette floor, stack cost, compression impact, runtime, and optimizer iterations across the saved fixture profiles. It measures the printed result through the same canonical color-to-height mapper the optimizer scores with, instead of a separately-implemented projection. diff --git a/src/App.tsx b/src/App.tsx index 00641b7..2815858 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -84,6 +84,7 @@ type AutoPaintPersisted = Pick< | 'optimizerSeed' | 'regionWeightingMode' | 'enhancedColorMatch' + | 'preserveSeparation' | 'allowRepeatedSwaps' | 'maxRepeatedSwaps' | 'transitionOpacity' @@ -126,6 +127,7 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { optimizerSeed: parsed.optimizerSeed, regionWeightingMode: parsed.regionWeightingMode, enhancedColorMatch: parsed.enhancedColorMatch ?? false, + preserveSeparation: parsed.preserveSeparation ?? false, maxRepeatedSwaps: normalizeRepeatLimit( parsed.maxRepeatedSwaps, parsed.allowRepeatedSwaps @@ -268,6 +270,8 @@ function App(): React.ReactElement | null { regionWeightingMode: autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode, enhancedColorMatch: autopaintHydrated.enhancedColorMatch ?? prev.enhancedColorMatch, + preserveSeparation: + autopaintHydrated.preserveSeparation ?? prev.preserveSeparation, maxRepeatedSwaps: autopaintHydrated.maxRepeatedSwaps ?? prev.maxRepeatedSwaps, transitionOpacity: autopaintHydrated.transitionOpacity ?? prev.transitionOpacity, @@ -289,6 +293,7 @@ function App(): React.ReactElement | null { optimizerSeed: threeDState.optimizerSeed, regionWeightingMode: threeDState.regionWeightingMode, enhancedColorMatch: threeDState.enhancedColorMatch, + preserveSeparation: threeDState.preserveSeparation, maxRepeatedSwaps: threeDState.maxRepeatedSwaps, transitionOpacity: threeDState.transitionOpacity, heightDithering: threeDState.heightDithering, @@ -302,6 +307,7 @@ function App(): React.ReactElement | null { threeDState.optimizerSeed, threeDState.regionWeightingMode, threeDState.enhancedColorMatch, + threeDState.preserveSeparation, threeDState.maxRepeatedSwaps, threeDState.transitionOpacity, threeDState.heightDithering, @@ -714,6 +720,7 @@ function App(): React.ReactElement | null { builtModelState.autoPaintResult?.filamentOrder } enhancedColorMatch={builtModelState.enhancedColorMatch} + preserveSeparation={builtModelState.preserveSeparation} heightDithering={builtModelState.heightDithering} ditherLineWidth={builtModelState.ditherLineWidth} smoothMeshing={builtModelState.smoothMeshing} diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 862c9bb..fe07c40 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -159,6 +159,8 @@ interface AutoPaintTabProps { // Enhanced matching options enhancedColorMatch: boolean; setEnhancedColorMatch: (v: boolean) => void; + preserveSeparation: boolean; + setPreserveSeparation: (v: boolean) => void; maxRepeatedSwaps: AutoPaintRepeatLimit; setMaxRepeatedSwaps: (v: AutoPaintRepeatLimit) => void; transitionOpacity: AutoPaintTransitionOpacity; @@ -219,6 +221,8 @@ export default function AutoPaintTab({ imageSwatches, enhancedColorMatch, setEnhancedColorMatch, + preserveSeparation, + setPreserveSeparation, maxRepeatedSwaps, setMaxRepeatedSwaps, transitionOpacity, @@ -689,6 +693,30 @@ export default function AutoPaintTab({
+
+ + +
+ {preserveSeparation && enhancedColorMatch && ( +

+ Maps every image color to a distinct printable color so + gradients keep their variation, at a small cost to per-color + accuracy. +

+ )}
diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index 4367f7a..54afd2a 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -116,6 +116,7 @@ export default function ThreeDControls({ const [paintMode, setPaintMode] = useState<'manual' | 'autopaint'>(initialPaintMode); const [autoPaintMaxHeight, setAutoPaintMaxHeight] = useState(undefined); const [enhancedColorMatch, setEnhancedColorMatch] = useState(persisted?.enhancedColorMatch ?? false); + const [preserveSeparation, setPreserveSeparation] = useState(persisted?.preserveSeparation ?? false); const [maxRepeatedSwaps, setMaxRepeatedSwaps] = useState( persisted?.maxRepeatedSwaps ?? (persisted?.allowRepeatedSwaps ? 4 : 0) ); @@ -166,6 +167,7 @@ export default function ThreeDControls({ paintMode, filaments, enhancedColorMatch, + preserveSeparation, maxRepeatedSwaps, transitionOpacity, heightDithering, @@ -177,7 +179,7 @@ export default function ThreeDControls({ smoothMeshing, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paintMode, filaments, enhancedColorMatch, maxRepeatedSwaps, transitionOpacity, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); + }, [paintMode, filaments, enhancedColorMatch, preserveSeparation, maxRepeatedSwaps, transitionOpacity, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); useEffect(() => { savePrintSettingsToStorage({ layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing }); @@ -225,6 +227,7 @@ export default function ThreeDControls({ slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, + preserveSeparation, maxRepeatedSwaps, transitionOpacity, optimizerAlgorithm, @@ -324,6 +327,7 @@ export default function ThreeDControls({ filaments, paintMode, enhancedColorMatch, + preserveSeparation, maxRepeatedSwaps, transitionOpacity, heightDithering, @@ -367,6 +371,7 @@ export default function ThreeDControls({ filaments, paintMode, enhancedColorMatch, + preserveSeparation, maxRepeatedSwaps, transitionOpacity, heightDithering, @@ -478,6 +483,8 @@ export default function ThreeDControls({ imageSwatches={filtered} enhancedColorMatch={enhancedColorMatch} setEnhancedColorMatch={handleEnhancedColorMatchChange} + preserveSeparation={preserveSeparation} + setPreserveSeparation={setPreserveSeparation} maxRepeatedSwaps={maxRepeatedSwaps} setMaxRepeatedSwaps={setMaxRepeatedSwaps} transitionOpacity={transitionOpacity} diff --git a/src/components/ThreeDView.tsx b/src/components/ThreeDView.tsx index 0665f57..b530081 100644 --- a/src/components/ThreeDView.tsx +++ b/src/components/ThreeDView.tsx @@ -13,6 +13,7 @@ import { import { LAYER_ACTIVATION_EPSILON } from '../lib/layerActivation'; import { normalizeHexColor as normalizeHexColorValue } from '../lib/colorUtils'; import { buildFlatPaintLayout, heightMapToFlatPaintLayerCounts } from '../lib/flatPaint'; +import { mapTargetsToPrintablePalette, type WeightedLab } from '../lib/autoPaint'; import { clampProgress, layeredBuildScanProgress, @@ -40,6 +41,7 @@ interface ThreeDViewProps { autoPaintTotalHeight?: number; // Total model height when auto-paint is enabled autoPaintFilamentOrder?: string[]; // Filament IDs in order (for cache invalidation) enhancedColorMatch?: boolean; // Use color-distance mapping instead of luminance + preserveSeparation?: boolean; // Assign each image color to a distinct printable color heightDithering?: boolean; // Stucki error diffusion on height map ditherLineWidth?: number; // Minimum dot size in mm for dithering smoothMeshing?: boolean; // Smooth connected boundaries using welded grid topology @@ -338,6 +340,7 @@ export default function ThreeDView({ autoPaintTotalHeight, autoPaintFilamentOrder, enhancedColorMatch = false, + preserveSeparation = false, heightDithering = false, ditherLineWidth = 0.42, smoothMeshing = false, @@ -616,6 +619,7 @@ export default function ThreeDView({ autoPaintTotalHeight, autoPaintFilamentOrder, // Include filament order to detect optimizer changes enhancedColorMatch, + preserveSeparation, heightDithering, ditherLineWidth, smoothMeshing, @@ -886,16 +890,29 @@ export default function ThreeDView({ }); } - // Pre-scan image luminance range for flat-zone sub-detail + // Pre-scan image luminance range for flat-zone sub-detail, + // and (in separation mode) the distinct image colors. let imgMinLum = 1, imgMaxLum = 0; + const sepColors = preserveSeparation + ? new Map() + : null; for (let py = minY; py < minY + boxH; py++) { for (let px = minX; px < minX + boxW; px++) { const idx = (py * fullW + px) * 4; if (data[idx + 3] === 0) continue; - const lum = getLuminance(data[idx], data[idx + 1], data[idx + 2]); + const pr0 = data[idx], + pg0 = data[idx + 1], + pb0 = data[idx + 2]; + const lum = getLuminance(pr0, pg0, pb0); if (lum < imgMinLum) imgMinLum = lum; if (lum > imgMaxLum) imgMaxLum = lum; + if (sepColors) { + const key = ((pr0 & 0xff) << 16) | ((pg0 & 0xff) << 8) | (pb0 & 0xff); + const existing = sepColors.get(key); + if (existing) existing.weight++; + else sepColors.set(key, { lab: toLab(pr0, pg0, pb0), weight: 1 }); + } } } if (imgMaxLum <= imgMinLum) imgMaxLum = imgMinLum + 0.001; @@ -904,11 +921,38 @@ export default function ThreeDView({ const maxModelH = cumulativeHeights[cumulativeHeights.length - 1] || 1; const minModelH = cumulativeHeights[0] || 0; + // Separation mode: assign each distinct image color to a + // DISTINCT printable color through the shared mapper, so no + // two image colors collapse onto the same surface color. + const separationHeights = new Map(); + if (sepColors && sepColors.size > 0) { + const sepPalette = swatchEntries.map((entry, si) => { + const [r, g, b] = hexToRGB(swatches[si].hex); + return { height: entry.height, lab: entry.lab, rgb: { r, g, b } }; + }); + const keys = [...sepColors.keys()]; + const sepTargets: WeightedLab[] = keys.map((key) => { + const c = sepColors.get(key)!; + return { L: c.lab.L, a: c.lab.a, b: c.lab.b, weight: c.weight }; + }); + const mapped = mapTargetsToPrintablePalette(sepPalette, sepTargets, { + preserveSeparation: true, + }); + mapped.forEach((m, i) => { + separationHeights.set( + keys[i], + Math.max(minModelH, Math.min(maxModelH, m.projectedHeight)) + ); + }); + } + // --- Pass 1: Compute continuous (un-snapped) heights --- // We deliberately do NOT snap to the layer grid here. // The RGB cache is still valid because it stores the ideal // continuous height; dithering happens spatially in Pass 2. - const colorHeightCache = new Map(); + // Separation assignments seed the cache so each color's + // pixels all land on its assigned printable height. + const colorHeightCache = new Map(separationHeights); for (let py = minY; py < minY + boxH; py++) { for (let px = minX; px < minX + boxW; px++) { @@ -1882,6 +1926,7 @@ export default function ThreeDView({ autoPaintTotalHeight, autoPaintFilamentOrder, enhancedColorMatch, + preserveSeparation, heightDithering, ditherLineWidth, smoothMeshing, diff --git a/src/hooks/useAutoPaintWorker.ts b/src/hooks/useAutoPaintWorker.ts index fb07d8b..7d79a3f 100644 --- a/src/hooks/useAutoPaintWorker.ts +++ b/src/hooks/useAutoPaintWorker.ts @@ -32,6 +32,7 @@ export interface UseAutoPaintWorkerOptions { slicerFirstLayerHeight: number; autoPaintMaxHeight?: number; enhancedColorMatch: boolean; + preserveSeparation: boolean; maxRepeatedSwaps: AutoPaintRepeatLimit; transitionOpacity: AutoPaintTransitionOpacity; optimizerAlgorithm: 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; @@ -75,6 +76,7 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, + preserveSeparation, maxRepeatedSwaps, transitionOpacity, optimizerAlgorithm, @@ -250,6 +252,7 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain algorithm: optimizerAlgorithm, maxExtraRepeats: maxRepeatedSwaps, transitionOpacity, + preserveSeparation, ...(optimizerSeed !== undefined && { seed: optimizerSeed }), }, }; @@ -280,6 +283,7 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain slicerFirstLayerHeight, autoPaintMaxHeight, enhancedColorMatch, + preserveSeparation, maxRepeatedSwaps, transitionOpacity, optimizerAlgorithm, diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index bfb8c1a..15707cc 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -1041,12 +1041,77 @@ export interface MappedTarget { * transition sliver — a color no pixel is ever assigned — and optimize that * fiction. Mirroring the collapse keeps the objective and the build consistent. */ -export function mapTargetsToPrintablePalette( +/** Minimum ΔE between two printable colors for them to count as "distinct". */ +const SEPARATION_MIN_DE = 2; + +/** + * Injective ("preserve color separation") mapping: assign each weighted image + * color to a DISTINCT printable color so perceptibly different image colors are + * never collapsed onto the same surface color. Dominant colors (higher weight) + * pick first, so large regions get the closest match and smaller ones absorb + * the shift needed to stay distinct. When there are more image colors than the + * curve exposes distinct printable colors, the surplus falls back to nearest + * (and may collide) — the only case that cannot be fully separated. + */ +function mapTargetsWithSeparation( palette: Array<{ height: number; lab: Lab; rgb: RGB }>, imageTargets: WeightedLab[] +): MappedTarget[] { + // Distinct printable colors, keeping a representative entry for each. + const distinct: Array<{ lab: Lab; height: number; paletteIndex: number }> = []; + for (let i = 0; i < palette.length; i++) { + const entry = palette[i]; + if (distinct.every((k) => optimizerColorDistance(k.lab, entry.lab) >= SEPARATION_MIN_DE)) { + distinct.push({ lab: entry.lab, height: entry.height, paletteIndex: i }); + } + } + + const result = new Array(imageTargets.length); + const used = new Set(); + // Assign dominant colors first. + const order = imageTargets.map((_, i) => i).sort((a, b) => imageTargets[b].weight - imageTargets[a].weight); + for (const i of order) { + const target = imageTargets[i]; + let bestJ = -1; + let bestDistance = Infinity; + let fallbackJ = 0; + let fallbackDistance = Infinity; + for (let j = 0; j < distinct.length; j++) { + const de = optimizerColorDistance(distinct[j].lab, target); + if (de < fallbackDistance) { + fallbackDistance = de; + fallbackJ = j; + } + if (!used.has(j) && de < bestDistance) { + bestDistance = de; + bestJ = j; + } + } + const j = bestJ >= 0 ? bestJ : fallbackJ; // surplus colors reuse nearest + if (bestJ >= 0) used.add(j); + result[i] = { + target, + paletteIndex: distinct[j].paletteIndex, + mappedLab: distinct[j].lab, + projectedHeight: distinct[j].height, + }; + } + return result; +} + +export function mapTargetsToPrintablePalette( + palette: Array<{ height: number; lab: Lab; rgb: RGB }>, + imageTargets: WeightedLab[], + options: { preserveSeparation?: boolean } = {} ): MappedTarget[] { if (palette.length === 0) return []; + // Separation mode: assign each distinct image color to a DISTINCT printable + // color so perceptibly different colors never collapse to one flat surface. + if (options.preserveSeparation) { + return mapTargetsWithSeparation(palette, imageTargets); + } + // Collapse consecutive near-identical layers into flat-zone nodes (ΔE<0.5), // matching ThreeDView. Each node keeps its height range and an averaged Lab. const COLLAPSE_DE_SQ = 0.25; // 0.5^2 @@ -1217,12 +1282,13 @@ const USEFUL_PALETTE_MATCH_DE = 8; */ export function scoreSequenceAgainstImage( palette: Array<{ height: number; lab: Lab; rgb: RGB }>, - imageTargets: WeightedLab[] + imageTargets: WeightedLab[], + options: { preserveSeparation?: boolean } = {} ): number { if (palette.length === 0) return Infinity; if (imageTargets.length === 0) return Infinity; - const mapped = mapTargetsToPrintablePalette(palette, imageTargets); + const mapped = mapTargetsToPrintablePalette(palette, imageTargets, options); // 1. Weighted realized color error (CIEDE2000), with a p95 tail term so a // few rare conspicuous colors cannot be sacrificed to lower the mean. diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index 627d8fe..ae0042b 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -63,6 +63,8 @@ export interface OptimizerOptions { maxExtraRepeats?: number; /** Transition opacity target used by the shared printable-palette scorer. */ transitionOpacity?: number; + /** Assign each image color to a distinct printable color (no collapse). */ + preserveSeparation?: boolean; seed?: number; // For deterministic results maxIterations?: number; // Algorithm-specific iteration limit temperature?: number; // Initial temperature for SA @@ -90,6 +92,7 @@ export interface ScoringContext { firstLayerHeight: number; maxHeight?: number; transitionOpacity?: number; + preserveSeparation?: boolean; } // ============================================================================ @@ -167,6 +170,7 @@ function tuningFingerprint(options: OptimizerOptions) { allowRepeatedSwaps: options.allowRepeatedSwaps ?? false, maxExtraRepeats: options.maxExtraRepeats ?? null, transitionOpacity: options.transitionOpacity ?? null, + preserveSeparation: options.preserveSeparation ?? false, maxIterations: options.maxIterations ?? null, temperature: options.temperature ?? null, coolingRate: options.coolingRate ?? null, @@ -242,7 +246,9 @@ export function createSequenceScorer(context: ScoringContext): (filaments: Filam ); paletteCache.set(sequenceKey, palette); } - return scoreSequenceAgainstImage(palette, context.imageColors); + return scoreSequenceAgainstImage(palette, context.imageColors, { + preserveSeparation: context.preserveSeparation, + }); }; } @@ -981,7 +987,12 @@ export function optimizeFilamentOrder( } let result: OptimizerResult; - const scoreSequence = createSequenceScorer(context); + // The scoring mode lives in OptimizerOptions; mirror it into the context the + // scorer reads so callers only need to set it in one place. + const scoreSequence = createSequenceScorer({ + ...context, + preserveSeparation: opts.preserveSeparation ?? context.preserveSeparation, + }); switch (resolved) { case 'exhaustive': diff --git a/src/types/index.ts b/src/types/index.ts index 99cac53..71f418c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -49,6 +49,8 @@ export interface ThreeDControlsStateShape { paintMode: 'manual' | 'autopaint'; // Enhanced color matching options enhancedColorMatch?: boolean; + /** Assign each image color to a distinct printable color (no collapse). */ + preserveSeparation?: boolean; /** Legacy persisted value. Migrate to maxRepeatedSwaps when loading. */ allowRepeatedSwaps?: boolean; /** Maximum extra non-adjacent filament occurrences the optimizer may add. */ From 9b60948cdadb0f68b0fc3268417206949325c27c Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 25 Jun 2026 11:37:20 +0300 Subject: [PATCH 26/31] Add 8-color frontlit-calibrated profile fixture --- .../8_Colors_Frontlit_Calibrated.kfil | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/assets/filament-profiles/8_Colors_Frontlit_Calibrated.kfil diff --git a/tests/assets/filament-profiles/8_Colors_Frontlit_Calibrated.kfil b/tests/assets/filament-profiles/8_Colors_Frontlit_Calibrated.kfil new file mode 100644 index 0000000..de4e1fb --- /dev/null +++ b/tests/assets/filament-profiles/8_Colors_Frontlit_Calibrated.kfil @@ -0,0 +1,49 @@ +{ + "id": "c2e9e401-d1bf-49c8-9166-1bdc487ffd64", + "name": "8 Colors Calibrated New", + "version": 1, + "filaments": [ + { + "id": "p9c63ms", + "color": "#ffffff", + "td": 6.4 + }, + { + "id": "plvjtmc", + "color": "#000000", + "td": 0.8 + }, + { + "id": "98z555k", + "color": "#ff5ec4", + "td": 5.6 + }, + { + "id": "w8cncoa", + "color": "#f7d000", + "td": 4.8 + }, + { + "id": "vsn9q6u", + "color": "#d83400", + "td": 5.6 + }, + { + "id": "azwg1yp", + "color": "#6300c5", + "td": 4 + }, + { + "id": "xyyxysq", + "color": "#00b8c4", + "td": 4 + }, + { + "id": "upcjpfe", + "color": "#04bb00", + "td": 4 + } + ], + "createdAt": 1782329253358, + "updatedAt": 1782329253358 +} \ No newline at end of file From bd7d734d53ac520bbd224b10a83d63e596090675 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 25 Jun 2026 12:01:40 +0300 Subject: [PATCH 27/31] Trim Auto-paint settings helper UI --- src/components/AutoPaintTab.tsx | 95 ++------------------------------- 1 file changed, 3 insertions(+), 92 deletions(-) diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index fe07c40..ae048f6 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -29,7 +29,6 @@ import type { AutoPaintResult, TransitionZone } from '../lib/autoPaint'; import type { AutoPaintProfile } from '../lib/profileManager'; import type { AutoPaintRepeatLimit, AutoPaintTransitionOpacity, Filament, Swatch } from '../types'; import type { CalibrationResult } from '../lib/calibration'; -import { getExactBaseOrderCount } from '../lib/optimizer'; import FilamentRow from './FilamentRow'; import { FilamentCalibrationWizard } from './FilamentCalibrationWizard'; import { getConfidenceLabel, getConfidenceColor } from '../lib/calibration'; @@ -40,70 +39,31 @@ type OptimizerTierValue = 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; interface OptimizerTierMeta { value: OptimizerTierValue; label: string; - blurb: string; - /** Relative output quality, 1–5, for the inline meter. */ - quality: number; - /** Relative speed, 1–5, for the inline meter (5 = fastest). */ - speed: number; } const OPTIMIZER_TIERS: readonly OptimizerTierMeta[] = [ { value: 'fast', label: 'Fast', - blurb: 'Narrow beam search for a quick preview.', - quality: 2, - speed: 5, }, { value: 'balanced', label: 'Balanced', - blurb: 'Full deterministic beam search; the recommended default.', - quality: 3, - speed: 4, }, { value: 'thorough', label: 'Thorough', - blurb: 'Full beam plus deeper multi-start refinement.', - quality: 4, - speed: 3, }, { value: 'deep', label: 'Deep', - blurb: 'Wide beam plus a much broader deterministic search.', - quality: 5, - speed: 2, }, { value: 'exact', label: 'Exact base order', - blurb: 'Enumerates every no-repeat base order.', - quality: 5, - speed: 1, }, ]; -/** Small 5-segment bar used to convey relative speed / quality of a tier. */ -function TierMeter({ label, value }: { label: string; value: number }): React.ReactElement { - return ( - - {label} - - {Array.from({ length: 5 }, (_, i) => ( - - ))} - - - ); -} - interface AutoPaintSliceData { virtualSwatches: Swatch[]; colorSliceHeights: number[]; @@ -258,17 +218,6 @@ export default function AutoPaintTab({ const [localOptimizerSeed, setLocalOptimizerSeed] = React.useState( optimizerSeed?.toString() ?? '' ); - const exactBaseOrderCount = getExactBaseOrderCount(filaments.length); - const activeTier = - OPTIMIZER_TIERS.find((tier) => tier.value === optimizerAlgorithm) ?? OPTIMIZER_TIERS[1]; - const optimizerTierDescription = - optimizerAlgorithm === 'exact' - ? `Checks all ${exactBaseOrderCount.toLocaleString()} no-repeat base orders.${ - filaments.length > 8 - ? ' Large profiles can take a long time; start another search to cancel.' - : '' - }` - : activeTier.blurb; // Calibration wizard state const [calibratingFilamentId, setCalibratingFilamentId] = React.useState(null); @@ -312,9 +261,6 @@ export default function AutoPaintTab({

Auto-paint

-

- Define filament colors and transmission distances for automatic painting -

@@ -710,13 +656,6 @@ export default function AutoPaintTab({ disabled={!enhancedColorMatch} />
- {preserveSeparation && enhancedColorMatch && ( -

- Maps every image color to a distinct printable color so - gradients keep their variation, at a small cost to per-color - accuracy. -

- )}
@@ -811,15 +750,9 @@ export default function AutoPaintTab({ className={`space-y-3 pt-2 transition-opacity ${enhancedColorMatch ? 'opacity-100' : 'opacity-40 pointer-events-none'}`} >
-
- -

- Advanced filament ordering optimization (requires enhanced color - matching) -

-
+
-
-
- - -
-

- {optimizerTierDescription} - {optimizerAlgorithm === 'exact' && maxRepeatedSwaps > 0 && ( - - {' '} - The base order is exact; up to { - maxRepeatedSwaps - }{' '} - repeated swaps are refined heuristically. - - )} -

-
-

- Higher opacity retains a longer physical color ramp, improving - color resolution at the cost of height, swaps, and runtime. -