From 91d23558acf4a017718c6c982140e33c49da20a4 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:46:13 -0500 Subject: [PATCH 01/81] Add support for importing filament profiles from HueForge spool CSV exports Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- CHANGELOG.md | 1 + src/components/AutoPaintTab.tsx | 2 +- src/docs/settings-and-controls.md | 27 +++++++++++++- src/hooks/useProfileManager.ts | 8 +++- src/lib/profileManager.ts | 57 +++++++++++++++++++++++++++++ tests/profileManager.test.ts | 61 +++++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc2a76..b438243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to Kromacut are documented in this file. - **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. - **Flat Paint mode (experimental)** - Added a Flat Paint option to Auto-paint that builds a uniform, face-down slab: each pixel column's layer order is reversed so the artwork sits flat against the build plate (pre-mirrored for face-down printing) under a transparent carrier layer, the back is filled with the foundation filament so every layer has the full footprint, and 3MF export merges the parts into one object per physical filament for AMS/toolchanger printers. Includes flat-mode print instructions, a performance warning for tall stacks, mutual exclusion with Smooth Meshing, and regression tests covering the layout, meshing, STL compaction, and 3MF grouping. - **Desktop update settings** - Added desktop-only settings to manually check for updates and control whether update notices run on startup. +- **HueForge spool CSV import** - Auto-paint filament profiles can now be imported from a HueForge spool library CSV export. Each spool is imported as a filament entry with its HueForge UUID preserved as the filament ID, named `--` (e.g. `Inland Basic-Light Brown-#bf9c81`). Column order in the CSV is flexible. ### Changed diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index ed7350d..669d7fa 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -353,7 +353,7 @@ export default function AutoPaintTab({ --`, for example `Inland Basic-Light Brown-#bf9c81`. HueForge UUIDs are preserved as filament IDs so re-importing the same library does not create duplicates. ## Desktop Update Notices diff --git a/src/hooks/useProfileManager.ts b/src/hooks/useProfileManager.ts index a6db5fb..9d1bdfa 100644 --- a/src/hooks/useProfileManager.ts +++ b/src/hooks/useProfileManager.ts @@ -10,6 +10,7 @@ import { deleteProfile as deleteProfileFromList, importProfiles, parseProfileFile, + parseHueForgeCSV, exportProfileBlob, profileFileName, loadLastProfileId, @@ -149,7 +150,12 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager if (!file) return; const reader = new FileReader(); reader.onload = () => { - const incoming = parseProfileFile(reader.result as string); + const content = reader.result as string; + const isCSV = file.name.toLowerCase().endsWith('.csv'); + const profileName = file.name.replace(/\.csv$/i, '') || 'HueForge Import'; + const incoming = isCSV + ? parseHueForgeCSV(content, profileName) + : parseProfileFile(content); if (!incoming) { console.error('Invalid profile file'); return; diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts index d6bcf3d..be56d1d 100644 --- a/src/lib/profileManager.ts +++ b/src/lib/profileManager.ts @@ -226,6 +226,63 @@ export function parseProfileFile(json: string): AutoPaintProfile[] | null { } } +/** + * Parse a HueForge spool library CSV into a single AutoPaint profile. + * + * The CSV must have a header row. Column order is flexible — columns are + * matched by name. Required columns: Color (hex), TD (float). Optional but + * recommended: Brand, Name, Uuid. + * + * Each filament's display name is formatted as `--`, + * e.g. `Inland Basic-Light Brown-#bf9c81`. Rows missing both Color and TD + * are skipped. UUIDs are used as filament IDs when present (braces stripped); + * a random UUID is generated for rows without one. + * + * Returns null if the input has no header, no valid data rows, or cannot be + * parsed. + */ +export function parseHueForgeCSV(csv: string, profileName = 'HueForge Import'): AutoPaintProfile[] | null { + const lines = csv.split(/\r?\n/).filter((l) => l.trim()); + if (lines.length < 2) return null; + + const headers = lines[0].split(',').map((h) => h.trim()); + const col = (row: string[], name: string) => { + const i = headers.indexOf(name); + return i >= 0 ? row[i]?.trim() ?? '' : ''; + }; + + const filaments: import('../types').Filament[] = []; + for (const line of lines.slice(1)) { + const row = line.split(','); + const color = col(row, 'Color'); + const tdRaw = col(row, 'TD'); + const td = parseFloat(tdRaw); + if (!color || isNaN(td)) continue; + + const rawId = col(row, 'Uuid').replace(/[{}]/g, ''); + const id = rawId || crypto.randomUUID(); + const colorName = col(row, 'Name'); + const brand = col(row, 'Brand') || undefined; + const name = brand && colorName ? `${brand}-${colorName}-${color}` : colorName || undefined; + + filaments.push({ id, color, td, name, brand }); + } + + if (filaments.length === 0) return null; + + const now = Date.now(); + return [ + { + id: crypto.randomUUID(), + name: profileName, + version: CURRENT_PROFILE_VERSION, + filaments, + createdAt: now, + updatedAt: now, + }, + ]; +} + /** Build an export blob for a profile. */ export function exportProfileBlob(profile: AutoPaintProfile): Blob { return new Blob([JSON.stringify(profile, null, 2)], { type: 'application/json' }); diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index 2d941a5..819c92a 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { profileFileName, renameProfile, + parseHueForgeCSV, type AutoPaintProfile, } from '../src/lib/profileManager.ts'; @@ -11,6 +12,66 @@ test('auto-paint profile exports use kfil filenames', () => { assert.equal(profileFileName('PLA Basic White'), 'PLA_Basic_White.kfil'); }); +const HUEFORGE_CSV = `Brand, Type, Color, Name, TD, Tags, Secondary_Type, Secondary_Color, Secondary_Strength, Owned, Uuid +Inland Basic,PLA,#bf9c81,Light Brown,1.7,,None,#0000ff,0,true,{631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9} +Overture Basic,PLA,#033877,Blue,3.5,,None,#0000ff,0,true,{c8518afd-068e-4a5c-90d2-9981d4d7edde}`; + +test('parseHueForgeCSV returns null for empty input', () => { + assert.equal(parseHueForgeCSV(''), null); + assert.equal(parseHueForgeCSV('Brand, Type, Color'), null); +}); + +test('parseHueForgeCSV returns null when no valid filament rows', () => { + const csv = `Brand, Type, Color, Name, TD, Tags, Secondary_Type, Secondary_Color, Secondary_Strength, Owned, Uuid +Inland Basic,PLA,,Light Brown,,,,,,true,{631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9}`; + assert.equal(parseHueForgeCSV(csv), null); +}); + +test('parseHueForgeCSV parses filaments from HueForge CSV', () => { + const profiles = parseHueForgeCSV(HUEFORGE_CSV, 'My Spools'); + assert.ok(profiles); + assert.equal(profiles.length, 1); + assert.equal(profiles[0].name, 'My Spools'); + assert.equal(profiles[0].filaments.length, 2); +}); + +test('parseHueForgeCSV maps color and TD correctly', () => { + const [profile] = parseHueForgeCSV(HUEFORGE_CSV)!; + const [first] = profile.filaments; + assert.equal(first.color, '#bf9c81'); + assert.equal(first.td, 1.7); +}); + +test('parseHueForgeCSV strips braces from UUIDs', () => { + const [profile] = parseHueForgeCSV(HUEFORGE_CSV)!; + assert.equal(profile.filaments[0].id, '631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9'); + assert.equal(profile.filaments[1].id, 'c8518afd-068e-4a5c-90d2-9981d4d7edde'); +}); + +test('parseHueForgeCSV formats names as --', () => { + const [profile] = parseHueForgeCSV(HUEFORGE_CSV)!; + assert.equal(profile.filaments[0].name, 'Inland Basic-Light Brown-#bf9c81'); + assert.equal(profile.filaments[1].name, 'Overture Basic-Blue-#033877'); +}); + +test('parseHueForgeCSV preserves brand field', () => { + const [profile] = parseHueForgeCSV(HUEFORGE_CSV)!; + assert.equal(profile.filaments[0].brand, 'Inland Basic'); + assert.equal(profile.filaments[1].brand, 'Overture Basic'); +}); + +test('parseHueForgeCSV handles columns in non-standard order', () => { + const csv = `TD, Name, Uuid, Color, Brand, Type +1.7,Light Brown,{631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9},#bf9c81,Inland Basic,PLA`; + const [profile] = parseHueForgeCSV(csv)!; + const [f] = profile.filaments; + assert.equal(f.color, '#bf9c81'); + assert.equal(f.td, 1.7); + assert.equal(f.brand, 'Inland Basic'); + assert.equal(f.name, 'Inland Basic-Light Brown-#bf9c81'); + assert.equal(f.id, '631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9'); +}); + test('auto-paint profiles can be renamed without changing filament data', () => { const profiles: AutoPaintProfile[] = [ { From 15380671b91c03f3faaa8cd86dcd301b5ed2fe61 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:35:46 -0500 Subject: [PATCH 02/81] Enhance CSV/TSV parsing in profile manager to handle quoted fields and delimiters Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/hooks/useProfileManager.ts | 4 +- src/lib/profileManager.ts | 75 +++++++++++++++++++++++++++++----- tests/profileManager.test.ts | 47 +++++++++++++++++++++ 3 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/hooks/useProfileManager.ts b/src/hooks/useProfileManager.ts index 9d1bdfa..1bab7cf 100644 --- a/src/hooks/useProfileManager.ts +++ b/src/hooks/useProfileManager.ts @@ -151,8 +151,8 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager const reader = new FileReader(); reader.onload = () => { const content = reader.result as string; - const isCSV = file.name.toLowerCase().endsWith('.csv'); - const profileName = file.name.replace(/\.csv$/i, '') || 'HueForge Import'; + const isCSV = /\.(csv|tsv)$/i.test(file.name); + const profileName = file.name.replace(/\.(csv|tsv)$/i, '') || 'HueForge Import'; const incoming = isCSV ? parseHueForgeCSV(content, profileName) : parseProfileFile(content); diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts index be56d1d..8b020d3 100644 --- a/src/lib/profileManager.ts +++ b/src/lib/profileManager.ts @@ -226,17 +226,70 @@ export function parseProfileFile(json: string): AutoPaintProfile[] | null { } } +/** Infer delimiter from the header line: tab if tabs are present, otherwise comma. */ +function detectDelimiter(headerLine: string): ',' | '\t' { + return headerLine.includes('\t') ? '\t' : ','; +} + /** - * Parse a HueForge spool library CSV into a single AutoPaint profile. + * Parse one CSV/TSV row into fields, handling RFC 4180 double-quoted fields + * (commas and newlines inside quotes, "" as escaped quote). + */ +function parseRow(line: string, delimiter: ',' | '\t'): string[] { + const fields: string[] = []; + let pos = 0; + + while (pos <= line.length) { + if (pos === line.length) { + // line ended with a delimiter — trailing empty field + fields.push(''); + break; + } + + if (line[pos] === '"') { + let field = ''; + pos++; // skip opening quote + for (;;) { + if (pos >= line.length) break; // unclosed quote — accept what we have + if (line[pos] === '"') { + if (pos + 1 < line.length && line[pos + 1] === '"') { + field += '"'; + pos += 2; // escaped quote "" + } else { + pos++; // closing quote + break; + } + } else { + field += line[pos++]; + } + } + fields.push(field); + if (pos < line.length && line[pos] === delimiter) pos++; + if (pos === line.length) break; // last field, no trailing delimiter + } else { + const end = line.indexOf(delimiter, pos); + if (end === -1) { + fields.push(line.slice(pos)); + break; + } + fields.push(line.slice(pos, end)); + pos = end + 1; + // if pos === line.length the loop continues and pushes trailing '' + } + } + + return fields; +} + +/** + * Parse a HueForge spool library CSV or TSV into a single AutoPaint profile. * - * The CSV must have a header row. Column order is flexible — columns are - * matched by name. Required columns: Color (hex), TD (float). Optional but - * recommended: Brand, Name, Uuid. + * Accepts comma-separated (CSV) or tab-separated (TSV) input — detected from + * the header row. Column order is flexible. Required columns: Color (hex), + * TD (float). Optional: Brand, Name, Uuid. * - * Each filament's display name is formatted as `--`, - * e.g. `Inland Basic-Light Brown-#bf9c81`. Rows missing both Color and TD - * are skipped. UUIDs are used as filament IDs when present (braces stripped); - * a random UUID is generated for rows without one. + * Quoted fields with embedded commas are handled per RFC 4180 ("" escapes a + * literal quote). Rows missing Color or TD are skipped. * * Returns null if the input has no header, no valid data rows, or cannot be * parsed. @@ -245,7 +298,9 @@ export function parseHueForgeCSV(csv: string, profileName = 'HueForge Import'): const lines = csv.split(/\r?\n/).filter((l) => l.trim()); if (lines.length < 2) return null; - const headers = lines[0].split(',').map((h) => h.trim()); + const delimiter = detectDelimiter(lines[0]); + const headers = parseRow(lines[0], delimiter).map((h) => h.trim()); + const col = (row: string[], name: string) => { const i = headers.indexOf(name); return i >= 0 ? row[i]?.trim() ?? '' : ''; @@ -253,7 +308,7 @@ export function parseHueForgeCSV(csv: string, profileName = 'HueForge Import'): const filaments: import('../types').Filament[] = []; for (const line of lines.slice(1)) { - const row = line.split(','); + const row = parseRow(line, delimiter); const color = col(row, 'Color'); const tdRaw = col(row, 'TD'); const td = parseFloat(tdRaw); diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index 819c92a..3aed807 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -72,6 +72,53 @@ test('parseHueForgeCSV handles columns in non-standard order', () => { assert.equal(f.id, '631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9'); }); +test('parseHueForgeCSV handles quoted fields containing commas', () => { + const csv = `Brand,Color,Name,TD,Uuid +"Inland, Basic",#bf9c81,"Light, Brown",1.7,{631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9}`; + const [profile] = parseHueForgeCSV(csv)!; + const [f] = profile.filaments; + assert.equal(f.brand, 'Inland, Basic'); + assert.equal(f.color, '#bf9c81'); + assert.equal(f.name, 'Inland, Basic-Light, Brown-#bf9c81'); + assert.equal(f.td, 1.7); +}); + +test('parseHueForgeCSV handles escaped quotes inside quoted fields', () => { + const csv = `Brand,Color,Name,TD +"Brand ""X""",#ff0000,Red,2.1`; + const [profile] = parseHueForgeCSV(csv)!; + assert.equal(profile.filaments[0].brand, 'Brand "X"'); +}); + +test('parseHueForgeCSV handles field with backslash and embedded quote (double-escape)', () => { + // brand value: backslash \" (backslash + quote, 12 chars) + // RFC 4180: wrap in quotes, double the interior " → "backslash \""" + // JS template: \\ for the literal backslash → "backslash \\""" + const csv = `Brand,Color,Name,TD +"backslash \\""",#aa1122,Red,1.5`; + const [profile] = parseHueForgeCSV(csv)!; + assert.equal(profile.filaments[0].brand, 'backslash \\"'); +}); + +test('parseHueForgeCSV parses TSV input', () => { + const tsv = `Brand\tColor\tName\tTD\tUuid +Inland Basic\t#bf9c81\tLight Brown\t1.7\t{631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9} +Overture Basic\t#033877\tBlue\t3.5\t{c8518afd-068e-4a5c-90d2-9981d4d7edde}`; + const profiles = parseHueForgeCSV(tsv, 'My Spools'); + assert.ok(profiles); + assert.equal(profiles[0].filaments.length, 2); + assert.equal(profiles[0].filaments[0].color, '#bf9c81'); + assert.equal(profiles[0].filaments[0].brand, 'Inland Basic'); + assert.equal(profiles[0].filaments[1].color, '#033877'); +}); + +test('parseHueForgeCSV TSV does not split on commas in values', () => { + const tsv = `Brand\tColor\tName\tTD +Inland, Basic\t#bf9c81\tLight Brown\t1.7`; + const [profile] = parseHueForgeCSV(tsv)!; + assert.equal(profile.filaments[0].brand, 'Inland, Basic'); +}); + test('auto-paint profiles can be renamed without changing filament data', () => { const profiles: AutoPaintProfile[] = [ { From 2f4a4e6472c242e1ec2808ddf98a06a5cf25089f Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:46:57 -0500 Subject: [PATCH 03/81] Enhance CSV parsing to normalize hex colors and skip invalid rows Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/lib/profileManager.ts | 6 ++++-- tests/profileManager.test.ts | 35 ++++++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts index 8b020d3..46efc79 100644 --- a/src/lib/profileManager.ts +++ b/src/lib/profileManager.ts @@ -1,4 +1,5 @@ import type { Filament } from '../types'; +import { normalizeHexColor } from './colorUtils.ts'; export interface AutoPaintProfile { id: string; @@ -309,10 +310,11 @@ export function parseHueForgeCSV(csv: string, profileName = 'HueForge Import'): const filaments: import('../types').Filament[] = []; for (const line of lines.slice(1)) { const row = parseRow(line, delimiter); - const color = col(row, 'Color'); + const colorRaw = col(row, 'Color'); + const color = normalizeHexColor(colorRaw, ''); const tdRaw = col(row, 'TD'); const td = parseFloat(tdRaw); - if (!color || isNaN(td)) continue; + if (!color || isNaN(td) || td < 0.5 || td > 10.0) continue; const rawId = col(row, 'Uuid').replace(/[{}]/g, ''); const id = rawId || crypto.randomUUID(); diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index 3aed807..387bf95 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -38,7 +38,7 @@ test('parseHueForgeCSV parses filaments from HueForge CSV', () => { test('parseHueForgeCSV maps color and TD correctly', () => { const [profile] = parseHueForgeCSV(HUEFORGE_CSV)!; const [first] = profile.filaments; - assert.equal(first.color, '#bf9c81'); + assert.equal(first.color, '#BF9C81'); assert.equal(first.td, 1.7); }); @@ -50,7 +50,7 @@ test('parseHueForgeCSV strips braces from UUIDs', () => { test('parseHueForgeCSV formats names as --', () => { const [profile] = parseHueForgeCSV(HUEFORGE_CSV)!; - assert.equal(profile.filaments[0].name, 'Inland Basic-Light Brown-#bf9c81'); + assert.equal(profile.filaments[0].name, 'Inland Basic-Light Brown-#BF9C81'); assert.equal(profile.filaments[1].name, 'Overture Basic-Blue-#033877'); }); @@ -65,10 +65,10 @@ test('parseHueForgeCSV handles columns in non-standard order', () => { 1.7,Light Brown,{631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9},#bf9c81,Inland Basic,PLA`; const [profile] = parseHueForgeCSV(csv)!; const [f] = profile.filaments; - assert.equal(f.color, '#bf9c81'); + assert.equal(f.color, '#BF9C81'); assert.equal(f.td, 1.7); assert.equal(f.brand, 'Inland Basic'); - assert.equal(f.name, 'Inland Basic-Light Brown-#bf9c81'); + assert.equal(f.name, 'Inland Basic-Light Brown-#BF9C81'); assert.equal(f.id, '631cbb3a-9db8-45b4-96cd-5d21a5f3b2e9'); }); @@ -78,8 +78,8 @@ test('parseHueForgeCSV handles quoted fields containing commas', () => { const [profile] = parseHueForgeCSV(csv)!; const [f] = profile.filaments; assert.equal(f.brand, 'Inland, Basic'); - assert.equal(f.color, '#bf9c81'); - assert.equal(f.name, 'Inland, Basic-Light, Brown-#bf9c81'); + assert.equal(f.color, '#BF9C81'); + assert.equal(f.name, 'Inland, Basic-Light, Brown-#BF9C81'); assert.equal(f.td, 1.7); }); @@ -107,7 +107,7 @@ Overture Basic\t#033877\tBlue\t3.5\t{c8518afd-068e-4a5c-90d2-9981d4d7edde}`; const profiles = parseHueForgeCSV(tsv, 'My Spools'); assert.ok(profiles); assert.equal(profiles[0].filaments.length, 2); - assert.equal(profiles[0].filaments[0].color, '#bf9c81'); + assert.equal(profiles[0].filaments[0].color, '#BF9C81'); assert.equal(profiles[0].filaments[0].brand, 'Inland Basic'); assert.equal(profiles[0].filaments[1].color, '#033877'); }); @@ -119,6 +119,27 @@ Inland, Basic\t#bf9c81\tLight Brown\t1.7`; assert.equal(profile.filaments[0].brand, 'Inland, Basic'); }); +test('parseHueForgeCSV skips rows with invalid hex color', () => { + const csv = `Brand,Color,Name,TD +Inland Basic,red,Light Brown,1.7 +Inland Basic,rgb(255,0,0),Light Brown,1.7 +Overture Basic,#033877,Blue,3.5`; + const [profile] = parseHueForgeCSV(csv)!; + assert.equal(profile.filaments.length, 1); + assert.equal(profile.filaments[0].color, '#033877'); +}); + +test('parseHueForgeCSV skips rows with TD out of plausible range', () => { + const csv = `Brand,Color,Name,TD +Inland Basic,#bf9c81,Light Brown,-1 +Inland Basic,#bf9c81,Light Brown,0 +Inland Basic,#bf9c81,Light Brown,99 +Overture Basic,#033877,Blue,3.5`; + const [profile] = parseHueForgeCSV(csv)!; + assert.equal(profile.filaments.length, 1); + assert.equal(profile.filaments[0].td, 3.5); +}); + test('auto-paint profiles can be renamed without changing filament data', () => { const profiles: AutoPaintProfile[] = [ { From 35f2e8573e729eb53187e011493567f7ac80c75e Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 18 Jun 2026 22:36:25 +0300 Subject: [PATCH 04/81] Prepare 3.2.0 development cycle --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- public/version.json | 4 ++-- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 7 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 396752a..e9f46aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Kromacut are documented in this file. +## v3.2.0 - unreleased + +### Added + +### Changed + +### Fixed + ## v3.1.0 - 2026-06-18 ### Added diff --git a/package-lock.json b/package-lock.json index e26282c..9e0073a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kromacut", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kromacut", - "version": "3.1.0", + "version": "3.2.0", "license": "AGPL-3.0-only", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 9939a14..9b40379 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kromacut", "private": true, - "version": "3.1.0", + "version": "3.2.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/public/version.json b/public/version.json index 3802684..42f8d13 100644 --- a/public/version.json +++ b/public/version.json @@ -1,5 +1,5 @@ { - "version": "3.1.0", + "version": "3.2.0", "download_url": "https://github.com/vycdev/Kromacut/releases/latest", - "release_notes": "Kromacut 3.1.0 adds orthographic preview mode, experimental Flat Paint, desktop update settings, and next-best-color filament suggestions." + "release_notes": "Upcoming Kromacut 3.2.0 release with new features and improvements." } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 99efa6e..61370cc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1907,7 +1907,7 @@ dependencies = [ [[package]] name = "kromacut" -version = "3.1.0" +version = "3.2.0" dependencies = [ "log", "reqwest 0.12.28", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3be2eaa..cfbae60 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kromacut" -version = "3.1.0" +version = "3.2.0" description = "Kromacut - Multi-color lithophane 3D print generator" authors = ["vycdev"] license = "AGPL-3.0-only" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3405864..3b89c1e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Kromacut", - "version": "3.1.0", + "version": "3.2.0", "identifier": "com.kromacut.lithophane", "build": { "frontendDist": "../dist", From 2191c2ec4f6e399ea42f883212c918b629435e15 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 20 Jun 2026 21:25:23 +0300 Subject: [PATCH 05/81] 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 06/81] 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 07/81] 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 08/81] 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 09/81] 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 10/81] 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 11/81] 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 8b838c54e7e2821f4ca804ab1c0cb72d9bd19821 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:04:06 -0500 Subject: [PATCH 12/81] update to note tsv in docs, add escaped newline parsing. Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- src/components/AutoPaintTab.tsx | 2 +- src/docs/settings-and-controls.md | 4 +- src/lib/profileManager.ts | 83 ++++++++++++++++++------------- tests/profileManager.test.ts | 11 ++++ 6 files changed, 64 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b438243..5182846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to Kromacut are documented in this file. - **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. - **Flat Paint mode (experimental)** - Added a Flat Paint option to Auto-paint that builds a uniform, face-down slab: each pixel column's layer order is reversed so the artwork sits flat against the build plate (pre-mirrored for face-down printing) under a transparent carrier layer, the back is filled with the foundation filament so every layer has the full footprint, and 3MF export merges the parts into one object per physical filament for AMS/toolchanger printers. Includes flat-mode print instructions, a performance warning for tall stacks, mutual exclusion with Smooth Meshing, and regression tests covering the layout, meshing, STL compaction, and 3MF grouping. - **Desktop update settings** - Added desktop-only settings to manually check for updates and control whether update notices run on startup. -- **HueForge spool CSV import** - Auto-paint filament profiles can now be imported from a HueForge spool library CSV export. Each spool is imported as a filament entry with its HueForge UUID preserved as the filament ID, named `--` (e.g. `Inland Basic-Light Brown-#bf9c81`). Column order in the CSV is flexible. +- **HueForge spool CSV/TSV import** - Auto-paint filament profiles can now be imported from a HueForge spool library CSV or TSV export. Each spool is imported as a filament entry with its HueForge UUID preserved as the filament ID, named `--` (e.g. `Inland Basic-Light Brown-#bf9c81`). Column order is flexible and quoted fields with embedded commas or newlines are handled per RFC 4180. ### Changed diff --git a/README.md b/README.md index 7d96f5f..4b28dfd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ Auto-paint configurations (filament lists) can be saved, loaded, and shared as * - **Load** — Select a saved profile from the dropdown to restore its filaments. - **Rename** — Change the selected profile name without changing its filaments. - **Export** — Download the current filaments as a `.kfil` file (JSON) to share with others. -- **Import** — Load a `.kfil`, legacy `.kapp`, or `.json` profile file from disk. +- **Import** — Load a `.kfil`, legacy `.kapp`, `.json`, or HueForge spool `.csv`/`.tsv` profile file from disk. - **Delete** — Remove a saved profile. Profiles are stored in browser `localStorage` and persist across sessions. diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 669d7fa..737b8da 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -353,7 +353,7 @@ export default function AutoPaintTab({ --`, for example `Inland Basic-Light Brown-#bf9c81`. HueForge UUIDs are preserved as filament IDs so re-importing the same library does not create duplicates. +HueForge spool library exports (`.csv` or `.tsv`) can be imported directly. Use **Export Spools** in HueForge to save a CSV, then click the upload icon in the Auto-paint filament profile toolbar and select the file. The delimiter (comma or tab) is detected automatically from the header row. Each spool becomes a filament entry named `--`, for example `Inland Basic-Light Brown-#bf9c81`. HueForge UUIDs are preserved as filament IDs so re-importing the same library does not create duplicates. ## Desktop Update Notices diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts index 46efc79..1b2c978 100644 --- a/src/lib/profileManager.ts +++ b/src/lib/profileManager.ts @@ -233,53 +233,65 @@ function detectDelimiter(headerLine: string): ',' | '\t' { } /** - * Parse one CSV/TSV row into fields, handling RFC 4180 double-quoted fields - * (commas and newlines inside quotes, "" as escaped quote). + * Parse a CSV/TSV string into rows of fields per RFC 4180: handles embedded + * commas, "" escaped quotes, and newlines inside quoted fields. */ -function parseRow(line: string, delimiter: ',' | '\t'): string[] { - const fields: string[] = []; +function parseCSV(content: string, delimiter: ',' | '\t'): string[][] { + const rows: string[][] = []; + let fields: string[] = []; + let field = ''; let pos = 0; + const len = content.length; - while (pos <= line.length) { - if (pos === line.length) { - // line ended with a delimiter — trailing empty field - fields.push(''); + while (pos <= len) { + if (pos === len) { + fields.push(field); + if (fields.some((f) => f.trim())) rows.push(fields); break; } - if (line[pos] === '"') { - let field = ''; - pos++; // skip opening quote + const ch = content[pos]; + + if (ch === '"') { + pos++; for (;;) { - if (pos >= line.length) break; // unclosed quote — accept what we have - if (line[pos] === '"') { - if (pos + 1 < line.length && line[pos + 1] === '"') { + if (pos >= len) break; // unclosed quote at EOF — accept what we have + if (content[pos] === '"') { + if (pos + 1 < len && content[pos + 1] === '"') { field += '"'; - pos += 2; // escaped quote "" + pos += 2; } else { pos++; // closing quote break; } } else { - field += line[pos++]; + field += content[pos++]; } } + } else if (ch === delimiter) { + fields.push(field); + field = ''; + pos++; + } else if (ch === '\r') { + pos++; + if (pos < len && content[pos] === '\n') pos++; fields.push(field); - if (pos < line.length && line[pos] === delimiter) pos++; - if (pos === line.length) break; // last field, no trailing delimiter + field = ''; + if (fields.some((f) => f.trim())) rows.push(fields); + fields = []; + } else if (ch === '\n') { + fields.push(field); + field = ''; + if (fields.some((f) => f.trim())) rows.push(fields); + fields = []; + pos++; } else { - const end = line.indexOf(delimiter, pos); - if (end === -1) { - fields.push(line.slice(pos)); - break; - } - fields.push(line.slice(pos, end)); - pos = end + 1; - // if pos === line.length the loop continues and pushes trailing '' + field += ch; + pos++; } } - return fields; + return rows; } /** @@ -289,18 +301,20 @@ function parseRow(line: string, delimiter: ',' | '\t'): string[] { * the header row. Column order is flexible. Required columns: Color (hex), * TD (float). Optional: Brand, Name, Uuid. * - * Quoted fields with embedded commas are handled per RFC 4180 ("" escapes a - * literal quote). Rows missing Color or TD are skipped. + * Quoted fields with embedded commas, newlines, and "" escaped quotes are + * supported per RFC 4180. Rows missing Color or TD are skipped. * * Returns null if the input has no header, no valid data rows, or cannot be * parsed. */ export function parseHueForgeCSV(csv: string, profileName = 'HueForge Import'): AutoPaintProfile[] | null { - const lines = csv.split(/\r?\n/).filter((l) => l.trim()); - if (lines.length < 2) return null; + const firstNewline = csv.indexOf('\n'); + const headerLine = firstNewline >= 0 ? csv.slice(0, firstNewline) : csv; + const delimiter = detectDelimiter(headerLine); + const rows = parseCSV(csv, delimiter); + if (rows.length < 2) return null; - const delimiter = detectDelimiter(lines[0]); - const headers = parseRow(lines[0], delimiter).map((h) => h.trim()); + const headers = rows[0].map((h) => h.trim()); const col = (row: string[], name: string) => { const i = headers.indexOf(name); @@ -308,8 +322,7 @@ export function parseHueForgeCSV(csv: string, profileName = 'HueForge Import'): }; const filaments: import('../types').Filament[] = []; - for (const line of lines.slice(1)) { - const row = parseRow(line, delimiter); + for (const row of rows.slice(1)) { const colorRaw = col(row, 'Color'); const color = normalizeHexColor(colorRaw, ''); const tdRaw = col(row, 'TD'); diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index 387bf95..941f631 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -112,6 +112,17 @@ Overture Basic\t#033877\tBlue\t3.5\t{c8518afd-068e-4a5c-90d2-9981d4d7edde}`; assert.equal(profiles[0].filaments[1].color, '#033877'); }); +test('parseHueForgeCSV handles quoted fields containing newlines', () => { + const csv = `Brand,Color,Name,TD +"Inland\nBasic",#bf9c81,"Light\nBrown",1.7`; + const [profile] = parseHueForgeCSV(csv)!; + const [f] = profile.filaments; + assert.equal(f.brand, 'Inland\nBasic'); + assert.equal(f.name, 'Inland\nBasic-Light\nBrown-#BF9C81'); + assert.equal(f.color, '#BF9C81'); + assert.equal(f.td, 1.7); +}); + test('parseHueForgeCSV TSV does not split on commas in values', () => { const tsv = `Brand\tColor\tName\tTD Inland, Basic\t#bf9c81\tLight Brown\t1.7`; From 46a666208f80bc4d3f3e79062a2feaa113edb946 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 21 Jun 2026 21:37:25 +0300 Subject: [PATCH 13/81] 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 14/81] 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 15/81] 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 16/81] 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 27/81] 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 28/81] 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 29/81] 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 30/81] 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 31/81] 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 32/81] 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. -

@@ -484,6 +483,18 @@ export default function AutoPaintTab({ Add Filament + {filaments.length > 0 && ( + + )} + {/* Max Height Constraint */} {filaments.length > 0 && (
@@ -1215,22 +1226,16 @@ export default function AutoPaintTab({
- {/* Calibration Wizard */} - {calibratingFilament && ( - - )} + {/* Calibration Dialog */} + ); } diff --git a/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx new file mode 100644 index 0000000..2e5b2b4 --- /dev/null +++ b/src/components/FilamentCalibrationDialog.tsx @@ -0,0 +1,702 @@ +/** + * Frontlit Filament Calibration Dialog + * + * Camera-free calibration: pick one or more filaments, choose the base layer each + * prints over (defaults to the darkest filament), download a calibration print + * (STL or multi-material 3MF), then report the single layer count at which each + * filament's wedge first matches its opaque reference rail. Each read is converted + * to a frontlit TD and saved to the filament profile. + */ + +import { useCallback, useMemo, useState } from 'react'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { X, Download, Check, Minus, ArrowLeft, ArrowRight, FlaskConical } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Filament } from '../types'; +import { + computeFrontlitCalibration, + predictFrontlitColor, + getConfidenceLabel, + getConfidenceColor, + type FrontlitCalibration, +} from '@/lib/calibration'; +import { + generateCalibrationStl, + generateCalibration3mf, + DEFAULT_CALIBRATION_PRINT_OPTIONS, + type CalibrationPrintOptions, + type CalibrationTile, +} from '@/lib/generateCalibrationPrint'; + +type Step = 'select' | 'base' | 'print' | 'measure'; +type PrintFormat = 'stl' | '3mf'; + +export interface CalibrationApplyUpdate { + id: string; + td: number; + calibration: FrontlitCalibration; +} + +interface FilamentCalibrationDialogProps { + open: boolean; + onClose: () => void; + filaments: Filament[]; + initialFilamentId?: string | null; + layerHeight: number; + firstLayerHeight: number; + onApply: (updates: CalibrationApplyUpdate[]) => void; +} + +const MIN_MAX_LAYERS = 4; +const MAX_MAX_LAYERS = 40; + +function filamentLabel(filament: Filament): string { + return filament.name || filament.brand || `Filament ${filament.color}`; +} + +function luminance(hex: string): number { + const h = hex.replace(/^#/, ''); + if (h.length < 6) return 0; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function rgbCss(rgb: [number, number, number]): string { + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function FilamentCalibrationDialog({ + open, + onClose, + filaments, + initialFilamentId, + layerHeight, + firstLayerHeight, + onApply, +}: FilamentCalibrationDialogProps) { + const [step, setStep] = useState('select'); + const [selectedIds, setSelectedIds] = useState>( + () => new Set(initialFilamentId ? [initialFilamentId] : []) + ); + const [maxLayers, setMaxLayers] = useState(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers); + const [calibrationLayerHeight, setCalibrationLayerHeight] = useState(layerHeight); + // Free-typing drafts for the numeric inputs; clamped to the committed value on blur. + const [maxLayersDraft, setMaxLayersDraft] = useState(() => + String(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers) + ); + const [layerHeightDraft, setLayerHeightDraft] = useState(() => String(layerHeight)); + const [format, setFormat] = useState('stl'); + const [reads, setReads] = useState>({}); + // filament id -> chosen base filament id (defaults to the darkest filament). + const [baseChoices, setBaseChoices] = useState>({}); + + const darkestFilamentId = useMemo(() => { + if (filaments.length === 0) return undefined; + return filaments.reduce((darkest, f) => + luminance(f.color) < luminance(darkest.color) ? f : darkest + ).id; + }, [filaments]); + + const resolveBaseId = useCallback( + (filamentId: string): string => baseChoices[filamentId] ?? darkestFilamentId ?? filamentId, + [baseChoices, darkestFilamentId] + ); + + const resolveBaseColor = useCallback( + (filamentId: string): string => { + const base = filaments.find((f) => f.id === resolveBaseId(filamentId)); + return base?.color ?? '#000000'; + }, + [filaments, resolveBaseId] + ); + + const setBaseChoice = useCallback((filamentId: string, baseId: string) => { + setBaseChoices((prev) => ({ ...prev, [filamentId]: baseId })); + }, []); + + const commitLayerHeight = useCallback(() => { + const parsed = Number(layerHeightDraft); + const next = Number.isFinite(parsed) + ? Math.max(0.04, Math.min(0.4, parsed)) + : calibrationLayerHeight; + setCalibrationLayerHeight(next); + setLayerHeightDraft(String(next)); + }, [layerHeightDraft, calibrationLayerHeight]); + + const commitMaxLayers = useCallback(() => { + const parsed = Math.round(Number(maxLayersDraft)); + const next = + Number.isFinite(parsed) && parsed > 0 + ? Math.max(MIN_MAX_LAYERS, Math.min(MAX_MAX_LAYERS, parsed)) + : maxLayers; + setMaxLayers(next); + setMaxLayersDraft(String(next)); + }, [maxLayersDraft, maxLayers]); + + const selectedFilaments = useMemo( + () => filaments.filter((f) => selectedIds.has(f.id)), + [filaments, selectedIds] + ); + + const printOptions = useMemo( + () => ({ + ...DEFAULT_CALIBRATION_PRINT_OPTIONS, + layerHeight: calibrationLayerHeight, + firstLayerHeight, + maxLayers, + }), + [calibrationLayerHeight, firstLayerHeight, maxLayers] + ); + + const reset = useCallback(() => { + setStep('select'); + setSelectedIds(new Set(initialFilamentId ? [initialFilamentId] : [])); + setMaxLayers(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers); + setMaxLayersDraft(String(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers)); + setCalibrationLayerHeight(layerHeight); + setLayerHeightDraft(String(layerHeight)); + setFormat('stl'); + setReads({}); + setBaseChoices({}); + }, [initialFilamentId, layerHeight]); + + const handleClose = useCallback(() => { + onClose(); + setTimeout(reset, 300); + }, [onClose, reset]); + + const toggleFilament = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const toggleAll = useCallback(() => { + setSelectedIds((prev) => + prev.size === filaments.length ? new Set() : new Set(filaments.map((f) => f.id)) + ); + }, [filaments]); + + const handleDownload = useCallback(async () => { + const tiles: CalibrationTile[] = selectedFilaments.map((f) => ({ + filamentId: f.id, + color: f.color, + baseFilamentId: resolveBaseId(f.id), + baseColor: resolveBaseColor(f.id), + name: filamentLabel(f), + })); + if (tiles.length === 0) return; + + if (format === 'stl') { + // The wedge geometry is identical for every filament, so one STL is + // printed once per filament (swapping the color above the base). + const blob = generateCalibrationStl(printOptions); + downloadBlob(blob, `kromacut-calibration-${maxLayers}layers.stl`); + } else { + // Slots follow the full profile order so they map to the user's machine. + const profileFilaments = filaments.map((f) => ({ + id: f.id, + color: f.color, + name: filamentLabel(f), + })); + const blob = await generateCalibration3mf(tiles, printOptions, profileFilaments); + downloadBlob(blob, `kromacut-calibration-${tiles.length}filaments.3mf`); + } + }, [selectedFilaments, filaments, format, printOptions, maxLayers, resolveBaseId, resolveBaseColor]); + + const setRead = useCallback((id: string, value: string) => { + setReads((prev) => ({ ...prev, [id]: value })); + }, []); + + // Per-filament calibration computed live from the entered reads. + const computed = useMemo(() => { + return selectedFilaments.map((filament) => { + const raw = reads[filament.id]; + const opacityLayers = Number.parseInt(raw ?? '', 10); + if (!Number.isFinite(opacityLayers) || opacityLayers < 1) { + return { filament, result: null, opacityLayers: null }; + } + const result = computeFrontlitCalibration({ + filamentColor: filament.color, + opacityLayers, + layerHeight: calibrationLayerHeight, + firstLayerHeight, + baseColor: resolveBaseColor(filament.id), + maxLayers, + }); + return { filament, result, opacityLayers }; + }); + }, [ + selectedFilaments, + reads, + calibrationLayerHeight, + firstLayerHeight, + maxLayers, + resolveBaseColor, + ]); + + const allReadsValid = + computed.length > 0 && computed.every((c) => c.result !== null && c.result.ok); + + const handleSave = useCallback(() => { + const updates: CalibrationApplyUpdate[] = []; + for (const entry of computed) { + if (entry.result && entry.result.ok) { + updates.push({ + id: entry.filament.id, + td: entry.result.calibration.tdSingleValue, + calibration: entry.result.calibration, + }); + } + } + if (updates.length > 0) onApply(updates); + handleClose(); + }, [computed, onApply, handleClose]); + + const renderSelect = () => ( + <> + + + + Calibrate Filaments + + +
+

+ Pick the filaments to calibrate. You'll print a small wedge for each over a + base layer, then read back a single number — no camera, no color picking. +

+ {filaments.length > 0 && ( + + )} +
+ {filaments.map((filament) => { + const selected = selectedIds.has(filament.id); + const isCalibrated = !!filament.calibration; + return ( + + ); + })} +
+
+ + + + + + ); + + const renderBase = () => ( + <> + + Pick Base Layers + +
+

+ Each filament is calibrated over a base, defaulting to your darkest filament. A + dark filament needs a lighter base (e.g. white) to read against — change its base + here. +

+
+ {selectedFilaments.map((filament) => { + const baseId = baseChoices[filament.id] ?? darkestFilamentId ?? ''; + return ( + + +
+
+ {filamentLabel(filament)} +
+
over base
+
+ +
+ ); + })} +
+
+ + + + + + ); + + const renderPrint = () => ( + <> + + Print the Calibration Wedge + +
+ +
+
+ + setLayerHeightDraft(e.target.value)} + onBlur={commitLayerHeight} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + }} + className="h-8" + /> +

+ Use the layer height you print your models at. +

+
+
+ + setMaxLayersDraft(e.target.value)} + onBlur={commitMaxLayers} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + }} + className="h-8" + /> +

+ The wedge runs 1…{maxLayers} layers. Raise it for very translucent + filaments. +

+
+
+ +
+ +
+ + +
+

+ {format === 'stl' + ? 'Single solid. Print it once per filament: base, then swap to the color above the base.' + : 'Colors + bases baked in for AMS / multi-material — all selected filaments in one print.'} +

+
+ + +
+ + +

How to read it back

+
    +
  • View the print flat under normal room light.
  • +
  • + The patches step from 1 layer (the tab end) up to {maxLayers}, with an + opaque reference rail running along the back edge. +
  • +
  • + Find the first patch{' '} + that looks identical to the rail beside it — that's its opacity layer. +
  • +
+
+
+ + + + + + ); + + const renderMeasure = () => ( + <> + + Enter Opacity Layers + +
+

+ For each filament, enter the first patch number that matched its reference rail. +

+
+ {computed.map(({ filament, result, opacityLayers }) => { + const calibration = result && result.ok ? result.calibration : null; + const errorText = result && !result.ok ? result.error : null; + const predicted = + calibration && opacityLayers + ? predictFrontlitColor( + filament.color, + opacityLayers, + calibrationLayerHeight, + calibration.td, + resolveBaseColor(filament.id) + ) + : null; + return ( + +
+ +
+
+ {filamentLabel(filament)} +
+
+ {filament.color} +
+
+
+ setRead(filament.id, e.target.value)} + placeholder="layer" + className="h-8 w-20 text-sm" + /> + + / {maxLayers} + +
+
+ + {calibration && ( +
+
+ + Match + + + {predicted && ( + + )} +
+
+
+ TD {calibration.tdSingleValue.toFixed(2)}mm +
+
+ {getConfidenceLabel(calibration.confidence)} +
+
+
+ )} + + {errorText && ( +

+ {errorText} +

+ )} +
+ ); + })} +
+
+ + + + + + ); + + return ( + { + if (!isOpen) handleClose(); + }} + > + + + Frontlit filament calibration: generate a print and read back opacity layers to + derive transmission distance. + + + {step === 'select' && renderSelect()} + {step === 'base' && renderBase()} + {step === 'print' && renderPrint()} + {step === 'measure' && renderMeasure()} + + + ); +} diff --git a/src/components/FilamentCalibrationWizard.tsx b/src/components/FilamentCalibrationWizard.tsx deleted file mode 100644 index b757a1e..0000000 --- a/src/components/FilamentCalibrationWizard.tsx +++ /dev/null @@ -1,1085 +0,0 @@ -/** - * Filament Calibration Wizard - * - * Multi-step wizard for calibrating filament Transmission Distance (TD) values. - * Guides users through printing test patches, measuring RGB values, and computing - * TD with confidence scoring. - */ - -import { useState, useCallback, useRef } from 'react'; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card } from '@/components/ui/card'; -import { X, Download, Upload } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { - calculateTDFromMeasurements, - DEFAULT_WHITE_REFERENCE, - getCalibrationInstructions, - getRecommendedLayerCounts, - canCalculateTD, - getConfidenceLabel, - getConfidenceColor, - RECOMMENDED_LAYER_COUNTS, - type CalibrationRgb, - type CalibrationMeasurement, - type CalibrationResult, -} from '@/lib/calibration'; -import { generateCalibrationPatchesStl } from '@/lib/generateCalibrationPatchesStl'; - -type WizardStep = 'intro' | 'print' | 'measure' | 'results'; -type SamplerTarget = 'white-reference' | 'measurement'; - -type RgbInputState = { r: string; g: string; b: string }; -type SamplerPoint = { x: number; y: number }; - -// The brush stays a consistent size on screen, then scales to the source image -// so the outlined area is the exact area being averaged. -const SAMPLER_BRUSH_RADIUS_PX = 16; -const SAMPLER_BRUSH_DIAMETER_PX = SAMPLER_BRUSH_RADIUS_PX * 2; - -const buildRgbInputState = (rgb?: CalibrationRgb): RgbInputState => ({ - r: String(rgb?.[0] ?? DEFAULT_WHITE_REFERENCE[0]), - g: String(rgb?.[1] ?? DEFAULT_WHITE_REFERENCE[1]), - b: String(rgb?.[2] ?? DEFAULT_WHITE_REFERENCE[2]), -}); - -const parseRgbInputState = (rgb: RgbInputState): CalibrationRgb => [ - Math.max(0, Math.min(255, Number.parseInt(rgb.r, 10) || 0)), - Math.max(0, Math.min(255, Number.parseInt(rgb.g, 10) || 0)), - Math.max(0, Math.min(255, Number.parseInt(rgb.b, 10) || 0)), -]; - -const rgbToInputState = (rgb: CalibrationRgb): RgbInputState => ({ - r: String(rgb[0]), - g: String(rgb[1]), - b: String(rgb[2]), -}); - -const formatRgb = (rgb: CalibrationRgb) => `RGB(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; - -function sampleAverageRgb( - ctx: CanvasRenderingContext2D, - centerX: number, - centerY: number, - radius: number -): CalibrationRgb { - const roundedRadius = Math.max(1, Math.round(radius)); - const startX = Math.max(0, centerX - roundedRadius); - const startY = Math.max(0, centerY - roundedRadius); - const endX = Math.min(ctx.canvas.width - 1, centerX + roundedRadius); - const endY = Math.min(ctx.canvas.height - 1, centerY + roundedRadius); - const width = endX - startX + 1; - const height = endY - startY + 1; - const imageData = ctx.getImageData(startX, startY, width, height).data; - - let totalR = 0; - let totalG = 0; - let totalB = 0; - let samples = 0; - - for (let y = startY; y <= endY; y++) { - for (let x = startX; x <= endX; x++) { - const distanceX = x - centerX; - const distanceY = y - centerY; - if (distanceX * distanceX + distanceY * distanceY > roundedRadius * roundedRadius) { - continue; - } - - const pixelIndex = ((y - startY) * width + (x - startX)) * 4; - const alpha = imageData[pixelIndex + 3] / 255; - if (alpha <= 0) continue; - totalR += imageData[pixelIndex] * alpha; - totalG += imageData[pixelIndex + 1] * alpha; - totalB += imageData[pixelIndex + 2] * alpha; - samples += alpha; - } - } - - if (samples <= 0) return [0, 0, 0]; - - return [ - Math.round(totalR / samples), - Math.round(totalG / samples), - Math.round(totalB / samples), - ]; -} - -interface FilamentCalibrationWizardProps { - open: boolean; - onClose: () => void; - onComplete: (result: CalibrationResult) => void; - filamentColor: string; - filamentName?: string; - layerHeight: number; - existingMeasurements?: CalibrationMeasurement[]; - existingWhiteReference?: CalibrationRgb; -} - -export function FilamentCalibrationWizard({ - open, - onClose, - onComplete, - filamentColor, - filamentName = 'Your filament', - layerHeight, - existingMeasurements = [], - existingWhiteReference, -}: FilamentCalibrationWizardProps) { - const [step, setStep] = useState('intro'); - const [measurements, setMeasurements] = useState( - existingMeasurements - ); - const [calibrationLayerHeight, setCalibrationLayerHeight] = useState(layerHeight); - const [whiteReferenceInput, setWhiteReferenceInput] = useState(() => - buildRgbInputState(existingWhiteReference) - ); - const [samplerTarget, setSamplerTarget] = useState('measurement'); - const [pickerImageSrc, setPickerImageSrc] = useState(null); - const [pickerStatus, setPickerStatus] = useState(null); - const [samplerPoint, setSamplerPoint] = useState(null); - const [lastSamplePoint, setLastSamplePoint] = useState(null); - const [currentLayers, setCurrentLayers] = useState(''); - const [currentRGB, setCurrentRGB] = useState({ r: '', g: '', b: '' }); - const [result, setResult] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const pickerFileInputRef = useRef(null); - const pickerImageRef = useRef(null); - const pickerCanvasRef = useRef(null); - - const { recommended } = getRecommendedLayerCounts(measurements); - const whiteReference = parseRgbInputState(whiteReferenceInput); - const currentMeasurementRgb = parseRgbInputState(currentRGB); - const activeSamplerLabel = - samplerTarget === 'white-reference' ? 'White Reference' : 'Measurement RGB'; - const activeSamplerRgb = - samplerTarget === 'white-reference' ? whiteReference : currentMeasurementRgb; - - const handlePickerImageSelected = useCallback( - (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = () => { - setPickerImageSrc(typeof reader.result === 'string' ? reader.result : null); - setSamplerPoint(null); - setLastSamplePoint(null); - setPickerStatus( - 'Image loaded. Move the circular brush over a uniform area, then click to sample it.' - ); - }; - reader.readAsDataURL(file); - event.target.value = ''; - }, - [] - ); - - const handlePickerImageLoad = useCallback(() => { - const image = pickerImageRef.current; - const canvas = pickerCanvasRef.current; - if (!image || !canvas) return; - - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(image, 0, 0); - }, []); - - const getSamplerCoordinates = useCallback((event: React.MouseEvent) => { - const image = pickerImageRef.current; - const canvas = pickerCanvasRef.current; - if (!image || !canvas) return null; - - const rect = image.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return null; - - const normalizedX = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); - const normalizedY = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)); - - return { - imageX: Math.min(canvas.width - 1, Math.floor(normalizedX * canvas.width)), - imageY: Math.min(canvas.height - 1, Math.floor(normalizedY * canvas.height)), - displayPoint: { x: normalizedX * 100, y: normalizedY * 100 }, - imageRadius: Math.max(1, (SAMPLER_BRUSH_RADIUS_PX / rect.width) * canvas.width), - }; - }, []); - - const handleSamplerMove = useCallback( - (event: React.MouseEvent) => { - const coordinates = getSamplerCoordinates(event); - if (coordinates) setSamplerPoint(coordinates.displayPoint); - }, - [getSamplerCoordinates] - ); - - const handleSamplerClick = useCallback( - (event: React.MouseEvent) => { - const canvas = pickerCanvasRef.current; - const coordinates = getSamplerCoordinates(event); - if (!canvas || !coordinates) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const rgb = sampleAverageRgb( - ctx, - coordinates.imageX, - coordinates.imageY, - coordinates.imageRadius - ); - setSamplerPoint(coordinates.displayPoint); - setLastSamplePoint(coordinates.displayPoint); - - if (samplerTarget === 'white-reference') { - setWhiteReferenceInput(rgbToInputState(rgb)); - } else { - setCurrentRGB(rgbToInputState(rgb)); - } - - setPickerStatus( - `Sampled RGB(${rgb[0]}, ${rgb[1]}, ${rgb[2]}) into ${ - samplerTarget === 'white-reference' ? 'white reference' : 'measurement RGB' - }.` - ); - }, - [getSamplerCoordinates, samplerTarget] - ); - - const handleAddMeasurement = useCallback(() => { - const layers = Math.max(1, Number.parseInt(currentLayers, 10) || 1); - const r = Math.max(0, Math.min(255, Number.parseInt(currentRGB.r, 10) || 0)); - const g = Math.max(0, Math.min(255, Number.parseInt(currentRGB.g, 10) || 0)); - const b = Math.max(0, Math.min(255, Number.parseInt(currentRGB.b, 10) || 0)); - - const rgb: CalibrationRgb = [r, g, b]; - - const newMeasurement: CalibrationMeasurement = { - layers, - rgb, - }; - - setMeasurements((prev) => [...prev, newMeasurement]); - setCurrentLayers(''); - setCurrentRGB({ r: '', g: '', b: '' }); - }, [currentLayers, currentRGB]); - - const handleRemoveMeasurement = useCallback((index: number) => { - setMeasurements((prev) => prev.filter((_, i) => i !== index)); - }, []); - - const handleCalculate = useCallback(() => { - const { ready, reason } = canCalculateTD(measurements, whiteReference); - if (!ready) { - setErrorMessage(reason || 'Cannot calculate TD yet.'); - return; - } - - try { - const { td, tdSingleValue, confidence } = calculateTDFromMeasurements( - measurements, - calibrationLayerHeight, - whiteReference, - filamentColor - ); - - const calibrationResult: CalibrationResult = { - color: filamentColor, - measurements, - whiteReference, - td, - tdSingleValue, - confidence, - calibrationDate: new Date().toISOString(), - notes: `Calibrated for ${filamentName}`, - }; - - setResult(calibrationResult); - setStep('results'); - setErrorMessage(null); - } catch (err) { - setErrorMessage(err instanceof Error ? err.message : 'Failed to calculate TD.'); - } - }, [measurements, calibrationLayerHeight, filamentColor, filamentName, whiteReference]); - - const handleComplete = useCallback(() => { - if (result) { - onComplete(result); - onClose(); - // Reset state - setStep('intro'); - setMeasurements(existingMeasurements); - setCalibrationLayerHeight(layerHeight); - setWhiteReferenceInput(buildRgbInputState(existingWhiteReference)); - setSamplerTarget('measurement'); - setPickerImageSrc(null); - setPickerStatus(null); - setSamplerPoint(null); - setLastSamplePoint(null); - setResult(null); - setErrorMessage(null); - } - }, [result, onComplete, onClose, existingMeasurements, existingWhiteReference, layerHeight]); - - const handleCancel = useCallback(() => { - onClose(); - // Reset state after a short delay to avoid visible state change before closing - setTimeout(() => { - setStep('intro'); - setMeasurements(existingMeasurements); - setCalibrationLayerHeight(layerHeight); - setWhiteReferenceInput(buildRgbInputState(existingWhiteReference)); - setSamplerTarget('measurement'); - setPickerImageSrc(null); - setPickerStatus(null); - setSamplerPoint(null); - setLastSamplePoint(null); - setCurrentLayers(''); - setCurrentRGB({ r: '', g: '', b: '' }); - setResult(null); - setErrorMessage(null); - }, 300); - }, [onClose, existingMeasurements, existingWhiteReference, layerHeight]); - - const renderIntro = () => ( - <> - - Calibrate Filament TD - -
-

- Calibrating Transmission Distance (TD) will give you more accurate - auto-paint results. -

-

You will need:

-
    -
  • {filamentName}
  • -
  • A 3D printer
  • -
  • A backlit white surface (phone screen works great)
  • -
  • A color picker tool (digital or app)
  • -
-

- This process takes about 15-20 minutes including print time. -

-
- - - - - - ); - - const handleDownloadPatches = () => { - const blob = generateCalibrationPatchesStl(RECOMMENDED_LAYER_COUNTS, calibrationLayerHeight); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `calibration_patches_${calibrationLayerHeight.toFixed(2)}mm.stl`; - a.click(); - URL.revokeObjectURL(url); - }; - - const renderPrintInstructions = () => { - const instructions = getCalibrationInstructions(calibrationLayerHeight); - - return ( - <> - - Step 1: Print Test Patches - -
- -
- {instructions.map((instruction, i) => ( -

- {i === 0 ? '📋' : i === instructions.length - 1 ? '📊' : '🔧'}{' '} - {instruction} -

- ))} -
-
- - -

Print Settings:

-
    -
  • - Filament:{' '} - {' '} - {filamentName} -
  • -
  • - Layer Height: - setCalibrationLayerHeight(Number(e.target.value))} - step="0.01" - min="0.05" - max="0.4" - className="w-20 h-7 text-xs" - /> - mm -
  • -
  • - Infill: 100% -
  • -
  • - Patch Size: 20mm × 20mm (or larger) -
  • -
  • - Layer Counts: {recommended.join(', ')} layers -
  • -
- -
- -

- 💡 Tip: Label each patch with its layer count using a marker. -

-
- - - - - - ); - }; - - const renderMeasurement = () => { - const { ready, reason } = canCalculateTD(measurements, whiteReference); - - return ( - <> - - Step 2: Measure RGB Values - -

- Keep the same backlit setup for every sample. Measure the empty backlight - first, then capture each printed patch from the center. -

- -
- -
-
-
- -

- Upload a photo or screenshot, choose where clicks should go, - then use the circular brush to capture an averaged RGB sample. -

-
- - -
- -
-
-
-
- Sampling Target -
-
- - -
-
- -
-
-
-
- Active Preview -
-
- {activeSamplerLabel} -
-
- {formatRgb(activeSamplerRgb)} -
-
-
-
-
- - {pickerImageSrc ? ( -
-
-
- Uploaded calibration sample setSamplerPoint(null)} - onClick={handleSamplerClick} - draggable={false} - /> - {lastSamplePoint && ( - -
- {pickerStatus ?? - 'Move the circular brush over a uniform area, then click to capture it.'} -
-
- ) : ( -
-
-

- No image loaded yet -

-

- Upload a calibration photo to sample directly from it, or - keep entering RGB values manually below. -

-
-
- )} - - -
- - -
- -
-
-
- -

- Sample the empty backlight first so the calibration uses - your real light source instead of assuming pure white. -

-
-
-
-
-
- Current -
-
- {formatRgb(whiteReference)} -
-
-
-
- -
-
- - - setWhiteReferenceInput((prev) => ({ - ...prev, - r: e.target.value, - })) - } - placeholder="255" - /> -
-
- - - setWhiteReferenceInput((prev) => ({ - ...prev, - g: e.target.value, - })) - } - placeholder="255" - /> -
-
- - - setWhiteReferenceInput((prev) => ({ - ...prev, - b: e.target.value, - })) - } - placeholder="255" - /> -
-
- -
-
- Tip: use the sampler with - {' '} - - Fill White Reference - - {' '} - selected. -
- -
-
- - - -
-
-
- -

- Enter the patch layer count, then fill or sample the RGB - value from the center of that patch. -

-
-
-
-
-
- Draft Sample -
-
- {formatRgb(currentMeasurementRgb)} -
-
-
-
- -
-
-
- Sampler shortcut: - {' '} - - {samplerTarget === 'measurement' - ? 'currently filling Measurement RGB' - : 'switch sampler target to Measurement RGB'} - -
-
- Layers {currentLayers || '2'} -
-
-
- -
-
- - setCurrentLayers(e.target.value)} - placeholder="2" - /> -
-
- - - setCurrentRGB((prev) => ({ - ...prev, - r: e.target.value, - })) - } - placeholder="0-255" - /> -
-
- - - setCurrentRGB((prev) => ({ - ...prev, - g: e.target.value, - })) - } - placeholder="0-255" - /> -
-
- - - setCurrentRGB((prev) => ({ - ...prev, - b: e.target.value, - })) - } - placeholder="0-255" - /> -
-
- - -
- -
- - {measurements.length > 0 ? ( - -
-
- -

- {measurements.length} captured so far. You need at least 3 - to calculate TD. -

-
-
- {measurements.map((m, i) => ( -
-
-
- {m.layers} layers -
-
-
-
- {formatRgb(m.rgb)} -
-
- Normalized from your white reference -
-
-
- -
- ))} -
-
- - ) : ( - -
-

- No measurements added yet -

-

- Recommended patch counts:{' '} - - {recommended.join(', ')} - -

-
-
- )} - - {!ready && reason && ( -
- {reason} -
- )} - - {errorMessage && ( -
- {errorMessage} -
- )} -
- - - - - - - ); - }; - - const renderResults = () => { - if (!result) return null; - - const confidenceLabel = getConfidenceLabel(result.confidence); - const confidenceColor = getConfidenceColor(result.confidence); - - return ( - <> - - Calibration Complete! 🎉 - -

- Your filament has been calibrated successfully. -

- -
- -
-
-
-
-

{filamentName}

-

- {result.measurements.length} measurements -

-
-
-
-

- {result.tdSingleValue.toFixed(2)}mm -

-

Auto-paint working TD

-
-
- -
-
-

R: {result.td[0].toFixed(2)}mm

-
-
-

G: {result.td[1].toFixed(2)}mm

-
-
-

B: {result.td[2].toFixed(2)}mm

-
-
- - {result.whiteReference && ( -
- White reference: - - RGB({result.whiteReference[0]}, {result.whiteReference[1]},{' '} - {result.whiteReference[2]}) - -
- )} - -
- Confidence: - - {confidenceLabel} ({(result.confidence * 100).toFixed(0)}%) - -
- -

- This value blends the measured patch fit with the filament color - heuristic when the photo data is noisy or weakly informative. -

- - -

- 💡 This calibration will be saved with your filament profile and improve - auto-paint accuracy. -

-
- - - - - - - ); - }; - - return ( - { - if (!isOpen) { - onClose(); - } - }}> - - - Filament calibration wizard for measuring transmission distance (TD) values to improve auto-paint results - - - {step === 'intro' && renderIntro()} - {step === 'print' && renderPrintInstructions()} - {step === 'measure' && renderMeasurement()} - {step === 'results' && renderResults()} - - - ); -} diff --git a/src/components/FilamentRow.tsx b/src/components/FilamentRow.tsx index 0ae437d..6ae3b83 100644 --- a/src/components/FilamentRow.tsx +++ b/src/components/FilamentRow.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Trash2, Wand2, FlaskConical, BadgeCheck } from 'lucide-react'; +import { Trash2, Wand2, BadgeCheck } from 'lucide-react'; import { HexColorPicker } from 'react-colorful'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { Filament } from '../types'; @@ -12,14 +12,12 @@ interface FilamentRowProps { filament: Filament; onUpdate: (id: string, updates: Partial>) => void; onRemove: (id: string) => void; - onCalibrate?: (id: string) => void; // Optional calibration callback } const FilamentRow = React.memo(function FilamentRow({ filament, onUpdate, onRemove, - onCalibrate, }: FilamentRowProps) { // Local state for the input value to allow free typing const [localTd, setLocalTd] = useState(filament.td.toString()); @@ -177,31 +175,14 @@ const FilamentRow = React.memo(function FilamentRow({ - {/* Calibration Button & Badge */} - {onCalibrate && ( -
- {isCalibrated && ( -
- - {confidenceLabel} -
- )} - + {/* Calibration Badge */} + {isCalibrated && ( +
+ + {confidenceLabel}
)} diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index d29597b..fa11b1c 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -497,6 +497,7 @@ export default function ThreeDControls({ error={autoPaintError} calibrationLayerHeight={calibrationLayerHeight} setCalibrationLayerHeight={setCalibrationLayerHeight} + firstLayerHeight={slicerFirstLayerHeight} filteredCount={filtered.length} imageSwatches={filtered} enhancedColorMatch={enhancedColorMatch} diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index 5054c20..f26d349 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -42,17 +42,18 @@ Auto-paint uses real filament colors and **Transmission Distance (TD)** values. - Filament color. - TD value. -Use the wand button to auto-estimate TD from color, or use the calibration button to measure TD from printed test patches. +Use the wand button to auto-estimate TD from color, or calibrate to measure TD from a printed wedge. ## Calibrating Filament TD -Click the calibration button on a filament row to open **Calibrate Filament TD**. The wizard has three parts: +Calibration is camera-free: you print a small wedge and read back a single number. Click the calibration button on a filament row, or **Calibrate Filaments** below the filament list, to open the dialog. It has four steps: -1. **Step 1: Print Test Patches** lists the filament, layer height, 100% infill, patch size, and layer counts. Use **Download Test Patches STL** if you want Kromacut to generate the patch model. -2. **Step 2: Measure RGB Values** lets you enter measurements manually or upload a photo with **Image Sampler**. Use **Fill White Reference** on the empty backlight first, then use **Fill Measurement RGB** for each printed patch. The visible circular brush averages the pixels inside it, so place it over a uniform patch area and away from edges or glare. -3. **Calibration Complete** shows the fitted TD value, RGB channel estimates, white reference, and confidence. Click **Save Calibration** to apply it to the filament. +1. **Select** the filaments you want to calibrate. +2. **Base layers** — choose the base filament each one prints over. It defaults to your darkest filament (matching how frontlit prints are viewed), and the base comes from your real profile filaments, so no extra "phantom" filament is added to the print. A dark filament has too little contrast with a dark base, so switch its base to a lighter one (e.g. white) here. +3. **Print** the calibration wedge. Set your layer height and the wedge length (max layers), then download an **STL** (any printer — print it once per filament, base then your color above the base) or a multi-material **3MF** (colors and bases baked in, for AMS/multi-material). Each filament prints as a row of patches from 1 layer up, with an opaque reference rail running along the edge beside the patches. A foot marks the 1-layer end. +4. **Enter Results** by reporting, for each filament, the **first patch that looks identical to the reference rail** beside it — that is its opacity layer. Kromacut converts that number into a frontlit TD (with per-channel values), shows a predicted-vs-reference swatch, the TD, and a confidence rating. Click **Save Calibration** to apply it. -Use at least three saved measurements before calculating TD. More measurements usually improve confidence if the lighting and sampling setup stays consistent. +Because the reference rail is the filament's own fully-opaque color, you are only judging whether a patch matches the rail right next to it — a comparison that stays reliable across lighting and screens. The measured TD is a material property, so it transfers to your real prints regardless of which base you calibrated against. ## Filament Profiles diff --git a/src/docs/troubleshooting.md b/src/docs/troubleshooting.md index 5a95206..86d82cf 100644 --- a/src/docs/troubleshooting.md +++ b/src/docs/troubleshooting.md @@ -45,7 +45,7 @@ Common causes: - The optimizer needs **Enhanced color matching** enabled. - The important subject is in the center or edges but **Region priority** is set to **Uniform**. -Use the calibration wizard and check **Result Confidence** for clues. +Calibrate your filaments and check **Result Confidence** for clues. ## The 3D Build Is Slow diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 4e2ff39..1d59f05 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -389,8 +389,10 @@ function transitionThicknessMultiplier(targetOpacity: number): number { } /** - * Frontlit prints behave optically like a much shorter effective TD. - * Scale user-entered TD values down for internal simulation. + * Fallback for UNCALIBRATED filaments only: frontlit prints behave optically like + * a much shorter effective TD, so a generic user-entered TD is scaled down for + * internal simulation. Calibrated filaments carry measured frontlit TDs and skip + * this — the frontlit calibration flow exists to replace this approximation. */ const FRONTLIT_TD_SCALE = 0.1; const USE_CALIBRATED_CHANNEL_TD = true; @@ -407,21 +409,13 @@ function calibratedTdChannels(filament: AutoPaintFilament): CalibrationRgb | und } function scaleFilamentForFrontlight(filament: Filament): Filament { - const scaledTd = filament.td * FRONTLIT_TD_SCALE; - if (!filament.calibration) { - return { ...filament, td: scaledTd }; + // Calibrated filaments already carry measured frontlit TDs (scalar + per + // channel), so they pass through unchanged. Only uncalibrated filaments get + // the generic frontlit approximation. + if (filament.calibration) { + return filament; } - - 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, - }, - }; + return { ...filament, td: filament.td * FRONTLIT_TD_SCALE }; } /** diff --git a/src/lib/calibration.ts b/src/lib/calibration.ts index 889358e..c8023a2 100644 --- a/src/lib/calibration.ts +++ b/src/lib/calibration.ts @@ -1,486 +1,287 @@ -import { estimateTDFromColor } from './colorUtils'; -import { blendSrgbChannel, srgbChannelToLinear } from './colorSpace'; - /** - * Filament Calibration System + * Frontlit filament calibration. * - * Implements TD (Transmission Distance) calibration workflow where users measure - * light transmission through stacked filament layers to derive accurate TD values. + * Camera-free workflow: the user prints a wedge of 1..N filament layers over a + * black base plus a fully-opaque reference patch, then reports the single layer + * count at which the wedge first becomes indistinguishable from the reference. + * That one integer is converted to a frontlit Transmission Distance (TD) here. * - * Calibration process: - * 1. User prints test patches at different layer counts (e.g., 2, 4, 6, 8, 10 layers) - * 2. User photographs patches on backlit surface and samples RGB values - * 3. Algorithm fits a linear-light Beer-Lambert curve to derive TD for each color channel - * 4. Confidence score computed based on fit quality and measurement consistency + * The "indistinguishable" judgment is a perceptual just-noticeable difference, so + * opacity is defined the same way: the transmission `T*` at which the over-black + * blend sits exactly `jnd` ΔE00 from the opaque filament color. `T*` depends only + * on the filament color and the JND (not on TD), so it is a per-color "effective + * opacity transmission" — `TD = -d_opaque / log10(T*)` recovers the Beer-Lambert + * form with a principled, color-aware constant instead of a hand-picked number. */ +import { blendSrgbChannel } from './colorSpace'; +import { deltaE2000 } from './colorDifference'; + // ============================================================================ -// Type Definitions +// Types // ============================================================================ -/** - * Single measurement point: layer count and measured RGB. - * - * Transmission is intentionally NOT stored. It is a derived quantity, and the - * TD fit recomputes it from `rgb` using the same linear-light blend model - * Auto-paint uses (see transmissionFromMeasuredBlend), so there is no second, - * divergent definition to keep in sync. - */ export type CalibrationRgb = [number, number, number]; -export interface CalibrationMeasurement { - layers: number; // Number of layers printed - rgb: CalibrationRgb; // Measured RGB value (0-255) +export interface FrontlitCalibration { + /** Layer count at which the wedge first matched the opaque reference. */ + opacityLayers: number; + /** Layer height the calibration print used (mm). */ + layerHeight: number; + /** First-layer height the calibration print used (mm). */ + firstLayerHeight: number; + /** Per-channel frontlit TD derived from the read (mm). */ + td: CalibrationRgb; + /** Scalar working TD used by auto-paint (mm). */ + tdSingleValue: number; + /** JND used for the opacity solve. */ + jnd: number; + /** Base color the wedge was calibrated over (hex). */ + baseColor?: string; + /** 0-1 quality score. */ + confidence: number; + /** Calibration basis, fixed for now. */ + basis: 'black-frontlit'; + /** ISO timestamp. */ + calibrationDate: string; + notes?: string; } -/** - * Complete calibration result for a filament - */ -export interface CalibrationResult { - color: string; // Hex color - measurements: CalibrationMeasurement[]; - whiteReference?: CalibrationRgb; // Measured backlight RGB used to normalize transmission - td: CalibrationRgb; // Fitted TD for R, G, B channels (mm) - tdSingleValue: number; // Auto-paint working TD derived from the measured samples (mm) - confidence: number; // 0-1 score based on fit quality - calibrationDate: string; // ISO timestamp - notes?: string; // Optional user notes -} +export type FrontlitCalibrationResult = + | { ok: true; calibration: FrontlitCalibration } + | { ok: false; error: string }; -/** - * Calibration wizard state - */ -export interface CalibrationState { +export interface FrontlitCalibrationInput { filamentColor: string; - measurements: CalibrationMeasurement[]; - whiteReference: CalibrationRgb; - currentStep: 'intro' | 'print' | 'measure' | 'results'; - layerHeight: number; // mm per layer + opacityLayers: number; + layerHeight: number; + firstLayerHeight: number; + /** Base color the wedge prints over (hex). Defaults to black. */ + baseColor?: string; + /** Highest layer count printed on the wedge; used to flag clipped reads. */ + maxLayers?: number; + jnd?: number; + notes?: string; } // ============================================================================ // Constants // ============================================================================ -export const RECOMMENDED_LAYER_COUNTS = [2, 4, 6, 8, 10]; -export const DEFAULT_WHITE_REFERENCE: CalibrationRgb = [255, 255, 255]; -const MIN_MEASUREMENTS = 3; -const CONFIDENCE_THRESHOLD_EXCELLENT = 0.9; -const CONFIDENCE_THRESHOLD_GOOD = 0.7; -const WORKING_TD_MIN = 0.4; -const WORKING_TD_MAX = 12.0; -const WORKING_TD_GRID_STEPS = 240; -const MIN_CHANNEL_CONTRAST = 12; - -const clampRgbChannel = (value: number, min: number) => { - if (!Number.isFinite(value)) return min; - return Math.min(255, Math.max(min, Math.round(value))); -}; - -function sanitizeWhiteReference( - whiteReference: CalibrationRgb = DEFAULT_WHITE_REFERENCE -): CalibrationRgb { - return [ - clampRgbChannel(whiteReference[0], 1), - clampRgbChannel(whiteReference[1], 1), - clampRgbChannel(whiteReference[2], 1), - ]; -} +/** Default base the calibration wedge prints on (black). */ +export const FRONTLIT_BASE: CalibrationRgb = [0, 0, 0]; +export const FRONTLIT_BASE_HEX = '#000000'; -export function validateWhiteReference(whiteReference: CalibrationRgb): { - valid: boolean; - error?: string; -} { - const [r, g, b] = whiteReference; - if (r < 1 || r > 255 || g < 1 || g > 255 || b < 1 || b > 255) { - return { - valid: false, - error: 'White reference RGB values must be between 1 and 255', - }; - } - return { valid: true }; -} +/** + * Perceptual just-noticeable difference (CIEDE2000) defining "indistinguishable + * from the opaque reference patch." ~1 is the textbook JND; 2 is forgiving for + * real room-light viewing. This is a human-vision constant, NOT fit to any + * filament — per-filament optics come from the read. Tune against physical + * frontlit prints. + */ +export const OPACITY_JND = 2.0; -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +const FRONTLIT_TD_MIN = 0.05; +const FRONTLIT_TD_MAX = 12.0; -function getBlendChannelWeights( - filamentRgb: CalibrationRgb, - whiteReference: CalibrationRgb -): CalibrationRgb { - const rawWeights: CalibrationRgb = [0, 1, 2].map((channel) => { - const contrast = Math.abs(whiteReference[channel] - filamentRgb[channel]); - return contrast >= MIN_CHANNEL_CONTRAST ? contrast : contrast * 0.25; - }) as CalibrationRgb; - const total = rawWeights.reduce((sum, weight) => sum + weight, 0); +const CONFIDENCE_THRESHOLD_EXCELLENT = 0.9; +const CONFIDENCE_THRESHOLD_GOOD = 0.7; - if (total <= 1e-6) { - return [1 / 3, 1 / 3, 1 / 3]; - } +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); - return rawWeights.map((weight) => weight / total) as CalibrationRgb; +function hexToRgb(hex: string): CalibrationRgb | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] + : null; } -function predictWorkingBlendRgb( +// ============================================================================ +// Opacity-read calibration +// ============================================================================ + +function blendOverBase( filamentRgb: CalibrationRgb, - whiteReference: CalibrationRgb, - td: number, - thickness: number + transmission: number, + base: CalibrationRgb = FRONTLIT_BASE ): CalibrationRgb { - const transmission = Math.pow(10, -thickness / td); return [ - Math.round(blendSrgbChannel(whiteReference[0], filamentRgb[0], transmission)), - Math.round(blendSrgbChannel(whiteReference[1], filamentRgb[1], transmission)), - Math.round(blendSrgbChannel(whiteReference[2], filamentRgb[2], transmission)), + blendSrgbChannel(base[0], filamentRgb[0], transmission), + blendSrgbChannel(base[1], filamentRgb[1], transmission), + blendSrgbChannel(base[2], filamentRgb[2], transmission), ]; } -function transmissionFromMeasuredBlend( - measured: number, - filament: number, - background: number -): number | undefined { - const filamentLinear = srgbChannelToLinear(filament); - const backgroundLinear = srgbChannelToLinear(background); - const contrast = backgroundLinear - filamentLinear; - - if (Math.abs(contrast) < 1e-6) return undefined; - - const transmission = (srgbChannelToLinear(measured) - filamentLinear) / contrast; - return transmission > 0 && transmission < 1 ? transmission : undefined; -} - -function evaluateWorkingTdFit( - measurements: CalibrationMeasurement[], - layerHeight: number, - filamentRgb: CalibrationRgb, - whiteReference: CalibrationRgb, - channelWeights: CalibrationRgb, - td: number -): number { - let weightedSquaredError = 0; - - for (const measurement of measurements) { - const thickness = measurement.layers * layerHeight; - const predicted = predictWorkingBlendRgb(filamentRgb, whiteReference, td, thickness); - - weightedSquaredError += - channelWeights[0] * Math.pow(predicted[0] - measurement.rgb[0], 2) + - channelWeights[1] * Math.pow(predicted[1] - measurement.rgb[1], 2) + - channelWeights[2] * Math.pow(predicted[2] - measurement.rgb[2], 2); - } - - return weightedSquaredError / measurements.length; -} - -function fitWorkingTdFromMeasurements( - measurements: CalibrationMeasurement[], - layerHeight: number, +/** + * Solve for the transmission `T*` at which the over-base blend is exactly `jnd` + * ΔE00 from the opaque filament color. ΔE00 grows monotonically with T (more + * base shows through), so a bisection converges to a unique value. + * + * Returns undefined when even full background (T=1, pure base) is within the JND + * of the filament — i.e. the filament has too little contrast with the base to be + * opacity-calibrated (e.g. a black filament over a black base; switch to a lighter + * base). + */ +export function solveOpacityTransmission( filamentColor: string, - whiteReference: CalibrationRgb = DEFAULT_WHITE_REFERENCE -): { td: number; confidence: number } { - const filamentRgb = hexToRgb(filamentColor); - if (!filamentRgb) { - return { td: estimateTDFromColor(filamentColor), confidence: 0.1 }; - } - - const reference = sanitizeWhiteReference(whiteReference); - const channelWeights = getBlendChannelWeights(filamentRgb, reference); - const heuristicTd = estimateTDFromColor(filamentColor); - const logMin = Math.log(WORKING_TD_MIN); - const logMax = Math.log(WORKING_TD_MAX); + jnd = OPACITY_JND, + baseColor: string = FRONTLIT_BASE_HEX +): number | undefined { + const rgb = hexToRgb(filamentColor); + const base = hexToRgb(baseColor) ?? FRONTLIT_BASE; + if (!rgb) return undefined; - let bestTd = heuristicTd; - let bestError = Number.POSITIVE_INFINITY; + const deltaAt = (t: number) => deltaE2000(blendOverBase(rgb, t, base), rgb); - for (let i = 0; i < WORKING_TD_GRID_STEPS; i++) { - const t = i / (WORKING_TD_GRID_STEPS - 1); - const candidateTd = Math.exp(logMin + (logMax - logMin) * t); - const error = evaluateWorkingTdFit( - measurements, - layerHeight, - filamentRgb, - reference, - channelWeights, - candidateTd - ); - - if (error < bestError) { - bestError = error; - bestTd = candidateTd; - } - } + // Not enough contrast with the base to ever reach the JND. + if (deltaAt(1) < jnd) return undefined; - let refineMin = Math.max(WORKING_TD_MIN, bestTd / 1.8); - let refineMax = Math.min(WORKING_TD_MAX, bestTd * 1.8); - - for (let pass = 0; pass < 2; pass++) { - let passBestTd = bestTd; - let passBestError = bestError; - - for (let i = 0; i < WORKING_TD_GRID_STEPS; i++) { - const t = i / (WORKING_TD_GRID_STEPS - 1); - const candidateTd = refineMin + (refineMax - refineMin) * t; - const error = evaluateWorkingTdFit( - measurements, - layerHeight, - filamentRgb, - reference, - channelWeights, - candidateTd - ); - - if (error < passBestError) { - passBestError = error; - passBestTd = candidateTd; - } - } - - bestTd = passBestTd; - bestError = passBestError; - refineMin = Math.max(WORKING_TD_MIN, bestTd / 1.35); - refineMax = Math.min(WORKING_TD_MAX, bestTd * 1.35); + let lo = 0; // ΔE = 0 at T=0 (pure filament) + let hi = 1; // ΔE maximal at T=1 (pure base) + for (let i = 0; i < 48; i++) { + const mid = (lo + hi) / 2; + if (deltaAt(mid) < jnd) lo = mid; + else hi = mid; } - - const weightedRmse = Math.sqrt(bestError); - const averageContrast = - (Math.abs(reference[0] - filamentRgb[0]) + - Math.abs(reference[1] - filamentRgb[1]) + - Math.abs(reference[2] - filamentRgb[2])) / - 3; - const contrastStrength = clamp(averageContrast / 255, 0, 1); - const measurementCoverage = clamp( - (measurements.length - MIN_MEASUREMENTS) / (RECOMMENDED_LAYER_COUNTS.length - MIN_MEASUREMENTS), - 0, - 1 - ); - const fitConfidence = clamp(1 - weightedRmse / 28, 0, 1); - const agreement = - Math.min(bestTd, heuristicTd) / Math.max(bestTd, heuristicTd, WORKING_TD_MIN); - const measurementInfluence = clamp( - fitConfidence * - (0.3 + 0.7 * contrastStrength) * - (0.6 + 0.4 * measurementCoverage) * - (0.35 + 0.65 * Math.sqrt(agreement)), - 0, - 0.9 - ); - - const blendedTd = clamp( - heuristicTd + (bestTd - heuristicTd) * measurementInfluence, - WORKING_TD_MIN, - WORKING_TD_MAX - ); - const confidence = clamp(0.25 + 0.75 * Math.max(fitConfidence, measurementInfluence), 0.1, 1); - - return { td: blendedTd, confidence }; + return (lo + hi) / 2; } -// ============================================================================ -// Core Calibration Algorithm -// ============================================================================ - /** - * Calculate TD from calibration measurements using Beer-Lambert law. - * - * Beer-Lambert: T = 10^(-d/TD) - * Where T = transmission, d = distance (layers × layer_height), TD = transmission distance - * - * Solving for TD: TD = -d / log10(T) - * - * For each channel, we compute TD from each measurement pair, then - * use weighted least-squares to fit a robust average. + * Derive per-channel TDs from the single measured TD, modulated by the filament + * color. Uses a `1 + value*5.8` shape per channel (the per-channel form of the + * estimateTDFromColor luminance heuristic): a brighter channel is more + * transmissive (larger TD). The brightest channel — which governs the opacity + * the eye reads — is anchored to `tdSingle`, so darker (more absorptive) channels + * scale down from there. */ -export function calculateTDFromMeasurements( - measurements: CalibrationMeasurement[], - layerHeight: number, - whiteReference: CalibrationRgb = DEFAULT_WHITE_REFERENCE, - filamentColor: string = '#808080' -): { td: [number, number, number]; tdSingleValue: number; confidence: number } { - if (measurements.length < MIN_MEASUREMENTS) { - throw new Error( - `Need at least ${MIN_MEASUREMENTS} measurements, got ${measurements.length}` - ); - } - - // Sort measurements by layer count - const sorted = [...measurements].sort((a, b) => a.layers - b.layers); - - // Compute TD for each channel independently - const tdChannels: [number, number, number] = [0, 0, 0]; - const confidences: [number, number, number] = [0, 0, 0]; - - const filamentRgb = hexToRgb(filamentColor) ?? [128, 128, 128]; - const reference = sanitizeWhiteReference(whiteReference); +export function deriveChannelTds(filamentColor: string, tdSingle: number): CalibrationRgb { + const rgb = hexToRgb(filamentColor) ?? [128, 128, 128]; + const raw = rgb.map((c) => 1 + (c / 255) * 5.8) as CalibrationRgb; + const anchor = Math.max(raw[0], raw[1], raw[2]); + const k = tdSingle / anchor; + return raw.map((value) => clamp(k * value, FRONTLIT_TD_MIN, FRONTLIT_TD_MAX)) as CalibrationRgb; +} - for (let channel = 0; channel < 3; channel++) { - const { td, confidence } = fitTDForChannel( - sorted, - channel, - layerHeight, - filamentRgb, - reference - ); - tdChannels[channel] = td; - confidences[channel] = confidence; +function frontlitConfidence(opacityLayers: number, tdSingle: number, maxLayers?: number): number { + let confidence = 0.9; + // Reads pinned at either extreme of the wedge are less trustworthy: the true + // opacity point may lie outside the printed range. + if (opacityLayers <= 1) confidence -= 0.3; + if (maxLayers !== undefined && opacityLayers >= maxLayers) confidence -= 0.3; + // A TD against either clamp edge means the read fell outside the model's band. + if (tdSingle <= FRONTLIT_TD_MIN * 1.5 || tdSingle >= FRONTLIT_TD_MAX * 0.9) { + confidence -= 0.2; } - - const workingFit = fitWorkingTdFromMeasurements( - sorted, - layerHeight, - filamentColor, - whiteReference - ); - const tdSingleValue = workingFit.td; - - // Overall confidence combines the per-channel fit stability with the - // working-TD fit quality used by auto-paint. - const confidence = (Math.min(...confidences) + workingFit.confidence) / 2; - - return { td: tdChannels, tdSingleValue, confidence }; + return clamp(confidence, 0.1, 1); } /** - * Fit TD for a single color channel using weighted least-squares + * Convert a single opacity-layer read into a frontlit calibration. + * + * The color wedge prints on top of the black base, so every color layer is a + * regular layer — `d_opaque = opacityLayers * layerHeight`. `firstLayerHeight` + * is stored (it governs the black base bottom layer and keeps auto-paint's + * thickness model consistent) but does not enter the color thickness. */ -function fitTDForChannel( - measurements: CalibrationMeasurement[], - channel: number, - layerHeight: number, - filamentRgb: CalibrationRgb, - whiteReference: CalibrationRgb -): { td: number; confidence: number } { - // Compute TD from each measurement - const tdEstimates: Array<{ td: number; thickness: number; transmission: number }> = []; - - for (const measurement of measurements) { - const transmission = transmissionFromMeasuredBlend( - measurement.rgb[channel], - filamentRgb[channel], - whiteReference[channel] - ); - if (transmission === undefined) continue; // Skip invalid or zero-contrast measurements - - const thickness = measurement.layers * layerHeight; - const td = -thickness / Math.log10(transmission); - - if (td > 0 && td < 100) { - // Sanity check: TD should be 0.5-20mm typically - tdEstimates.push({ td, thickness, transmission }); - } +export function computeFrontlitCalibration( + input: FrontlitCalibrationInput +): FrontlitCalibrationResult { + const { filamentColor, opacityLayers, layerHeight, firstLayerHeight } = input; + const jnd = input.jnd ?? OPACITY_JND; + const baseColor = input.baseColor ?? FRONTLIT_BASE_HEX; + + if (!Number.isFinite(opacityLayers) || opacityLayers < 1) { + return { ok: false, error: 'Opacity layer count must be at least 1.' }; } - - if (tdEstimates.length === 0) { - // Fallback: return default TD with low confidence - return { td: 2.0, confidence: 0.1 }; + if (!Number.isFinite(layerHeight) || layerHeight <= 0) { + return { ok: false, error: 'Layer height must be a positive number.' }; } - // Weighted average: measurements with moderate transmission (0.2-0.8) get higher weight - let weightedSum = 0; - let totalWeight = 0; - - for (const { td, transmission } of tdEstimates) { - // Weight function: peaks at T=0.5, but preserve a small contribution - // from valid measurements near either endpoint. - const weight = Math.max(0.05, 1 - Math.abs(transmission - 0.5) * 2); - weightedSum += td * weight; - totalWeight += weight; + const tStar = solveOpacityTransmission(filamentColor, jnd, baseColor); + if (tStar === undefined || tStar <= 0 || tStar >= 1) { + return { + ok: false, + error: 'Too little contrast with the base to calibrate. Pick a lighter base (e.g. white) for this filament.', + }; } - const tdFitted = weightedSum / totalWeight; + const dOpaque = opacityLayers * layerHeight; + const tdSingleValue = clamp(-dOpaque / Math.log10(tStar), FRONTLIT_TD_MIN, FRONTLIT_TD_MAX); + const td = deriveChannelTds(filamentColor, tdSingleValue); + const confidence = frontlitConfidence(opacityLayers, tdSingleValue, input.maxLayers); - // Calculate confidence based on consistency of estimates - const variance = - tdEstimates.reduce((sum, { td }) => sum + Math.pow(td - tdFitted, 2), 0) / - tdEstimates.length; - const stdDev = Math.sqrt(variance); - const coefficientOfVariation = stdDev / tdFitted; - - // Confidence: 1.0 if CV < 0.1, decreases linearly to 0.5 at CV = 0.4 - const confidence = Math.max(0.5, 1.0 - coefficientOfVariation * 2.5); - - return { td: tdFitted, confidence }; + return { + ok: true, + calibration: { + opacityLayers, + layerHeight, + firstLayerHeight, + td, + tdSingleValue, + jnd, + baseColor, + confidence, + basis: 'black-frontlit', + calibrationDate: new Date().toISOString(), + notes: input.notes, + }, + }; } /** - * Estimate expected RGB for a given layer count based on current TD estimate. - * Useful for showing preview during calibration. + * Predict the displayed color of `layers` of filament over the base, using + * per-channel TDs. Mirrors the auto-paint blend so calibration previews match. */ -export function predictTransmission( +export function predictFrontlitColor( filamentColor: string, layers: number, layerHeight: number, - td: [number, number, number], - whiteReference: CalibrationRgb = DEFAULT_WHITE_REFERENCE + td: CalibrationRgb, + baseColor: string = FRONTLIT_BASE_HEX ): CalibrationRgb { - const thickness = layers * layerHeight; - - // Parse filament color const rgb = hexToRgb(filamentColor); - if (!rgb) return [128, 128, 128]; - - // Beer-Lambert: T = 10^(-d/TD) - const transmission: [number, number, number] = [ - Math.pow(10, -thickness / td[0]), - Math.pow(10, -thickness / td[1]), - Math.pow(10, -thickness / td[2]), - ]; + const base = hexToRgb(baseColor) ?? FRONTLIT_BASE; + if (!rgb) return [...base] as CalibrationRgb; - const reference = sanitizeWhiteReference(whiteReference); - - // Simulate the same linear-light blend Auto-paint uses: the filament is - // the foreground and the measured backlight is the background. - return [ - Math.round(blendSrgbChannel(reference[0], rgb[0], transmission[0])), - Math.round(blendSrgbChannel(reference[1], rgb[1], transmission[1])), - Math.round(blendSrgbChannel(reference[2], rgb[2], transmission[2])), - ]; + const thickness = layers * layerHeight; + return [0, 1, 2].map((channel) => { + const transmission = Math.pow(10, -thickness / td[channel]); + return Math.round(blendSrgbChannel(base[channel], rgb[channel], transmission)); + }) as CalibrationRgb; } // ============================================================================ -// Confidence Scoring +// Confidence scoring (consumed by auto-paint + filament UI) // ============================================================================ /** - * Compute confidence score for a filament profile. - * Takes into account: - * - Whether calibration data exists - * - Quality of calibration fit - * - Age of calibration - * - Number of measurements + * Compute confidence for a filament profile: calibration quality with an age + * decay, or a TD-plausibility heuristic when uncalibrated. */ export function computeProfileConfidence(profile: { - calibration?: CalibrationResult; + calibration?: FrontlitCalibration; transmissionDistance: number; }): number { if (!profile.calibration) { - // No calibration data: base confidence on TD value - // Lower TD = more typical for lithophanes = higher confidence const td = profile.transmissionDistance; - if (td >= 1.0 && td <= 5.0) return 0.5; // Reasonable estimate - if (td >= 0.5 && td <= 10.0) return 0.3; // Plausible but uncertain - return 0.1; // Likely a guess + if (td >= 1.0 && td <= 5.0) return 0.5; + if (td >= 0.5 && td <= 10.0) return 0.3; + return 0.1; } const cal = profile.calibration; let confidence = cal.confidence; - // Penalize old calibrations (>6 months) const ageMs = Date.now() - new Date(cal.calibrationDate).getTime(); const ageMonths = ageMs / (1000 * 60 * 60 * 24 * 30); if (ageMonths > 6) { - confidence *= Math.max(0.7, 1 - (ageMonths - 6) / 24); // Decay over 2 years + confidence *= Math.max(0.7, 1 - (ageMonths - 6) / 24); // decay over 2 years } - // Bonus for more measurements - const measurementBonus = Math.min(0.1, cal.measurements.length * 0.02); - confidence = Math.min(1.0, confidence + measurementBonus); - - return confidence; + return clamp(confidence, 0, 1); } -/** - * Get confidence label for UI display - */ +/** Confidence label for UI display. */ export function getConfidenceLabel(confidence: number): string { if (confidence >= CONFIDENCE_THRESHOLD_EXCELLENT) return 'Excellent'; if (confidence >= CONFIDENCE_THRESHOLD_GOOD) return 'Good'; @@ -488,164 +289,10 @@ export function getConfidenceLabel(confidence: number): string { return 'Low'; } -/** - * Get confidence color for UI display (Tailwind classes) - */ +/** Confidence color (Tailwind classes) for UI display. */ export function getConfidenceColor(confidence: number): string { if (confidence >= CONFIDENCE_THRESHOLD_EXCELLENT) return 'text-green-600'; if (confidence >= CONFIDENCE_THRESHOLD_GOOD) return 'text-blue-600'; if (confidence >= 0.5) return 'text-yellow-600'; return 'text-red-600'; } - -// ============================================================================ -// Validation Helpers -// ============================================================================ - -/** - * Validate a calibration measurement - */ -export function validateMeasurement( - measurement: CalibrationMeasurement -): { valid: boolean; error?: string } { - if (measurement.layers < 1 || measurement.layers > 50) { - return { valid: false, error: 'Layer count must be between 1 and 50' }; - } - - const [r, g, b] = measurement.rgb; - if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) { - return { valid: false, error: 'RGB values must be between 0 and 255' }; - } - - return { valid: true }; -} - -/** - * Check if measurements are ready for TD calculation - */ -export function canCalculateTD( - measurements: CalibrationMeasurement[], - whiteReference: CalibrationRgb = DEFAULT_WHITE_REFERENCE -): { - ready: boolean; - reason?: string; -} { - const whiteReferenceValidation = validateWhiteReference(whiteReference); - if (!whiteReferenceValidation.valid) { - return { ready: false, reason: whiteReferenceValidation.error }; - } - - if (measurements.length < MIN_MEASUREMENTS) { - return { - ready: false, - reason: `Need at least ${MIN_MEASUREMENTS} measurements (have ${measurements.length})`, - }; - } - - // Check for duplicate layer counts - const layerCounts = new Set(measurements.map((m) => m.layers)); - if (layerCounts.size < measurements.length) { - return { ready: false, reason: 'Duplicate layer counts detected' }; - } - - // Validate each measurement - for (const measurement of measurements) { - const validation = validateMeasurement(measurement); - if (!validation.valid) { - return { ready: false, reason: validation.error }; - } - } - - return { ready: true }; -} - -/** - * Get recommended layer counts that haven't been measured yet - */ -export function getRecommendedLayerCounts( - existing: CalibrationMeasurement[] -): { recommended: number[]; measured: number[] } { - const measured = existing.map((m) => m.layers); - const recommended = RECOMMENDED_LAYER_COUNTS.filter((count) => !measured.includes(count)); - return { recommended, measured }; -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Parse hex color to RGB - */ -function hexToRgb(hex: string): [number, number, number] | null { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] - : null; -} - -/** - * Generate calibration instructions for user - */ -export function getCalibrationInstructions(layerHeight: number): string[] { - return [ - `Print test patches with ${RECOMMENDED_LAYER_COUNTS.join(', ')} layers each.`, - `Use your filament color with 100% infill.`, - `Layer height: ${layerHeight.toFixed(2)}mm.`, - `Place patches on a backlit white surface (e.g., phone screen at max brightness).`, - `Measure the bare backlight first and enter that RGB as the white reference.`, - `Photograph patches under consistent lighting.`, - `Use color picker tool to sample RGB values from center of each patch.`, - `Enter measurements in the calibration wizard.`, - ]; -} - -/** - * Export calibration result to JSON for sharing - */ -export function exportCalibration(result: CalibrationResult): string { - return JSON.stringify(result, null, 2); -} - -/** - * Import calibration result from JSON - */ -export function importCalibration(json: string): CalibrationResult { - const parsed = JSON.parse(json); - - // Validation - if ( - typeof parsed.color !== 'string' || - !Array.isArray(parsed.measurements) || - !Array.isArray(parsed.td) || - typeof parsed.tdSingleValue !== 'number' || - typeof parsed.confidence !== 'number' - ) { - throw new Error('Invalid calibration data format'); - } - - if (parsed.whiteReference !== undefined) { - if ( - !Array.isArray(parsed.whiteReference) || - parsed.whiteReference.length !== 3 || - !validateWhiteReference(parsed.whiteReference as CalibrationRgb).valid - ) { - throw new Error('Invalid calibration white reference'); - } - } - - const whiteReference = (parsed.whiteReference as CalibrationRgb | undefined) ?? undefined; - - // Keep only the fields we model; this also drops the legacy `transmission` - // key that older exports stored alongside each measurement. - const measurements = (parsed.measurements as CalibrationMeasurement[]).map(({ layers, rgb }) => ({ - layers, - rgb, - })); - - return { - ...(parsed as CalibrationResult), - whiteReference, - measurements, - }; -} diff --git a/src/lib/colorDifference.ts b/src/lib/colorDifference.ts new file mode 100644 index 0000000..af31fc7 --- /dev/null +++ b/src/lib/colorDifference.ts @@ -0,0 +1,121 @@ +/** + * Standalone perceptual color difference (CIEDE2000) on sRGB triplets. + * + * This is a leaf module that depends only on Math. Calibration math needs ΔE00 + * but cannot import it from `autoPaint.ts` — `autoPaint` already imports from + * `calibration`, so the reverse would be a cycle. Keeping the implementation + * here lets the optical/calibration code share one perceptual metric without + * pulling in the heavy auto-paint module. + * + * NOTE: `autoPaint.ts` and `nextBestColor.ts` still carry their own rgbToLab / + * CIEDE2000 copies. Consolidating all of them onto this leaf is a worthwhile + * follow-up cleanup, intentionally left out of the calibration redesign to keep + * the optimizer hot path untouched. + */ + +export type Lab = { L: number; a: number; b: number }; +export type Rgb = [number, number, number]; + +/** Convert an sRGB triplet (0-255) to CIE Lab (D65). */ +export function rgbToLab([r, g, b]: Rgb): Lab { + let rl = r / 255; + let gl = g / 255; + let bl = b / 255; + + rl = rl > 0.04045 ? Math.pow((rl + 0.055) / 1.055, 2.4) : rl / 12.92; + gl = gl > 0.04045 ? Math.pow((gl + 0.055) / 1.055, 2.4) : gl / 12.92; + bl = bl > 0.04045 ? Math.pow((bl + 0.055) / 1.055, 2.4) : bl / 12.92; + + rl *= 100; + gl *= 100; + bl *= 100; + + const x = rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375; + const y = rl * 0.2126729 + gl * 0.7151522 + bl * 0.072175; + const z = rl * 0.0193339 + gl * 0.119192 + bl * 0.9503041; + + const refX = 95.047; + const refY = 100.0; + const refZ = 108.883; + + let xr = x / refX; + let yr = y / refY; + let zr = z / refZ; + + const epsilon = 0.008856; + const kappa = 903.3; + + xr = xr > epsilon ? Math.cbrt(xr) : (kappa * xr + 16) / 116; + yr = yr > epsilon ? Math.cbrt(yr) : (kappa * yr + 16) / 116; + zr = zr > epsilon ? Math.cbrt(zr) : (kappa * zr + 16) / 116; + + return { + L: 116 * yr - 16, + a: 500 * (xr - yr), + b: 200 * (yr - zr), + }; +} + +/** CIEDE2000 color difference between two 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) + ); +} + +/** CIEDE2000 color difference between two sRGB triplets (0-255). */ +export function deltaE2000(a: Rgb, b: Rgb): number { + return deltaE2000Lab(rgbToLab(a), rgbToLab(b)); +} diff --git a/src/lib/generateCalibrationPatchesStl.ts b/src/lib/generateCalibrationPatchesStl.ts deleted file mode 100644 index 3455a66..0000000 --- a/src/lib/generateCalibrationPatchesStl.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Generates a binary STL blob containing a row of flat square test patches, - * one per layer count. Each patch is 20×20mm with height = layerCount × layerHeight. - * Patches are arranged along the X axis with 5mm gaps. - * - * No Three.js dependency — writes binary STL directly. - */ - -const PATCH_SIZE = 20; // mm - -/** Write a float32 little-endian at byte offset */ -function setF32(view: DataView, offset: number, value: number) { - view.setFloat32(offset, value, true); -} - -/** Write one STL triangle (50 bytes) */ -function writeTriangle( - view: DataView, - offset: number, - nx: number, ny: number, nz: number, - ax: number, ay: number, az: number, - bx: number, by: number, bz: number, - cx: number, cy: number, cz: number, -) { - setF32(view, offset, nx); setF32(view, offset + 4, ny); setF32(view, offset + 8, nz); - setF32(view, offset + 12, ax); setF32(view, offset + 16, ay); setF32(view, offset + 20, az); - setF32(view, offset + 24, bx); setF32(view, offset + 28, by); setF32(view, offset + 32, bz); - setF32(view, offset + 36, cx); setF32(view, offset + 40, cy); setF32(view, offset + 44, cz); - view.setUint16(offset + 48, 0, true); -} - -/** - * Write all 12 triangles for a box. - * Corner at (x0, 0, z0), size (w, d, h). Z is the build direction. - */ -function writeBox(view: DataView, offset: number, x0: number, w: number, h: number, d: number, z0 = 0): number { - const x1 = x0 + w, y1 = d, z1 = z0 + h; - - // Bottom (Z=z0, normal -Z) - offset = writeFace(view, offset, 0, 0, -1, x0,0,z0, x1,y1,z0, x1,0,z0); - offset = writeFace(view, offset, 0, 0, -1, x0,0,z0, x0,y1,z0, x1,y1,z0); - // Top (Z=z1, normal +Z) - offset = writeFace(view, offset, 0, 0, 1, x0,0,z1, x1,0,z1, x1,y1,z1); - offset = writeFace(view, offset, 0, 0, 1, x0,0,z1, x1,y1,z1, x0,y1,z1); - // Front (Y=0, normal -Y) - offset = writeFace(view, offset, 0, -1, 0, x0,0,z0, x1,0,z0, x1,0,z1); - offset = writeFace(view, offset, 0, -1, 0, x0,0,z0, x1,0,z1, x0,0,z1); - // Back (Y=d, normal +Y) - offset = writeFace(view, offset, 0, 1, 0, x0,y1,z0, x1,y1,z1, x1,y1,z0); - offset = writeFace(view, offset, 0, 1, 0, x0,y1,z0, x0,y1,z1, x1,y1,z1); - // Left (X=x0, normal -X) - offset = writeFace(view, offset, -1, 0, 0, x0,0,z0, x0,0,z1, x0,y1,z1); - offset = writeFace(view, offset, -1, 0, 0, x0,0,z0, x0,y1,z1, x0,y1,z0); - // Right (X=x1, normal +X) - offset = writeFace(view, offset, 1, 0, 0, x1,0,z0, x1,y1,z0, x1,y1,z1); - offset = writeFace(view, offset, 1, 0, 0, x1,0,z0, x1,y1,z1, x1,0,z1); - - return offset; -} - -function writeFace( - view: DataView, offset: number, - nx: number, ny: number, nz: number, - ax: number, ay: number, az: number, - bx: number, by: number, bz: number, - cx: number, cy: number, cz: number, -): number { - writeTriangle(view, offset, nx, ny, nz, ax, ay, az, bx, by, bz, cx, cy, cz); - return offset + 50; -} - -export function generateCalibrationPatchesStl( - layerCounts: number[], - layerHeight: number, -): Blob { - const TRIS_PER_BOX = 12; - const totalTris = layerCounts.length * TRIS_PER_BOX; - const buffer = new ArrayBuffer(80 + 4 + totalTris * 50); - const view = new DataView(buffer); - - const header = 'Kromacut Calibration Patches'; - for (let i = 0; i < header.length && i < 80; i++) view.setUint8(i, header.charCodeAt(i)); - view.setUint32(80, totalTris, true); - - let offset = 84; - layerCounts.forEach((count, i) => { - const x0 = i * PATCH_SIZE; - const patchHeight = count * layerHeight; - offset = writeBox(view, offset, x0, PATCH_SIZE, patchHeight, PATCH_SIZE); - }); - - return new Blob([buffer], { type: 'model/stl' }); -} diff --git a/src/lib/generateCalibrationPrint.ts b/src/lib/generateCalibrationPrint.ts new file mode 100644 index 0000000..6a87a0c --- /dev/null +++ b/src/lib/generateCalibrationPrint.ts @@ -0,0 +1,615 @@ +/** + * Frontlit calibration print generator. + * + * Each filament gets a tile: a base, a wedge of 1..N filament layers across the + * base, and a continuous opaque reference rail along the edge. The user finds the + * first wedge patch that looks identical to the rail and reports that layer count + * (see calibration.ts -> computeFrontlitCalibration). A foot marks the 1-layer end + * so orientation is unambiguous. The base color is chosen per filament (defaults + * to the darkest filament; dark filaments use a lighter base for contrast). + * + * Two output formats: + * - STL: one tile, single solid. The base/wedge split is by height, so a single + * filament change (base -> color, above the base) prints it on any machine. + * - 3MF: one or more tiles, each as a base object + a color object, with parts + * assigned to real profile-filament slots (no invented base), for AMS printers. + * + * No Three.js dependency. + */ + +import JSZip from 'jszip'; +import { normalizeHexColor } from './colorUtils'; +import { MINIMAL_PROJECT_SETTINGS, KROMACUT_CONFIG } from './slicerDefaults'; + +export interface CalibrationTile { + filamentId: string; + /** Filament display color (hex). */ + color: string; + /** Profile filament id used as this wedge's base. */ + baseFilamentId: string; + /** Base filament display color (hex), for fallback. */ + baseColor: string; + /** Human label for instructions / file names. */ + name: string; +} + +/** Ordered profile filament, so 3MF slots line up with the user's AMS/machine. */ +export interface CalibrationProfileFilament { + id: string; + color: string; + name: string; +} + +export interface CalibrationPrintOptions { + layerHeight: number; + firstLayerHeight: number; + /** Wedge runs 1..maxLayers. */ + maxLayers: number; + /** Black base thickness, in layers. Just enough to be opaque + provide a base. */ + baseLayers: number; + /** Patch footprint (mm). */ + patchSize: number; + /** Gap between patches (mm). */ + gap: number; +} + +export const DEFAULT_CALIBRATION_PRINT_OPTIONS: CalibrationPrintOptions = { + layerHeight: 0.08, + firstLayerHeight: 0.2, + maxLayers: 12, + baseLayers: 5, + patchSize: 10, + gap: 0.8, +}; + +/** Which of the four vertical corners are rounded. Omitted corners default to rounded. */ +type CornerRounding = { bl?: boolean; br?: boolean; tr?: boolean; tl?: boolean }; + +interface Box { + x0: number; + y0: number; + z0: number; + w: number; + d: number; + h: number; + round?: CornerRounding; +} + +interface TileGeometry { + baseBoxes: Box[]; + colorBoxes: Box[]; +} + +/** + * Thickness of the black base in mm. The slicer applies its own first-layer + * height when printing, so this is just `baseLayers` worth of regular layers — + * enough to be opaque black under the wedge, no thicker. + */ +function baseHeight(options: CalibrationPrintOptions): number { + return options.baseLayers * options.layerHeight; +} + +/** Width of the opaque reference rail along the wedge edge (mm). */ +const REFERENCE_RAIL_WIDTH = 2; +/** Layers the reference rail adds beyond the tallest patch so it reads as opaque. */ +const REFERENCE_MARGIN_LAYERS = 4; + +/** + * Build the box list for one tile at the given X origin. The color wedge sits on + * top of the black base, so every color layer is a regular layer. A continuous + * opaque reference rail runs along the back edge and the patches butt directly + * against it: each printed layer is then a single connected comb (rail spine + + * the patches reaching that height) instead of many separate islands, which cuts + * travel — and it puts each patch flush against the rail for easy comparison. + */ +function buildTileGeometry(options: CalibrationPrintOptions, originX = 0): TileGeometry { + const { layerHeight, maxLayers, patchSize, gap } = options; + const railLayers = maxLayers + REFERENCE_MARGIN_LAYERS; + const baseH = baseHeight(options); + const pad = gap; + const columnWidth = patchSize + gap; + const wedgeSpan = maxLayers * columnWidth - gap; // first patch left to last patch right + const railGap = 0; // patches touch the rail so each layer prints as one comb + const baseWidth = wedgeSpan + 2 * pad; + const baseDepth = patchSize + railGap + REFERENCE_RAIL_WIDTH + 2 * pad; + + const baseBoxes: Box[] = [ + { x0: originX, y0: 0, z0: 0, w: baseWidth, d: baseDepth, h: baseH }, + ]; + + // Orientation foot: extends the front-left of the base forward to mark the + // 1-layer end. It overlaps into the base (one fused solid, not a separate + // bump) and only its front corners are rounded, so it reads as part of the + // base outline and prints more cleanly. + const tabDepth = pad * 1.5; + baseBoxes.push({ + x0: originX, + y0: -tabDepth, + z0: 0, + // Reach to the middle of the first inter-patch gap. + w: pad + patchSize + gap / 2, + d: tabDepth + CORNER_RADIUS + 0.5, // overlap past the base's rounded corner + h: baseH, + round: { tr: false, tl: false }, // square the base-side corners + }); + + const colorBoxes: Box[] = []; + for (let layers = 1; layers <= maxLayers; layers++) { + colorBoxes.push({ + x0: originX + pad + (layers - 1) * columnWidth, + y0: pad, + z0: baseH, + w: patchSize, + d: patchSize, + h: layers * layerHeight, + // Square the back corners (toward the rail) for a flush junction. + round: { tr: false, tl: false }, + }); + } + // Opaque reference rail along the back edge, spanning the full wedge length. + colorBoxes.push({ + x0: originX + pad, + y0: pad + patchSize + railGap, + z0: baseH, + w: wedgeSpan, + d: REFERENCE_RAIL_WIDTH, + h: railLayers * layerHeight, + // Square the front corners (toward the patches) to meet them cleanly. + round: { bl: false, br: false }, + }); + + return { baseBoxes, colorBoxes }; +} + +// ============================================================================ +// Binary STL +// ============================================================================ + +function setF32(view: DataView, offset: number, value: number) { + view.setFloat32(offset, value, true); +} + +function writeTriangle( + view: DataView, + offset: number, + n: [number, number, number], + a: [number, number, number], + b: [number, number, number], + c: [number, number, number] +): number { + setF32(view, offset, n[0]); + setF32(view, offset + 4, n[1]); + setF32(view, offset + 8, n[2]); + setF32(view, offset + 12, a[0]); + setF32(view, offset + 16, a[1]); + setF32(view, offset + 20, a[2]); + setF32(view, offset + 24, b[0]); + setF32(view, offset + 28, b[1]); + setF32(view, offset + 32, b[2]); + setF32(view, offset + 36, c[0]); + setF32(view, offset + 40, c[1]); + setF32(view, offset + 44, c[2]); + view.setUint16(offset + 48, 0, true); + return offset + 50; +} + +const CORNER_RADIUS = 1.2; // mm — rounding applied to the vertical edges of every box. +const CORNER_SEGMENTS = 5; + +/** CCW (viewed from +Z) outline of a rounded rectangle; sharp corners emit a single point. */ +function roundedRectOutline( + x0: number, + y0: number, + x1: number, + y1: number, + r: number, + segments: number, + round: Required +): Array<[number, number]> { + const pts: Array<[number, number]> = []; + const corner = ( + cx: number, + cy: number, + startDeg: number, + endDeg: number, + isRound: boolean, + sharpX: number, + sharpY: number + ) => { + if (!isRound) { + pts.push([sharpX, sharpY]); + return; + } + for (let i = 0; i <= segments; i++) { + const a = ((startDeg + (endDeg - startDeg) * (i / segments)) * Math.PI) / 180; + pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); + } + }; + corner(x1 - r, y0 + r, 270, 360, round.br, x1, y0); // bottom-right + corner(x1 - r, y1 - r, 0, 90, round.tr, x1, y1); // top-right + corner(x0 + r, y1 - r, 90, 180, round.tl, x0, y1); // top-left + corner(x0 + r, y0 + r, 180, 270, round.bl, x0, y0); // bottom-left + return pts; +} + +function appendSharpBox(mesh: IndexedMesh, box: Box) { + const base = mesh.vertices.length / 3; + const { x0, y0, z0, w, d, h } = box; + const x1 = x0 + w; + const y1 = y0 + d; + const z1 = z0 + h; + const corners = [ + [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], + [x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], + ]; + for (const c of corners) mesh.vertices.push(c[0], c[1], c[2]); + const tris = [ + [0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], + [0, 1, 5], [0, 5, 4], [2, 3, 7], [2, 7, 6], + [0, 7, 3], [0, 4, 7], [1, 2, 6], [1, 6, 5], + ]; + for (const [a, b, c] of tris) mesh.triangles.push(base + a, base + b, base + c); +} + +/** Append a watertight prism with rounded vertical edges (or a sharp box if too thin). */ +function appendPrism(mesh: IndexedMesh, box: Box) { + const { x0, y0, z0, w, d, h, round } = box; + const r = Math.min(CORNER_RADIUS, w / 2 - 0.01, d / 2 - 0.01); + if (r < 0.1) { + appendSharpBox(mesh, box); + return; + } + + const x1 = x0 + w; + const y1 = y0 + d; + const z1 = z0 + h; + const rounding: Required = { + bl: round?.bl ?? true, + br: round?.br ?? true, + tr: round?.tr ?? true, + tl: round?.tl ?? true, + }; + const outline = roundedRectOutline(x0, y0, x1, y1, r, CORNER_SEGMENTS, rounding); + const n = outline.length; + const base = mesh.vertices.length / 3; + + for (const [px, py] of outline) mesh.vertices.push(px, py, z1); // top ring + for (const [px, py] of outline) mesh.vertices.push(px, py, z0); // bottom ring + let cx = 0; + let cy = 0; + for (const [px, py] of outline) { + cx += px; + cy += py; + } + cx /= n; + cy /= n; + const topCenter = base + 2 * n; + const bottomCenter = base + 2 * n + 1; + mesh.vertices.push(cx, cy, z1); + mesh.vertices.push(cx, cy, z0); + + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + const top = base + i; + const topNext = base + j; + const bottom = base + n + i; + const bottomNext = base + n + j; + mesh.triangles.push(topCenter, top, topNext); // top cap (+Z) + mesh.triangles.push(bottomCenter, bottomNext, bottom); // bottom cap (-Z) + mesh.triangles.push(top, bottom, topNext); // wall (outward) + mesh.triangles.push(topNext, bottom, bottomNext); + } +} + +function faceNormal( + a: [number, number, number], + b: [number, number, number], + c: [number, number, number] +): [number, number, number] { + const ux = b[0] - a[0]; + const uy = b[1] - a[1]; + const uz = b[2] - a[2]; + const vx = c[0] - a[0]; + const vy = c[1] - a[1]; + const vz = c[2] - a[2]; + const nx = uy * vz - uz * vy; + const ny = uz * vx - ux * vz; + const nz = ux * vy - uy * vx; + const len = Math.hypot(nx, ny, nz) || 1; + return [nx / len, ny / len, nz / len]; +} + +/** Single-filament calibration tile as a binary STL solid. */ +export function generateCalibrationStl(options: CalibrationPrintOptions): Blob { + const { baseBoxes, colorBoxes } = buildTileGeometry(options); + const mesh: IndexedMesh = { vertices: [], triangles: [] }; + for (const box of [...baseBoxes, ...colorBoxes]) appendPrism(mesh, box); + + const triCount = mesh.triangles.length / 3; + const buffer = new ArrayBuffer(80 + 4 + triCount * 50); + const view = new DataView(buffer); + const header = 'Kromacut Frontlit Calibration'; + for (let i = 0; i < header.length && i < 80; i++) view.setUint8(i, header.charCodeAt(i)); + view.setUint32(80, triCount, true); + + let offset = 84; + for (let t = 0; t < mesh.triangles.length; t += 3) { + const ia = mesh.triangles[t] * 3; + const ib = mesh.triangles[t + 1] * 3; + const ic = mesh.triangles[t + 2] * 3; + const a: [number, number, number] = [ + mesh.vertices[ia], + mesh.vertices[ia + 1], + mesh.vertices[ia + 2], + ]; + const b: [number, number, number] = [ + mesh.vertices[ib], + mesh.vertices[ib + 1], + mesh.vertices[ib + 2], + ]; + const c: [number, number, number] = [ + mesh.vertices[ic], + mesh.vertices[ic + 1], + mesh.vertices[ic + 2], + ]; + offset = writeTriangle(view, offset, faceNormal(a, b, c), a, b, c); + } + + return new Blob([buffer], { type: 'model/stl' }); +} + +// ============================================================================ +// 3MF (multi-material) +// ============================================================================ + +interface IndexedMesh { + vertices: number[]; // flat x,y,z + triangles: number[]; // flat v1,v2,v3 +} + +function meshXml(mesh: IndexedMesh): string { + const verts: string[] = ['']; + for (let i = 0; i < mesh.vertices.length; i += 3) { + verts.push( + `` + ); + } + verts.push(''); + const tris: string[] = ['']; + for (let i = 0; i < mesh.triangles.length; i += 3) { + tris.push( + `` + ); + } + tris.push(''); + return `${verts.join('')}${tris.join('')}`; +} + +function generateUUID(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** Translate every mesh in XY so the combined layout is centered on the origin. */ +function centerMeshesXY(meshes: IndexedMesh[]) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const mesh of meshes) { + for (let i = 0; i < mesh.vertices.length; i += 3) { + const x = mesh.vertices[i]; + const y = mesh.vertices[i + 1]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + if (!Number.isFinite(minX)) return; + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + for (const mesh of meshes) { + for (let i = 0; i < mesh.vertices.length; i += 3) { + mesh.vertices[i] -= cx; + mesh.vertices[i + 1] -= cy; + } + } +} + +/** + * Combined multi-material 3MF for one or more tiles laid out along X. + * + * Each filament is its own independently-arrangeable object, with its black base + * and colored wedge as two grouped parts inside it. Keeping base + color as parts + * of one object (rather than separate objects) avoids the "objects at multiple + * heights" merge prompt, while keeping the tiles as separate objects lets the + * slicer auto-arrange them and print by color. An embedded Bambu/Orca project + * (`model_settings.config` + `project_settings.config`) assigns the base to + * extruder 1 and each filament to its own extruder with the matching colour — that + * is what colours the parts rather than leaving them all white. + */ +export async function generateCalibration3mf( + tiles: CalibrationTile[], + options: CalibrationPrintOptions, + profileFilaments: CalibrationProfileFilament[] +): Promise { + const baseMatId = 1; + + // Slots follow the profile order so they line up with the user's AMS/machine + // (slot 1 = profile filament 1, etc.) — this keeps the base off slot 1, which + // slicers reserve for the machine's active filament, and uses real filaments + // rather than an invented phantom base. + const slotForId = new Map(); // filament id -> 1-based slot + const slotColors: string[] = []; + const slotNames: string[] = []; + const ensureSlot = (id: string, hex: string, name: string): number => { + let slot = slotForId.get(id); + if (slot === undefined) { + slot = slotColors.length + 1; // 1-based + slotForId.set(id, slot); + slotColors.push(normalizeHexColor(hex, '#808080')); + slotNames.push(name); + } + return slot; + }; + for (const f of profileFilaments) ensureSlot(f.id, f.color, f.name); + // Defensive: register any tile filament missing from the profile list. + for (const tile of tiles) { + ensureSlot(tile.baseFilamentId, tile.baseColor, 'Base'); + ensureSlot(tile.filamentId, tile.color, tile.name); + } + + const baseMaterialsXml = slotColors + .map((hex, i) => ``) + .join(''); + + // Initial Y spacing so the tile objects don't overlap before auto-arrange. + const rowPitch = options.patchSize + options.gap * 3.5 + 4; + + let nextId = 2; // 1 is the basematerials group + const objects: string[] = []; + const buildItems: string[] = []; + const settingObjects: string[] = []; + const instances: string[] = []; + + tiles.forEach((tile, index) => { + const { baseBoxes, colorBoxes } = buildTileGeometry(options, 0); + const baseMesh: IndexedMesh = { vertices: [], triangles: [] }; + for (const box of baseBoxes) appendPrism(baseMesh, box); + const colorMesh: IndexedMesh = { vertices: [], triangles: [] }; + for (const box of colorBoxes) appendPrism(colorMesh, box); + // Center each tile on its own origin so the build-item transform places it. + centerMeshesXY([baseMesh, colorMesh]); + + const baseSlot = ensureSlot(tile.baseFilamentId, tile.baseColor, 'Base'); + const colorSlot = ensureSlot(tile.filamentId, tile.color, tile.name); + const baseObjId = nextId++; + const colorObjId = nextId++; + const tileObjId = nextId++; + + objects.push( + `${meshXml(baseMesh)}` + ); + objects.push( + `${meshXml(colorMesh)}` + ); + // Each filament is its own object; its base + color are grouped as parts so + // there is no "objects at multiple heights" merge prompt, while the tiles + // stay separate objects the slicer can auto-arrange and sequence. + objects.push( + `` + + `` + + `` + + `` + ); + + const ty = (index * rowPitch).toFixed(3); + buildItems.push( + `` + ); + + settingObjects.push( + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + ); + instances.push( + ` \n` + + ` \n` + + ` \n` + + ` \n` + ); + }); + + const model = + `\n` + + `` + + `1` + + `` + + `${baseMaterialsXml}` + + objects.join('') + + `` + + `${buildItems.join('')}` + + ``; + + // Per-object/part extruder assignment (Bambu/Orca/Creality read this for + // colors). No block: the tiles stay independent, arrangeable objects. + const modelSettings = + `\n\n` + + settingObjects.join('') + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + instances.join('') + + ` \n` + + ``; + + // Embedded slicer project: defines the filament list + colours the parts use. + const filamentColours = slotColors; + const filamentCount = filamentColours.length; + const expand = (value: T, count: number): T[] => Array(count).fill(value); + const projectSettings = { + ...MINIMAL_PROJECT_SETTINGS, + layer_height: options.layerHeight.toString(), + initial_layer_print_height: Math.max(options.layerHeight, options.firstLayerHeight).toString(), + filament_colour: filamentColours, + filament_type: expand('PLA', filamentCount), + filament_settings_id: expand('Generic PLA @Kromacut 0.4 nozzle', filamentCount), + filament_vendor: expand('Generic', filamentCount), + }; + + const zip = new JSZip(); + zip.file( + '[Content_Types].xml', + `\n` + + `` + + `` + + `` + + `` + + `` + ); + zip.file( + '_rels/.rels', + `\n` + + `` + + `` + + `` + + `` + ); + zip.file('3D/3dmodel.model', model); + zip.file('Metadata/model_settings.config', modelSettings); + zip.file('Metadata/project_settings.config', JSON.stringify(projectSettings, null, 4)); + zip.file('Metadata/kromacut.config', KROMACUT_CONFIG); + + return zip.generateAsync({ type: 'blob', mimeType: 'model/3mf' }); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function to3mfColor(hex: string): string { + const normalized = normalizeHexColor(hex, '#808080'); + return `${normalized.toUpperCase()}FF`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 71f418c..ce31f97 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ import type { AutoPaintResult } from '../lib/autoPaint'; -import type { CalibrationResult } from '../lib/calibration'; +import type { FrontlitCalibration } 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]; @@ -31,7 +31,7 @@ export interface Filament { id: string; color: string; td: number; - calibration?: CalibrationResult; + calibration?: FrontlitCalibration; name?: string; brand?: string; } diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts index e80e449..d4576a7 100644 --- a/tests/autoPaint.test.ts +++ b/tests/autoPaint.test.ts @@ -147,11 +147,14 @@ test('transition-thickness cache distinguishes calibrated channel TDs', async () { ...baseStack[1], calibration: { - color: '#ffffff', - measurements: [], + opacityLayers: 6, + layerHeight: 0.1, + firstLayerHeight: 0.2, td: [0.5, 1, 2] as [number, number, number], tdSingleValue: 1, + jnd: 2, confidence: 1, + basis: 'black-frontlit' as const, calibrationDate: '2026-01-01T00:00:00.000Z', }, }, @@ -212,11 +215,14 @@ test('calibrated channel TDs flow through generated auto-paint preview slices', { ...baseFilaments[1], calibration: { - color: '#ffffff', - measurements: [], + opacityLayers: 6, + layerHeight: 0.1, + firstLayerHeight: 0.2, td: [8, 16, 32] as [number, number, number], tdSingleValue: 16, + jnd: 2, confidence: 1, + basis: 'black-frontlit' as const, calibrationDate: '2026-01-01T00:00:00.000Z', }, }, diff --git a/tests/calibration.test.ts b/tests/calibration.test.ts deleted file mode 100644 index 6306e8a..0000000 --- a/tests/calibration.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import assert from 'node:assert/strict'; -import { resolve } from 'node:path'; -import test from 'node:test'; -import { createServer } from 'vite'; - -type CalibrationModule = typeof import('../src/lib/calibration.ts'); -type AutoPaintModule = typeof import('../src/lib/autoPaint.ts'); - -let calibrationModule: Promise | null = null; -let autoPaintModule: Promise | null = null; - -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(); - } -} - -async function loadCalibrationModule(): Promise { - calibrationModule ??= loadViteModule('/src/lib/calibration.ts'); - return calibrationModule; -} - -async function loadAutoPaintModule(): Promise { - autoPaintModule ??= loadViteModule('/src/lib/autoPaint.ts'); - return autoPaintModule; -} - -function assertClose(actual: number, expected: number, tolerance: number, message?: string) { - assert.ok( - Math.abs(actual - expected) <= tolerance, - message ?? `expected ${actual} to be within ${tolerance} of ${expected}` - ); -} - -test('calibration predictions use Auto-paint’s linear-light transmission model', async () => { - const calibration = await loadCalibrationModule(); - const autoPaint = await loadAutoPaintModule(); - const halfTransmissionThickness = Math.log10(2); - const predicted = calibration.predictTransmission( - '#000000', - 1, - halfTransmissionThickness, - [1, 1, 1] - ); - const autoPaintBlend = autoPaint.blendColors( - { r: 255, g: 255, b: 255 }, - { r: 0, g: 0, b: 0 }, - 1, - halfTransmissionThickness - ); - - assert.deepEqual(predicted, [188, 188, 188]); - assert.deepEqual(predicted, [ - Math.round(autoPaintBlend.r), - Math.round(autoPaintBlend.g), - Math.round(autoPaintBlend.b), - ]); -}); - -test('calibration recovers channel TDs from linear-light patch measurements', async () => { - const calibration = await loadCalibrationModule(); - const layerHeight = 0.1; - const filamentColor = '#204080'; - const whiteReference: [number, number, number] = [230, 240, 255]; - const knownTd: [number, number, number] = [1.4, 2, 3.2]; - const measurements = [2, 4, 6, 8, 10].map((layers) => ({ - layers, - rgb: calibration.predictTransmission( - filamentColor, - layers, - layerHeight, - knownTd, - whiteReference - ), - })); - - const result = calibration.calculateTDFromMeasurements( - measurements, - layerHeight, - whiteReference, - filamentColor - ); - - for (let channel = 0; channel < knownTd.length; channel++) { - assertClose(result.td[channel], knownTd[channel], 0.03); - } - assert.ok(result.confidence > 0.7); -}); diff --git a/tests/calibrationPrint.test.ts b/tests/calibrationPrint.test.ts new file mode 100644 index 0000000..5cbd92f --- /dev/null +++ b/tests/calibrationPrint.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { createServer } from 'vite'; +import JSZip from 'jszip'; + +type GeneratorModule = typeof import('../src/lib/generateCalibrationPrint.ts'); + +let generatorModule: Promise | null = null; + +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(); + } +} + +const loadGenerator = () => + (generatorModule ??= loadViteModule('/src/lib/generateCalibrationPrint.ts')); + +const baseOptions = { + layerHeight: 0.08, + firstLayerHeight: 0.2, + maxLayers: 10, + baseLayers: 5, + patchSize: 12, + gap: 1.5, +}; + +test('generateCalibrationStl writes one box per base/wedge/reference patch', async () => { + const { generateCalibrationStl } = await loadGenerator(); + const blob = generateCalibrationStl(baseOptions); + const buffer = await blob.arrayBuffer(); + const view = new DataView(buffer); + + // Base slab + orientation tab + (maxLayers wedge patches) + 1 rail, each a + // rounded prism (some corners squared), so per-box triangle counts vary. Every + // prism contributes caps + walls in multiples of 4; check structural integrity. + const triangleCount = view.getUint32(80, true); + assert.ok(triangleCount > (2 + baseOptions.maxLayers + 1) * 12, 'non-trivial mesh'); + assert.equal(triangleCount % 4, 0, 'prisms contribute triangles in multiples of 4'); + assert.equal(buffer.byteLength, 84 + triangleCount * 50); +}); + +test('generateCalibration3mf builds slots in profile order and colors the parts', async () => { + const { generateCalibration3mf } = await loadGenerator(); + // Profile order matches the user's machine: white in slot 1, black in slot 2. + const profileFilaments = [ + { id: 'w', color: '#ffffff', name: 'White' }, + { id: 'k', color: '#000000', name: 'Black' }, + { id: 'a', color: '#ff0000', name: 'Red' }, + { id: 'b', color: '#00aa55', name: 'Green' }, + ]; + // Calibrate red + green over a black base (white is uncalibrated but still a slot). + const tiles = [ + { filamentId: 'a', color: '#ff0000', baseFilamentId: 'k', baseColor: '#000000', name: 'Red' }, + { filamentId: 'b', color: '#00aa55', baseFilamentId: 'k', baseColor: '#000000', name: 'Green' }, + ]; + const blob = await generateCalibration3mf(tiles, baseOptions, profileFilaments); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + + const model = await zip.file('3D/3dmodel.model')?.async('string'); + assert.ok(model, 'model XML present'); + assert.ok(zip.file('[Content_Types].xml'), 'content types present'); + assert.ok(zip.file('_rels/.rels'), 'relationships present'); + + // One material per profile filament (no invented phantom base). + const baseCount = (model!.match(/ | null = null; +let colorDifferenceModule: Promise | null = null; +let colorSpaceModule: Promise | null = null; + +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(); + } +} + +const loadCalibration = () => + (calibrationModule ??= loadViteModule('/src/lib/calibration.ts')); +const loadColorDifference = () => + (colorDifferenceModule ??= loadViteModule('/src/lib/colorDifference.ts')); +const loadColorSpace = () => + (colorSpaceModule ??= loadViteModule('/src/lib/colorSpace.ts')); + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace(/^#/, ''); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +/** + * Simulate the physical opacity read for a filament that perfectly follows a + * single-TD Beer-Lambert model: the smallest integer layer count whose over-black + * blend is within `jnd` of the opaque filament color. Mirrors what a user would + * report off the printed wedge. + */ +async function simulateOpacityRead( + filamentColor: string, + tdTrue: number, + layerHeight: number, + jnd: number, + maxLayers = 200, + baseColor = '#000000' +): Promise { + const { blendSrgbChannel } = await loadColorSpace(); + const { deltaE2000 } = await loadColorDifference(); + const rgb = hexToRgb(filamentColor); + const base = hexToRgb(baseColor); + + for (let layers = 1; layers <= maxLayers; layers++) { + const t = Math.pow(10, -(layers * layerHeight) / tdTrue); + const blended: [number, number, number] = [ + blendSrgbChannel(base[0], rgb[0], t), + blendSrgbChannel(base[1], rgb[1], t), + blendSrgbChannel(base[2], rgb[2], t), + ]; + if (deltaE2000(blended, rgb) <= jnd) return layers; + } + return maxLayers; +} + +test('solveOpacityTransmission tolerates more background for darker (lower-contrast) colors', async () => { + const { solveOpacityTransmission, OPACITY_JND } = await loadCalibration(); + + const tWhite = solveOpacityTransmission('#ffffff', OPACITY_JND); + const tDarkGray = solveOpacityTransmission('#282828', OPACITY_JND); + + assert.ok(tWhite !== undefined && tDarkGray !== undefined); + // A bright color reaches the JND with only a sliver of background; a dark, + // low-contrast color stays within the JND even with much more black showing. + assert.ok(tWhite! < tDarkGray!, `expected T*(white)=${tWhite} < T*(darkgray)=${tDarkGray}`); + assert.ok(tWhite! > 0 && tWhite! < 1); + assert.ok(tDarkGray! > 0 && tDarkGray! < 1); +}); + +test('solveOpacityTransmission returns undefined for colors that cannot beat the black base', async () => { + const { solveOpacityTransmission } = await loadCalibration(); + assert.equal(solveOpacityTransmission('#000000'), undefined); + assert.equal(solveOpacityTransmission('#020202'), undefined); +}); + +test('computeFrontlitCalibration recovers the true TD from a synthetic read (round-trip)', async () => { + const { computeFrontlitCalibration, OPACITY_JND } = await loadCalibration(); + const layerHeight = 0.04; + + const cases: Array<{ color: string; tdTrue: number }> = [ + { color: '#ffd400', tdTrue: 0.5 }, + { color: '#c08040', tdTrue: 0.35 }, + { color: '#3050c0', tdTrue: 0.28 }, + { color: '#a0a0a0', tdTrue: 0.45 }, + ]; + + for (const { color, tdTrue } of cases) { + const opacityLayers = await simulateOpacityRead(color, tdTrue, layerHeight, OPACITY_JND); + const result = computeFrontlitCalibration({ + filamentColor: color, + opacityLayers, + layerHeight, + firstLayerHeight: 0.2, + }); + + assert.ok(result.ok, `expected ${color} to calibrate`); + if (!result.ok) return; + + const recovered = result.calibration.tdSingleValue; + const relError = Math.abs(recovered - tdTrue) / tdTrue; + // Integer-layer quantization is the only error source; keep well inside it. + assert.ok( + relError < 0.2, + `${color}: recovered TD ${recovered.toFixed(3)} vs true ${tdTrue} (relErr ${(relError * 100).toFixed(1)}%, read ${opacityLayers} layers)` + ); + } +}); + +test('computeFrontlitCalibration: more layers to opacity means a larger TD for the same color', async () => { + const { computeFrontlitCalibration } = await loadCalibration(); + const base = { + filamentColor: '#3050c0', + layerHeight: 0.04, + firstLayerHeight: 0.2, + }; + + const few = computeFrontlitCalibration({ ...base, opacityLayers: 4 }); + const many = computeFrontlitCalibration({ ...base, opacityLayers: 12 }); + + assert.ok(few.ok && many.ok); + if (!few.ok || !many.ok) return; + assert.ok( + many.calibration.tdSingleValue > few.calibration.tdSingleValue, + `expected TD(12 layers) > TD(4 layers)` + ); +}); + +test('deriveChannelTds anchors the brightest channel and shortens absorptive ones', async () => { + const { deriveChannelTds } = await loadCalibration(); + const tdSingle = 0.4; + + const red = deriveChannelTds('#ff0000', tdSingle); + // Red channel governs opacity for a red filament: anchored to the single TD. + assert.ok(Math.abs(red[0] - tdSingle) < 1e-6, `red anchor ${red[0]} != ${tdSingle}`); + assert.ok(red[0] > red[1], 'red TD should exceed green TD'); + assert.ok(red[0] > red[2], 'red TD should exceed blue TD'); + + // A neutral gray gives three equal channel TDs at the single value. + const gray = deriveChannelTds('#808080', tdSingle); + assert.ok(Math.abs(gray[0] - gray[1]) < 1e-6 && Math.abs(gray[1] - gray[2]) < 1e-6); + assert.ok(Math.abs(gray[0] - tdSingle) < 1e-6); +}); + +test('a lighter base lets a near-black filament calibrate (round-trip over white)', async () => { + const { computeFrontlitCalibration, solveOpacityTransmission, OPACITY_JND } = + await loadCalibration(); + + // Black over a black base has no contrast; over white it does. + assert.equal(solveOpacityTransmission('#000000', OPACITY_JND, '#000000'), undefined); + const tWhite = solveOpacityTransmission('#000000', OPACITY_JND, '#ffffff'); + assert.ok(tWhite !== undefined && tWhite > 0 && tWhite < 1); + + // Round-trip a dark filament over a white base. + const color = '#101418'; + const tdTrue = 0.25; + const layerHeight = 0.04; + const opacityLayers = await simulateOpacityRead( + color, + tdTrue, + layerHeight, + OPACITY_JND, + 200, + '#ffffff' + ); + const result = computeFrontlitCalibration({ + filamentColor: color, + opacityLayers, + layerHeight, + firstLayerHeight: 0.2, + baseColor: '#ffffff', + }); + assert.ok(result.ok, 'dark filament calibrates over a white base'); + if (!result.ok) return; + assert.equal(result.calibration.baseColor, '#ffffff'); + const relErr = Math.abs(result.calibration.tdSingleValue - tdTrue) / tdTrue; + assert.ok(relErr < 0.2, `recovered ${result.calibration.tdSingleValue} vs ${tdTrue}`); +}); + +test('computeFrontlitCalibration rejects invalid reads and uncalibratable colors', async () => { + const { computeFrontlitCalibration } = await loadCalibration(); + + const badLayers = computeFrontlitCalibration({ + filamentColor: '#3050c0', + opacityLayers: 0, + layerHeight: 0.04, + firstLayerHeight: 0.2, + }); + assert.equal(badLayers.ok, false); + + const blackFilament = computeFrontlitCalibration({ + filamentColor: '#000000', + opacityLayers: 5, + layerHeight: 0.04, + firstLayerHeight: 0.2, + }); + assert.equal(blackFilament.ok, false); +}); diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index b92031b..f207f7d 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -186,11 +186,14 @@ test('cache keys include all weighted clusters and optimizer tuning', async () = ? { ...filament, calibration: { - color: filament.color, - measurements: [], + opacityLayers: 6, + layerHeight: 0.1, + firstLayerHeight: 0.2, td: [1.2, 1.8, 2.4] as [number, number, number], tdSingleValue: filament.td, + jnd: 2, confidence: 1, + basis: 'black-frontlit' as const, calibrationDate: '2026-01-01T00:00:00.000Z', }, } From bd4fa71940a263318afa09128750542e6db41bfc Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 2 Jul 2026 08:18:04 +0300 Subject: [PATCH 39/81] Remove the Load TD Test header button Drop the header shortcut and its wiring (Header prop, App handler, Image icon import) and update the docs/README references to load via upload or drag-and-drop. The tdTest.png asset stays as an example image. --- README.md | 6 +++--- src/App.tsx | 10 ---------- src/components/Header.tsx | 13 +------------ src/docs/loading-images.md | 6 +----- src/docs/overview.md | 2 +- src/docs/quick-start.md | 2 +- src/docs/settings-and-controls.md | 11 +++++------ 7 files changed, 12 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 92b0358..9004c14 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A quick look at what Kromacut produces from a source image to a printable model: | Sliced by Colors | Real Print | | Fuji Sliced | Printed Result | -Another minimal test you can try yourself in the app header: the Transmission Distance (TD) test image. +Another minimal test you can try yourself: the included Transmission Distance (TD) test image (`src/assets/tdTest.png`). | TD Test | |---| @@ -280,12 +280,12 @@ See the [Auto-paint section](#auto-paint) above for full details. ### TD test image (for manual mode experimentation) - This repository includes a small test image `tdTest.png` (found in `src/assets/tdTest.png`). -- The app header includes a `Load TD Test` button that will load that image into the preview so you can quickly experiment with manual layer ordering and per-color slice heights. +- Load it into the preview with the toolbar upload button or drag-and-drop so you can quickly experiment with manual layer ordering and per-color slice heights. - This is primarily useful for testing manual mode workflows or understanding how layer heights affect perceived color. Quick manual workflow experiment: -1. Click `Load TD Test` in the app header. +1. Load `tdTest.png` (upload button or drag-and-drop). 2. Reduce colors to your desired number. 3. Switch to Manual mode (3D Controls → Manual tab). 4. Adjust per-color slice heights and ordering. diff --git a/src/App.tsx b/src/App.tsx index 2815858..d5381a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,6 @@ import { } from './types'; import ThreeDView from './components/ThreeDView'; import logo from './assets/logo.png'; -import tdTestImg from './assets/tdTest.png'; import CanvasPreview from './components/CanvasPreview'; import type { CanvasPreviewHandle } from './components/CanvasPreview'; import { SwatchesPanel } from './components/SwatchesPanel'; @@ -478,15 +477,6 @@ function App(): React.ReactElement | null { docsOpen={docsOpen} onBackToApp={backToApp} onToggleDocs={toggleDocs} - onLoadTest={() => { - invalidate(); - setImage(tdTestImg, true); - setMode('2d'); - if (parseDocsLocation(window.location)) { - window.history.pushState(null, '', '/'); - } - setDocsOpen(false); - }} /> {docsOpen && (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 50a6a93..b89741d 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -6,7 +6,6 @@ import { BookOpen, CheckCircle2, Download, - Image, Heart, Loader2, Moon, @@ -44,7 +43,6 @@ import githubIcon from '../assets/github.svg'; import redditIcon from '../assets/reddit.svg'; interface Props { - onLoadTest: () => void; docsOpen: boolean; onBackToApp: () => void; onToggleDocs: () => void; @@ -53,7 +51,7 @@ interface Props { const appVersion = __APP_VERSION__; type UpdateCheckStatus = 'idle' | 'checking' | 'available' | 'current' | 'error'; -export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onToggleDocs }) => { +export const Header: React.FC = ({ docsOpen, onBackToApp, onToggleDocs }) => { const [themeMode, setThemeMode] = React.useState(() => getStoredThemeMode()); const [settingsOpen, setSettingsOpen] = React.useState(false); const [checkOnStartup, setCheckOnStartup] = React.useState(() => getUpdateCheckOnStartup()); @@ -189,15 +187,6 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT )} {docsOpen ? 'Back to app' : 'Docs'} -
diff --git a/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx index 2e5b2b4..678fee1 100644 --- a/src/components/FilamentCalibrationDialog.tsx +++ b/src/components/FilamentCalibrationDialog.tsx @@ -8,7 +8,7 @@ * to a frontlit TD and saved to the filament profile. */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertDialog, AlertDialogContent, @@ -41,6 +41,7 @@ import { import { generateCalibrationStl, generateCalibration3mf, + calibrationBaseHeight, DEFAULT_CALIBRATION_PRINT_OPTIONS, type CalibrationPrintOptions, type CalibrationTile, @@ -94,6 +95,10 @@ function downloadBlob(blob: Blob, filename: string) { URL.revokeObjectURL(url); } +function formatMm(value: number): string { + return `${value.toFixed(2)} mm`; +} + export function FilamentCalibrationDialog({ open, onClose, @@ -103,10 +108,16 @@ export function FilamentCalibrationDialog({ firstLayerHeight, onApply, }: FilamentCalibrationDialogProps) { + const initialSelection = useCallback(() => { + const id = + initialFilamentId && filaments.some((filament) => filament.id === initialFilamentId) + ? initialFilamentId + : null; + return new Set(id ? [id] : []); + }, [filaments, initialFilamentId]); + const [step, setStep] = useState('select'); - const [selectedIds, setSelectedIds] = useState>( - () => new Set(initialFilamentId ? [initialFilamentId] : []) - ); + const [selectedIds, setSelectedIds] = useState>(() => initialSelection()); const [maxLayers, setMaxLayers] = useState(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers); const [calibrationLayerHeight, setCalibrationLayerHeight] = useState(layerHeight); // Free-typing drafts for the numeric inputs; clamped to the committed value on blur. @@ -118,6 +129,7 @@ export function FilamentCalibrationDialog({ const [reads, setReads] = useState>({}); // filament id -> chosen base filament id (defaults to the darkest filament). const [baseChoices, setBaseChoices] = useState>({}); + const wasOpenRef = useRef(open); const darkestFilamentId = useMemo(() => { if (filaments.length === 0) return undefined; @@ -126,6 +138,11 @@ export function FilamentCalibrationDialog({ ).id; }, [filaments]); + const filamentById = useMemo( + () => new Map(filaments.map((filament) => [filament.id, filament])), + [filaments] + ); + const resolveBaseId = useCallback( (filamentId: string): string => baseChoices[filamentId] ?? darkestFilamentId ?? filamentId, [baseChoices, darkestFilamentId] @@ -133,10 +150,18 @@ export function FilamentCalibrationDialog({ const resolveBaseColor = useCallback( (filamentId: string): string => { - const base = filaments.find((f) => f.id === resolveBaseId(filamentId)); + const base = filamentById.get(resolveBaseId(filamentId)); return base?.color ?? '#000000'; }, - [filaments, resolveBaseId] + [filamentById, resolveBaseId] + ); + + const resolveBaseLabel = useCallback( + (filamentId: string): string => { + const base = filamentById.get(resolveBaseId(filamentId)); + return base ? filamentLabel(base) : 'Base'; + }, + [filamentById, resolveBaseId] ); const setBaseChoice = useCallback((filamentId: string, baseId: string) => { @@ -176,10 +201,12 @@ export function FilamentCalibrationDialog({ }), [calibrationLayerHeight, firstLayerHeight, maxLayers] ); + const firstLayerPrintHeight = Math.max(calibrationLayerHeight, firstLayerHeight); + const swapZ = calibrationBaseHeight(printOptions); const reset = useCallback(() => { setStep('select'); - setSelectedIds(new Set(initialFilamentId ? [initialFilamentId] : [])); + setSelectedIds(initialSelection()); setMaxLayers(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers); setMaxLayersDraft(String(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers)); setCalibrationLayerHeight(layerHeight); @@ -187,7 +214,12 @@ export function FilamentCalibrationDialog({ setFormat('stl'); setReads({}); setBaseChoices({}); - }, [initialFilamentId, layerHeight]); + }, [initialSelection, layerHeight]); + + useEffect(() => { + if (open && !wasOpenRef.current) reset(); + wasOpenRef.current = open; + }, [open, reset]); const handleClose = useCallback(() => { onClose(); @@ -520,11 +552,40 @@ export function FilamentCalibrationDialog({

{format === 'stl' - ? 'Single solid. Print it once per filament: base, then swap to the color above the base.' + ? 'Single solid. Print one copy per selected filament using the matching base and swap line below.' : 'Colors + bases baked in for AMS / multi-material — all selected filaments in one print.'}

+ {format === 'stl' && ( +
+

+ Slice at layer height {formatMm(calibrationLayerHeight)} and first + layer {formatMm(firstLayerPrintHeight)}. +

+
+ {selectedFilaments.map((filament) => ( +
+ + + + {filamentLabel(filament)} + + : base = {resolveBaseLabel(filament.id)}, swap after + layer {printOptions.baseLayers} / Z {formatMm(swapZ)}. + +
+ ))} +
+
+ )} + + {onCalibrate && ( + + )} + {/* Calibration Badge */} {isCalibrated && (
; function calibratedTdChannels(filament: AutoPaintFilament): CalibrationRgb | undefined { if (!USE_CALIBRATED_CHANNEL_TD) return undefined; - const channels = filament.calibration?.td; + const channels = sanitizeFrontlitCalibration(filament.calibration)?.td; if (!channels || channels.some((td) => !Number.isFinite(td) || td <= 0)) { return undefined; } @@ -412,10 +416,10 @@ function scaleFilamentForFrontlight(filament: Filament): Filament { // Calibrated filaments already carry measured frontlit TDs (scalar + per // channel), so they pass through unchanged. Only uncalibrated filaments get // the generic frontlit approximation. - if (filament.calibration) { + if (sanitizeFrontlitCalibration(filament.calibration)) { return filament; } - return { ...filament, td: filament.td * FRONTLIT_TD_SCALE }; + return { ...filament, td: filament.td * FRONTLIT_TD_SCALE, calibration: undefined }; } /** diff --git a/src/lib/calibration.ts b/src/lib/calibration.ts index c8023a2..b674a2c 100644 --- a/src/lib/calibration.ts +++ b/src/lib/calibration.ts @@ -37,11 +37,11 @@ export interface FrontlitCalibration { /** JND used for the opacity solve. */ jnd: number; /** Base color the wedge was calibrated over (hex). */ - baseColor?: string; + baseColor: string; /** 0-1 quality score. */ confidence: number; - /** Calibration basis, fixed for now. */ - basis: 'black-frontlit'; + /** Calibration method. `baseColor` records the actual base used. */ + basis: 'frontlit'; /** ISO timestamp. */ calibrationDate: string; notes?: string; @@ -96,6 +96,67 @@ function hexToRgb(hex: string): CalibrationRgb | null { : null; } +const isFinitePositive = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value > 0; + +function sanitizeCalibrationRgb(value: unknown): CalibrationRgb | undefined { + if (!Array.isArray(value) || value.length !== 3) return undefined; + const [r, g, b] = value; + if (!isFinitePositive(r) || !isFinitePositive(g) || !isFinitePositive(b)) return undefined; + return [r, g, b]; +} + +/** + * Accept only the camera-free frontlit calibration shape. Older backlit photo + * calibrations are intentionally stripped on load rather than migrated. + */ +export function sanitizeFrontlitCalibration(value: unknown): FrontlitCalibration | undefined { + if (!value || typeof value !== 'object') return undefined; + const candidate = value as Record; + if (candidate.basis !== 'frontlit') return undefined; + if ( + typeof candidate.opacityLayers !== 'number' || + !Number.isFinite(candidate.opacityLayers) || + candidate.opacityLayers < 1 + ) { + return undefined; + } + if (!isFinitePositive(candidate.layerHeight)) return undefined; + if (!isFinitePositive(candidate.firstLayerHeight)) return undefined; + if (!isFinitePositive(candidate.tdSingleValue)) return undefined; + if (!isFinitePositive(candidate.jnd)) return undefined; + if (typeof candidate.baseColor !== 'string' || !hexToRgb(candidate.baseColor)) { + return undefined; + } + if (typeof candidate.confidence !== 'number' || !Number.isFinite(candidate.confidence)) { + return undefined; + } + if ( + typeof candidate.calibrationDate !== 'string' || + !Number.isFinite(new Date(candidate.calibrationDate).getTime()) + ) { + return undefined; + } + + const td = sanitizeCalibrationRgb(candidate.td); + if (!td) return undefined; + + const sanitized: FrontlitCalibration = { + opacityLayers: candidate.opacityLayers, + layerHeight: candidate.layerHeight, + firstLayerHeight: candidate.firstLayerHeight, + td, + tdSingleValue: candidate.tdSingleValue, + jnd: candidate.jnd, + baseColor: candidate.baseColor, + confidence: clamp(candidate.confidence, 0, 1), + basis: 'frontlit', + calibrationDate: candidate.calibrationDate, + }; + if (typeof candidate.notes === 'string') sanitized.notes = candidate.notes; + return sanitized; +} + // ============================================================================ // Opacity-read calibration // ============================================================================ @@ -221,7 +282,7 @@ export function computeFrontlitCalibration( jnd, baseColor, confidence, - basis: 'black-frontlit', + basis: 'frontlit', calibrationDate: new Date().toISOString(), notes: input.notes, }, @@ -259,17 +320,17 @@ export function predictFrontlitColor( * decay, or a TD-plausibility heuristic when uncalibrated. */ export function computeProfileConfidence(profile: { - calibration?: FrontlitCalibration; + calibration?: unknown; transmissionDistance: number; }): number { - if (!profile.calibration) { + const cal = sanitizeFrontlitCalibration(profile.calibration); + if (!cal) { const td = profile.transmissionDistance; if (td >= 1.0 && td <= 5.0) return 0.5; if (td >= 0.5 && td <= 10.0) return 0.3; return 0.1; } - const cal = profile.calibration; let confidence = cal.confidence; const ageMs = Date.now() - new Date(cal.calibrationDate).getTime(); diff --git a/src/lib/generateCalibrationPrint.ts b/src/lib/generateCalibrationPrint.ts index 6a87a0c..f859832 100644 --- a/src/lib/generateCalibrationPrint.ts +++ b/src/lib/generateCalibrationPrint.ts @@ -81,12 +81,16 @@ interface TileGeometry { } /** - * Thickness of the black base in mm. The slicer applies its own first-layer - * height when printing, so this is just `baseLayers` worth of regular layers — - * enough to be opaque black under the wedge, no thicker. + * Thickness of the base in mm. The first printed layer uses the slicer's first + * layer height, then the remaining base layers use the regular layer height. + * This keeps the base top and every wedge/rail top on the slicer's Z grid. */ -function baseHeight(options: CalibrationPrintOptions): number { - return options.baseLayers * options.layerHeight; +export function calibrationBaseHeight( + options: Pick +): number { + if (!Number.isFinite(options.baseLayers) || options.baseLayers <= 0) return 0; + const firstLayer = Math.max(options.layerHeight, options.firstLayerHeight); + return firstLayer + Math.max(0, options.baseLayers - 1) * options.layerHeight; } /** Width of the opaque reference rail along the wedge edge (mm). */ @@ -105,7 +109,7 @@ const REFERENCE_MARGIN_LAYERS = 4; function buildTileGeometry(options: CalibrationPrintOptions, originX = 0): TileGeometry { const { layerHeight, maxLayers, patchSize, gap } = options; const railLayers = maxLayers + REFERENCE_MARGIN_LAYERS; - const baseH = baseHeight(options); + const baseH = calibrationBaseHeight(options); const pad = gap; const columnWidth = patchSize + gap; const wedgeSpan = maxLayers * columnWidth - gap; // first patch left to last patch right diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts index d6bcf3d..0ae8adb 100644 --- a/src/lib/profileManager.ts +++ b/src/lib/profileManager.ts @@ -1,4 +1,5 @@ import type { Filament } from '../types'; +import { sanitizeFrontlitCalibration } from './calibration'; export interface AutoPaintProfile { id: string; @@ -14,16 +15,66 @@ export const CURRENT_PROFILE_VERSION = 1; const PROFILES_STORAGE_KEY = 'kromacut.autopaint.profiles'; const LAST_PROFILE_KEY = 'kromacut.autopaint.lastProfileId'; +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +export function sanitizeProfileFilament(value: unknown): Filament | null { + if (!isRecord(value)) return null; + if ( + typeof value.id !== 'string' || + typeof value.color !== 'string' || + typeof value.td !== 'number' || + !Number.isFinite(value.td) + ) { + return null; + } + + const filament: Filament = { + id: value.id, + color: value.color, + td: value.td, + }; + if (typeof value.name === 'string') filament.name = value.name; + if (typeof value.brand === 'string') filament.brand = value.brand; + + const calibration = sanitizeFrontlitCalibration(value.calibration); + if (calibration) filament.calibration = calibration; + return filament; +} + +function sanitizeProfile(value: unknown): AutoPaintProfile | null { + if (!isRecord(value)) return null; + if ( + typeof value.id !== 'string' || + typeof value.name !== 'string' || + !Array.isArray(value.filaments) + ) { + return null; + } + + const now = Date.now(); + return { + id: value.id, + name: value.name, + version: typeof value.version === 'number' ? value.version : CURRENT_PROFILE_VERSION, + filaments: value.filaments + .map((filament) => sanitizeProfileFilament(filament)) + .filter((filament): filament is Filament => filament !== null), + createdAt: typeof value.createdAt === 'number' ? value.createdAt : now, + updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : now, + }; +} + export function loadProfiles(): AutoPaintProfile[] { try { const raw = localStorage.getItem(PROFILES_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as AutoPaintProfile[]; if (!Array.isArray(parsed)) return []; - return parsed.filter( - (p) => - typeof p.id === 'string' && typeof p.name === 'string' && Array.isArray(p.filaments) - ); + return parsed + .map((profile) => sanitizeProfile(profile)) + .filter((profile): profile is AutoPaintProfile => profile !== null); } catch { return []; } @@ -162,10 +213,9 @@ export function importProfiles( // Validate required fields if (!raw || typeof raw.name !== 'string' || !Array.isArray(raw.filaments)) continue; - const validFilaments = raw.filaments.filter( - (f) => - typeof f.id === 'string' && typeof f.color === 'string' && typeof f.td === 'number' - ); + const validFilaments = raw.filaments + .map((filament) => sanitizeProfileFilament(filament)) + .filter((filament): filament is Filament => filament !== null); const now = Date.now(); const profile: AutoPaintProfile = { diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts index d4576a7..5130694 100644 --- a/tests/autoPaint.test.ts +++ b/tests/autoPaint.test.ts @@ -153,8 +153,9 @@ test('transition-thickness cache distinguishes calibrated channel TDs', async () td: [0.5, 1, 2] as [number, number, number], tdSingleValue: 1, jnd: 2, + baseColor: '#000000', confidence: 1, - basis: 'black-frontlit' as const, + basis: 'frontlit' as const, calibrationDate: '2026-01-01T00:00:00.000Z', }, }, @@ -186,6 +187,40 @@ test('calibrated per-channel TDs tint blends without changing scalar TD behavior ); }); +test('legacy photo calibrations are ignored and use the frontlit fallback scale', async () => { + const { generateAutoLayers } = await loadAutoPaintModule(); + const filaments = [ + { id: 'black', color: '#000000', td: 1 }, + { id: 'white', color: '#ffffff', td: 16 }, + ]; + const legacyFilaments = [ + filaments[0], + { + ...filaments[1], + calibration: { + measurements: [{ color: '#ffffff', rgb: [245, 245, 245], thickness: 0.4 }], + whiteReference: [255, 255, 255], + td: [8, 16, 32], + tdSingleValue: 16, + confidence: 1, + calibrationDate: '2025-01-01T00:00:00.000Z', + }, + }, + ] as unknown as Parameters[0]; + const swatches = [ + { hex: '#000000', count: 1 }, + { hex: '#ffffff', count: 1 }, + ]; + + const baseline = generateAutoLayers(filaments, swatches, 0.1, 0.2, undefined, false); + const legacy = generateAutoLayers(legacyFilaments, swatches, 0.1, 0.2, undefined, false); + + assert.deepEqual(legacy.layers, baseline.layers); + assert.deepEqual(legacy.transitionZones, baseline.transitionZones); + assert.equal(legacy.totalHeight, baseline.totalHeight); + assert.equal(legacy.confidenceFactors.calibrationQuality, baseline.confidenceFactors.calibrationQuality); +}); + test('Beer-Lambert blends operate in linear light before returning sRGB', async () => { const { blendColors } = await loadAutoPaintModule(); const halfTransmissionThickness = Math.log10(2); @@ -221,8 +256,9 @@ test('calibrated channel TDs flow through generated auto-paint preview slices', td: [8, 16, 32] as [number, number, number], tdSingleValue: 16, jnd: 2, + baseColor: '#000000', confidence: 1, - basis: 'black-frontlit' as const, + basis: 'frontlit' as const, calibrationDate: '2026-01-01T00:00:00.000Z', }, }, diff --git a/tests/calibrationPrint.test.ts b/tests/calibrationPrint.test.ts index 5cbd92f..23ca587 100644 --- a/tests/calibrationPrint.test.ts +++ b/tests/calibrationPrint.test.ts @@ -53,6 +53,22 @@ test('generateCalibrationStl writes one box per base/wedge/reference patch', asy assert.equal(buffer.byteLength, 84 + triangleCount * 50); }); +test('calibration base and patch tops align to the first-layer-aware slicer grid', async () => { + const { calibrationBaseHeight } = await loadGenerator(); + const baseH = calibrationBaseHeight(baseOptions); + const firstLayerTop = Math.max(baseOptions.layerHeight, baseOptions.firstLayerHeight); + + assert.equal(baseH, 0.52); + for (let layers = 0; layers <= baseOptions.maxLayers; layers++) { + const top = baseH + layers * baseOptions.layerHeight; + const gridSteps = (top - firstLayerTop) / baseOptions.layerHeight; + assert.ok( + Math.abs(gridSteps - Math.round(gridSteps)) < 1e-9, + `top ${top} must land on the slicer grid` + ); + } +}); + test('generateCalibration3mf builds slots in profile order and colors the parts', async () => { const { generateCalibration3mf } = await loadGenerator(); // Profile order matches the user's machine: white in slot 1, black in slot 2. diff --git a/tests/frontlitCalibration.test.ts b/tests/frontlitCalibration.test.ts index 07494bd..6c87a60 100644 --- a/tests/frontlitCalibration.test.ts +++ b/tests/frontlitCalibration.test.ts @@ -192,10 +192,47 @@ test('a lighter base lets a near-black filament calibrate (round-trip over white assert.ok(result.ok, 'dark filament calibrates over a white base'); if (!result.ok) return; assert.equal(result.calibration.baseColor, '#ffffff'); + assert.equal(result.calibration.basis, 'frontlit'); const relErr = Math.abs(result.calibration.tdSingleValue - tdTrue) / tdTrue; assert.ok(relErr < 0.2, `recovered ${result.calibration.tdSingleValue} vs ${tdTrue}`); }); +test('sanitizeFrontlitCalibration accepts only the new frontlit calibration shape', async () => { + const { computeFrontlitCalibration, sanitizeFrontlitCalibration } = await loadCalibration(); + const result = computeFrontlitCalibration({ + filamentColor: '#ffffff', + opacityLayers: 6, + layerHeight: 0.08, + firstLayerHeight: 0.2, + }); + assert.ok(result.ok); + if (!result.ok) return; + + const sanitized = sanitizeFrontlitCalibration(result.calibration); + assert.ok(sanitized); + assert.equal(sanitized.basis, 'frontlit'); + assert.deepEqual(sanitized.td, result.calibration.td); + assert.equal(sanitized.tdSingleValue, result.calibration.tdSingleValue); + assert.equal( + sanitizeFrontlitCalibration({ + measurements: [], + whiteReference: [255, 255, 255], + td: [2, 3, 4], + tdSingleValue: 3, + confidence: 0.9, + calibrationDate: '2025-01-01T00:00:00.000Z', + }), + undefined + ); + assert.equal( + sanitizeFrontlitCalibration({ + ...result.calibration, + basis: 'black-frontlit', + }), + undefined + ); +}); + test('computeFrontlitCalibration rejects invalid reads and uncalibratable colors', async () => { const { computeFrontlitCalibration } = await loadCalibration(); diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index f207f7d..1440206 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -192,8 +192,9 @@ test('cache keys include all weighted clusters and optimizer tuning', async () = td: [1.2, 1.8, 2.4] as [number, number, number], tdSingleValue: filament.td, jnd: 2, + baseColor: '#000000', confidence: 1, - basis: 'black-frontlit' as const, + basis: 'frontlit' as const, calibrationDate: '2026-01-01T00:00:00.000Z', }, } diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index 2d941a5..81e5e26 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -1,17 +1,65 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { createServer } from 'vite'; -import { - profileFileName, - renameProfile, - type AutoPaintProfile, -} from '../src/lib/profileManager.ts'; +import type { AutoPaintProfile } from '../src/lib/profileManager.ts'; -test('auto-paint profile exports use kfil filenames', () => { +type ProfileManagerModule = typeof import('../src/lib/profileManager.ts'); + +let profileManagerModule: Promise | null = null; + +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(); + } +} + +const loadProfileManager = () => + (profileManagerModule ??= loadViteModule('/src/lib/profileManager.ts')); + +const oldPhotoCalibration = { + measurements: [{ color: '#ffffff', rgb: [245, 240, 230], thickness: 0.4 }], + whiteReference: [255, 255, 255], + td: [2.5, 3.5, 4.5], + tdSingleValue: 3.5, + confidence: 0.92, + calibrationDate: '2025-01-01T00:00:00.000Z', +}; + +function createMemoryStorage() { + const values = new Map(); + return { + getItem: (key: string) => (values.has(key) ? values.get(key)! : null), + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + }; +} + +test('auto-paint profile exports use kfil filenames', async () => { + const { profileFileName } = await loadProfileManager(); assert.equal(profileFileName('PLA Basic White'), 'PLA_Basic_White.kfil'); }); -test('auto-paint profiles can be renamed without changing filament data', () => { +test('auto-paint profiles can be renamed without changing filament data', async () => { + const { renameProfile } = await loadProfileManager(); const profiles: AutoPaintProfile[] = [ { id: 'profile-1', @@ -29,3 +77,78 @@ test('auto-paint profiles can be renamed without changing filament data', () => assert.deepEqual(renamed[0].filaments, profiles[0].filaments); assert.ok(renamed[0].updatedAt >= profiles[0].updatedAt); }); + +test('profile imports strip legacy photo calibration objects', async () => { + const { importProfiles } = await loadProfileManager(); + const incoming = [ + { + id: 'profile-old', + name: 'Legacy Profile', + version: 1, + createdAt: 1, + updatedAt: 1, + filaments: [ + { + id: 'filament-old', + color: '#ffffff', + td: 3.5, + calibration: oldPhotoCalibration, + }, + ], + }, + ] as unknown as AutoPaintProfile[]; + + const result = importProfiles([], incoming); + + assert.equal(result.imported.length, 1); + assert.equal(result.imported[0].filaments[0].calibration, undefined); +}); + +test('stored auto-paint profiles strip legacy photo calibration objects on load', async () => { + const { loadProfiles } = await loadProfileManager(); + const existingStorage = Reflect.get(globalThis, 'localStorage') as + | ReturnType + | undefined; + const storage = existingStorage ?? createMemoryStorage(); + if (!existingStorage) { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }); + } + + const previousProfiles = storage.getItem('kromacut.autopaint.profiles'); + storage.setItem( + 'kromacut.autopaint.profiles', + JSON.stringify([ + { + id: 'profile-stored', + name: 'Stored Legacy Profile', + version: 1, + createdAt: 1, + updatedAt: 1, + filaments: [ + { + id: 'filament-stored', + color: '#ffffff', + td: 3.5, + calibration: oldPhotoCalibration, + }, + ], + }, + ]) + ); + + try { + const loaded = loadProfiles(); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].filaments[0].calibration, undefined); + } finally { + if (previousProfiles === null) { + storage.removeItem('kromacut.autopaint.profiles'); + } else { + storage.setItem('kromacut.autopaint.profiles', previousProfiles); + } + if (!existingStorage) Reflect.deleteProperty(globalThis, 'localStorage'); + } +}); From 8d0a2a76f98e192eadc175146262c0e7f02760fe Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 2 Jul 2026 11:09:08 +0300 Subject: [PATCH 41/81] Add multi-base color calibration --- CHANGELOG.md | 2 +- src/components/FilamentCalibrationDialog.tsx | 624 ++++++++++++++----- src/docs/3d-mode.md | 16 +- src/lib/calibration.ts | 523 +++++++++++++++- tests/frontlitCalibration.test.ts | 185 +++++- 5 files changed, 1183 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ed358..ac338f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Added -- **Frontlit filament calibration** - Replaced the photo-based TD calibration with a camera-free workflow. Pick one or more filaments, choose the base layer each prints over (defaults to your darkest filament; dark filaments can use a lighter base for contrast), then download a calibration wedge (single-solid STL for any printer, or a multi-material 3MF for AMS) that prints 1..N filament layers over that base alongside an opaque reference rail, and report the single layer count at which each filament's patch first matches the rail. Kromacut converts that read into a frontlit Transmission Distance (scalar plus per-channel) using a perceptual just-noticeable-difference model, with a live predicted-vs-reference preview and confidence rating. The 3MF assigns parts to your real profile-filament slots (no invented base filament) and lays each tile out as an independently-arrangeable object. The old backlit photo wizard, its image sampler, and stored RGB measurements are removed, and legacy stored RGB calibrations are stripped on load; recalibrate any previously calibrated filaments. +- **Frontlit filament calibration** - Replaced the photo-based TD calibration with a camera-free workflow. Pick one or more filaments, choose the base layer each prints over (defaults to your darkest filament; dark filaments can use a lighter base for contrast), then download a calibration wedge (single-solid STL for any printer, or a multi-material 3MF for AMS) that prints 1..N filament layers over that base alongside an opaque reference rail, and report the layer count at which each filament/base patch first matches the rail. Quick mode keeps the single-base scalar calibration; Accurate mode repeats the read over multiple bases to fit measured RGB-channel TDs directly, stores the read set (plus optional merge points), and fits a shared session JND when enough multi-base data is present. Kromacut converts those reads into a frontlit Transmission Distance using a perceptual just-noticeable-difference model, with a live predicted-vs-reference preview and confidence rating. The 3MF assigns parts to your real profile-filament slots (no invented base filament) and lays each tile out as an independently-arrangeable object. The old backlit photo wizard, its image sampler, and stored RGB measurements are removed, and legacy stored RGB calibrations are stripped on load; recalibrate any previously calibrated filaments. - **Preserve color separation (Auto-paint)** - Enhanced color matching can keep each distinct 2D image color mapped to a distinct printable color when the stack exposes enough printable colors, preserving gradients that would otherwise collapse onto a flat surface. It is mutually exclusive with Height dithering because both modes change the same printable height map. - **Auto-paint optimization progress** - Enhanced matching now reports approximate search progress while the background optimizer runs. - **Auto-paint test and benchmark coverage** - Added deterministic stack goldens, layer-invariant regression coverage, realized CIEDE2000 quality-budget tests, and an on-demand benchmark for fixture profiles using the same printable-stack mapper as the optimizer. diff --git a/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx index 678fee1..fdda748 100644 --- a/src/components/FilamentCalibrationDialog.tsx +++ b/src/components/FilamentCalibrationDialog.tsx @@ -22,21 +22,30 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card } from '@/components/ui/card'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { X, Download, Check, Minus, ArrowLeft, ArrowRight, FlaskConical } from 'lucide-react'; + X, + Download, + Check, + Minus, + ArrowLeft, + ArrowRight, + FlaskConical, + AlertTriangle, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import type { Filament } from '../types'; import { computeFrontlitCalibration, + computeFrontlitCalibrationSession, + deriveChannelTds, predictFrontlitColor, + predictOpacityLayersForTds, + sanitizeFrontlitCalibration, + solveOpacityTransmission, getConfidenceLabel, getConfidenceColor, + OPACITY_JND, type FrontlitCalibration, + type FrontlitCalibrationRead, } from '@/lib/calibration'; import { generateCalibrationStl, @@ -49,6 +58,7 @@ import { type Step = 'select' | 'base' | 'print' | 'measure'; type PrintFormat = 'stl' | '3mf'; +type CalibrationMode = 'quick' | 'accurate'; export interface CalibrationApplyUpdate { id: string; @@ -68,6 +78,7 @@ interface FilamentCalibrationDialogProps { const MIN_MAX_LAYERS = 4; const MAX_MAX_LAYERS = 40; +const MAX_BASES_PER_FILAMENT = 3; function filamentLabel(filament: Filament): string { return filament.name || filament.brand || `Filament ${filament.color}`; @@ -86,6 +97,43 @@ function rgbCss(rgb: [number, number, number]): string { return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; } +function hexToRgbTuple(hex: string): [number, number, number] { + const h = hex.replace(/^#/, ''); + return [ + parseInt(h.slice(0, 2), 16) || 0, + parseInt(h.slice(2, 4), 16) || 0, + parseInt(h.slice(4, 6), 16) || 0, + ]; +} + +function readKey(filamentId: string, baseId: string): string { + return `${filamentId}::${baseId}`; +} + +function parseLayerInput(value: string | undefined): number | null { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isFinite(parsed) && parsed >= 1 ? parsed : null; +} + +function governingChannel( + filamentColor: string, + baseColor: string, + td: [number, number, number] +): number { + const filament = hexToRgbTuple(filamentColor); + const base = hexToRgbTuple(baseColor); + let best = 0; + let bestScore = -Infinity; + for (let channel = 0; channel < 3; channel++) { + const score = Math.abs(filament[channel] - base[channel]) * td[channel]; + if (score > bestScore) { + best = channel; + bestScore = score; + } + } + return best; +} + function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -126,9 +174,12 @@ export function FilamentCalibrationDialog({ ); const [layerHeightDraft, setLayerHeightDraft] = useState(() => String(layerHeight)); const [format, setFormat] = useState('stl'); + const [mode, setMode] = useState('quick'); const [reads, setReads] = useState>({}); - // filament id -> chosen base filament id (defaults to the darkest filament). - const [baseChoices, setBaseChoices] = useState>({}); + const [mergeReads, setMergeReads] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + // filament id -> chosen base filament ids (defaults are auto-picked). + const [baseChoices, setBaseChoices] = useState>({}); const wasOpenRef = useRef(open); const darkestFilamentId = useMemo(() => { @@ -138,35 +189,130 @@ export function FilamentCalibrationDialog({ ).id; }, [filaments]); + const lightestFilamentId = useMemo(() => { + if (filaments.length === 0) return undefined; + return filaments.reduce((lightest, f) => + luminance(f.color) > luminance(lightest.color) ? f : lightest + ).id; + }, [filaments]); + const filamentById = useMemo( () => new Map(filaments.map((filament) => [filament.id, filament])), [filaments] ); - const resolveBaseId = useCallback( - (filamentId: string): string => baseChoices[filamentId] ?? darkestFilamentId ?? filamentId, - [baseChoices, darkestFilamentId] - ); + const estimateChannelTds = useCallback((filament: Filament): [number, number, number] => { + const calibration = sanitizeFrontlitCalibration(filament.calibration); + if (calibration) return calibration.td; + return deriveChannelTds(filament.color, Math.max(0.05, filament.td * 0.1)); + }, []); + + const recommendedBaseIds = useCallback( + (filament: Filament): string[] => { + const targetCount = mode === 'accurate' ? 2 : 1; + const td = estimateChannelTds(filament); + const candidates = filaments + .map((base) => { + if (!solveOpacityTransmission(filament.color, OPACITY_JND, base.color)) { + return null; + } + const predicted = predictOpacityLayersForTds( + filament.color, + td, + calibrationLayerHeight, + OPACITY_JND, + base.color, + maxLayers + ); + if (predicted === undefined) return null; + const useful = predicted > 1 && predicted <= maxLayers; + return { + base, + predicted, + useful, + channel: governingChannel(filament.color, base.color, td), + }; + }) + .filter( + (candidate): candidate is NonNullable => candidate !== null + ); - const resolveBaseColor = useCallback( - (filamentId: string): string => { - const base = filamentById.get(resolveBaseId(filamentId)); - return base?.color ?? '#000000'; + const useful = candidates.filter((candidate) => candidate.useful); + const pool = useful.length > 0 ? useful : candidates; + if (pool.length === 0) { + return [lightestFilamentId ?? darkestFilamentId ?? filament.id]; + } + + const selected: typeof pool = []; + const anchor = + pool.find((candidate) => candidate.base.id === darkestFilamentId) ?? + [...pool].sort((a, b) => luminance(a.base.color) - luminance(b.base.color))[0]; + selected.push(anchor); + + while (selected.length < targetCount && selected.length < pool.length) { + const covered = new Set(selected.map((candidate) => candidate.channel)); + const next = pool + .filter( + (candidate) => !selected.some((item) => item.base.id === candidate.base.id) + ) + .sort((a, b) => { + const aDiversity = covered.has(a.channel) ? 0 : 1; + const bDiversity = covered.has(b.channel) ? 0 : 1; + const aMidness = + 1 - Math.abs(a.predicted - maxLayers / 2) / Math.max(1, maxLayers / 2); + const bMidness = + 1 - Math.abs(b.predicted - maxLayers / 2) / Math.max(1, maxLayers / 2); + return bDiversity * 10 + bMidness - (aDiversity * 10 + aMidness); + })[0]; + if (!next) break; + selected.push(next); + } + + return selected.map((candidate) => candidate.base.id); }, - [filamentById, resolveBaseId] + [ + calibrationLayerHeight, + darkestFilamentId, + estimateChannelTds, + filaments, + lightestFilamentId, + maxLayers, + mode, + ] ); - const resolveBaseLabel = useCallback( - (filamentId: string): string => { - const base = filamentById.get(resolveBaseId(filamentId)); - return base ? filamentLabel(base) : 'Base'; + const resolveBaseIds = useCallback( + (filamentId: string): string[] => { + const filament = filamentById.get(filamentId); + if (!filament) return []; + const explicit = baseChoices[filamentId]?.filter((id) => filamentById.has(id)); + if (explicit && explicit.length > 0) return explicit; + return recommendedBaseIds(filament).filter((id) => filamentById.has(id)); }, - [filamentById, resolveBaseId] + [baseChoices, filamentById, recommendedBaseIds] ); - const setBaseChoice = useCallback((filamentId: string, baseId: string) => { - setBaseChoices((prev) => ({ ...prev, [filamentId]: baseId })); - }, []); + const toggleBaseChoice = useCallback( + (filamentId: string, baseId: string) => { + const current = resolveBaseIds(filamentId); + if (mode === 'quick') { + setBaseChoices((prev) => ({ ...prev, [filamentId]: [baseId] })); + return; + } + + let next: string[]; + if (current.includes(baseId)) { + next = current.length > 1 ? current.filter((id) => id !== baseId) : current; + } else { + next = + current.length >= MAX_BASES_PER_FILAMENT + ? [...current.slice(1), baseId] + : [...current, baseId]; + } + setBaseChoices((prev) => ({ ...prev, [filamentId]: next })); + }, + [mode, resolveBaseIds] + ); const commitLayerHeight = useCallback(() => { const parsed = Number(layerHeightDraft); @@ -192,6 +338,22 @@ export function FilamentCalibrationDialog({ [filaments, selectedIds] ); + const calibrationTargets = useMemo(() => { + return selectedFilaments.flatMap((filament) => + resolveBaseIds(filament.id) + .map((baseId) => { + const base = filamentById.get(baseId); + if (!base) return null; + return { + key: readKey(filament.id, base.id), + filament, + base, + }; + }) + .filter((target): target is NonNullable => target !== null) + ); + }, [filamentById, resolveBaseIds, selectedFilaments]); + const printOptions = useMemo( () => ({ ...DEFAULT_CALIBRATION_PRINT_OPTIONS, @@ -212,7 +374,10 @@ export function FilamentCalibrationDialog({ setCalibrationLayerHeight(layerHeight); setLayerHeightDraft(String(layerHeight)); setFormat('stl'); + setMode('quick'); setReads({}); + setMergeReads({}); + setIsSaving(false); setBaseChoices({}); }, [initialSelection, layerHeight]); @@ -242,12 +407,12 @@ export function FilamentCalibrationDialog({ }, [filaments]); const handleDownload = useCallback(async () => { - const tiles: CalibrationTile[] = selectedFilaments.map((f) => ({ - filamentId: f.id, - color: f.color, - baseFilamentId: resolveBaseId(f.id), - baseColor: resolveBaseColor(f.id), - name: filamentLabel(f), + const tiles: CalibrationTile[] = calibrationTargets.map(({ filament, base }) => ({ + filamentId: filament.id, + color: filament.color, + baseFilamentId: base.id, + baseColor: base.color, + name: `${filamentLabel(filament)} over ${filamentLabel(base)}`, })); if (tiles.length === 0) return; @@ -264,58 +429,116 @@ export function FilamentCalibrationDialog({ name: filamentLabel(f), })); const blob = await generateCalibration3mf(tiles, printOptions, profileFilaments); - downloadBlob(blob, `kromacut-calibration-${tiles.length}filaments.3mf`); + downloadBlob(blob, `kromacut-calibration-${tiles.length}reads.3mf`); } - }, [selectedFilaments, filaments, format, printOptions, maxLayers, resolveBaseId, resolveBaseColor]); + }, [calibrationTargets, filaments, format, printOptions, maxLayers]); - const setRead = useCallback((id: string, value: string) => { - setReads((prev) => ({ ...prev, [id]: value })); + const setRead = useCallback((key: string, value: string) => { + setReads((prev) => ({ ...prev, [key]: value })); + }, []); + + const setMergeRead = useCallback((key: string, value: string) => { + setMergeReads((prev) => ({ ...prev, [key]: value })); }, []); // Per-filament calibration computed live from the entered reads. const computed = useMemo(() => { - return selectedFilaments.map((filament) => { - const raw = reads[filament.id]; - const opacityLayers = Number.parseInt(raw ?? '', 10); - if (!Number.isFinite(opacityLayers) || opacityLayers < 1) { - return { filament, result: null, opacityLayers: null }; + const entries = selectedFilaments.map((filament) => { + const targets = calibrationTargets.filter( + (target) => target.filament.id === filament.id + ); + const readValues: FrontlitCalibrationRead[] = []; + let complete = targets.length > 0; + for (const target of targets) { + const opacityLayers = parseLayerInput(reads[target.key]); + if (opacityLayers === null) { + complete = false; + continue; + } + readValues.push({ + baseColor: target.base.color, + opacityLayers, + }); } - const result = computeFrontlitCalibration({ - filamentColor: filament.color, - opacityLayers, - layerHeight: calibrationLayerHeight, - firstLayerHeight, - baseColor: resolveBaseColor(filament.id), - maxLayers, - }); - return { filament, result, opacityLayers }; + const input = complete + ? { + filamentColor: filament.color, + opacityLayers: readValues[0]?.opacityLayers, + layerHeight: calibrationLayerHeight, + firstLayerHeight, + baseColor: readValues[0]?.baseColor, + reads: readValues, + maxLayers, + } + : null; + return { + filament, + targets, + input, + result: input ? computeFrontlitCalibration(input) : null, + jndSource: null as 'default' | 'session-fit' | null, + }; }); + return entries; }, [ selectedFilaments, + calibrationTargets, reads, calibrationLayerHeight, firstLayerHeight, maxLayers, - resolveBaseColor, ]); const allReadsValid = computed.length > 0 && computed.every((c) => c.result !== null && c.result.ok); const handleSave = useCallback(() => { - const updates: CalibrationApplyUpdate[] = []; - for (const entry of computed) { - if (entry.result && entry.result.ok) { - updates.push({ - id: entry.filament.id, - td: entry.result.calibration.tdSingleValue, - calibration: entry.result.calibration, - }); - } - } - if (updates.length > 0) onApply(updates); - handleClose(); - }, [computed, onApply, handleClose]); + if (isSaving) return; + setIsSaving(true); + const saveEntries = computed.filter((entry) => entry.input && entry.result?.ok); + window.requestAnimationFrame(() => { + window.setTimeout(() => { + try { + const session = computeFrontlitCalibrationSession({ + filaments: saveEntries.map((entry) => { + const readsWithMerge: FrontlitCalibrationRead[] = entry.targets.map( + (target) => { + const opacityLayers = parseLayerInput(reads[target.key])!; + const mergeLayers = parseLayerInput(mergeReads[target.key]); + return { + baseColor: target.base.color, + opacityLayers, + mergeLayers: mergeLayers ?? undefined, + }; + } + ); + return { + ...entry.input!, + reads: readsWithMerge, + opacityLayers: readsWithMerge[0]?.opacityLayers, + baseColor: readsWithMerge[0]?.baseColor, + }; + }), + maxLayers, + }); + const updates: CalibrationApplyUpdate[] = []; + session.results.forEach((result, index) => { + if (!result.ok) return; + const entry = saveEntries[index]; + updates.push({ + id: entry.filament.id, + td: result.calibration.tdSingleValue, + calibration: result.calibration, + }); + }); + if (updates.length > 0) onApply(updates); + handleClose(); + } finally { + setIsSaving(false); + } + }); + }); + }, [computed, reads, mergeReads, maxLayers, onApply, handleClose, isSaving]); const renderSelect = () => ( <> @@ -421,48 +644,85 @@ export function FilamentCalibrationDialog({

- Each filament is calibrated over a base, defaulting to your darkest filament. A - dark filament needs a lighter base (e.g. white) to read against — change its base - here. + Quick mode uses one base per filament. Accurate mode repeats the same read over + multiple bases so Kromacut can measure RGB TDs directly.

+
+ + +
{selectedFilaments.map((filament) => { - const baseId = baseChoices[filament.id] ?? darkestFilamentId ?? ''; + const baseIds = resolveBaseIds(filament.id); return ( - - -
-
- {filamentLabel(filament)} + +
+ +
+
+ {filamentLabel(filament)} +
+
+ {mode === 'accurate' + ? `${baseIds.length} base reads selected` + : '1 base read selected'} +
-
over base
- + {selected && ( + + )} + + ); + })} +
); })} @@ -552,8 +812,8 @@ export function FilamentCalibrationDialog({

{format === 'stl' - ? 'Single solid. Print one copy per selected filament using the matching base and swap line below.' - : 'Colors + bases baked in for AMS / multi-material — all selected filaments in one print.'} + ? 'Single solid. Print one copy for each filament/base read using the matching swap line below.' + : 'Colors + bases baked in for AMS / multi-material - all selected reads in one print.'}

@@ -564,9 +824,9 @@ export function FilamentCalibrationDialog({ layer {formatMm(firstLayerPrintHeight)}.

- {selectedFilaments.map((filament) => ( + {calibrationTargets.map(({ key, filament, base }) => (
{filamentLabel(filament)} - - : base = {resolveBaseLabel(filament.id)}, swap after - layer {printOptions.baseLayers} / Z {formatMm(swapZ)}. + {' '} + over {filamentLabel(base)}: swap after layer{' '} + {printOptions.baseLayers} / Z {formatMm(swapZ)}.
))} @@ -601,8 +861,9 @@ export function FilamentCalibrationDialog({ opaque reference rail running along the back edge.
  • - Find the first patch{' '} - that looks identical to the rail beside it — that's its opacity layer. + Find the{' '} + first patch that + looks identical to the rail beside it — that's its opacity layer.
  • @@ -627,22 +888,12 @@ export function FilamentCalibrationDialog({

    - For each filament, enter the first patch number that matched its reference rail. + Enter the first patch number that matched the rail for each filament/base print.

    - {computed.map(({ filament, result, opacityLayers }) => { + {computed.map(({ filament, targets, result, jndSource }) => { const calibration = result && result.ok ? result.calibration : null; const errorText = result && !result.ok ? result.error : null; - const predicted = - calibration && opacityLayers - ? predictFrontlitColor( - filament.color, - opacityLayers, - calibrationLayerHeight, - calibration.td, - resolveBaseColor(filament.id) - ) - : null; return (
    @@ -658,40 +909,127 @@ export function FilamentCalibrationDialog({ {filament.color}
    -
    - setRead(filament.id, e.target.value)} - placeholder="layer" - className="h-8 w-20 text-sm" - /> - - / {maxLayers} - +
    + {targets.length} read{targets.length === 1 ? '' : 's'}
    +
    +
    + Base + Match + Merge +
    + {targets.map((target) => { + const opacityLayers = parseLayerInput(reads[target.key]); + const mergeLayers = parseLayerInput(mergeReads[target.key]); + const mergeAfterMatch = + mergeLayers !== null && + opacityLayers !== null && + mergeLayers > opacityLayers; + const predicted = + calibration && opacityLayers + ? predictFrontlitColor( + filament.color, + opacityLayers, + calibrationLayerHeight, + calibration.td, + target.base.color + ) + : null; + return ( +
    +
    + + + over {filamentLabel(target.base)} + +
    +
    + + setRead(target.key, e.target.value) + } + placeholder="match" + title="First patch that matches the reference rail" + className="h-8 w-20 text-sm" + /> + + / {maxLayers} + +
    +
    + + setMergeRead(target.key, e.target.value) + } + placeholder="merge" + title="Optional: last patch that still looks different from the patch before it" + className={cn( + 'h-8 w-20 text-sm', + mergeAfterMatch && + 'border-amber-500/70 bg-amber-500/10' + )} + /> + {mergeAfterMatch && ( + + + + )} + {predicted && ( + + )} +
    +
    + ); + })} +
    + {calibration && (
    - Match + Fit - {predicted && ( - - )} + + {calibration.channelSource === 'measured' + ? 'measured RGB TDs' + : 'heuristic RGB TDs'} + {jndSource === 'session-fit' + ? ' / session JND' + : ''} +
    @@ -724,9 +1062,9 @@ export function FilamentCalibrationDialog({ Back - diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index ab20b0c..b479393 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -46,12 +46,12 @@ Use the wand button to auto-estimate TD from color, or calibrate to measure TD f ## Calibrating Filament TD -Calibration is camera-free: you print a small wedge and read back a single number. Click the calibration button on a filament row, or **Calibrate Filaments** below the filament list, to open the dialog. It has four steps: +Calibration is camera-free: you print a small wedge and read back one or more opacity numbers. Click the calibration button on a filament row, or **Calibrate Filaments** below the filament list, to open the dialog. It has four steps: 1. **Select** the filaments you want to calibrate. -2. **Base layers** — choose the base filament each one prints over. It defaults to your darkest filament (matching how frontlit prints are viewed), and the base comes from your real profile filaments, so no extra "phantom" filament is added to the print. A dark filament has too little contrast with a dark base, so switch its base to a lighter one (e.g. white) here. -3. **Print** the calibration wedge. Set your layer height and the wedge length (max layers), then download an **STL** (any printer) or a multi-material **3MF** (colors and bases baked in, for AMS/multi-material). For STL, use the shown layer height and first-layer height, print one copy per selected filament, load the listed base filament, then swap after the shown layer/Z height to the filament being calibrated. Each filament prints as a row of patches from 1 layer up, with an opaque reference rail running along the edge beside the patches. A foot marks the 1-layer end. -4. **Enter Results** by reporting, for each filament, the **first patch that looks identical to the reference rail** beside it — that is its opacity layer. Kromacut converts that number into a frontlit TD (with per-channel values), shows a predicted-vs-reference swatch, the TD, and a confidence rating. Click **Save Calibration** to apply it. +2. **Base layers** - choose **Quick** for one base read, or **Accurate** for two or three base reads per filament. Kromacut auto-picks useful bases from your real profile filaments, and you can override them with the base swatches. +3. **Print** the calibration wedge. Set your layer height and the wedge length (max layers), then download an **STL** (any printer) or a multi-material **3MF** (colors and bases baked in, for AMS/multi-material). For STL, use the shown layer height and first-layer height, print one copy for each filament/base read, load the listed base filament, then swap after the shown layer/Z height to the filament being calibrated. Each print has patches from 1 layer up, with an opaque reference rail running along the edge beside the patches. A foot marks the 1-layer end. +4. **Enter Results** by reporting, for each filament/base row, the **first patch that looks identical to the reference rail** beside it. Kromacut uses one read for quick scalar calibration, or combines multi-base reads to measure per-channel TDs and, when the session has enough data, fit the shared JND. The optional merge field records where adjacent steps stop looking different; it is stored as model-checking evidence and does not block saving. Because the reference rail is the filament's own fully-opaque color, you are only judging whether a patch matches the rail right next to it — a comparison that stays reliable across lighting and screens. The measured TD is a material property, so it transfers to your real prints regardless of which base you calibrated against. @@ -107,10 +107,10 @@ Flat Paint and **Smooth Meshing** are mutually exclusive. Turning one on turns t ## Optimizer Settings -| Setting | Meaning | -| --------------- | ------------------------------------------------------------ | -| Algorithm | Fast, Balanced, Thorough, Deep, or Exact base order. | -| Region priority | Uniform, Center-weighted, or Edge-weighted matching. | +| Setting | Meaning | +| --------------- | ------------------------------------------------------------------ | +| 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. | Start with **Balanced**. It uses a full deterministic beam search and is the best diff --git a/src/lib/calibration.ts b/src/lib/calibration.ts index b674a2c..ace74bf 100644 --- a/src/lib/calibration.ts +++ b/src/lib/calibration.ts @@ -23,6 +23,13 @@ import { deltaE2000 } from './colorDifference'; export type CalibrationRgb = [number, number, number]; +export interface FrontlitCalibrationRead { + baseColor: string; + opacityLayers: number; + /** Optional adjacent-step merge point, stored as model-form evidence. */ + mergeLayers?: number; +} + export interface FrontlitCalibration { /** Layer count at which the wedge first matched the opaque reference. */ opacityLayers: number; @@ -44,6 +51,12 @@ export interface FrontlitCalibration { basis: 'frontlit'; /** ISO timestamp. */ calibrationDate: string; + /** All opacity reads used by a multi-base fit. Absent means Phase-1 single read. */ + reads?: FrontlitCalibrationRead[]; + /** Whether RGB TD ratios came from the color heuristic or a multi-base fit. */ + channelSource?: 'heuristic' | 'measured'; + /** Whether the JND was the default constant or fitted across the session. */ + jndSource?: 'default' | 'session-fit'; notes?: string; } @@ -53,17 +66,33 @@ export type FrontlitCalibrationResult = export interface FrontlitCalibrationInput { filamentColor: string; - opacityLayers: number; + opacityLayers?: number; layerHeight: number; firstLayerHeight: number; /** Base color the wedge prints over (hex). Defaults to black. */ baseColor?: string; + /** Multi-base reads for the same filament. The first read is the compatibility anchor. */ + reads?: FrontlitCalibrationRead[]; /** Highest layer count printed on the wedge; used to flag clipped reads. */ maxLayers?: number; jnd?: number; + jndSource?: 'default' | 'session-fit'; notes?: string; } +export interface FrontlitCalibrationSessionInput { + filaments: FrontlitCalibrationInput[]; + /** Highest layer count printed on the wedge; used for JND fit bounds and confidence. */ + maxLayers?: number; +} + +export interface FrontlitCalibrationSessionResult { + jnd: number; + jndSource: 'default' | 'session-fit'; + results: FrontlitCalibrationResult[]; + residual: number; +} + // ============================================================================ // Constants // ============================================================================ @@ -83,6 +112,9 @@ export const OPACITY_JND = 2.0; const FRONTLIT_TD_MIN = 0.05; const FRONTLIT_TD_MAX = 12.0; +const JND_FIT_MIN = 1.0; +const JND_FIT_MAX = 3.0; +const DEFAULT_PREDICT_MAX_LAYERS = 240; const CONFIDENCE_THRESHOLD_EXCELLENT = 0.9; const CONFIDENCE_THRESHOLD_GOOD = 0.7; @@ -99,6 +131,8 @@ function hexToRgb(hex: string): CalibrationRgb | null { const isFinitePositive = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0; +type ReadResidualMode = 'interval' | 'center'; + function sanitizeCalibrationRgb(value: unknown): CalibrationRgb | undefined { if (!Array.isArray(value) || value.length !== 3) return undefined; const [r, g, b] = value; @@ -106,6 +140,35 @@ function sanitizeCalibrationRgb(value: unknown): CalibrationRgb | undefined { return [r, g, b]; } +function sanitizeCalibrationRead(value: unknown): FrontlitCalibrationRead | undefined { + if (!value || typeof value !== 'object') return undefined; + const candidate = value as Record; + if ( + typeof candidate.baseColor !== 'string' || + !hexToRgb(candidate.baseColor) || + typeof candidate.opacityLayers !== 'number' || + !Number.isFinite(candidate.opacityLayers) || + candidate.opacityLayers < 1 + ) { + return undefined; + } + const read: FrontlitCalibrationRead = { + baseColor: candidate.baseColor, + opacityLayers: candidate.opacityLayers, + }; + if (candidate.mergeLayers !== undefined) { + if ( + typeof candidate.mergeLayers !== 'number' || + !Number.isFinite(candidate.mergeLayers) || + candidate.mergeLayers < 1 + ) { + return undefined; + } + read.mergeLayers = candidate.mergeLayers; + } + return read; +} + /** * Accept only the camera-free frontlit calibration shape. Older backlit photo * calibrations are intentionally stripped on load rather than migrated. @@ -153,6 +216,23 @@ export function sanitizeFrontlitCalibration(value: unknown): FrontlitCalibration basis: 'frontlit', calibrationDate: candidate.calibrationDate, }; + if (candidate.reads !== undefined) { + if (!Array.isArray(candidate.reads)) return undefined; + const reads = candidate.reads.map(sanitizeCalibrationRead); + if (reads.some((read) => !read)) return undefined; + sanitized.reads = reads as FrontlitCalibrationRead[]; + if (sanitized.reads.length === 0) return undefined; + } + if (candidate.channelSource === 'heuristic' || candidate.channelSource === 'measured') { + sanitized.channelSource = candidate.channelSource; + } else if (candidate.channelSource !== undefined) { + return undefined; + } + if (candidate.jndSource === 'default' || candidate.jndSource === 'session-fit') { + sanitized.jndSource = candidate.jndSource; + } else if (candidate.jndSource !== undefined) { + return undefined; + } if (typeof candidate.notes === 'string') sanitized.notes = candidate.notes; return sanitized; } @@ -173,6 +253,219 @@ function blendOverBase( ]; } +function blendOverBaseWithTds( + filamentRgb: CalibrationRgb, + td: CalibrationRgb, + thickness: number, + base: CalibrationRgb +): CalibrationRgb { + return [0, 1, 2].map((channel) => { + const transmission = Math.pow(10, -thickness / td[channel]); + return blendSrgbChannel(base[channel], filamentRgb[channel], transmission); + }) as CalibrationRgb; +} + +function opacityLayerThreshold( + filamentRgb: CalibrationRgb, + td: CalibrationRgb, + layerHeight: number, + jnd: number, + base: CalibrationRgb, + maxLayers = DEFAULT_PREDICT_MAX_LAYERS +): number | undefined { + const deltaAt = (thickness: number) => + deltaE2000(blendOverBaseWithTds(filamentRgb, td, thickness, base), filamentRgb); + + if (deltaAt(0) < jnd) return undefined; + + let hi = Math.max(layerHeight, maxLayers * layerHeight); + let guard = 0; + while (deltaAt(hi) > jnd && guard < 8) { + hi *= 1.8; + guard++; + } + if (deltaAt(hi) > jnd) return maxLayers + 1; + + let lo = 0; + for (let i = 0; i < 36; i++) { + const mid = (lo + hi) / 2; + if (deltaAt(mid) > jnd) lo = mid; + else hi = mid; + } + return hi / layerHeight; +} + +function quantizedReadResidual(predictedThreshold: number, observedLayers: number): number { + const lowerExclusive = Math.max(0, observedLayers - 1); + if (predictedThreshold > lowerExclusive && predictedThreshold <= observedLayers) return 0; + if (predictedThreshold <= lowerExclusive) return lowerExclusive - predictedThreshold; + return predictedThreshold - observedLayers; +} + +function predictedReadResidual( + filamentRgb: CalibrationRgb, + td: CalibrationRgb, + read: FrontlitCalibrationRead, + layerHeight: number, + jnd: number, + maxLayers?: number, + mode: ReadResidualMode = 'interval' +): number { + const base = hexToRgb(read.baseColor); + if (!base) return 50; + const predicted = opacityLayerThreshold( + filamentRgb, + td, + layerHeight, + jnd, + base, + Math.max(maxLayers ?? 0, read.opacityLayers + 24, DEFAULT_PREDICT_MAX_LAYERS) + ); + if (predicted === undefined) return 50; + if (mode === 'center') return predicted - (read.opacityLayers - 0.5); + return quantizedReadResidual(predicted, read.opacityLayers); +} + +function scalarTdFromRead( + filamentColor: string, + read: FrontlitCalibrationRead, + layerHeight: number, + jnd: number +): number | undefined { + const tStar = solveOpacityTransmission(filamentColor, jnd, read.baseColor); + if (tStar === undefined || tStar <= 0 || tStar >= 1) return undefined; + return clamp( + -(read.opacityLayers * layerHeight) / Math.log10(tStar), + FRONTLIT_TD_MIN, + FRONTLIT_TD_MAX + ); +} + +interface ChannelFit { + td: CalibrationRgb; + residual: number; + dataResidual: number; + rmsLayers: number; + prior: CalibrationRgb; + tdSingleValue: number; +} + +function fitChannelTdsForReads( + filamentColor: string, + reads: FrontlitCalibrationRead[], + layerHeight: number, + jnd: number, + maxLayers?: number, + residualMode: ReadResidualMode = 'interval' +): ChannelFit | undefined { + const filamentRgb = hexToRgb(filamentColor); + if (!filamentRgb || reads.length === 0) return undefined; + + const anchorScalar = scalarTdFromRead(filamentColor, reads[0], layerHeight, jnd); + if (!anchorScalar) return undefined; + const prior = deriveChannelTds(filamentColor, anchorScalar); + if (reads.length === 1) { + const dataResidual = + predictedReadResidual( + filamentRgb, + prior, + reads[0], + layerHeight, + jnd, + maxLayers, + residualMode + ) ** 2; + return { + td: prior, + residual: dataResidual, + dataResidual, + rmsLayers: Math.sqrt(dataResidual), + prior, + tdSingleValue: anchorScalar, + }; + } + + const regularizationWeight = reads.length <= 2 ? 0.08 : 0.035; + const score = (td: CalibrationRgb) => { + let dataResidual = 0; + for (const read of reads) { + const r = predictedReadResidual( + filamentRgb, + td, + read, + layerHeight, + jnd, + maxLayers, + residualMode + ); + dataResidual += r * r; + } + const reg = + regularizationWeight * + td.reduce((sum, value, index) => { + const ratio = Math.log2(value / prior[index]); + return sum + ratio * ratio; + }, 0); + return { total: dataResidual + reg, dataResidual }; + }; + + let bestTd = prior; + let best = score(bestTd); + const coarseOffsets = [-1.2, -0.6, 0, 0.6, 1.2]; + for (const r of coarseOffsets) { + for (const g of coarseOffsets) { + for (const b of coarseOffsets) { + const candidate: CalibrationRgb = [ + clamp(prior[0] * Math.exp(r), FRONTLIT_TD_MIN, FRONTLIT_TD_MAX), + clamp(prior[1] * Math.exp(g), FRONTLIT_TD_MIN, FRONTLIT_TD_MAX), + clamp(prior[2] * Math.exp(b), FRONTLIT_TD_MIN, FRONTLIT_TD_MAX), + ]; + const candidateScore = score(candidate); + if (candidateScore.total < best.total) { + bestTd = candidate; + best = candidateScore; + } + } + } + } + + for (let step = 0.45; step >= 0.035; step *= 0.55) { + let improved = true; + let passes = 0; + while (improved && passes < 6) { + improved = false; + passes++; + for (const r of [-step, 0, step]) { + for (const g of [-step, 0, step]) { + for (const b of [-step, 0, step]) { + if (r === 0 && g === 0 && b === 0) continue; + const candidate: CalibrationRgb = [ + clamp(bestTd[0] * Math.exp(r), FRONTLIT_TD_MIN, FRONTLIT_TD_MAX), + clamp(bestTd[1] * Math.exp(g), FRONTLIT_TD_MIN, FRONTLIT_TD_MAX), + clamp(bestTd[2] * Math.exp(b), FRONTLIT_TD_MIN, FRONTLIT_TD_MAX), + ]; + const candidateScore = score(candidate); + if (candidateScore.total + 1e-9 < best.total) { + bestTd = candidate; + best = candidateScore; + improved = true; + } + } + } + } + } + } + + return { + td: bestTd, + residual: best.total, + dataResidual: best.dataResidual, + rmsLayers: Math.sqrt(best.dataResidual / reads.length), + prior, + tdSingleValue: Math.max(...bestTd), + }; +} + /** * Solve for the transmission `T*` at which the over-base blend is exactly `jnd` * ΔE00 from the opaque filament color. ΔE00 grows monotonically with T (more @@ -207,6 +500,28 @@ export function solveOpacityTransmission( return (lo + hi) / 2; } +/** + * Predict the first wedge patch that should match the reference rail for a set + * of per-channel TDs. Returns maxLayers + 1 when it would not converge within + * the printed wedge. + */ +export function predictOpacityLayersForTds( + filamentColor: string, + td: CalibrationRgb, + layerHeight: number, + jnd = OPACITY_JND, + baseColor: string = FRONTLIT_BASE_HEX, + maxLayers = DEFAULT_PREDICT_MAX_LAYERS +): number | undefined { + const filamentRgb = hexToRgb(filamentColor); + const base = hexToRgb(baseColor); + if (!filamentRgb || !base) return undefined; + const threshold = opacityLayerThreshold(filamentRgb, td, layerHeight, jnd, base, maxLayers); + if (threshold === undefined) return undefined; + if (threshold > maxLayers) return maxLayers + 1; + return Math.max(1, Math.ceil(threshold - 1e-9)); +} + /** * Derive per-channel TDs from the single measured TD, modulated by the filament * color. Uses a `1 + value*5.8` shape per channel (the per-channel form of the @@ -236,6 +551,62 @@ function frontlitConfidence(opacityLayers: number, tdSingle: number, maxLayers?: return clamp(confidence, 0.1, 1); } +function multiReadConfidence( + reads: FrontlitCalibrationRead[], + fit: ChannelFit, + maxLayers?: number +): number { + let confidence = reads.length > 1 ? 0.96 : 0.9; + for (const read of reads) { + if (read.opacityLayers <= 1) confidence -= 0.12; + if (maxLayers !== undefined && read.opacityLayers >= maxLayers) confidence -= 0.12; + } + if (fit.td.some((td) => td <= FRONTLIT_TD_MIN * 1.5 || td >= FRONTLIT_TD_MAX * 0.9)) { + confidence -= 0.16; + } + if (reads.length > 1) { + confidence -= clamp(fit.rmsLayers * 0.16, 0, 0.35); + } + return clamp(confidence, 0.1, 1); +} + +function normalizeInputReads( + input: FrontlitCalibrationInput +): { ok: true; reads: FrontlitCalibrationRead[] } | { ok: false; error: string } { + const rawReads = + input.reads && input.reads.length > 0 + ? input.reads + : input.opacityLayers !== undefined + ? [ + { + baseColor: input.baseColor ?? FRONTLIT_BASE_HEX, + opacityLayers: input.opacityLayers, + }, + ] + : []; + + if (rawReads.length === 0) { + return { ok: false, error: 'Enter at least one opacity read.' }; + } + + const reads: FrontlitCalibrationRead[] = []; + for (const raw of rawReads) { + const read = sanitizeCalibrationRead(raw); + if (!read) { + return { + ok: false, + error: 'Each opacity read needs a valid base color and layer count.', + }; + } + reads.push(read); + } + return { ok: true, reads }; +} + +function appendCalibrationNote(existing: string | undefined, note: string): string { + return existing ? `${existing} ${note}` : note; +} + /** * Convert a single opacity-layer read into a frontlit calibration. * @@ -247,9 +618,14 @@ function frontlitConfidence(opacityLayers: number, tdSingle: number, maxLayers?: export function computeFrontlitCalibration( input: FrontlitCalibrationInput ): FrontlitCalibrationResult { - const { filamentColor, opacityLayers, layerHeight, firstLayerHeight } = input; + const { filamentColor, layerHeight, firstLayerHeight } = input; const jnd = input.jnd ?? OPACITY_JND; - const baseColor = input.baseColor ?? FRONTLIT_BASE_HEX; + const normalized = normalizeInputReads(input); + if (!normalized.ok) return normalized; + const { reads } = normalized; + const anchorRead = reads[0]; + const opacityLayers = anchorRead.opacityLayers; + const baseColor = anchorRead.baseColor; if (!Number.isFinite(opacityLayers) || opacityLayers < 1) { return { ok: false, error: 'Opacity layer count must be at least 1.' }; @@ -257,19 +633,38 @@ export function computeFrontlitCalibration( if (!Number.isFinite(layerHeight) || layerHeight <= 0) { return { ok: false, error: 'Layer height must be a positive number.' }; } + if (!Number.isFinite(firstLayerHeight) || firstLayerHeight <= 0) { + return { ok: false, error: 'First-layer height must be a positive number.' }; + } - const tStar = solveOpacityTransmission(filamentColor, jnd, baseColor); - if (tStar === undefined || tStar <= 0 || tStar >= 1) { - return { - ok: false, - error: 'Too little contrast with the base to calibrate. Pick a lighter base (e.g. white) for this filament.', - }; + for (const read of reads) { + const tStar = solveOpacityTransmission(filamentColor, jnd, read.baseColor); + if (tStar === undefined || tStar <= 0 || tStar >= 1) { + return { + ok: false, + error: 'Too little contrast with one selected base to calibrate. Pick a base with stronger contrast for this filament.', + }; + } } - const dOpaque = opacityLayers * layerHeight; - const tdSingleValue = clamp(-dOpaque / Math.log10(tStar), FRONTLIT_TD_MIN, FRONTLIT_TD_MAX); - const td = deriveChannelTds(filamentColor, tdSingleValue); - const confidence = frontlitConfidence(opacityLayers, tdSingleValue, input.maxLayers); + const fit = fitChannelTdsForReads(filamentColor, reads, layerHeight, jnd, input.maxLayers); + if (!fit) { + return { ok: false, error: 'Could not fit a calibration from these reads.' }; + } + const channelSource = reads.length > 1 ? 'measured' : 'heuristic'; + const td = fit.td; + const tdSingleValue = fit.tdSingleValue; + const confidence = + reads.length > 1 + ? multiReadConfidence(reads, fit, input.maxLayers) + : frontlitConfidence(opacityLayers, tdSingleValue, input.maxLayers); + let notes = input.notes; + if (reads.length > 1 && fit.rmsLayers > 0.75) { + notes = appendCalibrationNote( + notes, + `Multi-base reads disagree by about ${fit.rmsLayers.toFixed(1)} layers under the best fit.` + ); + } return { ok: true, @@ -284,11 +679,111 @@ export function computeFrontlitCalibration( confidence, basis: 'frontlit', calibrationDate: new Date().toISOString(), - notes: input.notes, + reads: + reads.length > 1 || reads.some((read) => read.mergeLayers !== undefined) + ? reads + : undefined, + channelSource, + jndSource: input.jndSource ?? 'default', + notes, }, }; } +function calibrationFitResidualForJnd(input: FrontlitCalibrationInput, jnd: number): number { + const normalized = normalizeInputReads(input); + if (!normalized.ok || normalized.reads.length < 2) return 0; + const fit = fitChannelTdsForReads( + input.filamentColor, + normalized.reads, + input.layerHeight, + jnd, + input.maxLayers, + 'center' + ); + return fit ? fit.dataResidual : 1_000; +} + +function fitSessionJnd( + inputs: FrontlitCalibrationInput[], + maxLayers?: number +): { jnd: number; residual: number } | undefined { + const eligible = inputs.filter((input) => { + const normalized = normalizeInputReads({ + ...input, + maxLayers: input.maxLayers ?? maxLayers, + }); + return normalized.ok && normalized.reads.length >= 2; + }); + if (eligible.length < 2) return undefined; + + const evaluate = (jnd: number) => + eligible.reduce( + (sum, input) => + sum + + calibrationFitResidualForJnd( + { ...input, maxLayers: input.maxLayers ?? maxLayers }, + jnd + ), + 0 + ); + + let bestJnd = OPACITY_JND; + let bestResidual = evaluate(OPACITY_JND); + for (let jnd = JND_FIT_MIN; jnd <= JND_FIT_MAX + 1e-9; jnd += 0.2) { + const rounded = Number(jnd.toFixed(2)); + const residual = evaluate(rounded); + if (residual < bestResidual) { + bestJnd = rounded; + bestResidual = residual; + } + } + const fineStart = Math.max(JND_FIT_MIN, bestJnd - 0.16); + const fineEnd = Math.min(JND_FIT_MAX, bestJnd + 0.16); + for (let jnd = fineStart; jnd <= fineEnd + 1e-9; jnd += 0.04) { + const rounded = Number(jnd.toFixed(2)); + const residual = evaluate(rounded); + if (residual < bestResidual) { + bestJnd = rounded; + bestResidual = residual; + } + } + + const defaultResidual = evaluate(OPACITY_JND); + const improvement = defaultResidual - bestResidual; + if (improvement < Math.max(0.1, eligible.length * 0.04)) return undefined; + if (bestJnd <= JND_FIT_MIN + 1e-9 || bestJnd >= JND_FIT_MAX - 1e-9) return undefined; + return { jnd: bestJnd, residual: bestResidual }; +} + +export function computeFrontlitCalibrationSession( + input: FrontlitCalibrationSessionInput +): FrontlitCalibrationSessionResult { + const jndFit = fitSessionJnd(input.filaments, input.maxLayers); + const jnd = jndFit?.jnd ?? OPACITY_JND; + const jndSource = jndFit ? 'session-fit' : 'default'; + const results = input.filaments.map((filament) => + computeFrontlitCalibration({ + ...filament, + maxLayers: filament.maxLayers ?? input.maxLayers, + jnd, + jndSource, + }) + ); + const residual = + jndFit?.residual ?? + input.filaments.reduce( + (sum, filament) => + sum + + calibrationFitResidualForJnd( + { ...filament, maxLayers: filament.maxLayers ?? input.maxLayers }, + jnd + ), + 0 + ); + return { jnd, jndSource, results, residual }; +} + /** * Predict the displayed color of `layers` of filament over the base, using * per-channel TDs. Mirrors the auto-paint blend so calibration previews match. diff --git a/tests/frontlitCalibration.test.ts b/tests/frontlitCalibration.test.ts index 6c87a60..c5feee1 100644 --- a/tests/frontlitCalibration.test.ts +++ b/tests/frontlitCalibration.test.ts @@ -33,7 +33,9 @@ async function loadViteModule(modulePath: string): Promise { const loadCalibration = () => (calibrationModule ??= loadViteModule('/src/lib/calibration.ts')); const loadColorDifference = () => - (colorDifferenceModule ??= loadViteModule('/src/lib/colorDifference.ts')); + (colorDifferenceModule ??= loadViteModule( + '/src/lib/colorDifference.ts' + )); const loadColorSpace = () => (colorSpaceModule ??= loadViteModule('/src/lib/colorSpace.ts')); @@ -73,6 +75,31 @@ async function simulateOpacityRead( return maxLayers; } +async function simulateOpacityReadWithTds( + filamentColor: string, + tdTrue: [number, number, number], + layerHeight: number, + jnd: number, + maxLayers = 200, + baseColor = '#000000' +): Promise { + const { blendSrgbChannel } = await loadColorSpace(); + const { deltaE2000 } = await loadColorDifference(); + const rgb = hexToRgb(filamentColor); + const base = hexToRgb(baseColor); + + for (let layers = 1; layers <= maxLayers; layers++) { + const thickness = layers * layerHeight; + const blended: [number, number, number] = [ + blendSrgbChannel(base[0], rgb[0], Math.pow(10, -thickness / tdTrue[0])), + blendSrgbChannel(base[1], rgb[1], Math.pow(10, -thickness / tdTrue[1])), + blendSrgbChannel(base[2], rgb[2], Math.pow(10, -thickness / tdTrue[2])), + ]; + if (deltaE2000(blended, rgb) <= jnd) return layers; + } + return maxLayers; +} + test('solveOpacityTransmission tolerates more background for darker (lower-contrast) colors', async () => { const { solveOpacityTransmission, OPACITY_JND } = await loadCalibration(); @@ -197,6 +224,154 @@ test('a lighter base lets a near-black filament calibrate (round-trip over white assert.ok(relErr < 0.2, `recovered ${result.calibration.tdSingleValue} vs ${tdTrue}`); }); +test('multi-base calibration fits measured per-channel TDs and stores reads', async () => { + const { computeFrontlitCalibration, OPACITY_JND, predictOpacityLayersForTds } = + await loadCalibration(); + const layerHeight = 0.04; + const color = '#ffd43b'; + const trueTd: [number, number, number] = [0.42, 0.55, 0.18]; + const bases = ['#000000', '#2030ff', '#7a0030']; + const reads = await Promise.all( + bases.map(async (baseColor) => ({ + baseColor, + opacityLayers: await simulateOpacityReadWithTds( + color, + trueTd, + layerHeight, + OPACITY_JND, + 120, + baseColor + ), + })) + ); + + const result = computeFrontlitCalibration({ + filamentColor: color, + layerHeight, + firstLayerHeight: 0.2, + reads, + maxLayers: 120, + }); + + assert.ok(result.ok, 'multi-base calibration should fit'); + if (!result.ok) return; + assert.equal(result.calibration.channelSource, 'measured'); + assert.equal(result.calibration.reads?.length, reads.length); + assert.equal(result.calibration.baseColor, reads[0].baseColor); + + for (const read of reads) { + const predicted = predictOpacityLayersForTds( + color, + result.calibration.td, + layerHeight, + OPACITY_JND, + read.baseColor, + 120 + ); + assert.ok(predicted !== undefined); + assert.ok( + Math.abs(predicted! - read.opacityLayers) <= 1, + `base ${read.baseColor}: predicted ${predicted}, observed ${read.opacityLayers}` + ); + } +}); + +test('session JND fit recovers an identifiable synthetic multi-filament session', async () => { + const { computeFrontlitCalibrationSession } = await loadCalibration(); + const layerHeight = 0.035; + const trueJnd = 1.1; + const cases: Array<{ + color: string; + td: [number, number, number]; + bases: string[]; + }> = [ + { color: '#ffd43b', td: [0.42, 0.55, 0.18], bases: ['#000000', '#2030ff', '#7a0030'] }, + { color: '#2f7cff', td: [0.18, 0.32, 0.62], bases: ['#000000', '#ffea00', '#7a2000'] }, + { color: '#f5f5f5', td: [0.46, 0.48, 0.5], bases: ['#000000', '#003060', '#602000'] }, + ]; + + const filaments = await Promise.all( + cases.map(async ({ color, td, bases }) => ({ + filamentColor: color, + layerHeight, + firstLayerHeight: 0.2, + maxLayers: 140, + reads: await Promise.all( + bases.map(async (baseColor) => ({ + baseColor, + opacityLayers: await simulateOpacityReadWithTds( + color, + td, + layerHeight, + trueJnd, + 140, + baseColor + ), + })) + ), + })) + ); + + const session = computeFrontlitCalibrationSession({ filaments, maxLayers: 140 }); + assert.equal(session.jndSource, 'session-fit'); + assert.ok(Math.abs(session.jnd - trueJnd) <= 0.25, `fit JND ${session.jnd}`); + assert.ok(session.results.every((result) => result.ok)); +}); + +test('session JND fit falls back when the synthetic JND is outside the accepted band', async () => { + const { computeFrontlitCalibrationSession, OPACITY_JND } = await loadCalibration(); + // These reads come from a synthetic session generated with a JND above the + // accepted [1, 3] band. Do not record a session-fit JND for that case. + const session = computeFrontlitCalibrationSession({ + filaments: [ + { + filamentColor: '#ffd43b', + layerHeight: 0.035, + firstLayerHeight: 0.2, + maxLayers: 140, + reads: [ + { baseColor: '#000000', opacityLayers: 8 }, + { baseColor: '#2030ff', opacityLayers: 8 }, + ], + }, + { + filamentColor: '#2f7cff', + layerHeight: 0.035, + firstLayerHeight: 0.2, + maxLayers: 140, + reads: [ + { baseColor: '#000000', opacityLayers: 6 }, + { baseColor: '#ffea00', opacityLayers: 8 }, + ], + }, + ], + maxLayers: 140, + }); + assert.equal(session.jndSource, 'default'); + assert.equal(session.jnd, OPACITY_JND); +}); + +test('session JND fit falls back to the default for too little multi-base data', async () => { + const { computeFrontlitCalibrationSession, OPACITY_JND } = await loadCalibration(); + const session = computeFrontlitCalibrationSession({ + filaments: [ + { + filamentColor: '#ffd43b', + layerHeight: 0.04, + firstLayerHeight: 0.2, + reads: [ + { baseColor: '#000000', opacityLayers: 8 }, + { baseColor: '#2030ff', opacityLayers: 5 }, + ], + }, + ], + maxLayers: 40, + }); + + assert.equal(session.jndSource, 'default'); + assert.equal(session.jnd, OPACITY_JND); +}); + test('sanitizeFrontlitCalibration accepts only the new frontlit calibration shape', async () => { const { computeFrontlitCalibration, sanitizeFrontlitCalibration } = await loadCalibration(); const result = computeFrontlitCalibration({ @@ -211,6 +386,7 @@ test('sanitizeFrontlitCalibration accepts only the new frontlit calibration shap const sanitized = sanitizeFrontlitCalibration(result.calibration); assert.ok(sanitized); assert.equal(sanitized.basis, 'frontlit'); + assert.equal(sanitized.channelSource, 'heuristic'); assert.deepEqual(sanitized.td, result.calibration.td); assert.equal(sanitized.tdSingleValue, result.calibration.tdSingleValue); assert.equal( @@ -231,6 +407,13 @@ test('sanitizeFrontlitCalibration accepts only the new frontlit calibration shap }), undefined ); + assert.equal( + sanitizeFrontlitCalibration({ + ...result.calibration, + reads: [{ baseColor: '#000000', opacityLayers: 5, mergeLayers: 0 }], + }), + undefined + ); }); test('computeFrontlitCalibration rejects invalid reads and uncalibratable colors', async () => { From 607b550cb35f80da762852990c2b36e1abc389e1 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Fri, 3 Jul 2026 14:27:44 +0300 Subject: [PATCH 42/81] Migrate TD to hiding distance with single-meaning storage Filament `td` now always stores the frontlit hiding distance in mm - the depth at which a filament visually hides what's beneath it - for calibrated and uncalibrated filaments alike (profile schema v2). Uncalibrated values from older profiles, .kfil/.kapp files, and saved app state convert automatically (x0.1, once, version-gated) on load or import; the runtime frontlit scaling layer is removed, which also fixes calibrated filaments being double-scaled in the recommended-height estimate. The x0.1 factor was validated by the physical calibration of an 8-filament set, whose profile and first-print findings are recorded in tests/assets and .agents. Every filament now blends with per-channel hiding distances - measured when calibrated, derived from the swatch color otherwise - so uncalibrated previews and stacks are color-shaped instead of channel-uniform. The filament row shows HD with per-channel tooltips, a convert-from-TD entry (conventional backlit/lithophane TD is roughly 10x the hiding distance), and a wand estimate re-baselined to the new scale. The next-filament suggestion blends in linear light to match the optical pipeline and recommends hiding distances on the stored scale (previously a calibrated neighbor's value would be re-scaled into a 10x-too-opaque filament). Tests: v1->v2 migration across all three load paths, double-import idempotence, channel derivation, estimator rescale, linear-light blend spot checks, and a calibrated-profile baseline captured before the migration proving byte-identical auto-paint output after it. Goldens regenerated for the uncalibrated channel change. Docs, changelog, README, and AGENTS.md updated to the new terminology. Breaking: profile schema v2 - older files are converted on load/import with no notice; exports are v2. --- .agents/calibration-improvements.md | 20 +- .agents/calibration-phase1-review-issues.md | 174 ++++++ .agents/calibration-phase2-color.md | 228 +++++++ .agents/calibration-phase2-review-issues.md | 119 ++++ .agents/hiding-distance-migration.md | 207 +++++++ .agents/next-best-color-td-fix.md | 174 ++++++ .../2026-07-02-logo-test/findings.md | 89 +++ .../2026-07-02-logo-test/image.png | Bin 0 -> 68424 bytes .../2026-07-02-logo-test/print-photo.jpg | Bin 0 -> 496982 bytes .gitignore | 3 + AGENTS.md | 9 +- CHANGELOG.md | 1 + README.md | 4 +- src/App.tsx | 20 +- src/components/AutoPaintTab.tsx | 4 +- src/components/FilamentCalibrationDialog.tsx | 37 +- src/components/FilamentRow.tsx | 100 +++- src/docs/3d-mode.md | 14 +- src/docs/faq.md | 12 +- src/docs/overview.md | 6 +- src/docs/quick-start.md | 6 +- src/docs/settings-and-controls.md | 2 +- src/docs/troubleshooting.md | 2 +- src/hooks/useFilaments.ts | 4 +- src/lib/autoPaint.ts | 71 +-- src/lib/calibration.ts | 38 +- src/lib/colorUtils.ts | 25 +- src/lib/nextBestColor.ts | 36 +- src/lib/profileManager.ts | 53 +- tests/assets/auto-paint-goldens.json | 560 +++++++++--------- .../assets/autopaint-calibrated-baseline.json | 123 ++++ .../8_Colors_Calibrated_New.kfil | 287 +++++++++ tests/autoPaint.test.ts | 54 +- tests/autoPaintGoldenFixtures.ts | 24 +- tests/export3mf.test.ts | 7 +- tests/hidingDistance.test.ts | 120 ++++ tests/nextBestColor.test.ts | 53 +- tests/profileManager.test.ts | 138 +++++ 38 files changed, 2367 insertions(+), 457 deletions(-) create mode 100644 .agents/calibration-phase1-review-issues.md create mode 100644 .agents/calibration-phase2-color.md create mode 100644 .agents/calibration-phase2-review-issues.md create mode 100644 .agents/hiding-distance-migration.md create mode 100644 .agents/next-best-color-td-fix.md create mode 100644 .agents/print-findings/2026-07-02-logo-test/findings.md create mode 100644 .agents/print-findings/2026-07-02-logo-test/image.png create mode 100644 .agents/print-findings/2026-07-02-logo-test/print-photo.jpg create mode 100644 tests/assets/autopaint-calibrated-baseline.json create mode 100644 tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil create mode 100644 tests/hidingDistance.test.ts diff --git a/.agents/calibration-improvements.md b/.agents/calibration-improvements.md index 3927eb6..fab283c 100644 --- a/.agents/calibration-improvements.md +++ b/.agents/calibration-improvements.md @@ -201,18 +201,14 @@ The user has OK'd breaking changes with **no migration notice**. So: --- -## Phase 2 — color accuracy (combos), still later - -Single-filament frontlit TD fixes "too many / too few layers" and most of the -color drift, because the preview uses the same TD. Fine-grained accuracy of -*stacked color blends* is a second pass: print a matrix of 2–3 color combinations, -**pruned to only printable combos using the Phase-1 TDs** (no longer assuming a -high TD ⇒ far fewer cells), and correct the blend predictor. - -Open question for Phase 2: keep it camera-free. On-screen color matching reintro- -duces the monitor problem, so prefer ordinal/threshold-style reads here too (e.g. -"at which layer does color A stop showing through color B") rather than absolute -color matching. To be designed after Phase 1 lands. +## Phase 2 — color accuracy (combos) + +Designed in [calibration-phase2-color.md](calibration-phase2-color.md): the Phase-1 +opacity read repeated over 2–3 different colored bases per filament measures the +per-channel TDs directly (replacing the `1 + c*5.8` heuristic ratios), and the +cross-base consistency of those reads pins the JND as one global fitted constant. +Camera-free, same tile geometry, same "match the rail" judgment; pruned to +printable combos using the Phase-1 TDs as originally intended. --- diff --git a/.agents/calibration-phase1-review-issues.md b/.agents/calibration-phase1-review-issues.md new file mode 100644 index 0000000..2e3a6b7 --- /dev/null +++ b/.agents/calibration-phase1-review-issues.md @@ -0,0 +1,174 @@ +# Phase 1 review — issues to fix before Phase 2 + +> **Status: ALL RESOLVED (2026-07-02).** All six issues below were fixed in the +> follow-up changes (calibration sanitizer + load-path stripping, first-layer-aware +> base height, STL swap instructions, `basis: 'frontlit'` rename, per-row calibrate +> button with reset-on-open, changelog cleanup). Verified: 224 tests pass, tsc and +> eslint clean. Kept for history; Phase 2 is unblocked. + +Review of commits `354f736` (frontlit opacity-read calibration) and `bd4fa71` (Load TD +Test button removal) against the plan in +[calibration-improvements.md](calibration-improvements.md). The core fit +(`solveOpacityTransmission`, `computeFrontlitCalibration`, `deriveChannelTds`), the +dialog flow, the 3MF slot mapping, and the round-trip tests all match the plan and are +in good shape. The issues below are ordered by severity; #1 and #2 are blockers for +calling Phase 1 done. + +--- + +## 1. HIGH — Old persisted calibrations are not stripped on load, and now bypass the frontlit scale + +The plan requires: "old `CalibrationResult` fields are simply **ignored/stripped on +load**." That stripping was never implemented — the commit touched no persistence code. + +Three load paths pass `filament.calibration` through untyped/unvalidated: + +- `loadProfiles()` — [profileManager.ts:17-30](../src/lib/profileManager.ts#L17-L30) + (localStorage `kromacut.autopaint.profiles`) +- `importProfiles()` filament validation — [profileManager.ts:165-168](../src/lib/profileManager.ts#L165-L168) + (`.kfil` / profile JSON import; only checks `id`/`color`/`td`) +- `loadAutoPaintPersisted()` — [App.tsx:108-142](../src/App.tsx#L108-L142) + (localStorage `kromacut.autopaint.v1`, `parsed.filaments` used as-is) + +Why this is actively harmful, not just cosmetic: the old `CalibrationResult` shape +(`{ measurements, whiteReference, td: [r,g,b], tdSingleValue, confidence, +calibrationDate }`) survives a JSON round-trip and **structurally satisfies the two +runtime checks that matter**: + +- `scaleFilamentForFrontlight()` ([autoPaint.ts:411-419](../src/lib/autoPaint.ts#L411-L419)) + skips the `FRONTLIT_TD_SCALE = 0.1` fallback for any truthy `calibration`. +- `calibratedTdChannels()` ([autoPaint.ts:402-409](../src/lib/autoPaint.ts#L402-L409)) + only checks that `calibration.td` is a finite-positive array — which the old backlit + fit also produced. + +So a user with an old photo calibration gets their **backlit** TDs (~2–6 mm, which the +old pipeline multiplied by 0.1) fed into the simulation as if they were measured +frontlit TDs — roughly 10× too transmissive, silently, producing drastically wrong +layer stacks. The changelog says "recalibrate any previously calibrated filaments" but +nothing in code enforces or even detects it. `computeProfileConfidence()` and the +FilamentRow "calibrated" badge also accept the old object (it has `confidence` + +`calibrationDate`, and the old flow also set `td = tdSingleValue`, so the +`calibrationMatchesTd` guard passes). + +**Fix:** add a single validator (e.g. in `calibration.ts`: +`sanitizeFrontlitCalibration(value: unknown): FrontlitCalibration | undefined`) that +accepts an object only if it matches the new shape — cheapest reliable discriminators +are `basis === 'black-frontlit'` plus finite `opacityLayers >= 1` (old shape has +neither) — and returns `undefined` otherwise. Apply it to `filament.calibration` in all +three load paths above. A defensive `basis`/`opacityLayers` check inside +`calibratedTdChannels`/`scaleFilamentForFrontlight` is cheap extra insurance since +worker inputs come from persisted state. Add a regression test: a profile JSON carrying +an old-shape calibration loads with `calibration === undefined` and the filament falls +back to the `FRONTLIT_TD_SCALE` path. + +--- + +## 2. HIGH — Calibration print geometry ignores first-layer height, so patch layer counts are wrong when sliced + +`baseHeight()` ([generateCalibrationPrint.ts:88-90](../src/lib/generateCalibrationPrint.ts#L88-L90)) +is `baseLayers × layerHeight`, with a comment claiming "The slicer applies its own +first-layer height when printing, so this is just baseLayers worth of regular layers." +That reasoning is inverted: because the slicer's first layer is taller, the Z grid is +`firstLayer + n × layerHeight`, and a base built from regular layers only does NOT land +on that grid. + +Defaults make it concrete: `layerHeight 0.08`, `firstLayerHeight 0.2` (and the embedded +3MF project sets `initial_layer_print_height = max(layerHeight, firstLayerHeight)` = +0.2, [generateCalibrationPrint.ts:571](../src/lib/generateCalibrationPrint.ts#L571)). +Slicer layer tops: 0.2, 0.28, 0.36, 0.44, … Base top: 5 × 0.08 = **0.40 mm — mid-layer**. +Every wedge patch top (0.40 + i × 0.08) is then also mid-layer. The slicer will +realize each patch with a color layer count that differs from the nominal patch number +(off by one, or with a partial base/color boundary layer), so the number the user reads +back does not correspond to `opacityLayers × layerHeight` of filament — the exact bias +the plan warned about ("Thickness must use the real layer-height model, not +`layers × layerHeight`"). + +**Fix:** make the base absorb the first layer: +`baseHeight = max(layerHeight, firstLayerHeight) + (baseLayers − 1) × layerHeight`. +Then the base top and every patch/rail top land exactly on the slicer grid (0.2 + 4×0.08 += 0.52; patches at 0.52 + i×0.08 ✓), and each color patch is exactly `i` regular +layers, which is what `computeFrontlitCalibration` assumes. Update the stale comment, +the `calibrationPrint.test.ts` expectations, and add an assertion that the base top and +patch tops are integer multiples of `layerHeight` above `max(layerHeight, +firstLayerHeight)`. Note the wedge/rail math itself needs no change — only the base +thickness. + +This matters for both formats: the 3MF embeds the matching first-layer height itself; +for STL the dialog should tell the user which layer height AND first-layer height to +slice with (see #3). + +--- + +## 3. MEDIUM — STL flow lacks concrete per-filament print/swap instructions + +The plan specifies generated text instructions for STL ("load black, print to layer X; +swap to filament A"). What shipped is one generic sentence ("base, then swap to the +color above the base" — [FilamentCalibrationDialog.tsx:523](../src/components/FilamentCalibrationDialog.tsx#L523)). +Two concrete gaps: + +- **No per-filament base reminder.** The Base step lets the user pick a different base + per filament, and the TD fit uses that choice (`resolveBaseColor` → + `computeFrontlitCalibration.baseColor`). At print time nothing shows which base goes + with which filament; printing over the wrong base silently produces a wrong fit. +- **No exact swap point.** After fixing #2 the swap is well-defined: pause/swap after + layer `baseLayers`, i.e. at Z = `max(layerHeight, firstLayerHeight) + + (baseLayers − 1) × layerHeight`. Show the layer number and Z, plus "slice at layer + height H, first layer F" so the user's slicer settings match the geometry. + +A per-filament line in the Print step ("Red: base = Black, swap after layer 5 / +Z 0.52 mm") is enough; a downloadable .txt is optional polish. + +--- + +## 4. MEDIUM — `basis: 'black-frontlit'` is hardcoded even for non-black bases + +`computeFrontlitCalibration` always stores `basis: 'black-frontlit'` +([calibration.ts:224](../src/lib/calibration.ts#L224)) while also storing the actual +`baseColor`, which the base-picker feature lets be white or anything else. A +calibration over white then carries a self-contradictory record. This is a persisted +user-data field in a brand-new format — rename it **now**, before it ships, rather than +migrating later. Suggestion: `basis: 'frontlit'` with `baseColor` as the sole source of +truth (keep `baseColor` required rather than optional). If #1's sanitizer keys on +`basis`, update both together. + +--- + +## 5. LOW — `initialFilamentId` preselect is dead and would be stale if wired + +The only caller is `handleOpenCalibration()` with no id +([AutoPaintTab.tsx:490](../src/components/AutoPaintTab.tsx#L490)), so per-filament +preselect never happens. Even if an id were passed, the dialog captures +`initialFilamentId` in a `useState` initializer at mount +([FilamentCalibrationDialog.tsx:107-109](../src/components/FilamentCalibrationDialog.tsx#L107-L109)) +and only re-reads it in `reset()`, which runs via a 300 ms `setTimeout` holding a +possibly stale closure — racing AutoPaintTab's own 300 ms reset of +`calibratingFilamentId` to null. Either delete the prop and the AutoPaintTab id +plumbing, or fix properly (sync selection in an effect when `open` flips true). + +--- + +## 6. LOW — Stale changelog entry for the deleted photo sampler + +`CHANGELOG.md` unreleased "Changed" still lists **"Calibration image sampler"** +(circular brush overlay) — a refinement to the photo wizard that this same unreleased +version deleted. Per AGENTS.md changelog guidance (entries classify against the last +release), remove that bullet; the frontlit-calibration "Added" bullet already covers +the replacement. + +--- + +## Reviewed and explicitly OK (no action) + +- Fit math: `T*` bisection over any base, `TD = -d/log10(T*)`, brightest-channel anchor + in `deriveChannelTds` — all match the plan; round-trip tests recover synthetic TDs + within integer-read quantization. +- The base-picker (vs the plan's fixed black base) is a sound generalization — the solve + handles arbitrary bases and errors out on low contrast with a helpful message. +- Keeping `FRONTLIT_TD_SCALE` as the *uncalibrated-only* fallback rather than deleting + it outright is a reasonable reading of the plan's "re-baseline the default." +- Overlapping closed shells in the STL (orientation foot into base, patches butting the + rail): slicers union coincident shells within one solid; fine for a standalone + calibration part. +- 3MF slot mapping to real profile filaments in profile order, no phantom base, no + `` block — matches the refined design and is test-covered. +- All 219 tests pass on the current branch. diff --git a/.agents/calibration-phase2-color.md b/.agents/calibration-phase2-color.md new file mode 100644 index 0000000..0d11b15 --- /dev/null +++ b/.agents/calibration-phase2-color.md @@ -0,0 +1,228 @@ +# Calibration Phase 2 — color accuracy via multi-base opacity reads + +Status: core implementation complete; metamer-pair validation strip still open. Builds directly on the Phase 1 +frontlit calibration (see [calibration-improvements.md](calibration-improvements.md), +implemented in `354f736` + the review fixes). + +## Goal + +Phase 1 pins each filament's overall optical magnitude (scalar frontlit TD) from a +single opacity read over one base. Two things remain heuristic: + +1. **Per-channel TD ratios** — currently derived from the swatch color via the + `1 + c/255 × 5.8` shape ([`deriveChannelTds`](../src/lib/calibration.ts)), anchored + to the measured scalar. The ratios were never measured; they drive every stacked + color blend the optimizer and preview compute, so they are the dominant remaining + error source for intermediate/blended colors. +2. **The JND constant** — [`OPACITY_JND = 2.0`](../src/lib/calibration.ts) defines + "indistinguishable from the reference rail" and sets the global TD scale + (`TD = -d/log10(T*)` where `T*` depends on the JND). It was never validated against + physical prints (the Phase-1 "Still to pin" item). + +Phase 2 measures both, stays **camera-free**, and changes **only the calibration +side** — the optimizer and preview already consume per-channel TDs +(`calibration.td`), so better numbers flow through with zero pipeline changes. + +## Core mechanism: the Phase-1 read, repeated over different colored bases + +Phase 1 already generalized the opacity read to arbitrary bases (base-picker + +`solveOpacityTransmission(color, jnd, baseColor)`). Phase 2's entire measurement is: +**read the same filament over 2–3 different bases.** + +Why this measures per-channel TDs: the opacity read is governed by the _slowest_ +channel to converge, and which channel that is depends on the base. + +- Yellow over **black**: R and G must build up from 0 → governed by td_r, td_g. +- Yellow over **blue**: the blue channel must attenuate 255 → ~0 → governed by td_b. +- Each additional base with a different channel signature adds an independent + constraint on the (td_r, td_g, td_b) triple. + +No new read type, no new geometry, no new judgment for the user's eye — still +"first patch that matches the rail beside it," which is the whole reason the +Phase-1 flow is robust. + +### The fit + +Per filament, given reads `{(base_i, n_i)}` at layer height `h`: + +- Model: per-channel transmission `T_c(d) = 10^(-d / td_c)`; blended color = + `blendSrgbChannel(base_c, filament_c, T_c(d))` per channel (the exact functions the + optimizer uses). +- Predicted read for a candidate `(td_r, td_g, td_b)`: + `n̂_i = min { n : ΔE00(blend(base_i, A, T(n·h)), A) ≤ JND }`. +- Fit: minimize `Σ_i (n̂_i − n_i)²` (or a smooth surrogate on log-TD) over the + channel TDs, **regularized toward the Phase-1 anchor structure** (brightest-channel + anchor + heuristic ratios as the prior) so 2 reads still give a stable answer. + Deterministic — coarse grid + local refinement, no randomness. +- With **1 read** (Phase-1 case) the fit degenerates to exactly the current + `deriveChannelTds` behavior. Phase 2 is a strict superset; single-base calibration + keeps working unchanged. + +Integer reads quantize each constraint, so prefer overdetermination (3 bases) over +exact solving, and keep `maxLayers` large enough that reads land mid-wedge (the +confidence logic already penalizes clipped reads). + +### Optional secondary read: the adjacent-step merge point (the Phase-3 detector) + +On the same wedge, the user can optionally also report **the last patch that still +looks different from its neighbor** (walking up the wedge, the point where adjacent +steps become indistinguishable). Still a same/not-same judgment between two +physically adjacent patches — same trust model as the rail read. Observed in +practice on the first physical Phase-1 print: large visible steps in the first few +layers, then diminishing returns well before the rail match. + +Why it matters: the exponential model makes a _fixed quantitative prediction_ about +where this merge point sits relative to the rail-match point. Per channel, +step-to-step change ∝ `r^n(1−r)` while distance-to-rail ∝ `r^n` (with +`r = 10^(-h/td)`), so neighbors merge when the remaining gap to the rail is still +≈ `JND/(1−r)` — e.g. ~3× JND at h=0.08, TD≈0.5. Diminishing returns per layer is +therefore **expected** and does not by itself indicate scattering. + +What _would_ indicate scattering (a longer-than-exponential tail): merge points +landing systematically earlier — relative to the rail match — than the fitted +exponential predicts, consistently across filaments. The merge read costs nothing +extra to print, is optional at entry (never blocks saving a calibration), and is +stored alongside the opacity reads purely as model-form evidence. It can also serve +as a soft extra constraint in the TD fit when present. + +### JND: fit it from the same data instead of hand-tuning + +For one filament read over two bases, the implied TDs must agree — and +`T*(A over B1)` vs `T*(A over B2)` respond _differently_ to the JND (the ΔE00 +geometry along each blend path differs), so cross-base consistency identifies it. + +- Treat JND as **one global parameter shared across all filaments and reads** in a + calibration session (it is a human-vision constant, never per-filament — same + principle as Phase 1). +- Fit: outer 1-D search of JND over a sane band **[1.0, 3.0]**, minimizing the total + cross-base fit residual from the per-filament fits above; many (filament × base) + reads vs. one parameter → well overdetermined. +- Fallback: if the session has too few multi-base reads or the residual curve is + flat, keep `OPACITY_JND = 2.0` and report that in the UI/notes. Never silently fit + JND from a single filament. +- Once fitted, store the session JND and use it for subsequent single-read + calibrations too. + +This absorbs the unfinished Phase-1 validation task ("sanity-check JND against the +7-color frontlit set"): the first real Phase-2 calibration session of that filament +set _is_ the JND measurement. + +## Combo pruning (which bases to print each filament over) + +Auto-pick 2–3 bases per filament from the user's own profile filaments, pruned with +the Phase-1 TDs exactly as the original plan intended: + +- Drop pairs where `solveOpacityTransmission` returns undefined (no contrast → no + read possible; already handled/errored in Phase 1). +- Drop pairs whose **predicted** read (from current TDs) is 1 (base hidden + immediately — zero information) or > maxLayers (never converges in the wedge). +- Among survivors, greedily pick bases maximizing channel diversity: score each + candidate base by how differently it weights the three channels for this filament + (e.g., which channel the predicted read is governed by), and pick a set covering + different governing channels. Black (or the darkest filament) stays in the set as + the anchor read. +- The user can override the auto-picked set (same Select UI as Phase 1's base step, + now multi-select per filament). + +Note: bases only contribute their _surface color_ (they print opaque), so an +uncalibrated filament can serve as a base — no calibration-order dependency. + +## Print & UX + +- **Geometry:** unchanged tile generator. Phase 2 print = one tile per + (filament, base) pair instead of one per filament. Combined 3MF tiles or one STL + printed per pair (still exactly one swap each); the per-filament swap-instruction + list from the review fixes extends naturally ("Yellow over Blue: base = Blue, swap + after layer 5 / Z 0.52 mm"). +- **Dialog:** same four steps. Base step gains multi-select; Measure step shows one + read row per (filament, base) with the same live predicted swatch. A "quick" + single-base mode (= Phase 1 behavior) remains the default for one-off filaments; + "accurate" multi-base mode is the Phase 2 addition. +- Keep the read count honest: 5 filaments × 3 bases = 15 reads is the practical + ceiling for one session; default to 2 bases (anchor + one contrast base) and let + the user opt into 3. + +## Data shape + +Extend `FrontlitCalibration` **backward-compatibly** (the sanitizer from the review +fixes must keep accepting Phase-1 records): + +```ts +interface FrontlitCalibration { + // existing Phase-1 fields unchanged; opacityLayers/baseColor describe the + // anchor read for compatibility + ... + /** All reads used for the fit (anchor read included). Absent = Phase-1 single read. + * mergeLayers = optional adjacent-step merge point (Phase-3 evidence). */ + reads?: Array<{ baseColor: string; opacityLayers: number; mergeLayers?: number }>; + /** 'heuristic' (Phase-1 ratio model) or 'measured' (multi-base fit). */ + channelSource?: 'heuristic' | 'measured'; + /** JND actually used; already stored. Add where it came from. */ + jndSource?: 'default' | 'session-fit'; +} +``` + +Sanitizer: `reads`, `channelSource`, `jndSource` optional; validate elements if +present. No migration needed — Phase-1 records read as anchor-only. + +Confidence: multi-base calibrations with consistent reads score higher; a filament +whose reads _disagree_ badly under the best fit gets flagged (that disagreement is +also the scattering signal — see below). + +## Phase 3 gate (scattering) — decided by Phase-2 data, not folded in + +Phase 3 = upgrading the blend model from single-exponential absorption to something +scattering-aware (Kubelka-Munk style). **Deliberately NOT included in Phase 2**: + +- It adds ~2 parameters per channel per filament; fitting that from a handful of + integer reads is ill-conditioned without evidence the extra form is needed. +- The blend function sits on the optimizer hot path; KM composition is more + expensive — only pay if the data demands it. +- A model-form change shifts every stack prediction (goldens, previews, existing + calibrations churn). Do it once, with evidence, not speculatively. + +Phase 2 instead **collects the decision data as a byproduct**: + +1. Cross-base fit residuals: multi-base reads that no per-channel TD triple can + reconcile (beyond integer quantization) → absorption model at its limit. Surface + as reduced confidence + a note, per filament. +2. Adjacent-step merge reads (above) landing systematically earlier than the fitted + exponential predicts → long-tail/scattering signature. +3. Metamer-pair validation disagreements concentrated on stacks involving specific + filaments (white/pastel filaments are the likely offenders — TiO2 is nearly pure + scattering). + +Gate: if ≥2 of these signals agree across the 7-color set, design Phase 3 (likely a +single scattering-fraction parameter per filament, not full per-channel KM, fitted +from the same reads). If not, Phase 3 is unnecessary and the exponential stands. + +## Explicitly out of scope (v1 of Phase 2) + +- **Scattering model itself** — detection only, per the gate above. +- **Camera/photo anything.** Still banned. +- **On-screen color matching.** Still banned (monitor problem). + +## Validation & acceptance + +- **Synthetic round-trips:** generate reads from a known (td_r, td_g, td_b) + JND, + run the fit, recover within integer-read quantization (mirrors the Phase-1 test + style in `tests/frontlitCalibration.test.ts`). +- **JND identifiability test:** synthetic multi-filament session with a known JND; + the outer fit recovers it; a flat-residual session falls back to 2.0. +- **Physical acceptance — metamer pairs:** after calibrating the 7-color set, have + the app print a small strip of stack _pairs_ the model predicts to be + indistinguishable (e.g., "2 layers A over B" next to "1 layer A' over B'" with + predicted ΔE00 < JND) plus a few pairs predicted clearly different. Count + agreements by eye. This is a yes/no ordinal judgment (camera-free, monitor-free) + and directly tests what auto-paint relies on: stacked-blend predictions. + +## Open questions / to pin during implementation + +- Default bases per filament: 2 vs 3 (read burden vs. fit conditioning). +- Fit details: exact objective surrogate, regularization weight toward the + heuristic prior, grid resolution. Must stay deterministic. +- Whether the session-fitted JND should retroactively rescale existing Phase-1 + calibrations (they stored their JND, so it's computable — probably yes, with the + stored `jnd` recording what was actually used). +- Where the metamer-pair validation strip lives in the UI (part of the calibration + dialog vs. a separate "verify calibration" action). diff --git a/.agents/calibration-phase2-review-issues.md b/.agents/calibration-phase2-review-issues.md new file mode 100644 index 0000000..f7bc5d7 --- /dev/null +++ b/.agents/calibration-phase2-review-issues.md @@ -0,0 +1,119 @@ +# Phase 2 review — issues to fix + +> **Status: RESOLVED (2026-07-02).** All four items fixed: the session JND fit moved +> out of the per-keystroke path into `handleSave` (live typing now costs ~60 ms per +> filament; merge reads no longer retrigger computation), the dead ternary is gone, +> the measure step gained Base/Match/Merge column headers, tooltips, and an amber +> warning when merge > match, and the design doc tracks the metamer strip as open. +> Verified: 227 tests, tsc, eslint green. +> +> **Residual follow-ups — RESOLVED (2026-07-02):** +> 1. ~~Synchronous save freeze~~ — Save now sets an `isSaving` "Fitting…" state, +> disables the button, and defers the session fit past a paint +> (`requestAnimationFrame` + `setTimeout`), resetting state in `finally`. +> 2. ~~Band-edge JND acceptance~~ — `fitSessionJnd` rejects solutions at +> `JND_FIT_MIN`/`JND_FIT_MAX` and falls back to the default JND; covered by a +> regression test with an out-of-band synthetic session. +> 3. Cosmetic dead "session JND" badge branch in the live measure view — accepted +> as-is (the badge still applies to saved calibrations). +> +> Verified after the fixes: 228 tests, tsc, eslint green. + +Review of the uncommitted Phase 2 implementation (multi-base opacity reads, measured +per-channel TDs, session JND fit) against +[calibration-phase2-color.md](calibration-phase2-color.md). + +**Overall: the implementation is faithful to the design and the math is right.** +Multi-base reads fit measured channel TDs with the heuristic as prior (single-read +degenerates exactly to Phase-1 behavior); the JND session fit uses a center-mode +residual for identifiability, requires ≥2 multi-base filaments, and has a +flat-residual fallback to 2.0; reads/channelSource/jndSource persist +backward-compatibly through the sanitizer; base auto-picking prunes by predicted +read usefulness and channel diversity; merge reads are stored as evidence only. +Tests cover the multi-base round-trip, JND recovery (within 0.25 of a synthetic +truth), and the fallback. 227 tests, tsc, eslint all green. + +One significant issue and a few small ones: + +--- + +## 1. HIGH (UX/perf) — the session fit freezes the UI for seconds on every keystroke + +`computed` in +[FilamentCalibrationDialog.tsx](../src/components/FilamentCalibrationDialog.tsx) +calls `computeFrontlitCalibrationSession` synchronously inside a `useMemo` whose +deps include `reads` and `mergeReads` — i.e. it re-runs on **every keystroke** in +the measure step. Once ≥2 filaments have complete multi-base reads, that call +includes `fitSessionJnd`: ~20 JND candidates × (full channel-TD fit per eligible +filament), where one fit is ~500 score evaluations × reads × a 36-iteration ΔE00 +bisection. + +Measured: the synthetic session test (3 filaments × 3 reads) takes **4.3 s** in +node; a realistic 3×2 session is on the order of 1.5–3 s. The freeze lands at the +worst moment — while the user is typing the *last* reads (and typing a merge value, +which doesn't even affect the fit, retriggers the whole thing). AGENTS.md: +long-running work must keep the UI responsive. + +Suggested fixes, in order of preference (can combine): + +1. **Don't refit on keystrokes.** Debounce the session computation (~400 ms) AND + exclude `mergeReads` from the fit input entirely (merge values are stored at + save time; they have zero effect on the fit — build them into the reads array + only in `handleSave`). +2. **Move the session fit off the main thread** using the existing worker pattern + (`useAutoPaintWorker` precedent) if debouncing alone still feels janky. +3. **Cheapen the fit**: cache `opacityLayerThreshold` results keyed by + (td-triple, base, jnd) across the JND grid; cut the bisection to ~20 iterations + (layer-resolution accuracy needs far less than 2^-36); skip re-running the + per-filament *display* fit when only the JND search is exploring. + +A pragmatic minimal version: keep per-filament fits live (each ~25–75 ms, fine), +but run the **JND session fit only on demand** — when the user leaves the last +input / presses Save, with the badge updating from "default JND" to "session JND" +at that point. Everything stays deterministic. + +## 2. LOW — pointless ternary in `computeFrontlitCalibration` + +[calibration.ts](../src/lib/calibration.ts): +`const tdSingleValue = reads.length > 1 ? fit.tdSingleValue : fit.tdSingleValue;` +— both branches are identical. Replace with `const tdSingleValue = fit.tdSingleValue;`. + +## 3. LOW (UX) — the "merge" input is unexplained in the dialog + +The measure row shows two bare number inputs with placeholders "match" and +"merge". The docs explain the merge read, but the dialog doesn't — a first-time +user won't know what to type there or that it's optional. Add a `title` tooltip +(e.g. "Optional: first patch that looks the same as the patch before it") and/or a +tiny column-header row above the inputs. Also consider a soft validation hint when +`mergeLayers > opacityLayers` (physically odd: neighbors can't merge after the +rail match) — accept it, but flag it visually. + +## 4. NOTE (not a defect) — metamer-pair validation strip not included + +The design's physical acceptance artifact (printable strips of stacks predicted +indistinguishable vs. clearly different) is not in this change. Fine to land +Phase 2 without it, but it is the third Phase-3 gate signal, so it should be +tracked as the remaining open piece of Phase 2 rather than dropped. + +--- + +## Reviewed and explicitly OK + +- Single-read path returns exactly the Phase-1 result (prior = heuristic channel + TDs anchored to the T* scalar); no behavior change for quick mode. +- `tdSingleValue = max(channel TDs)` for multi-read fits — consistent with the + brightest-channel-anchor convention. +- JND fit: center-mode residual (interval mode would be flat within quantization + intervals → unidentifiable); ≥2 eligible filaments required; improvement + threshold before accepting; deterministic coarse+fine grid in [1.0, 3.0]. +- Session JND, when fitted, is applied to the same session's single-read filaments + too, and recorded via `jndSource: 'session-fit'` — matches the design. +- Sanitizer extensions are strict (bad `reads`/`channelSource`/`jndSource` reject + the whole record) and Phase-1 records without `reads` still pass. +- Base auto-pick: prunes no-contrast and predicted-read-1 / >maxLayers pairs, + anchors on the darkest useful base, prefers channel diversity then mid-wedge + predicted reads; accurate mode defaults to 2 bases, capped at 3. +- Disagreeing multi-base reads (rms > 0.75 layers) reduce confidence and append a + note — the Phase-3 residual signal is surfaced as designed. +- Uncalibrated filaments' predictions for pruning use `td × 0.1` mirroring + `FRONTLIT_TD_SCALE` — consistent with auto-paint's fallback. diff --git a/.agents/hiding-distance-migration.md b/.agents/hiding-distance-migration.md new file mode 100644 index 0000000..b4fea21 --- /dev/null +++ b/.agents/hiding-distance-migration.md @@ -0,0 +1,207 @@ +# Hiding Distance — rename, single-meaning storage, and per-channel model + +> **Status: IMPLEMENTED (2026-07-03, by Claude).** All sections landed, including the +> folded-in nextBestColor work (§4). Verified: 240 tests (12 new), tsc, eslint green; +> 24 auto-paint goldens regenerated; the calibrated-path regression guard +> (`tests/assets/autopaint-calibrated-baseline.json`, captured pre-change) proves +> byte-identical output for the 8-color calibrated profile. Implementation notes vs +> plan: `effectiveFrontlitTd` was unnecessary (single-meaning storage covers it); +> `.kapp` test fixtures stay authentic v1 files with migration applied in the +> fixture readers; the `profileManager→calibration→colorSpace/colorDifference/colorUtils` +> import chain gained explicit `.ts` extensions for plain-node test/script loading; +> one nextBestColor ranking test updated (linear light legitimately changes the +> winning candidate, and its old assertion contradicted its own comment). +> +> **Post-implementation self-review (2026-07-03)** found and fixed two issues: +> (1) the ×0.1 migration now rounds to 4 decimals — raw doubles like +> `1.1 × 0.1 = 0.11000000000000001` would have shown verbatim in the HD input; +> (2) migration gates re-keyed to fixed version constants (`TD_MIGRATION_VERSION` +> in profileManager, `>=` schema check in App.tsx) so a future v3 bump cannot +> re-apply the ×0.1 to v2 data. Goldens regenerated once more after the rounding +> change (a 1e-18 td delta had flipped one optimizer scenario). Final state: +> 240 tests, tsc, eslint green. + +Status: implemented. +**Supersedes** [next-best-color-td-fix.md](next-best-color-td-fix.md) (never started — +its defects are either auto-resolved by this migration or folded in below). + +## Decisions (user-approved) + +- **Name:** "Hiding Distance" (HD), unit mm — the depth of material at which a + filament visually hides what's beneath it. Display/docs rename only; the storage + field stays `td` (`.kfil` compat is a user-data boundary per AGENTS.md). +- **Storage becomes single-meaning:** `filament.td` always stores the *frontlit* + hiding distance, for calibrated and uncalibrated filaments alike. The current + dual meaning (backlit-community scale for uncalibrated, frontlit for calibrated, + reconciled by a runtime ×0.1) is the root cause of the nextBestColor bug class + and dies here. +- **Conventional TD entry:** users can still type a spool-sheet / MakerWorld-test + TD; the UI converts it (× `FRONTLIT_TD_SCALE = 0.1`) at entry. The factor is + empirically validated: the user's physical calibration of 8 filaments measured + HD ≈ old backlit TD × 0.1 across the set. +- **Uncalibrated filaments get heuristic per-channel HDs** (approved sub-decision). + Today they blend channel-uniform (`[td,td,td]`); the `deriveChannelTds` color + heuristic is only used inside calibration fitting. After this change every + filament blends with channel HDs — measured (calibrated) or derived from the + swatch color (uncalibrated). This is the biggest behavior change in the bundle: + uncalibrated blend outputs change and goldens must be regenerated. + +## 1. Data migration (the critical part — treat as a persistence boundary) + +New semantic: `td` = frontlit HD. Old uncalibrated values (~1–6) must be scaled +×0.1 exactly once. + +- Bump `CURRENT_PROFILE_VERSION` 1 → 2 in + [profileManager.ts](../src/lib/profileManager.ts). Migration rule applied on + every load/import when `version < 2` (or missing): + - filament **without** valid calibration → `td *= FRONTLIT_TD_SCALE` + - filament **with** calibration → `td = calibration.tdSingleValue` (defensive + re-sync; already equal in practice) + - stamp `version: 2`. +- Apply in **all three load paths** (same trio as the legacy-calibration + stripping): `loadProfiles` (localStorage `kromacut.autopaint.profiles`), + `importProfiles` (`.kfil` files in the wild are version 1 — must convert on + import), and `loadAutoPaintPersisted` in [App.tsx](../src/App.tsx). + - ⚠️ `kromacut.autopaint.v1` has **no version field**. Add one to the persisted + shape (e.g. `schemaVersion: 2`); absent ⇒ old ⇒ migrate filaments as above, + then persist with the marker. Do not key the migration off heuristics like + "td looks big". +- New exports write `version: 2`. Importing a v2 file must NOT re-scale + (regression test: double-import idempotence). +- Do not clamp or "sanitize" values during migration beyond the existing filament + validation — convert as-is. + +## 2. Runtime model — delete the ×0.1 layer, add channel HDs everywhere + +- **Move `FRONTLIT_TD_SCALE` to [calibration.ts](../src/lib/calibration.ts)** and + export it; it survives only as (a) the entry-conversion factor and (b) the + migration factor. Delete `scaleFilamentForFrontlight` from + [autoPaint.ts](../src/lib/autoPaint.ts) and both call sites (~lines 1430, 1545) + — post-migration, `td` is already frontlit. +- **`autoPaint.ts:1633`** (`totalTD = Σ f.td × FRONTLIT_TD_SCALE`): note this + currently scales raw tds of ALL filaments — for calibrated filaments that is a + latent double-scaling bug today. Replace with `Σ f.td` (now uniformly HD) and + check what consumes it for a behavior impact note. +- **Shared channel helper** in calibration.ts: + + ```ts + /** Per-channel hiding distances: measured when calibrated, else derived + * from the swatch color around the scalar HD. */ + export function channelHds(filament: { color: string; td: number; calibration?: unknown }): CalibrationRgb { + return sanitizeFrontlitCalibration(filament.calibration)?.td + ?? deriveChannelTds(filament.color, filament.td); + } + ``` + + Replace `calibratedTdChannels` (autoPaint.ts:406) with it; drop the + `USE_CALIBRATED_CHANNEL_TD` flag. Audit that every blend/transition consumer + (`blendColors`, `calculateTransitionThickness`, transition zones, optimizer + scoring context, worker payloads, optimizer cache keys) now receives channel + HDs for uncalibrated filaments too. Cache keys already include color+td, so + derived channels are covered. +- **`estimateTDFromColor`** ([colorUtils.ts:39](../src/lib/colorUtils.ts#L39)): + re-baseline to return HD directly — `(1.0 + luminance × 5.8) × FRONTLIT_TD_SCALE` + (≈0.10–0.68). Rename to `estimateHidingDistanceFromColor` (keep an alias export + only if call-site churn is a concern; prefer full rename, it's internal). + Call sites: FilamentRow wand (:176), `useFilaments.ts:19` default for new + filaments. +- **`computeProfileConfidence`** uncalibrated plausibility bands rescale ×0.1: + `1.0–5.0 → 0.10–0.50` (0.5 score), `0.5–10 → 0.05–1.0` (0.3 score). +- **FilamentCalibrationDialog `estimateChannelTds`** (:207): the `filament.td * 0.1` + becomes just `filament.td` (or the shared `channelHds` helper — preferred). +- **Calibration flow itself is unchanged** — it already produces and stores HD. + +## 3. UI + +- **Label rename:** "TD" → "HD" wherever shown (FilamentRow input suffix, + calibration dialog select list + fit card "TD 0.51mm" → "HD 0.51 mm", + next-best-color suggestion card). Tooltip on the input: "Hiding distance (mm): + depth at which this filament hides what's beneath it. Measured by calibration, + or estimated from color." +- **Input range:** current blur clamp is `0.1–100` — black's measured 0.05 can't + even be typed. New clamp ~`0.01–2.0`, step 0.01. +- **"From TD" conversion affordance** on the filament row (small button or popover + next to the HD input): user enters a conventional/backlit TD value, field stores + `value × FRONTLIT_TD_SCALE`, marked uncalibrated (clears calibration like any + manual edit). Label it plainly, e.g. "Convert from TD (lithophane/backlit + value)". This is the community-facing bridge — keep it discoverable. +- **Per-channel visibility (read-only):** show `R/G/B` HD values (2 decimals, mm) + — for calibrated filaments on the existing calibration badge tooltip labeled + "measured"; for uncalibrated on the HD input tooltip labeled "estimated from + color". No editing of channels (manual scalar edits already clear calibration — + keep that semantics). +- New-filament default (`useFilaments.ts`) comes out on HD scale automatically via + the re-baselined estimator. + +## 4. nextBestColor (folded in from the superseded plan) + +Post-migration, its raw `f.td` usage becomes correct automatically (the old plan's +defects #1 and #2 vanish). Remaining work: + +- `recommendedTd` (:386) needs **no conversion** — it borrows the neighbor's td, + which is now HD, and the added filament's uncalibrated path expects HD. Just + verify and update the card label. +- **Blend in linear light**: `blendRgb` (:107) lerps gamma sRGB; rework to lerp in + linear light matching `blendSrgbChannel` semantics (linearise/delinearise + helpers already exist in-file), and fix the stale "matches autoPaint's + blendColors" comment. +- Keep scalar-HD curves (no per-channel in this heuristic — planning tool, keep it + light). No changes to candidate generation/scoring/ranking. + +## 5. Tests + +- **Migration:** v1 profile (uncalibrated td 4.0) loads as 0.40; calibrated + filament untouched; v2 double-import idempotent; `kromacut.autopaint.v1` without + the schema marker migrates once; mixed profile correct. Extend + `tests/profileManager.test.ts`. +- **Calibrated-path regression (the key acceptance guard):** auto-paint output for + an all-calibrated profile (use + `tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil`) must be + **identical before and after this change** — calibrated filaments' channel HDs + and scalars are untouched by the migration. +- **Uncalibrated behavior change:** goldens change intentionally (channel HDs + + no more runtime scaling — net optics identical in magnitude but now + color-shaped). Regenerate via `npm run test:autopaint:update`; re-check + `autoPaintQuality` budgets still pass. +- **Estimator rescale:** `estimateHidingDistanceFromColor('#ffffff') ≈ 0.68`, + `('#000000') = 0.1`. +- **channelHds helper:** calibrated → measured triple; uncalibrated → derived + triple anchored at the scalar. +- **nextBestColor:** new `tests/nextBestColor.test.ts` — linear-light blend spot + check (50% transmission of white over black ≈ linear midpoint ~188 sRGB, not + 128); suggestion for the calibrated fixture returns `candidate.td` on HD scale + (~0.3–0.6); recommendedTd ≈ nearest neighbor's td. + +## 6. Docs & changelog + +- `src/docs/` (`3d-mode.md` filament/TD sections + calibration walkthrough, + `settings-and-controls.md`, `troubleshooting.md`, `quick-start.md` — grep for + "TD" and "Transmission Distance"): rename to Hiding Distance with a one-line + bridge ("previously shown as TD; conventional lithophane TD values can be + entered via Convert from TD — they're ≈10× the hiding distance"). +- `CHANGELOG.md` (unreleased): under **Changed**, one bullet covering: the rename, + the single-meaning storage + automatic one-time conversion of existing profiles + and `.kfil` imports (call out `version: 2`), the Convert-from-TD entry, and + per-channel blending for uncalibrated filaments. The latent + calibrated-double-scaling fix at autoPaint.ts:1633 goes under **Fixed** only if + it changed released behavior — it's within the unreleased calibration work, so + fold it in. +- AGENTS.md "Domain Notes" TD bullet: update the terminology so future agents + aren't taught the old dual meaning. + +## Acceptance + +- Old `.kfil` (v1, uncalibrated td 4.0) imports as HD 0.40; exporting produces v2; + re-importing that is a no-op. +- `8_Colors_Calibrated_New` profile: identical auto-paint output pre/post change. +- New filament defaults, wand estimates, and next-best suggestions all land in + ~0.1–0.7 HD range. +- UI shows "HD"; typing a conventional TD via the converter stores value×0.1. +- Full suite + regenerated goldens + tsc + lint green. + +## Out of scope + +- Renaming the `td` storage field or `FrontlitCalibration.td` (data compat). +- Editable per-channel values. +- Per-channel curves in nextBestColor. +- Any calibration-flow changes (it already produces HD). diff --git a/.agents/next-best-color-td-fix.md b/.agents/next-best-color-td-fix.md new file mode 100644 index 0000000..949e7ad --- /dev/null +++ b/.agents/next-best-color-td-fix.md @@ -0,0 +1,174 @@ +# Fix next-best-color TD scale handling (post-calibration cleanup) + +> **SUPERSEDED (2026-07-02) — do not implement.** Execution never started; this +> plan is replaced by [hiding-distance-migration.md](hiding-distance-migration.md), +> which makes `filament.td` single-meaning (always frontlit hiding distance). That +> migration auto-resolves defects #1 and #2 below, and its §4 carries over the +> remaining work (linear-light blend + tests). Kept for the defect analysis. + +Status: superseded. + +## Context + +`src/lib/nextBestColor.ts` (the "Suggest next filament" heuristic, run in +`nextBestColor.worker.ts`) predates the frontlit calibration work and still treats +`filament.td` as one homogeneous quantity. Since Phase 1/2, `td` means two +different things: + +- **Uncalibrated filament:** backlit-community-scale TD (what users type from spool + sheets / TD test prints, ~1–6 mm). Auto-paint multiplies it by + `FRONTLIT_TD_SCALE = 0.1` before simulating (`scaleFilamentForFrontlight`, + [autoPaint.ts:415](../src/lib/autoPaint.ts#L415)). +- **Calibrated filament:** measured *frontlit* blending distance (~0.2–0.6 mm), + consumed as-is. + +`nextBestColor` receives **raw** profile filaments and never distinguishes the two. +Verified against the user's real calibrated profile +(`tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil`, frontlit TDs +0.37–0.56 mm). + +## The three defects + +### 1. Simulation mixes incommensurate TD scales + +`blendRgb`/`buildBlendCurve` use raw `f.td` +([nextBestColor.ts:205](../src/lib/nextBestColor.ts#L205), :221–222, :323–324). + +- All-calibrated profile: curves are frontlit-consistent by accident (raw = frontlit). OK. +- All-uncalibrated profile: curves are ~10× too transmissive vs. what auto-paint + will actually do (pre-existing bug, predates calibration). +- **Mixed profile: nonsense** — a calibrated 0.4 mm filament and an uncalibrated + 4.0 mm filament are blended on the same curves as if commensurate. + +### 2. `recommendedTd` output is on the wrong scale for calibrated profiles + +The suggestion borrows the nearest filament's raw `td` +([nextBestColor.ts:379-386](../src/lib/nextBestColor.ts#L379-L386)) and +AutoPaintTab adds the suggested filament **uncalibrated** with that value +([AutoPaintTab.tsx:1204](../src/components/AutoPaintTab.tsx#L1204)). The pipeline +then multiplies it by 0.1: + +- Nearest filament uncalibrated (td 4.0) → suggestion 4.0 → effective 0.40 ✓ +- Nearest filament calibrated (td 0.40) → suggestion 0.40 → **effective 0.04 mm, + ~10× too opaque**. This is the broken case for every calibrated profile — the + user's new normal. + +### 3. Blend runs in gamma space; auto-paint blends in linear light + +`blendRgb` ([nextBestColor.ts:107-111](../src/lib/nextBestColor.ts#L107-L111)) +lerps raw sRGB values. Its comment claims it "matches autoPaint's blendColors", +but auto-paint moved to linear-light compositing (`blendSrgbChannel` in +[colorSpace.ts:30](../src/lib/colorSpace.ts#L30)) in this release cycle. The +comment is stale and the curves are systematically darker mid-blend than the real +pipeline's. + +## Fix + +### A. Single source of truth for the effective frontlit TD + +Move the constant + add a helper in **`src/lib/calibration.ts`** (a leaf module the +worker can import without dragging in autoPaint's optimizer — the reason +nextBestColor inlines its color math): + +```ts +export const FRONTLIT_TD_SCALE = 0.1; + +/** Scalar frontlit TD for simulation: measured value if calibrated, else the + * backlit-scale user entry scaled down. Mirrors auto-paint's + * scaleFilamentForFrontlight. */ +export function effectiveFrontlitTd(filament: { + td: number; + calibration?: unknown; +}): number { + return sanitizeFrontlitCalibration(filament.calibration) + ? filament.td + : filament.td * FRONTLIT_TD_SCALE; +} +``` + +Update `autoPaint.ts` to import `FRONTLIT_TD_SCALE` from calibration.ts (delete its +local copy; keep `scaleFilamentForFrontlight`'s behavior identical — it can use the +helper for the scalar). No behavior change in auto-paint; regression-covered by the +existing legacy-calibration test. + +### B. Normalize TDs at the top of `nextBestColor` + +```ts +const filamentTds: number[] = filaments.map((f) => effectiveFrontlitTd(f)); +``` + +Everything downstream (pair curves, `estimatedTd`, candidate curves, winner +curves) then runs on commensurate frontlit values. Note `buildBlendCurve`'s span +(`3 × fgTd`) becomes ~1.2 mm instead of ~12 mm for uncalibrated filaments — that is +the fix working, not a regression. + +### C. Emit `recommendedTd` on the uncalibrated (backlit) scale + +The suggested filament is added *uncalibrated*, so its stored td must be what the +×0.1 path expects. Convert the borrowed effective value back: + +```ts +const recommendedTd = effectiveFrontlitTd(filaments[nearestFilamentIdx]) / FRONTLIT_TD_SCALE; +``` + +(≡ unchanged value when borrowing from an uncalibrated filament; ×10 when borrowing +from a calibrated one.) Keep borrowing from the nearest filament by ΔE — it +captures material similarity; `estimateTDFromColor` remains the wand's job. + +### D. Blend in linear light + +Rework `blendRgb` to lerp in linear light and return sRGB, matching +`blendSrgbChannel` semantics. The linearise/delinearise helpers already exist in +this file (used by `rgbToLab`/`labToHex`) — reuse them rather than importing +colorSpace, to keep the worker bundle self-contained (or import from +`colorSpace.ts` if the bundle impact is nil — implementer's choice; colorSpace is +tiny and React-free). Fix the stale comment either way. + +Scalar-TD-only curves remain fine for this heuristic (see non-goals). + +## Tests + +There is currently no `nextBestColor` test. Add `tests/nextBestColor.test.ts` +(vite-ssr loader pattern like `frontlitCalibration.test.ts`) covering at least: + +1. **Scale normalization:** two profiles describing the *same physical filaments* — + one uncalibrated (td 4.0), one calibrated (td 0.40 + valid `FrontlitCalibration` + with matching channel TDs) — produce the **same suggestion hex** for the same + swatches (curves identical after normalization). +2. **Recommended TD scale:** with a calibrated profile, the returned + `candidate.td` is on the backlit scale (≈ borrowed frontlit value ÷ 0.1), so + that `effectiveFrontlitTd({ td: candidate.td })` ≈ the neighbor's measured + frontlit TD. +3. **Linear-light blend:** `blendRgb`-equivalent output matches + `blendSrgbChannel` for a couple of spot values (e.g. 50% transmission of white + over black ≈ linear-light midpoint ~188, not gamma midpoint 128). + +The `8_Colors_Calibrated_New.kfil` fixture can serve as realistic calibrated input +for test 1/2 if convenient. + +## Docs & changelog + +- `CHANGELOG.md` (unreleased): under **Fixed** — suggestion TD now accounts for + calibrated (frontlit) profiles instead of recommending a 10×-too-opaque value; + suggestion blending matches the linear-light optical model. Per AGENTS.md, if it + reads better, fold into the existing next-best-color bullet's section instead of + a standalone entry (the feature shipped in v3.1.0, so **Fixed** is correct). +- `src/docs/*`: only if any doc states how the recommended TD is derived (check + `3d-mode.md`'s suggestion paragraph); the user-visible behavior ("recommended + starting TD") is unchanged in kind. + +## Non-goals (explicitly out of scope) + +- **No renaming of TD anywhere** (UI labels, fields, docs). The + "blending distance" naming discussion is a separate, user-gated task. +- No per-channel TD curves in the suggestion heuristic — scalar effective TD is + sufficient for inventory planning; keep the module light. +- No changes to candidate generation, scoring weights, or ranking. +- No changes to auto-paint behavior — step A must be a pure refactor there. + +## Acceptance + +- `npm test` green (including the new nextBestColor tests), `npx tsc -b` and + `npm run lint` clean. +- Manual sanity: with `8_Colors_Calibrated_New` loaded, "Suggest next filament" + returns a candidate whose TD is ~3–6 (backlit scale), not ~0.4. diff --git a/.agents/print-findings/2026-07-02-logo-test/findings.md b/.agents/print-findings/2026-07-02-logo-test/findings.md new file mode 100644 index 0000000..6113e2f --- /dev/null +++ b/.agents/print-findings/2026-07-02-logo-test/findings.md @@ -0,0 +1,89 @@ +# Print findings — Kromacut logo test (2026-07-02) + +![alt text](image.png) +![alt text](print-photo.jpg) + +First real auto-paint print with the measured frontlit calibration +(`tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil`, 8 filaments, +accurate-mode 2-base reads, JND 2.0 default). + +Images: `image.png` — the app's 3D preview projection; `print-photo.jpg` — photo +of the physical print on a dark mat (downscaled from the 22 MB camera original, +which stays untracked next to it). + +Print setup: normal mode (NOT flat paint — no transparent carrier; the grey rim +in the photo is a border/brim, not a carrier). Transition opacity: default +(0.8 unless changed). Layer height 0.08 mm. + +## Overall verdict + +- **Hue placement is accurate across the whole wheel** — green→yellow→orange→red + transitions and the purple family land where the preview says. The calibrated + endpoints are doing their job. +- **Systematic desaturation/wash:** the print is uniformly lighter and less + saturated than the preview, worst in dark saturated colors (cyan → pale blue, + violet → lavender, red → orange-ish). + +## Specific failures (user-observed) + +1. **Orange over pink did not shift toward red** as the preview predicted — it + stayed orange. +2. **First layer of cyan over purple is not the predicted darker blue** — it + reads as light, washed purplish. + +## Analysis + +### Weak-channel TDs are heuristic floors, not measurements (primary suspect) + +Both failures are thin layers over a saturated, differently-hued base, failing +the same way: the model predicts the top filament's complementary channel kills +the base almost instantly; physically it does not. + +- Cyan `#00b8c4` fitted `td = [0.068, 0.354, 0.373]`. `td_R = 0.068 mm` ⇒ one + 0.08 mm layer transmits only ~7% red ⇒ purple's red should die ⇒ dark blue. + Reality: red survives ⇒ true `td_R` is substantially larger. +- That 0.068 was never measured: cyan's swatch R = 0, so the `1 + c×5.8` + heuristic pins the channel at its floor, and the black + white base reads + constrain it weakly — the fit's regularization left it at the prior. Same for + orange `#d83400`'s G/B channels (0.137 / 0.103) interacting with pink. +- **Actionable:** recalibrate with complementary-hue bases that stress the weak + channel directly — cyan over a red/orange base (pins `td_R`), orange over a + cyan/blue base (pins `td_G`/`td_B`). Accurate mode's manual base override + supports this today. More generally: the auto base picker prefers channel + diversity but chose black+white here; consider whether it should specifically + seek a base that is bright in the filament's darkest channel. + +### Partial-opacity stacks (contributing, magnitude unknown) + +Auto-paint stops stacks at the transition-opacity target (default 0.8), so +regions show ~20% of the underlying stack. The calibration pins the opacity +*endpoint*; the *interior* of the curve is assumed exponential per channel. If +real filaments scatter, partial stacks pick up unmodeled white backscatter → +wash. Cheap probe: reprint a section at transition opacity 0.95 and compare +saturation gain against the preview's own change. + +### Emissive display vs reflective print (irreducible) + +The preview on a dark background on a monitor emits saturated light a reflective +object cannot return; deep blues/cyans suffer most. Even a perfect model looks +more vivid on screen. Sets a ceiling on "matching the preview". + +## Phase-3 gate ledger (scattering evidence) + +Signal 3 (stack prediction failures): **one entry, partially implicating.** The +wash direction (extra lightness, weak complementary-channel kill in thin layers) +is consistent with scattering, but confounded with the heuristic-floor channel +TDs, which absorption-model recalibration with better bases may fix. Signals 1 +(fit residuals) and 2 (merge reads) from the calibration session remain +negative. **Gate still not triggered** — retest after complementary-base +recalibration. + +## Follow-ups (in order) + +1. Recalibrate cyan (red base) and orange (cyan/blue base); reprint the affected + regions or small stack patches; recheck failures 1 and 2. +2. Transition-opacity 0.95 comparison print for the wash magnitude question. +3. Build the metamer-pair validation strip (still-open Phase 2 item) — it tests + exactly these cross-color stack predictions in a controlled way. +4. If failures persist with well-pinned channels → real Phase-3 (scattering) + evidence; revisit the gate. diff --git a/.agents/print-findings/2026-07-02-logo-test/image.png b/.agents/print-findings/2026-07-02-logo-test/image.png new file mode 100644 index 0000000000000000000000000000000000000000..ea2b7cbea2d69fd0bce6cefa4422b6df781d7f49 GIT binary patch literal 68424 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 U+a0v7;11SxB`w8rc~kWN0UJ6N6#xJL literal 0 HcmV?d00001 diff --git a/.agents/print-findings/2026-07-02-logo-test/print-photo.jpg b/.agents/print-findings/2026-07-02-logo-test/print-photo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7eaf0312d6f427394d164c74439e3777d7d7a46d GIT binary patch literal 496982 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
    u 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�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 z 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�_k0PjzG=5__WF0rzazv8KS=~v}JgO+3 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�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�fuvb2yiM9lKvkQN0QBQOZ;?4h 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

    Uj)Z1D6r{RbR4?|9D6fUr| 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 literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index 4cbec2b..44757d0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ test-results/ src-tauri/target/ src-tauri/gen/ src-tauri/WixTools/ + +# Full-size camera originals in print findings (compressed copies are committed) +.agents/print-findings/**/DSC*.JPG diff --git a/AGENTS.md b/AGENTS.md index 0379664..8e52645 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,12 @@ geometry, slicer behavior, and print instructions at the same time. ## Domain Notes -- **Transmission Distance (TD):** Used by auto-paint to model light through thin filament layers - with Beer-Lambert-style optical simulation. TD is not just a display value; changing how it is - stored or rounded can change generated layer stacks. +- **Hiding Distance (HD):** Used by auto-paint to model light through thin filament layers with + Beer-Lambert-style optical simulation. `filament.td` stores the frontlit hiding distance in mm + (profile schema v2); the field keeps its historical `td` name for data compatibility. Conventional + backlit/lithophane Transmission Distance values are an *input format* only — converted ×0.1 + (`FRONTLIT_TD_SCALE`) at entry or during the one-time v1→v2 migration, never stored. HD is not + just a display value; changing how it is stored or rounded can change generated layer stacks. - **Auto-paint layers:** Auto-paint chooses physical filament stacks for target image colors. The worker path exists so optimizer choices such as exhaustive, simulated annealing, and genetic search do not freeze the UI. diff --git a/CHANGELOG.md b/CHANGELOG.md index ac338f4..b34f856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to Kromacut are documented in this file. ### Changed +- **Hiding Distance replaces TD** - Filament opacity is now expressed as a frontlit **Hiding Distance (HD)** in millimetres: the depth at which a filament visually hides what's beneath it, matching how prints are actually viewed. Stored values become single-meaning (profile schema v2): uncalibrated values from older profiles, `.kfil`/`.kapp` files, and saved app state are converted automatically (×0.1, one time) on load or import, and the old internal frontlit scaling layer is removed — this also fixes calibrated filaments being double-scaled in the recommended-height estimate. A convert button on each filament row accepts conventional backlit/lithophane TD values (≈10× the HD) and converts them on entry, the wand estimate now produces HD directly, and per-channel values are visible in tooltips. Uncalibrated filaments now blend with per-channel hiding distances estimated from their color (previously channel-uniform), so uncalibrated previews and stacks are more color-faithful; the next-filament suggestion simulates blends in linear light and recommends HDs on the stored scale. - **Auto-paint enhanced matching** - Optimizer scoring now follows the same layer-snapped, Max Height-compressed printable stack used by preview and export, scores realized print error in CIEDE2000 with a weighted p95 tail term, and uses complete target-color/cache inputs so repeated runs are deterministic. - **Auto-paint optimizer controls** - Replaced the older optimizer choices with deterministic effort tiers (Fast, Balanced, Thorough, Deep, and Exact base order), selector-based repeated swaps, transition-detail endpoints, and explicit stable seed handling. Legacy saved values migrate to the nearest current tier. - **Auto-paint optical model** - Beer-Lambert blending now runs in linear-light sRGB, and calibrated filaments use measured RGB-channel TDs for blend simulation and transition-zone thickness. Recalibrate profiles created with earlier releases before using them for new prints. diff --git a/README.md b/README.md index 9004c14..1c3ae97 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, five deterministic optimizer effort tiers, and region weighting for spatial priority. +**Auto-paint mode** — Define your actual filaments (color + hiding 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. @@ -44,7 +44,7 @@ Another minimal test you can try yourself: the included Transmission Distance (T - 3D model export to binary STL or 3MF (Preview) suitable for multi-material slicers. - 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. +- **Filament calibration** — Accurately measure frontlit hiding distances from a camera-free printed wedge, with per-channel values and confidence scoring. - **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. diff --git a/src/App.tsx b/src/App.tsx index aa58ad1..c2c3187 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,7 +43,7 @@ import { defaultDocSlug } from './docs'; import { loadCameraMode, saveCameraMode } from './lib/cameraPrefs'; import { buildDocsPath, parseDocsLocation } from './lib/docs/navigation'; import { applyHomeSeo } from './lib/seo'; -import { sanitizeProfileFilament } from './lib/profileManager'; +import { migrateLegacyFilamentTd, sanitizeProfileFilament } from './lib/profileManager'; import { AlertDialog, AlertDialogContent, @@ -93,6 +93,11 @@ type AutoPaintPersisted = Pick< | 'flatPaint' >; +// Schema v2: filament `td` values store frontlit hiding distances. State +// persisted without this marker predates the change and carries uncalibrated +// tds on the conventional backlit scale, which must be migrated once on load. +const AUTOPAINT_SCHEMA_VERSION = 2; + function isOneOf(value: unknown, values: T): value is T[number] { return typeof value === 'number' && values.includes(value as T[number]); } @@ -113,13 +118,19 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const parsed = JSON.parse(raw) as any; if (!parsed || !Array.isArray(parsed.filaments)) return null; - const filaments = parsed.filaments + const sanitized = parsed.filaments .map((filament: unknown) => sanitizeProfileFilament(filament)) .filter( (filament: ReturnType): filament is NonNullable< ReturnType > => filament !== null ); + const persistedSchema = + typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : 1; + const filaments = + persistedSchema >= AUTOPAINT_SCHEMA_VERSION + ? sanitized + : sanitized.map(migrateLegacyFilamentTd); // Migrate legacy `autoPaintEnabled` boolean → `paintMode` const paintMode: 'manual' | 'autopaint' = parsed.paintMode === 'autopaint' || parsed.paintMode === 'manual' @@ -151,7 +162,10 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { const saveAutoPaintPersisted = (value: AutoPaintPersisted) => { try { - localStorage.setItem(AUTOPAINT_STORAGE_KEY, JSON.stringify(value)); + localStorage.setItem( + AUTOPAINT_STORAGE_KEY, + JSON.stringify({ schemaVersion: AUTOPAINT_SCHEMA_VERSION, ...value }) + ); } catch { // ignore storage errors } diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index c231a34..d5ad631 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -1165,8 +1165,8 @@ export default function AutoPaintTab({

    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

    - - TD:{' '} + + HD:{' '} {nextBestResult.candidate.td.toFixed(2)} diff --git a/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx index fdda748..cdacec1 100644 --- a/src/components/FilamentCalibrationDialog.tsx +++ b/src/components/FilamentCalibrationDialog.tsx @@ -34,12 +34,11 @@ import { import { cn } from '@/lib/utils'; import type { Filament } from '../types'; import { + channelHds, computeFrontlitCalibration, computeFrontlitCalibrationSession, - deriveChannelTds, predictFrontlitColor, predictOpacityLayersForTds, - sanitizeFrontlitCalibration, solveOpacityTransmission, getConfidenceLabel, getConfidenceColor, @@ -201,11 +200,11 @@ export function FilamentCalibrationDialog({ [filaments] ); - const estimateChannelTds = useCallback((filament: Filament): [number, number, number] => { - const calibration = sanitizeFrontlitCalibration(filament.calibration); - if (calibration) return calibration.td; - return deriveChannelTds(filament.color, Math.max(0.05, filament.td * 0.1)); - }, []); + const estimateChannelTds = useCallback( + // `td` already stores the frontlit hiding distance (schema v2). + (filament: Filament): [number, number, number] => channelHds(filament), + [] + ); const recommendedBaseIds = useCallback( (filament: Filament): string[] => { @@ -616,7 +615,7 @@ export function FilamentCalibrationDialog({ {filamentLabel(filament)} - TD {filament.td.toFixed(2)}mm + HD {filament.td.toFixed(2)} mm {isCalibrated ? ' · calibrated' : ''} @@ -645,7 +644,7 @@ export function FilamentCalibrationDialog({

    Quick mode uses one base per filament. Accurate mode repeats the same read over - multiple bases so Kromacut can measure RGB TDs directly. + multiple bases so Kromacut can measure RGB hiding distances directly.

    + + +
    +

    Convert from TD

    +

    + Enter a conventional Transmission Distance (spool sheet or lithophane + TD test). It converts to a frontlit hiding distance (≈ TD × 0.1). +

    +
    + setConvertDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConvertFromTd(); + }} + placeholder="e.g. 4.0" + className="h-8 text-sm" + /> + +
    +
    +
    + + + {/* Auto-estimate HD Button */} @@ -199,7 +269,7 @@ const FilamentRow = React.memo(function FilamentRow({ {isCalibrated && (
    {confidenceLabel} diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index b479393..2b4ca35 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -36,24 +36,24 @@ The first color is the starting color. Later colors become swap steps. For best ## Auto-paint -Auto-paint uses real filament colors and **Transmission Distance (TD)** values. Add each filament you plan to use, then set: +Auto-paint uses real filament colors and **Hiding Distance (HD)** values — the depth of material, in millimetres, at which a filament visually hides what's beneath it. Add each filament you plan to use, then set: - Filament name. - Filament color. -- TD value. +- HD value. -Use the wand button to auto-estimate TD from color, or calibrate to measure TD from a printed wedge. +Use the wand button to auto-estimate the hiding distance from color, calibrate to measure it from a printed wedge, or use the convert button to enter a conventional Transmission Distance (the lithophane/backlit TD from a spool sheet or TD test print) — a conventional TD is roughly 10× the hiding distance, and Kromacut converts it for you. -## Calibrating Filament TD +## Calibrating Filament Hiding Distance Calibration is camera-free: you print a small wedge and read back one or more opacity numbers. Click the calibration button on a filament row, or **Calibrate Filaments** below the filament list, to open the dialog. It has four steps: 1. **Select** the filaments you want to calibrate. 2. **Base layers** - choose **Quick** for one base read, or **Accurate** for two or three base reads per filament. Kromacut auto-picks useful bases from your real profile filaments, and you can override them with the base swatches. 3. **Print** the calibration wedge. Set your layer height and the wedge length (max layers), then download an **STL** (any printer) or a multi-material **3MF** (colors and bases baked in, for AMS/multi-material). For STL, use the shown layer height and first-layer height, print one copy for each filament/base read, load the listed base filament, then swap after the shown layer/Z height to the filament being calibrated. Each print has patches from 1 layer up, with an opaque reference rail running along the edge beside the patches. A foot marks the 1-layer end. -4. **Enter Results** by reporting, for each filament/base row, the **first patch that looks identical to the reference rail** beside it. Kromacut uses one read for quick scalar calibration, or combines multi-base reads to measure per-channel TDs and, when the session has enough data, fit the shared JND. The optional merge field records where adjacent steps stop looking different; it is stored as model-checking evidence and does not block saving. +4. **Enter Results** by reporting, for each filament/base row, the **first patch that looks identical to the reference rail** beside it. Kromacut uses one read for quick scalar calibration, or combines multi-base reads to measure per-channel hiding distances and, when the session has enough data, fit the shared JND. The optional merge field records where adjacent steps stop looking different; it is stored as model-checking evidence and does not block saving. -Because the reference rail is the filament's own fully-opaque color, you are only judging whether a patch matches the rail right next to it — a comparison that stays reliable across lighting and screens. The measured TD is a material property, so it transfers to your real prints regardless of which base you calibrated against. +Because the reference rail is the filament's own fully-opaque color, you are only judging whether a patch matches the rail right next to it — a comparison that stays reliable across lighting and screens. The measured hiding distance is a material property, so it transfers to your real prints regardless of which base you calibrated against. ## Filament Profiles @@ -89,7 +89,7 @@ Enhanced matching scores the palette that is already visible in 2D mode; it does 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. +When a filament has been calibrated, Auto-paint uses its measured red, green, and blue hiding distances for both transition colors and transition thickness. Uncalibrated filaments use per-channel values estimated from their color around the scalar HD. 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. ## Flat Paint diff --git a/src/docs/faq.md b/src/docs/faq.md index b325b61..1d3560d 100644 --- a/src/docs/faq.md +++ b/src/docs/faq.md @@ -7,21 +7,23 @@ description: Short answers to common Kromacut questions. # FAQ -## What Is Transmission Distance? +## What Is Hiding Distance? -Transmission Distance, or **TD**, describes how much light passes through a filament at a given thickness. Auto-paint uses TD to estimate how stacked filament layers will look. +Hiding Distance, or **HD**, is the depth of material (in mm) at which a filament visually hides whatever is beneath it when viewed front-lit — the way finished prints are actually seen. Auto-paint uses HD to estimate how stacked filament layers will look. -Lower TD usually means a darker or more opaque filament. Higher TD usually means a lighter or more translucent filament. +Lower HD means a more opaque filament that covers in fewer layers. Higher HD means a more translucent filament that needs more depth. + +HD replaces the Transmission Distance (TD) shown in earlier versions. Conventional TD describes backlit transmission (as measured by lithophane TD test prints) and is roughly 10× the hiding distance; you can still enter a conventional TD via the convert button on a filament row and Kromacut converts it for you. ## Should I Use Manual Or Auto-paint? Use **Manual** when you want direct artistic control over color order and layer heights. -Use **Auto-paint** when you have real filament colors and TD values and want Kromacut to plan the stack automatically. +Use **Auto-paint** when you have real filament colors and hiding distance values and want Kromacut to plan the stack automatically. ## Do I Need To Calibrate Filaments? -You can start with estimated TD values. You can also check whether the filament maker, seller, or community has published Transmission Distance values for the exact filament you own. +You can start with estimated hiding distances. You can also check whether the filament maker, seller, or community has published Transmission Distance values for the exact filament you own — enter those through the convert button and Kromacut turns them into hiding distances. Calibration usually improves Auto-paint results, especially when published values are unavailable or the result still looks wrong. Calibration is most useful when: diff --git a/src/docs/overview.md b/src/docs/overview.md index b937591..f1c7424 100644 --- a/src/docs/overview.md +++ b/src/docs/overview.md @@ -28,9 +28,9 @@ Kromacut has two printing workflows in 3D mode. | Workflow | Use it when | What you control | | ---------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- | | Manual | You want direct control over each image color. | Color order, per-color slice heights, print settings, and swaps. | -| Auto-paint | You want Kromacut to plan the physical filament stack. | Filament colors, Transmission Distance values, max height, and optimizer options. | +| Auto-paint | You want Kromacut to plan the physical filament stack. | Filament colors, hiding distance values, max height, and optimizer options. | -Manual mode starts from the image colors shown in the **Image colors** panel. Auto-paint starts from your real filaments and their **TD** values, then generates printable layers for the image. +Manual mode starts from the image colors shown in the **Image colors** panel. Auto-paint starts from your real filaments and their **hiding distance (HD)** values, then generates printable layers for the image. ## What You See In The App @@ -46,7 +46,7 @@ The workspace has three main areas: ## Good First Project -Start with a high-contrast image that has a clear subject and limited background detail. Reduce it to 4 to 16 colors, then use Manual mode if you already know your layer order or Auto-paint if you have calibrated filament TD values. +Start with a high-contrast image that has a clear subject and limited background detail. Reduce it to 4 to 16 colors, then use Manual mode if you already know your layer order or Auto-paint if you have calibrated filament hiding distances. --- diff --git a/src/docs/quick-start.md b/src/docs/quick-start.md index dac5869..f8d0c22 100644 --- a/src/docs/quick-start.md +++ b/src/docs/quick-start.md @@ -55,17 +55,17 @@ Click **3D**. Set the print basics first: Use **Manual** when you want direct control over the reduced image colors. Manual mode uses the swatches from **Image colors**: drag colors into the print order you want, then use each row slider to decide how much height that color contributes. This is a good first choice when you already know the layer order you want or you are matching a small, simple palette. -Use **Auto-paint** when you want Kromacut to plan the physical filament stack for you. Auto-paint starts from your real filaments instead of the reduced image swatches, then uses each filament's color and **Transmission Distance (TD)** to estimate how stacked layers will look. +Use **Auto-paint** when you want Kromacut to plan the physical filament stack for you. Auto-paint starts from your real filaments instead of the reduced image swatches, then uses each filament's color and **Hiding Distance (HD)** — the depth at which it hides what's beneath — to estimate how stacked layers will look. For a first Auto-paint run: 1. Add the filaments you actually plan to print with. 2. Set each filament color as accurately as you can. -3. Set each filament's **TD**. The wand estimate is fine for experimenting, but calibrated TD values usually give better results. +3. Set each filament's **HD**. The wand estimate is fine for experimenting, and you can convert a conventional TD value (≈10× the HD); calibrated hiding distances usually give the best results. 4. Leave **Max Height** on **Auto** at first. 5. Enable **Enhanced color matching** if the first result misses important colors or you want the optimizer to search for a better filament order. -After Auto-paint computes a stack, check the transition zones and confidence details before exporting. Low confidence usually means the filament set is missing a useful color, the TD values need calibration, or the max height is too restrictive. +After Auto-paint computes a stack, check the transition zones and confidence details before exporting. Low confidence usually means the filament set is missing a useful color, the hiding distances need calibration, or the max height is too restrictive. ## Generate And Export diff --git a/src/docs/settings-and-controls.md b/src/docs/settings-and-controls.md index dcbef3d..e655dab 100644 --- a/src/docs/settings-and-controls.md +++ b/src/docs/settings-and-controls.md @@ -61,7 +61,7 @@ Use custom palettes when you want the reduced image to match a known filament se ## Filament Profile Files -Auto-paint filament profiles use `.kfil`. Older `.kapp` profile files can still be imported. Profile files contain filament colors, names, TD values, and calibration data when available. +Auto-paint filament profiles use `.kfil`. Older `.kapp` profile files can still be imported. Profile files contain filament colors, names, hiding distance values, and calibration data when available. Profiles saved by older versions stored uncalibrated values on the conventional TD scale; they are converted automatically (×0.1) when loaded or imported. Use profile import and export to move calibrated filaments between browsers or share them with another Kromacut user. diff --git a/src/docs/troubleshooting.md b/src/docs/troubleshooting.md index 86d82cf..e153943 100644 --- a/src/docs/troubleshooting.md +++ b/src/docs/troubleshooting.md @@ -39,7 +39,7 @@ Use **Dedither** after reducing colors. If the image still has too many isolated Common causes: -- Filament TD values are estimates instead of calibrated values. +- Filament hiding distances are estimates instead of calibrated values. - The filament set does not cover the image colors well. - **Max Height** is compressing transition zones too much. - The optimizer needs **Enhanced color matching** enabled. diff --git a/src/hooks/useFilaments.ts b/src/hooks/useFilaments.ts index e6f3810..3b0f759 100644 --- a/src/hooks/useFilaments.ts +++ b/src/hooks/useFilaments.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import type { Filament } from '../types'; -import { estimateTDFromColor } from '../lib/colorUtils'; +import { estimateHidingDistanceFromColor } from '../lib/colorUtils'; export interface UseFilamentsOptions { initial?: Filament[]; @@ -16,7 +16,7 @@ export function useFilaments(options: UseFilamentsOptions = {}) { { id: Math.random().toString(36).substring(2, 9), color: defaultColor, - td: estimateTDFromColor(defaultColor), + td: estimateHidingDistanceFromColor(defaultColor), }, ]); }, []); diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 8315ecb..dbc5cc9 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -16,8 +16,8 @@ import { type ScoringContext, } from './optimizer'; import { + channelHds, computeProfileConfidence, - sanitizeFrontlitCalibration, type CalibrationRgb, } from './calibration'; import { blendSrgbChannel } from './colorSpace'; @@ -392,36 +392,13 @@ function transitionThicknessMultiplier(targetOpacity: number): number { return -Math.log10(1 - targetOpacity); } -/** - * Fallback for UNCALIBRATED filaments only: frontlit prints behave optically like - * a much shorter effective TD, so a generic user-entered TD is scaled down for - * internal simulation. Calibrated filaments carry measured frontlit TDs and skip - * this — the frontlit calibration flow exists to replace this approximation. - */ -const FRONTLIT_TD_SCALE = 0.1; -const USE_CALIBRATED_CHANNEL_TD = true; +// `filament.td` stores the frontlit hiding distance directly (profile schema v2) +// for calibrated and uncalibrated filaments alike, so no runtime scaling is +// needed. Per-channel values come from `channelHds`: measured when calibrated, +// derived from the swatch color otherwise. type AutoPaintFilament = Pick; -function calibratedTdChannels(filament: AutoPaintFilament): CalibrationRgb | undefined { - if (!USE_CALIBRATED_CHANNEL_TD) return undefined; - const channels = sanitizeFrontlitCalibration(filament.calibration)?.td; - if (!channels || channels.some((td) => !Number.isFinite(td) || td <= 0)) { - return undefined; - } - return channels; -} - -function scaleFilamentForFrontlight(filament: Filament): Filament { - // Calibrated filaments already carry measured frontlit TDs (scalar + per - // channel), so they pass through unchanged. Only uncalibrated filaments get - // the generic frontlit approximation. - if (sanitizeFrontlitCalibration(filament.calibration)) { - return filament; - } - return { ...filament, td: filament.td * FRONTLIT_TD_SCALE, calibration: undefined }; -} - /** * Simulate adding filament layers until the blended color matches the * target pure filament color (DeltaE < threshold), or until the filament @@ -517,12 +494,9 @@ export function calculateIdealHeight( // 0.05 = 10^(-thickness/TD) → thickness = TD × log10(20) ≈ TD × 1.3 // Dark filaments have low TD (e.g. 0.5mm) → foundation ≈ 0.65mm const firstFilament = sortedFilaments[0]; - // Use the least-opaque (largest) calibrated channel TD when available so - // every channel reaches ~95% opacity; fall back to the scalar working TD. - const firstFilamentChannels = calibratedTdChannels(firstFilament); - const foundationTd = firstFilamentChannels - ? Math.max(...firstFilamentChannels) - : firstFilament.td; + // Use the least-opaque (largest) channel hiding distance so every channel + // reaches ~95% opacity. + const foundationTd = Math.max(...channelHds(firstFilament)); const opacityThickness = foundationTd * 1.3; // 95% opaque // Ensure at least the base thickness (avoid unnecessary extra layers) const foundationThickness = Math.max(baseThickness, opacityThickness); @@ -531,7 +505,7 @@ export function calculateIdealHeight( filamentId: firstFilament.id, filamentColor: firstFilament.color, filamentTd: firstFilament.td, - filamentTdChannels: calibratedTdChannels(firstFilament), + filamentTdChannels: channelHds(firstFilament), startHeight: 0, endHeight: foundationThickness, idealThickness: foundationThickness, @@ -543,7 +517,7 @@ export function calculateIdealHeight( for (let i = 1; i < sortedFilaments.length; i++) { const filament = sortedFilaments[i]; const filamentRgb = hexToRgb(filament.color); - const transitionTd = calibratedTdChannels(filament) ?? filament.td; + const transitionTd = channelHds(filament); // Calculate how thick this zone needs to be const transitionKey = [ @@ -553,11 +527,7 @@ export function calculateIdealHeight( filamentRgb.r, filamentRgb.g, filamentRgb.b, - ...( - typeof transitionTd === 'number' - ? [transitionTd] - : transitionTd - ), + ...transitionTd, layerHeight, transitionOpacity, ].join(':'); @@ -577,7 +547,7 @@ export function calculateIdealHeight( filamentId: filament.id, filamentColor: filament.color, filamentTd: filament.td, - filamentTdChannels: calibratedTdChannels(filament), + filamentTdChannels: channelHds(filament), startHeight: currentHeight, endHeight: currentHeight + transitionThickness, idealThickness: transitionThickness, @@ -1426,16 +1396,12 @@ function findBestFilamentOrderWithOptimizer( transitionOpacity: optimizerOptions.transitionOpacity, }; - // Apply frontlit TD scale - const scaledFilaments = filaments.map(scaleFilamentForFrontlight); - - // Run optimizer - const result = optimizeFilamentOrder(scaledFilaments, context, { + // Run optimizer (tds are hiding distances; no scaling needed) + const result = optimizeFilamentOrder(filaments, context, { ...optimizerOptions, allowRepeatedSwaps, }); - // Map back to original filaments (unscaled TDs) const sortedFilaments = result.order.map((sf) => filaments.find((f) => f.id === sf.id) ).filter((f): f is Filament => f !== undefined); @@ -1541,12 +1507,9 @@ export function generateAutoLayers( }); } - // Apply frontlit TD scale for internal simulation - const scaledFilaments = sortedFilaments.map(scaleFilamentForFrontlight); - // --- STEP 3: CALCULATE IDEAL HEIGHT WITH TRANSITION ZONES --- const { idealHeight, zones } = calculateIdealHeight( - scaledFilaments, + sortedFilaments, layerHeight, Math.max(firstLayerHeight, layerHeight), undefined, @@ -1629,8 +1592,8 @@ export function calculateRecommendedHeight( ): number { if (filaments.length === 0) return 2.0; - // Sum of TDs gives a rough estimate of total transition space needed - const totalTD = filaments.reduce((sum, f) => sum + f.td * FRONTLIT_TD_SCALE, 0); + // Sum of hiding distances gives a rough estimate of transition space needed + const totalTD = filaments.reduce((sum, f) => sum + f.td, 0); // Typically need about 0.8x to 1.2x the sum of TDs const estimated = totalTD * 0.9; diff --git a/src/lib/calibration.ts b/src/lib/calibration.ts index ace74bf..62b0153 100644 --- a/src/lib/calibration.ts +++ b/src/lib/calibration.ts @@ -14,8 +14,8 @@ * form with a principled, color-aware constant instead of a hand-picked number. */ -import { blendSrgbChannel } from './colorSpace'; -import { deltaE2000 } from './colorDifference'; +import { blendSrgbChannel } from './colorSpace.ts'; +import { deltaE2000 } from './colorDifference.ts'; // ============================================================================ // Types @@ -110,6 +110,16 @@ export const FRONTLIT_BASE_HEX = '#000000'; */ export const OPACITY_JND = 2.0; +/** + * Conversion factor between conventional (backlit/lithophane) Transmission + * Distance values and the frontlit hiding distance the app stores and + * simulates with. Used only when converting user-entered conventional TD + * values and when migrating pre-v2 persisted profiles; the runtime pipeline + * consumes hiding distances directly. Empirically validated: measured hiding + * distances of a physical 8-filament set landed at ≈ backlit TD × 0.1. + */ +export const FRONTLIT_TD_SCALE = 0.1; + const FRONTLIT_TD_MIN = 0.05; const FRONTLIT_TD_MAX = 12.0; const JND_FIT_MIN = 1.0; @@ -525,7 +535,7 @@ export function predictOpacityLayersForTds( /** * Derive per-channel TDs from the single measured TD, modulated by the filament * color. Uses a `1 + value*5.8` shape per channel (the per-channel form of the - * estimateTDFromColor luminance heuristic): a brighter channel is more + * estimateHidingDistanceFromColor luminance heuristic): a brighter channel is more * transmissive (larger TD). The brightest channel — which governs the opacity * the eye reads — is anchored to `tdSingle`, so darker (more absorptive) channels * scale down from there. @@ -538,6 +548,23 @@ export function deriveChannelTds(filamentColor: string, tdSingle: number): Calib return raw.map((value) => clamp(k * value, FRONTLIT_TD_MIN, FRONTLIT_TD_MAX)) as CalibrationRgb; } +/** + * Per-channel hiding distances for a filament: the measured triple when a valid + * frontlit calibration exists, otherwise derived from the swatch color around + * the scalar hiding distance. Every blend consumer should go through this so + * calibrated and uncalibrated filaments share one channel model. + */ +export function channelHds(filament: { + color: string; + td: number; + calibration?: unknown; +}): CalibrationRgb { + return ( + sanitizeFrontlitCalibration(filament.calibration)?.td ?? + deriveChannelTds(filament.color, filament.td) + ); +} + function frontlitConfidence(opacityLayers: number, tdSingle: number, maxLayers?: number): number { let confidence = 0.9; // Reads pinned at either extreme of the wedge are less trustworthy: the true @@ -820,9 +847,10 @@ export function computeProfileConfidence(profile: { }): number { const cal = sanitizeFrontlitCalibration(profile.calibration); if (!cal) { + // Plausibility bands for an uncalibrated hiding distance (mm, frontlit). const td = profile.transmissionDistance; - if (td >= 1.0 && td <= 5.0) return 0.5; - if (td >= 0.5 && td <= 10.0) return 0.3; + if (td >= 0.1 && td <= 0.5) return 0.5; + if (td >= 0.05 && td <= 1.0) return 0.3; return 0.1; } diff --git a/src/lib/colorUtils.ts b/src/lib/colorUtils.ts index 74b4c29..7a5526e 100644 --- a/src/lib/colorUtils.ts +++ b/src/lib/colorUtils.ts @@ -2,6 +2,8 @@ * Shared color utility functions. */ +import { FRONTLIT_TD_SCALE } from './calibration.ts'; + /** * Normalize a hex color to canonical `#RRGGBB` uppercase form. * Accepts values with or without the leading '#'; anything that is not a @@ -26,17 +28,19 @@ export function hexLuminance(hex: string): number { } /** - * Estimate Transmission Distance (TD) from a hex color. + * Estimate the frontlit hiding distance (mm) from a hex color. * - * TD is related to how much light passes through the filament: - * - Darker / more opaque colors usually have lower TD - * - Lighter / more translucent colors usually have higher TD - * - Saturation and hue can shift TD slightly around the luminance baseline + * Hiding distance is the depth of material at which a filament visually hides + * what's beneath it: + * - Darker / more opaque colors hide sooner (smaller value) + * - Lighter / more translucent colors need more depth (larger value) + * - Saturation and hue shift the estimate slightly around the luminance baseline * - * This heuristic is intentionally conservative and should be replaced by + * The heuristic shape is the historical backlit-TD estimate rescaled by + * FRONTLIT_TD_SCALE; it is intentionally conservative and should be replaced by * measured calibration data whenever possible. */ -export function estimateTDFromColor(hex: string): number { +export function estimateHidingDistanceFromColor(hex: string): number { const h = hex.replace(/^#/, ''); const r = parseInt(h.slice(0, 2), 16) / 255; const g = parseInt(h.slice(2, 4), 16) / 255; @@ -100,9 +104,10 @@ export function estimateTDFromColor(hex: string): number { estimatedTD = 0.8 + luminance * 2.7; // Range: ~0.8-1.2mm } - // Clamp to realistic range for PLA filaments + // Clamp to realistic range for PLA filaments (still on the legacy TD scale) estimatedTD = Math.max(0.6, Math.min(8.5, estimatedTD)); - // Round to 1 decimal place - return Math.round(estimatedTD * 10) / 10; + // Convert the legacy backlit-TD shape to a frontlit hiding distance and + // round to 2 decimals (values live in roughly 0.06–0.85 mm). + return Math.round(estimatedTD * FRONTLIT_TD_SCALE * 100) / 100; } \ No newline at end of file diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts index c9485cb..d793d9f 100644 --- a/src/lib/nextBestColor.ts +++ b/src/lib/nextBestColor.ts @@ -103,11 +103,34 @@ export function hexToLab(hex: string): Lab { export { labToHex }; -// Beer-Lambert layer blend: matches autoPaint's blendColors (operates in sRGB [0-255]). -function blendRgb(bg: RGB, fg: RGB, td: number, thickness: number): RGB { +// sRGB [0-255] channel <-> linear light, matching colorSpace.ts semantics. +function srgbChannelToLinear(channel: number): number { + const s = Math.max(0, Math.min(255, channel)) / 255; + return s <= SRGB_LINEARISE_THRESHOLD + ? s / SRGB_LINEARISE_SCALE + : Math.pow((s + SRGB_LINEARISE_OFFSET) / SRGB_LINEARISE_DENOM, SRGB_LINEARISE_GAMMA); +} + +function linearChannelToSrgb(channel: number): number { + const c = Math.max(0, Math.min(1, channel)); + const normalized = + c <= SRGB_LINEAR_THRESHOLD + ? c * SRGB_LINEARISE_SCALE + : SRGB_LINEARISE_DENOM * Math.pow(c, 1 / SRGB_LINEARISE_GAMMA) - SRGB_LINEARISE_OFFSET; + return normalized * 255; +} + +// Beer-Lambert layer blend in linear light: matches autoPaint's blendColors / +// colorSpace.blendSrgbChannel (composite the transmissive foreground over the +// background in linear light, then re-encode to sRGB). +export function blendRgb(bg: RGB, fg: RGB, td: number, thickness: number): RGB { if (td <= 0 || thickness <= 0) return bg; const t = Math.pow(0.1, thickness / td); - return { r: fg.r + (bg.r - fg.r) * t, g: fg.g + (bg.g - fg.g) * t, b: fg.b + (bg.b - fg.b) * t }; + const blendChannel = (bgC: number, fgC: number) => + linearChannelToSrgb( + srgbChannelToLinear(fgC) * (1 - t) + srgbChannelToLinear(bgC) * t + ); + return { r: blendChannel(bg.r, fg.r), g: blendChannel(bg.g, fg.g), b: blendChannel(bg.b, fg.b) }; } const BLEND_CURVE_STEPS = 16; @@ -154,7 +177,7 @@ const EXTRAP_BLEND_RATIOS = [0.3, 0.5, 0.7]; export interface ColorCandidate { hex: string; - /** Recommended starting TD, derived from the nearest existing filament by ΔE. */ + /** Recommended starting hiding distance (mm), borrowed from the nearest existing filament by ΔE. */ td: number; /** * % reduction in blend-aware weighted-average ΔE vs current filament set. @@ -376,7 +399,8 @@ export function nextBestColor( if (newReachable < effectiveReachable[i]) pixelsCaptured += counts[i]; } - // TD: borrow from nearest existing filament by ΔE. + // Hiding distance: borrow from the nearest existing filament by ΔE. `td` is + // already the frontlit hiding distance (schema v2), so no scale conversion. let nearestFilamentIdx = 0; let nearestDE = Infinity; for (let fi = 0; fi < filamentLabs.length; fi++) { @@ -394,7 +418,7 @@ export function nextBestColor( `${pool.length} candidates (${scores.length} scored)` ); console.log(` Baseline avg ΔE: ${baselineAvgDeltaE.toFixed(2)} (blend-aware)`); - console.log(` Suggestion: ${winner.hex.toUpperCase()} TD ${recommendedTd.toFixed(2)}`); + console.log(` Suggestion: ${winner.hex.toUpperCase()} HD ${recommendedTd.toFixed(2)}`); console.log( ` Accuracy gain: +${improvementPct.toFixed(1)}% ` + `(${pixelsCaptured.toLocaleString()} px / ` + diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts index 0ae8adb..2906c13 100644 --- a/src/lib/profileManager.ts +++ b/src/lib/profileManager.ts @@ -1,5 +1,5 @@ import type { Filament } from '../types'; -import { sanitizeFrontlitCalibration } from './calibration'; +import { FRONTLIT_TD_SCALE, sanitizeFrontlitCalibration } from './calibration.ts'; export interface AutoPaintProfile { id: string; @@ -10,7 +10,19 @@ export interface AutoPaintProfile { updatedAt: number; } -export const CURRENT_PROFILE_VERSION = 1; +/** + * Profile schema versions: + * - v1: uncalibrated `td` stored on the conventional backlit TD scale (~1–6 mm) + * and scaled ×0.1 at simulation time. + * - v2: `td` always stores the frontlit hiding distance (mm) directly, for + * calibrated and uncalibrated filaments alike. + */ +export const CURRENT_PROFILE_VERSION = 2; + +/** Schema version that introduced hiding-distance td storage. The td migration + * applies to profiles below this version only — never re-gate it on + * CURRENT_PROFILE_VERSION, or a future bump would re-scale v2 profiles. */ +const TD_MIGRATION_VERSION = 2; const PROFILES_STORAGE_KEY = 'kromacut.autopaint.profiles'; const LAST_PROFILE_KEY = 'kromacut.autopaint.lastProfileId'; @@ -43,6 +55,29 @@ export function sanitizeProfileFilament(value: unknown): Filament | null { return filament; } +/** + * Convert a schema-v1 filament to v2 semantics: uncalibrated tds move from the + * conventional backlit TD scale to the frontlit hiding distance (×0.1); + * calibrated filaments re-sync to their measured scalar. Must run exactly once + * per filament, gated by the stored schema version. + */ +export function migrateLegacyFilamentTd(filament: Filament): Filament { + const calibration = sanitizeFrontlitCalibration(filament.calibration); + if (calibration) { + return { ...filament, td: calibration.tdSingleValue }; + } + // Round away binary-float noise from the ×0.1 (e.g. 1.1 → 0.11000000000000001); + // 4 decimals is far below the optical resolution of any read. + return { ...filament, td: Math.round(filament.td * FRONTLIT_TD_SCALE * 1e4) / 1e4 }; +} + +function sanitizeProfileFilaments(value: unknown[], version: number): Filament[] { + const filaments = value + .map((filament) => sanitizeProfileFilament(filament)) + .filter((filament): filament is Filament => filament !== null); + return version < TD_MIGRATION_VERSION ? filaments.map(migrateLegacyFilamentTd) : filaments; +} + function sanitizeProfile(value: unknown): AutoPaintProfile | null { if (!isRecord(value)) return null; if ( @@ -53,14 +88,13 @@ function sanitizeProfile(value: unknown): AutoPaintProfile | null { return null; } + const version = typeof value.version === 'number' ? value.version : 1; const now = Date.now(); return { id: value.id, name: value.name, - version: typeof value.version === 'number' ? value.version : CURRENT_PROFILE_VERSION, - filaments: value.filaments - .map((filament) => sanitizeProfileFilament(filament)) - .filter((filament): filament is Filament => filament !== null), + version: CURRENT_PROFILE_VERSION, + filaments: sanitizeProfileFilaments(value.filaments, version), createdAt: typeof value.createdAt === 'number' ? value.createdAt : now, updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : now, }; @@ -213,15 +247,14 @@ export function importProfiles( // Validate required fields if (!raw || typeof raw.name !== 'string' || !Array.isArray(raw.filaments)) continue; - const validFilaments = raw.filaments - .map((filament) => sanitizeProfileFilament(filament)) - .filter((filament): filament is Filament => filament !== null); + const rawVersion = typeof raw.version === 'number' ? raw.version : 1; + const validFilaments = sanitizeProfileFilaments(raw.filaments, rawVersion); const now = Date.now(); const profile: AutoPaintProfile = { id: raw.id && typeof raw.id === 'string' ? raw.id : crypto.randomUUID(), name: raw.name, - version: typeof raw.version === 'number' ? raw.version : CURRENT_PROFILE_VERSION, + version: CURRENT_PROFILE_VERSION, filaments: validFilaments, createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now, updatedAt: now, diff --git a/tests/assets/auto-paint-goldens.json b/tests/assets/auto-paint-goldens.json index 914f056..6419461 100644 --- a/tests/assets/auto-paint-goldens.json +++ b/tests/assets/auto-paint-goldens.json @@ -16,7 +16,7 @@ "filamentId": "p9c63ms", "startHeight": 0.16, "endHeight": 0.64, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.48 } ], @@ -40,7 +40,7 @@ "filamentId": "p9c63ms", "startHeight": 0.16, "endHeight": 0.64, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.48 } ], @@ -64,7 +64,7 @@ "filamentId": "p9c63ms", "startHeight": 0.16, "endHeight": 0.64, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.48 } ], @@ -73,6 +73,8 @@ }, "B&W / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ + "plvjtmc", + "p9c63ms", "plvjtmc", "p9c63ms" ], @@ -88,11 +90,25 @@ "filamentId": "p9c63ms", "startHeight": 0.16, "endHeight": 0.64, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.48 + }, + { + "filamentId": "plvjtmc", + "startHeight": 0.64, + "endHeight": 0.72, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "p9c63ms", + "startHeight": 0.72, + "endHeight": 1.12, + "idealThickness": 0.42, + "actualThickness": 0.4 } ], - "totalHeight": 0.64, + "totalHeight": 1.12, "compressionRatio": 1 }, "B&W / large-jpeg / enhanced=false / repeats=false": { @@ -112,7 +128,7 @@ "filamentId": "p9c63ms", "startHeight": 0.16, "endHeight": 0.64, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.48 } ], @@ -136,7 +152,7 @@ "filamentId": "p9c63ms", "startHeight": 0.16, "endHeight": 0.64, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.48 } ], @@ -145,26 +161,26 @@ }, "B&W / large-jpeg / enhanced=true / repeats=false": { "filamentOrder": [ - "plvjtmc", - "p9c63ms" + "p9c63ms", + "plvjtmc" ], "transitionZones": [ { - "filamentId": "plvjtmc", + "filamentId": "p9c63ms", "startHeight": 0, - "endHeight": 0.16, - "idealThickness": 0.16, - "actualThickness": 0.16 + "endHeight": 0.8, + "idealThickness": 0.78, + "actualThickness": 0.8 }, { - "filamentId": "p9c63ms", - "startHeight": 0.16, - "endHeight": 0.64, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.48 + "filamentId": "plvjtmc", + "startHeight": 0.8, + "endHeight": 0.88, + "idealThickness": 0.08, + "actualThickness": 0.08 } ], - "totalHeight": 0.64, + "totalHeight": 0.88, "compressionRatio": 1 }, "B&W / large-jpeg / enhanced=true / repeats=true": { @@ -185,7 +201,7 @@ "filamentId": "p9c63ms", "startHeight": 0.16, "endHeight": 0.64, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.48 }, { @@ -218,7 +234,7 @@ "filamentId": "h8zuocq", "startHeight": 0.16, "endHeight": 0.48, - "idealThickness": 0.245, + "idealThickness": 0.24499999999999997, "actualThickness": 0.32 }, { @@ -232,7 +248,7 @@ "filamentId": "y202l1e", "startHeight": 0.72, "endHeight": 1.04, - "idealThickness": 0.329, + "idealThickness": 0.32899999999999996, "actualThickness": 0.32 } ], @@ -258,7 +274,7 @@ "filamentId": "h8zuocq", "startHeight": 0.16, "endHeight": 0.48, - "idealThickness": 0.245, + "idealThickness": 0.24499999999999997, "actualThickness": 0.32 }, { @@ -272,7 +288,7 @@ "filamentId": "y202l1e", "startHeight": 0.72, "endHeight": 1.04, - "idealThickness": 0.329, + "idealThickness": 0.32899999999999996, "actualThickness": 0.32 } ], @@ -291,7 +307,7 @@ "filamentId": "xud8mzr", "startHeight": 0, "endHeight": 0.56, - "idealThickness": 0.4810000000000001, + "idealThickness": 0.481, "actualThickness": 0.56 }, { @@ -305,14 +321,14 @@ "filamentId": "h8zuocq", "startHeight": 0.64, "endHeight": 0.88, - "idealThickness": 0.245, + "idealThickness": 0.24499999999999997, "actualThickness": 0.24 }, { "filamentId": "y202l1e", "startHeight": 0.88, "endHeight": 1.2, - "idealThickness": 0.329, + "idealThickness": 0.32899999999999996, "actualThickness": 0.32 } ], @@ -326,9 +342,9 @@ "y202l1e", "plvjtmc", "y202l1e", + "plvjtmc", "xud8mzr", - "y202l1e", - "plvjtmc" + "y202l1e" ], "transitionZones": [ { @@ -342,14 +358,14 @@ "filamentId": "h8zuocq", "startHeight": 0.16, "endHeight": 0.48, - "idealThickness": 0.245, + "idealThickness": 0.24499999999999997, "actualThickness": 0.32 }, { "filamentId": "y202l1e", "startHeight": 0.48, "endHeight": 0.8, - "idealThickness": 0.329, + "idealThickness": 0.32899999999999996, "actualThickness": 0.32 }, { @@ -363,29 +379,29 @@ "filamentId": "y202l1e", "startHeight": 0.88, "endHeight": 1.2, - "idealThickness": 0.329, + "idealThickness": 0.32899999999999996, "actualThickness": 0.32 }, { - "filamentId": "xud8mzr", + "filamentId": "plvjtmc", "startHeight": 1.2, - "endHeight": 1.44, + "endHeight": 1.28, + "idealThickness": 0.08, + "actualThickness": 0.08 + }, + { + "filamentId": "xud8mzr", + "startHeight": 1.28, + "endHeight": 1.52, "idealThickness": 0.259, "actualThickness": 0.24 }, { "filamentId": "y202l1e", - "startHeight": 1.44, - "endHeight": 1.76, - "idealThickness": 0.329, - "actualThickness": 0.32 - }, - { - "filamentId": "plvjtmc", - "startHeight": 1.76, + "startHeight": 1.52, "endHeight": 1.84, - "idealThickness": 0.08, - "actualThickness": 0.08 + "idealThickness": 0.32899999999999996, + "actualThickness": 0.32 } ], "totalHeight": 1.84, @@ -410,7 +426,7 @@ "filamentId": "h8zuocq", "startHeight": 0.16, "endHeight": 0.48, - "idealThickness": 0.245, + "idealThickness": 0.24499999999999997, "actualThickness": 0.32 }, { @@ -424,7 +440,7 @@ "filamentId": "y202l1e", "startHeight": 0.72, "endHeight": 1.04, - "idealThickness": 0.329, + "idealThickness": 0.32899999999999996, "actualThickness": 0.32 } ], @@ -450,7 +466,7 @@ "filamentId": "h8zuocq", "startHeight": 0.16, "endHeight": 0.48, - "idealThickness": 0.245, + "idealThickness": 0.24499999999999997, "actualThickness": 0.32 }, { @@ -464,7 +480,7 @@ "filamentId": "y202l1e", "startHeight": 0.72, "endHeight": 1.04, - "idealThickness": 0.329, + "idealThickness": 0.32899999999999996, "actualThickness": 0.32 } ], @@ -475,8 +491,8 @@ "filamentOrder": [ "plvjtmc", "h8zuocq", - "y202l1e", - "xud8mzr" + "xud8mzr", + "y202l1e" ], "transitionZones": [ { @@ -490,22 +506,22 @@ "filamentId": "h8zuocq", "startHeight": 0.16, "endHeight": 0.48, - "idealThickness": 0.245, + "idealThickness": 0.24499999999999997, "actualThickness": 0.32 }, { - "filamentId": "y202l1e", + "filamentId": "xud8mzr", "startHeight": 0.48, - "endHeight": 0.8, - "idealThickness": 0.329, - "actualThickness": 0.32 + "endHeight": 0.72, + "idealThickness": 0.24, + "actualThickness": 0.24 }, { - "filamentId": "xud8mzr", - "startHeight": 0.8, + "filamentId": "y202l1e", + "startHeight": 0.72, "endHeight": 1.04, - "idealThickness": 0.259, - "actualThickness": 0.24 + "idealThickness": 0.32899999999999996, + "actualThickness": 0.32 } ], "totalHeight": 1.04, @@ -513,29 +529,29 @@ }, "GH#27 / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ - "xud8mzr", "h8zuocq", - "plvjtmc", "y202l1e", "plvjtmc", - "h8zuocq", + "y202l1e", "plvjtmc", + "xud8mzr", + "y202l1e", "xud8mzr" ], "transitionZones": [ { - "filamentId": "xud8mzr", + "filamentId": "h8zuocq", "startHeight": 0, - "endHeight": 0.56, - "idealThickness": 0.4810000000000001, - "actualThickness": 0.56 + "endHeight": 0.48, + "idealThickness": 0.45499999999999996, + "actualThickness": 0.48 }, { - "filamentId": "h8zuocq", - "startHeight": 0.56, + "filamentId": "y202l1e", + "startHeight": 0.48, "endHeight": 0.8, - "idealThickness": 0.24, - "actualThickness": 0.24 + "idealThickness": 0.32899999999999996, + "actualThickness": 0.32 }, { "filamentId": "plvjtmc", @@ -548,7 +564,7 @@ "filamentId": "y202l1e", "startHeight": 0.88, "endHeight": 1.2, - "idealThickness": 0.329, + "idealThickness": 0.32899999999999996, "actualThickness": 0.32 }, { @@ -559,28 +575,28 @@ "actualThickness": 0.08 }, { - "filamentId": "h8zuocq", + "filamentId": "xud8mzr", "startHeight": 1.28, - "endHeight": 1.52, - "idealThickness": 0.245, - "actualThickness": 0.24 + "endHeight": 1.6, + "idealThickness": 0.259, + "actualThickness": 0.32 }, { - "filamentId": "plvjtmc", - "startHeight": 1.52, - "endHeight": 1.6, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "y202l1e", + "startHeight": 1.6, + "endHeight": 1.92, + "idealThickness": 0.32899999999999996, + "actualThickness": 0.32 }, { "filamentId": "xud8mzr", - "startHeight": 1.6, - "endHeight": 1.84, + "startHeight": 1.92, + "endHeight": 2.16, "idealThickness": 0.259, "actualThickness": 0.24 } ], - "totalHeight": 1.84, + "totalHeight": 2.16, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=false / repeats=false": { @@ -606,7 +622,7 @@ "filamentId": "azwg1yp", "startHeight": 0.16, "endHeight": 0.4, - "idealThickness": 0.19599999999999998, + "idealThickness": 0.196, "actualThickness": 0.24 }, { @@ -627,7 +643,7 @@ "filamentId": "upcjpfe", "startHeight": 0.96, "endHeight": 1.28, - "idealThickness": 0.343, + "idealThickness": 0.34299999999999997, "actualThickness": 0.32 }, { @@ -648,7 +664,7 @@ "filamentId": "p9c63ms", "startHeight": 2.08, "endHeight": 2.48, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.4 } ], @@ -678,7 +694,7 @@ "filamentId": "azwg1yp", "startHeight": 0.16, "endHeight": 0.4, - "idealThickness": 0.19599999999999998, + "idealThickness": 0.196, "actualThickness": 0.24 }, { @@ -699,7 +715,7 @@ "filamentId": "upcjpfe", "startHeight": 0.96, "endHeight": 1.28, - "idealThickness": 0.343, + "idealThickness": 0.34299999999999997, "actualThickness": 0.32 }, { @@ -720,7 +736,7 @@ "filamentId": "p9c63ms", "startHeight": 2.08, "endHeight": 2.48, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.4 } ], @@ -730,11 +746,10 @@ "Current 8 Colors / logo-png / enhanced=true / repeats=false": { "filamentOrder": [ "plvjtmc", - "98z555k", - "upcjpfe", "azwg1yp", "xyyxysq", "w8cncoa", + "98z555k", "p9c63ms", "vsn9q6u" ], @@ -746,161 +761,154 @@ "idealThickness": 0.16, "actualThickness": 0.16 }, - { - "filamentId": "98z555k", - "startHeight": 0.16, - "endHeight": 0.48, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.32 - }, - { - "filamentId": "upcjpfe", - "startHeight": 0.48, - "endHeight": 0.8, - "idealThickness": 0.343, - "actualThickness": 0.32 - }, { "filamentId": "azwg1yp", - "startHeight": 0.8, - "endHeight": 1.04, - "idealThickness": 0.19599999999999998, + "startHeight": 0.16, + "endHeight": 0.4, + "idealThickness": 0.196, "actualThickness": 0.24 }, { "filamentId": "xyyxysq", - "startHeight": 1.04, - "endHeight": 1.36, + "startHeight": 0.4, + "endHeight": 0.72, "idealThickness": 0.357, "actualThickness": 0.32 }, { "filamentId": "w8cncoa", - "startHeight": 1.36, - "endHeight": 1.84, + "startHeight": 0.72, + "endHeight": 1.2, "idealThickness": 0.44099999999999995, "actualThickness": 0.48 }, + { + "filamentId": "98z555k", + "startHeight": 1.2, + "endHeight": 1.44, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.24 + }, { "filamentId": "p9c63ms", - "startHeight": 1.84, - "endHeight": 2.24, - "idealThickness": 0.42000000000000004, - "actualThickness": 0.4 + "startHeight": 1.44, + "endHeight": 1.92, + "idealThickness": 0.42, + "actualThickness": 0.48 }, { "filamentId": "vsn9q6u", - "startHeight": 2.24, - "endHeight": 2.48, + "startHeight": 1.92, + "endHeight": 2.16, "idealThickness": 0.26599999999999996, "actualThickness": 0.24 } ], - "totalHeight": 2.48, + "totalHeight": 2.16, "compressionRatio": 1 }, "Current 8 Colors / logo-png / enhanced=true / repeats=true": { "filamentOrder": [ - "98z555k", - "vsn9q6u", - "upcjpfe", + "plvjtmc", "azwg1yp", "xyyxysq", - "upcjpfe", "w8cncoa", "vsn9q6u", - "plvjtmc", + "98z555k", "azwg1yp", "upcjpfe", + "xyyxysq", + "98z555k", + "vsn9q6u", "azwg1yp" ], "transitionZones": [ { - "filamentId": "98z555k", + "filamentId": "plvjtmc", "startHeight": 0, - "endHeight": 0.56, - "idealThickness": 0.507, - "actualThickness": 0.56 - }, - { - "filamentId": "vsn9q6u", - "startHeight": 0.56, - "endHeight": 0.8, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.24 - }, - { - "filamentId": "upcjpfe", - "startHeight": 0.8, - "endHeight": 1.12, - "idealThickness": 0.343, - "actualThickness": 0.32 + "endHeight": 0.16, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { "filamentId": "azwg1yp", - "startHeight": 1.12, - "endHeight": 1.36, - "idealThickness": 0.19599999999999998, + "startHeight": 0.16, + "endHeight": 0.4, + "idealThickness": 0.196, "actualThickness": 0.24 }, { "filamentId": "xyyxysq", - "startHeight": 1.36, - "endHeight": 1.68, + "startHeight": 0.4, + "endHeight": 0.72, "idealThickness": 0.357, "actualThickness": 0.32 }, - { - "filamentId": "upcjpfe", - "startHeight": 1.68, - "endHeight": 2.08, - "idealThickness": 0.343, - "actualThickness": 0.4 - }, { "filamentId": "w8cncoa", - "startHeight": 2.08, - "endHeight": 2.48, + "startHeight": 0.72, + "endHeight": 1.2, "idealThickness": 0.44099999999999995, - "actualThickness": 0.4 + "actualThickness": 0.48 }, { "filamentId": "vsn9q6u", - "startHeight": 2.48, - "endHeight": 2.72, + "startHeight": 1.2, + "endHeight": 1.44, "idealThickness": 0.26599999999999996, "actualThickness": 0.24 }, { - "filamentId": "plvjtmc", - "startHeight": 2.72, - "endHeight": 2.8, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "98z555k", + "startHeight": 1.44, + "endHeight": 1.76, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.32 }, { "filamentId": "azwg1yp", - "startHeight": 2.8, - "endHeight": 3.04, - "idealThickness": 0.19599999999999998, - "actualThickness": 0.24 + "startHeight": 1.76, + "endHeight": 1.92, + "idealThickness": 0.196, + "actualThickness": 0.16 }, { "filamentId": "upcjpfe", - "startHeight": 3.04, - "endHeight": 3.36, - "idealThickness": 0.343, + "startHeight": 1.92, + "endHeight": 2.24, + "idealThickness": 0.34299999999999997, "actualThickness": 0.32 }, + { + "filamentId": "xyyxysq", + "startHeight": 2.24, + "endHeight": 2.64, + "idealThickness": 0.357, + "actualThickness": 0.4 + }, + { + "filamentId": "98z555k", + "startHeight": 2.64, + "endHeight": 2.88, + "idealThickness": 0.27299999999999996, + "actualThickness": 0.24 + }, + { + "filamentId": "vsn9q6u", + "startHeight": 2.88, + "endHeight": 3.04, + "idealThickness": 0.16, + "actualThickness": 0.16 + }, { "filamentId": "azwg1yp", - "startHeight": 3.36, - "endHeight": 3.6, - "idealThickness": 0.19599999999999998, + "startHeight": 3.04, + "endHeight": 3.28, + "idealThickness": 0.196, "actualThickness": 0.24 } ], - "totalHeight": 3.6, + "totalHeight": 3.28, "compressionRatio": 1 }, "Current 8 Colors / large-jpeg / enhanced=false / repeats=false": { @@ -926,7 +934,7 @@ "filamentId": "azwg1yp", "startHeight": 0.16, "endHeight": 0.4, - "idealThickness": 0.19599999999999998, + "idealThickness": 0.196, "actualThickness": 0.24 }, { @@ -947,7 +955,7 @@ "filamentId": "upcjpfe", "startHeight": 0.96, "endHeight": 1.28, - "idealThickness": 0.343, + "idealThickness": 0.34299999999999997, "actualThickness": 0.32 }, { @@ -968,7 +976,7 @@ "filamentId": "p9c63ms", "startHeight": 2.08, "endHeight": 2.48, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.4 } ], @@ -998,7 +1006,7 @@ "filamentId": "azwg1yp", "startHeight": 0.16, "endHeight": 0.4, - "idealThickness": 0.19599999999999998, + "idealThickness": 0.196, "actualThickness": 0.24 }, { @@ -1019,7 +1027,7 @@ "filamentId": "upcjpfe", "startHeight": 0.96, "endHeight": 1.28, - "idealThickness": 0.343, + "idealThickness": 0.34299999999999997, "actualThickness": 0.32 }, { @@ -1040,7 +1048,7 @@ "filamentId": "p9c63ms", "startHeight": 2.08, "endHeight": 2.48, - "idealThickness": 0.42000000000000004, + "idealThickness": 0.42, "actualThickness": 0.4 } ], @@ -1049,170 +1057,154 @@ }, "Current 8 Colors / large-jpeg / enhanced=true / repeats=false": { "filamentOrder": [ - "w8cncoa", "azwg1yp", - "plvjtmc", - "vsn9q6u", "xyyxysq", - "upcjpfe", - "98z555k" + "vsn9q6u", + "p9c63ms", + "plvjtmc" ], "transitionZones": [ - { - "filamentId": "w8cncoa", - "startHeight": 0, - "endHeight": 0.88, - "idealThickness": 0.8190000000000001, - "actualThickness": 0.88 - }, { "filamentId": "azwg1yp", - "startHeight": 0.88, - "endHeight": 1.04, - "idealThickness": 0.19599999999999998, - "actualThickness": 0.16 + "startHeight": 0, + "endHeight": 0.4, + "idealThickness": 0.36400000000000005, + "actualThickness": 0.4 }, { - "filamentId": "plvjtmc", - "startHeight": 1.04, - "endHeight": 1.12, - "idealThickness": 0.08, - "actualThickness": 0.08 + "filamentId": "xyyxysq", + "startHeight": 0.4, + "endHeight": 0.8, + "idealThickness": 0.357, + "actualThickness": 0.4 }, { "filamentId": "vsn9q6u", - "startHeight": 1.12, - "endHeight": 1.44, + "startHeight": 0.8, + "endHeight": 1.04, "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 - }, - { - "filamentId": "xyyxysq", - "startHeight": 1.44, - "endHeight": 1.76, - "idealThickness": 0.357, - "actualThickness": 0.32 + "actualThickness": 0.24 }, { - "filamentId": "upcjpfe", - "startHeight": 1.76, - "endHeight": 2.08, - "idealThickness": 0.343, - "actualThickness": 0.32 + "filamentId": "p9c63ms", + "startHeight": 1.04, + "endHeight": 1.44, + "idealThickness": 0.42, + "actualThickness": 0.4 }, { - "filamentId": "98z555k", - "startHeight": 2.08, - "endHeight": 2.4, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.32 + "filamentId": "plvjtmc", + "startHeight": 1.44, + "endHeight": 1.52, + "idealThickness": 0.08, + "actualThickness": 0.08 } ], - "totalHeight": 2.4, + "totalHeight": 1.52, "compressionRatio": 1 }, "Current 8 Colors / large-jpeg / enhanced=true / repeats=true": { "filamentOrder": [ - "azwg1yp", - "upcjpfe", - "vsn9q6u", - "plvjtmc", "98z555k", "upcjpfe", - "vsn9q6u", "xyyxysq", - "vsn9q6u", "p9c63ms", - "98z555k", - "upcjpfe" + "plvjtmc", + "xyyxysq", + "plvjtmc", + "p9c63ms", + "xyyxysq", + "azwg1yp", + "w8cncoa", + "plvjtmc" ], "transitionZones": [ { - "filamentId": "azwg1yp", + "filamentId": "98z555k", "startHeight": 0, - "endHeight": 0.4, - "idealThickness": 0.364, - "actualThickness": 0.4 + "endHeight": 0.56, + "idealThickness": 0.507, + "actualThickness": 0.56 }, { "filamentId": "upcjpfe", - "startHeight": 0.4, - "endHeight": 0.72, - "idealThickness": 0.343, + "startHeight": 0.56, + "endHeight": 0.88, + "idealThickness": 0.34299999999999997, "actualThickness": 0.32 }, { - "filamentId": "vsn9q6u", - "startHeight": 0.72, - "endHeight": 1.04, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 + "filamentId": "xyyxysq", + "startHeight": 0.88, + "endHeight": 1.28, + "idealThickness": 0.357, + "actualThickness": 0.4 + }, + { + "filamentId": "p9c63ms", + "startHeight": 1.28, + "endHeight": 1.68, + "idealThickness": 0.42, + "actualThickness": 0.4 }, { "filamentId": "plvjtmc", - "startHeight": 1.04, - "endHeight": 1.12, + "startHeight": 1.68, + "endHeight": 1.76, "idealThickness": 0.08, "actualThickness": 0.08 }, { - "filamentId": "98z555k", - "startHeight": 1.12, - "endHeight": 1.36, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.24 + "filamentId": "xyyxysq", + "startHeight": 1.76, + "endHeight": 2.08, + "idealThickness": 0.357, + "actualThickness": 0.32 }, { - "filamentId": "upcjpfe", - "startHeight": 1.36, - "endHeight": 1.68, - "idealThickness": 0.343, - "actualThickness": 0.32 + "filamentId": "plvjtmc", + "startHeight": 2.08, + "endHeight": 2.16, + "idealThickness": 0.08, + "actualThickness": 0.08 }, { - "filamentId": "vsn9q6u", - "startHeight": 1.68, - "endHeight": 2, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.32 + "filamentId": "p9c63ms", + "startHeight": 2.16, + "endHeight": 2.64, + "idealThickness": 0.42, + "actualThickness": 0.48 }, { "filamentId": "xyyxysq", - "startHeight": 2, - "endHeight": 2.32, + "startHeight": 2.64, + "endHeight": 2.96, "idealThickness": 0.357, "actualThickness": 0.32 }, { - "filamentId": "vsn9q6u", - "startHeight": 2.32, - "endHeight": 2.56, - "idealThickness": 0.26599999999999996, - "actualThickness": 0.24 + "filamentId": "azwg1yp", + "startHeight": 2.96, + "endHeight": 3.12, + "idealThickness": 0.16, + "actualThickness": 0.16 }, { - "filamentId": "p9c63ms", - "startHeight": 2.56, - "endHeight": 3.04, - "idealThickness": 0.42000000000000004, + "filamentId": "w8cncoa", + "startHeight": 3.12, + "endHeight": 3.6, + "idealThickness": 0.44099999999999995, "actualThickness": 0.48 }, { - "filamentId": "98z555k", - "startHeight": 3.04, - "endHeight": 3.28, - "idealThickness": 0.27299999999999996, - "actualThickness": 0.24 - }, - { - "filamentId": "upcjpfe", - "startHeight": 3.28, - "endHeight": 3.6, - "idealThickness": 0.343, - "actualThickness": 0.32 + "filamentId": "plvjtmc", + "startHeight": 3.6, + "endHeight": 3.68, + "idealThickness": 0.08, + "actualThickness": 0.08 } ], - "totalHeight": 3.6, + "totalHeight": 3.68, "compressionRatio": 1 } } diff --git a/tests/assets/autopaint-calibrated-baseline.json b/tests/assets/autopaint-calibrated-baseline.json new file mode 100644 index 0000000..965e636 --- /dev/null +++ b/tests/assets/autopaint-calibrated-baseline.json @@ -0,0 +1,123 @@ +{ + "standard": { + "totalHeight": 2.4, + "idealHeight": 2.3681866826143154, + "filamentOrder": [ + "plvjtmc", + "azwg1yp", + "vsn9q6u", + "upcjpfe", + "98z555k", + "xyyxysq", + "w8cncoa", + "p9c63ms" + ], + "layers": [ + { + "filamentId": "plvjtmc", + "filamentColor": "#000000", + "startHeight": 0, + "endHeight": 0.16 + }, + { + "filamentId": "azwg1yp", + "filamentColor": "#6300c5", + "startHeight": 0.16, + "endHeight": 0.48 + }, + { + "filamentId": "vsn9q6u", + "filamentColor": "#d83400", + "startHeight": 0.48, + "endHeight": 0.88 + }, + { + "filamentId": "upcjpfe", + "filamentColor": "#04bb00", + "startHeight": 0.88, + "endHeight": 1.12 + }, + { + "filamentId": "98z555k", + "filamentColor": "#ff5ec4", + "startHeight": 1.12, + "endHeight": 1.52 + }, + { + "filamentId": "xyyxysq", + "filamentColor": "#00b8c4", + "startHeight": 1.52, + "endHeight": 1.76 + }, + { + "filamentId": "w8cncoa", + "filamentColor": "#f7d000", + "startHeight": 1.76, + "endHeight": 2.08 + }, + { + "filamentId": "p9c63ms", + "filamentColor": "#ffffff", + "startHeight": 2.08, + "endHeight": 2.4 + } + ] + }, + "enhancedFast": { + "totalHeight": 2.08, + "idealHeight": 2.01150600769796, + "filamentOrder": [ + "plvjtmc", + "p9c63ms", + "vsn9q6u", + "w8cncoa", + "98z555k", + "xyyxysq", + "azwg1yp" + ], + "layers": [ + { + "filamentId": "plvjtmc", + "filamentColor": "#000000", + "startHeight": 0, + "endHeight": 0.16 + }, + { + "filamentId": "p9c63ms", + "filamentColor": "#ffffff", + "startHeight": 0.16, + "endHeight": 0.56 + }, + { + "filamentId": "vsn9q6u", + "filamentColor": "#d83400", + "startHeight": 0.56, + "endHeight": 0.88 + }, + { + "filamentId": "w8cncoa", + "filamentColor": "#f7d000", + "startHeight": 0.88, + "endHeight": 1.2 + }, + { + "filamentId": "98z555k", + "filamentColor": "#ff5ec4", + "startHeight": 1.2, + "endHeight": 1.52 + }, + { + "filamentId": "xyyxysq", + "filamentColor": "#00b8c4", + "startHeight": 1.52, + "endHeight": 1.84 + }, + { + "filamentId": "azwg1yp", + "filamentColor": "#6300c5", + "startHeight": 1.84, + "endHeight": 2.08 + } + ] + } +} \ No newline at end of file diff --git a/tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil b/tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil new file mode 100644 index 0000000..61f5083 --- /dev/null +++ b/tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil @@ -0,0 +1,287 @@ +{ + "id": "c2e9e401-d1bf-49c8-9166-1bdc487ffd64", + "name": "8 Colors Calibrated New", + "version": 1, + "filaments": [ + { + "id": "p9c63ms", + "color": "#ffffff", + "td": 0.5077096875055095, + "calibration": { + "opacityLayers": 7, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.4908894086367234, + 0.5077096875055095, + 0.49943726291595697 + ], + "tdSingleValue": 0.5077096875055095, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.9587611519194583, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.797Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 7, + "mergeLayers": 5 + }, + { + "baseColor": "#00b8c4", + "opacityLayers": 7, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default" + } + }, + { + "id": "plvjtmc", + "color": "#000000", + "td": 0.05, + "calibration": { + "opacityLayers": 1, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.05, + 0.05, + 0.05 + ], + "tdSingleValue": 0.05, + "jnd": 2, + "baseColor": "#6300c5", + "confidence": 0.467990454631796, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.806Z", + "reads": [ + { + "baseColor": "#6300c5", + "opacityLayers": 1 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 1 + } + ], + "channelSource": "measured", + "jndSource": "default" + } + }, + { + "id": "98z555k", + "color": "#ff5ec4", + "td": 0.519286177194662, + "calibration": { + "opacityLayers": 7, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.519286177194662, + 0.23963829238245016, + 0.41680651754298187 + ], + "tdSingleValue": 0.519286177194662, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.96, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.819Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 7, + "mergeLayers": 5 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 5, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default" + } + }, + { + "id": "w8cncoa", + "color": "#f7d000", + "td": 0.44542466216175114, + "calibration": { + "opacityLayers": 6, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.44542466216175114, + 0.3595734312582509, + 0.0673046271932013 + ], + "tdSingleValue": 0.44542466216175114, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.7999999999999999, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.838Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 6, + "mergeLayers": 5 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 4, + "mergeLayers": 3 + } + ], + "channelSource": "measured", + "jndSource": "default" + } + }, + { + "id": "vsn9q6u", + "color": "#d83400", + "td": 0.5646261641279794, + "calibration": { + "opacityLayers": 8, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.5646261641279794, + 0.13741866688897755, + 0.10291354194296926 + ], + "tdSingleValue": 0.5646261641279794, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.9557348503300864, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.855Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 8, + "mergeLayers": 5 + }, + { + "baseColor": "#f7d000", + "opacityLayers": 4, + "mergeLayers": 3 + } + ], + "channelSource": "measured", + "jndSource": "default" + } + }, + { + "id": "azwg1yp", + "color": "#6300c5", + "td": 0.3743629711219067, + "calibration": { + "opacityLayers": 4, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.22211060078297443, + 0.06830463482833873, + 0.3743629711219067 + ], + "tdSingleValue": 0.3743629711219067, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.7999999999999999, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.868Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 4, + "mergeLayers": 3 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 5, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default" + } + }, + { + "id": "xyyxysq", + "color": "#00b8c4", + "td": 0.3725880555637347, + "calibration": { + "opacityLayers": 5, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.06826408547833909, + 0.353955975762588, + 0.3725880555637347 + ], + "tdSingleValue": 0.3725880555637347, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.7999999999999999, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.880Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 5, + "mergeLayers": 4 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 5, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default" + } + }, + { + "id": "upcjpfe", + "color": "#04bb00", + "td": 0.37055468605919367, + "calibration": { + "opacityLayers": 5, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.1553450909824613, + 0.37055468605919367, + 0.07053705952903433 + ], + "tdSingleValue": 0.37055468605919367, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.7946766727762713, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.898Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 5, + "mergeLayers": 4 + }, + { + "baseColor": "#f7d000", + "opacityLayers": 4, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default" + } + } + ], + "createdAt": 1783004932247, + "updatedAt": 1783004932247 +} \ No newline at end of file diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts index 5130694..0a52dac 100644 --- a/tests/autoPaint.test.ts +++ b/tests/autoPaint.test.ts @@ -187,7 +187,7 @@ test('calibrated per-channel TDs tint blends without changing scalar TD behavior ); }); -test('legacy photo calibrations are ignored and use the frontlit fallback scale', async () => { +test('legacy photo calibrations are ignored and blend like uncalibrated filaments', async () => { const { generateAutoLayers } = await loadAutoPaintModule(); const filaments = [ { id: 'black', color: '#000000', td: 1 }, @@ -512,8 +512,10 @@ test('auto-paint caps and optimizer palettes use the same discrete printable sta 'an impossible cap must not be rounded up past the requested maximum' ); + // tds are hiding distances consumed as-is; the optimizer and the preview + // now share the exact same filament inputs with no internal scaling. const palette = buildAchievableColorPalette( - filaments.map((filament) => ({ ...filament, td: filament.td * 0.1 })), + filaments, layerHeight, firstLayerHeight, maxHeight @@ -612,3 +614,51 @@ test('auto-paint slice data never returns more than 500 layers', async () => { assert.ok(slices.colorSliceHeights.length <= 500); assert.equal(slices.colorSliceHeights.length, 500); }); + +test('calibrated profiles produce identical output across the hiding-distance migration', async () => { + // Baseline captured immediately before the schema-v2 (hiding distance) + // migration with the same inputs: the calibrated path must be unaffected + // by removing the runtime frontlit scaling. + const { generateAutoLayers } = await loadAutoPaintModule(); + const { readFileSync } = await import('node:fs'); + const profile = JSON.parse( + readFileSync( + resolve(process.cwd(), 'tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil'), + 'utf8' + ) + ) as { filaments: Array<{ id: string; color: string; td: number }> }; + const baseline = JSON.parse( + readFileSync(resolve(process.cwd(), 'tests/assets/autopaint-calibrated-baseline.json'), 'utf8') + ); + const swatches = [ + { hex: '#000000', count: 120 }, + { hex: '#ffffff', count: 80 }, + { hex: '#d83400', count: 60 }, + { hex: '#00b8c4', count: 50 }, + { hex: '#6300c5', count: 40 }, + { hex: '#f7d000', count: 30 }, + ]; + const normalize = (result: { + totalHeight: number; + idealHeight: number; + filamentOrder: string[]; + layers: unknown[]; + }) => + JSON.parse( + JSON.stringify({ + totalHeight: result.totalHeight, + idealHeight: result.idealHeight, + filamentOrder: result.filamentOrder, + layers: result.layers, + }) + ); + + const standard = generateAutoLayers(profile.filaments, swatches, 0.08, 0.16, undefined, false); + assert.deepEqual(normalize(standard), baseline.standard); + + const enhanced = generateAutoLayers(profile.filaments, swatches, 0.08, 0.16, undefined, true, false, { + algorithm: 'fast', + seed: 42, + }); + assert.deepEqual(normalize(enhanced), baseline.enhancedFast); +}); diff --git a/tests/autoPaintGoldenFixtures.ts b/tests/autoPaintGoldenFixtures.ts index 371c331..4fcf653 100644 --- a/tests/autoPaintGoldenFixtures.ts +++ b/tests/autoPaintGoldenFixtures.ts @@ -1,6 +1,10 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { + CURRENT_PROFILE_VERSION, + migrateLegacyFilamentTd, +} from '../src/lib/profileManager.ts'; import { colorSwatchesFromJpegBlocks, logoFixturePath, @@ -34,19 +38,27 @@ 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; + const parsed = JSON.parse(readFileSync(profilePath, 'utf8')) as Partial & { + version?: number; + }; if (!parsed.name || !parsed.filaments || parsed.filaments.length !== expectedFilamentCount) { throw new Error(`Unexpected auto-paint profile fixture: ${fileName}`); } + // Fixture .kapp files are authentic schema-v1 exports; run them through the + // real td migration so scenarios use hiding distances like live profiles. + const version = typeof parsed.version === 'number' ? parsed.version : 1; return { name: parsed.name, - filaments: parsed.filaments.map((filament) => ({ - id: filament.id, - color: filament.color, - td: filament.td, - })), + filaments: parsed.filaments.map((filament) => { + const shaped = { + id: filament.id, + color: filament.color, + td: filament.td, + }; + return version < CURRENT_PROFILE_VERSION ? migrateLegacyFilamentTd(shaped) : shaped; + }), }; } diff --git a/tests/export3mf.test.ts b/tests/export3mf.test.ts index df2c1c6..0a24f02 100644 --- a/tests/export3mf.test.ts +++ b/tests/export3mf.test.ts @@ -115,9 +115,12 @@ function loadFilamentProfileFixture( ): FilamentProfileFixture { const raw = JSON.parse( readFileSync(resolve(filamentProfilesRoot, fileName), 'utf8') - ) as Partial; + ) as Partial & { version?: number }; const name = raw.name; const filaments = raw.filaments; + // .kapp fixtures are schema-v1 exports: uncalibrated tds are on the legacy + // backlit scale and convert to hiding distances (×0.1) like live imports. + const legacyTdScale = (raw.version ?? 1) < 2 ? 0.1 : 1; if (typeof name !== 'string') { assert.fail(`${fileName} should include a profile name`); @@ -141,7 +144,7 @@ function loadFilamentProfileFixture( return { id: filament.id, color: normalizeFixtureHex(filament.color, `${fileName} filament ${index}`), - td: filament.td, + td: filament.td * legacyTdScale, }; }), }; diff --git a/tests/hidingDistance.test.ts b/tests/hidingDistance.test.ts new file mode 100644 index 0000000..b23eca8 --- /dev/null +++ b/tests/hidingDistance.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { createServer } from 'vite'; + +import type { Filament } from '../src/types/index.ts'; + +type ColorUtilsModule = typeof import('../src/lib/colorUtils.ts'); +type CalibrationModule = typeof import('../src/lib/calibration.ts'); +type ProfileManagerModule = typeof import('../src/lib/profileManager.ts'); + +let colorUtilsModule: Promise | null = null; +let calibrationModule: Promise | null = null; +let profileManagerModule: Promise | null = null; + +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(); + } +} + +const loadColorUtils = () => + (colorUtilsModule ??= loadViteModule('/src/lib/colorUtils.ts')); +const loadCalibration = () => + (calibrationModule ??= loadViteModule('/src/lib/calibration.ts')); +const loadProfileManager = () => + (profileManagerModule ??= loadViteModule('/src/lib/profileManager.ts')); + +test('estimateHidingDistanceFromColor returns frontlit-scale values', async () => { + const { estimateHidingDistanceFromColor } = await loadColorUtils(); + // Legacy backlit-TD shape × FRONTLIT_TD_SCALE, rounded to 2 decimals. + assert.equal(estimateHidingDistanceFromColor('#ffffff'), 0.71); + assert.equal(estimateHidingDistanceFromColor('#000000'), 0.08); + + for (const hex of ['#ff0000', '#00b8c4', '#808080', '#f7d000']) { + const hd = estimateHidingDistanceFromColor(hex); + assert.ok(hd >= 0.06 && hd <= 0.85, `${hex}: ${hd} out of the HD range`); + } +}); + +test('channelHds returns measured channels for calibrated filaments', async () => { + const { channelHds } = await loadCalibration(); + const filament: Filament = { + id: 'cal', + color: '#00b8c4', + td: 0.37, + calibration: { + opacityLayers: 5, + layerHeight: 0.08, + firstLayerHeight: 0.16, + td: [0.068, 0.354, 0.373], + tdSingleValue: 0.373, + jnd: 2, + baseColor: '#000000', + confidence: 0.8, + basis: 'frontlit', + calibrationDate: '2026-07-02T00:00:00.000Z', + }, + }; + assert.deepEqual(channelHds(filament), [0.068, 0.354, 0.373]); +}); + +test('channelHds derives color-shaped channels for uncalibrated filaments', async () => { + const { channelHds, deriveChannelTds } = await loadCalibration(); + const filament: Filament = { id: 'uncal', color: '#ff0000', td: 0.4 }; + const channels = channelHds(filament); + assert.deepEqual(channels, deriveChannelTds('#ff0000', 0.4)); + // Brightest channel anchors at the scalar; dark channels shrink. + assert.ok(Math.abs(channels[0] - 0.4) < 1e-9, 'red channel anchors the scalar HD'); + assert.ok(channels[1] < channels[0] && channels[2] < channels[0]); +}); + +test('channelHds ignores invalid calibration shapes and falls back to derivation', async () => { + const { channelHds, deriveChannelTds } = await loadCalibration(); + const filament = { + id: 'legacy', + color: '#ff0000', + td: 0.4, + calibration: { measurements: [], td: [2, 3, 4], tdSingleValue: 3 }, + } as unknown as Filament; + assert.deepEqual(channelHds(filament), deriveChannelTds('#ff0000', 0.4)); +}); + +test('migrateLegacyFilamentTd scales uncalibrated tds and re-syncs calibrated ones', async () => { + const { migrateLegacyFilamentTd } = await loadProfileManager(); + const { FRONTLIT_TD_SCALE } = await loadCalibration(); + const uncalibrated: Filament = { id: 'a', color: '#ffffff', td: 4.0 }; + assert.ok(Math.abs(migrateLegacyFilamentTd(uncalibrated).td - 4.0 * FRONTLIT_TD_SCALE) < 1e-12); + + const calibrated: Filament = { + id: 'b', + color: '#ffffff', + td: 0.51, + calibration: { + opacityLayers: 7, + layerHeight: 0.08, + firstLayerHeight: 0.16, + td: [0.49, 0.51, 0.5], + tdSingleValue: 0.51, + jnd: 2, + baseColor: '#000000', + confidence: 0.9, + basis: 'frontlit', + calibrationDate: '2026-07-02T00:00:00.000Z', + }, + }; + assert.equal(migrateLegacyFilamentTd(calibrated).td, 0.51); +}); diff --git a/tests/nextBestColor.test.ts b/tests/nextBestColor.test.ts index 580d26d..aea8369 100644 --- a/tests/nextBestColor.test.ts +++ b/tests/nextBestColor.test.ts @@ -67,10 +67,11 @@ test('identifies the most impactful missing color', () => { }); test('blend-aware: far candidate wins when its segment covers the common color territory', () => { - // With only BLACK and an achromatic image, near-white creates a segment - // (#eeeeee↔BLACK) that spans the full L-axis, passing through #888888's Lab - // position — so adding it captures those pixels via blending. Blend-aware - // scoring correctly prefers the longer segment even at count=1. + // With only BLACK and an achromatic image, a lighter-grey candidate creates + // a segment toward BLACK that passes through #888888's Lab position — so + // adding it captures the common mid-grey pixels via blending while also + // reaching toward the rare near-white. Under linear-light blending the + // extrapolated light grey wins over picking #888888 directly. const r = nextBestColor( [BLACK], [ @@ -79,7 +80,9 @@ test('blend-aware: far candidate wins when its segment covers the common color t ] ); assert.ok(r.candidate !== null); - assert.equal(r.candidate.hex, '#888888'); + assert.equal(r.candidate.hex, '#c9c9c9'); + // The winner's blend segment must capture the common mid-grey pixels. + assert.ok(r.candidate.pixelsCaptured >= 1000); }); test('pixel count weighting: common color beats rare one when blend segments diverge', () => { @@ -314,3 +317,43 @@ test('baselineAvgDeltaE is 0 when all swatches exactly match filaments', () => { const r = nextBestColor([RED], [{ hex: '#ff0000', count: 50 }]); assert.ok(r.baselineAvgDeltaE < 1, `expected near 0, got ${r.baselineAvgDeltaE}`); }); + +// --------------------------------------------------------------------------- +// Hiding-distance semantics + linear-light blending +// --------------------------------------------------------------------------- + +test('blendRgb composites in linear light like blendSrgbChannel', async () => { + const { blendRgb } = await import('../src/lib/nextBestColor.ts'); + // 50% transmission of white background through a black layer: the + // linear-light midpoint re-encodes to ~188 sRGB, not the gamma midpoint 128. + const halfTransmissionThickness = Math.log10(2); // t = 10^(-th/td) = 0.5 at td=1 + const blended = blendRgb( + { r: 255, g: 255, b: 255 }, + { r: 0, g: 0, b: 0 }, + 1, + halfTransmissionThickness + ); + assert.ok(Math.abs(blended.r - 188) < 1.5, `expected ~188, got ${blended.r}`); + assert.ok(Math.abs(blended.g - blended.r) < 1e-9 && Math.abs(blended.b - blended.r) < 1e-9); +}); + +test('recommended hiding distance is borrowed on the stored (frontlit) scale', () => { + // Calibrated-profile-like HD values (~0.3–0.6 mm). The suggestion must come + // back on the same scale so the added filament simulates correctly. + const filaments: Filament[] = [ + { id: 'black', color: '#000000', td: 0.05 }, + { id: 'white', color: '#ffffff', td: 0.51 }, + { id: 'red', color: '#d83400', td: 0.56 }, + ]; + const r = nextBestColor(filaments, [ + { hex: '#000000', count: 100 }, + { hex: '#00b8c4', count: 400 }, + ]); + assert.ok(r.candidate !== null, 'expected a candidate'); + const tds = filaments.map((f) => f.td); + assert.ok( + tds.includes(r.candidate!.td), + `candidate td ${r.candidate!.td} must be borrowed from an existing filament` + ); + assert.ok(r.candidate!.td <= 2, 'td must be on the hiding-distance scale'); +}); diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index 81e5e26..474f602 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -152,3 +152,141 @@ test('stored auto-paint profiles strip legacy photo calibration objects on load' if (!existingStorage) Reflect.deleteProperty(globalThis, 'localStorage'); } }); + +test('v1 profile imports migrate uncalibrated tds to hiding distances', async () => { + const { importProfiles, CURRENT_PROFILE_VERSION } = await loadProfileManager(); + const incoming = [ + { + id: 'profile-v1', + name: 'Legacy Scale Profile', + version: 1, + createdAt: 1, + updatedAt: 1, + filaments: [ + { id: 'f-uncal', color: '#ffffff', td: 4.0 }, + ], + }, + ] as unknown as AutoPaintProfile[]; + + const result = importProfiles([], incoming); + assert.equal(result.imported.length, 1); + assert.equal(result.imported[0].version, CURRENT_PROFILE_VERSION); + assert.ok(Math.abs(result.imported[0].filaments[0].td - 0.4) < 1e-12); +}); + +test('v1 profile imports re-sync calibrated tds to the measured scalar', async () => { + const { importProfiles } = await loadProfileManager(); + const calibration = { + opacityLayers: 6, + layerHeight: 0.08, + firstLayerHeight: 0.16, + td: [0.4, 0.45, 0.5], + tdSingleValue: 0.5, + jnd: 2, + baseColor: '#000000', + confidence: 0.9, + basis: 'frontlit', + calibrationDate: '2026-07-01T00:00:00.000Z', + }; + const incoming = [ + { + id: 'profile-v1-cal', + name: 'Legacy Calibrated Profile', + version: 1, + createdAt: 1, + updatedAt: 1, + filaments: [{ id: 'f-cal', color: '#ffffff', td: 0.5, calibration }], + }, + ] as unknown as AutoPaintProfile[]; + + const result = importProfiles([], incoming); + assert.equal(result.imported.length, 1); + // Calibrated filaments never get the ×0.1 conversion; td stays the measured scalar. + assert.equal(result.imported[0].filaments[0].td, 0.5); + assert.ok(result.imported[0].filaments[0].calibration); +}); + +test('v2 profile imports are not re-scaled (double-import idempotence)', async () => { + const { importProfiles, exportProfileBlob, parseProfileFile, CURRENT_PROFILE_VERSION } = + await loadProfileManager(); + const incoming = [ + { + id: 'profile-v2', + name: 'HD Profile', + version: CURRENT_PROFILE_VERSION, + createdAt: 1, + updatedAt: 1, + filaments: [{ id: 'f-hd', color: '#ffffff', td: 0.4 }], + }, + ] as unknown as AutoPaintProfile[]; + + const first = importProfiles([], incoming); + assert.equal(first.imported[0].filaments[0].td, 0.4); + + // Export → parse → re-import must be a no-op on td values. + const blob = exportProfileBlob(first.imported[0]); + const json = await blob.text(); + const reparsed = parseProfileFile(json); + assert.ok(reparsed); + const second = importProfiles([], reparsed!); + // Same id → overwrite path; td unchanged. + assert.equal(second.profiles.length, 1); + const stored = second.profiles.find((p) => p.id === 'profile-v2'); + assert.ok(stored); + assert.equal(stored!.filaments[0].td, 0.4); +}); + +test('stored v1 profiles migrate tds once on load', async () => { + const { loadProfiles, CURRENT_PROFILE_VERSION } = await loadProfileManager(); + const existingStorage = Reflect.get(globalThis, 'localStorage') as + | ReturnType + | undefined; + const storage = existingStorage ?? createMemoryStorage(); + if (!existingStorage) { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }); + } + + const previousProfiles = storage.getItem('kromacut.autopaint.profiles'); + storage.setItem( + 'kromacut.autopaint.profiles', + JSON.stringify([ + { + id: 'stored-v1', + name: 'Stored Legacy Scale', + version: 1, + createdAt: 1, + updatedAt: 1, + filaments: [{ id: 'f1', color: '#f7d000', td: 6.3 }], + }, + { + id: 'stored-v2', + name: 'Stored HD', + version: CURRENT_PROFILE_VERSION, + createdAt: 1, + updatedAt: 1, + filaments: [{ id: 'f2', color: '#f7d000', td: 0.63 }], + }, + ]) + ); + + try { + const loaded = loadProfiles(); + assert.equal(loaded.length, 2); + const v1 = loaded.find((p) => p.id === 'stored-v1'); + const v2 = loaded.find((p) => p.id === 'stored-v2'); + assert.ok(v1 && v2); + assert.ok(Math.abs(v1!.filaments[0].td - 0.63) < 1e-12, 'v1 td migrates ×0.1'); + assert.equal(v2!.filaments[0].td, 0.63, 'v2 td untouched'); + assert.equal(v1!.version, CURRENT_PROFILE_VERSION); + } finally { + if (previousProfiles === null) { + storage.removeItem('kromacut.autopaint.profiles'); + } else { + storage.setItem('kromacut.autopaint.profiles', previousProfiles); + } + if (!existingStorage) Reflect.deleteProperty(globalThis, 'localStorage'); + } +}); From 5aa9c780f870a71e7602b460a856b35ee616b1d3 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 4 Jul 2026 09:47:42 +0300 Subject: [PATCH 43/81] Remove the per-row calibrate button The flask button on each filament row duplicated the Calibrate Filaments button below the list. Drop it along with the now-unused initialFilamentId preselect plumbing in AutoPaintTab and the calibration dialog; the dialog always opens on an empty selection. --- src/components/AutoPaintTab.tsx | 8 +------- src/components/FilamentCalibrationDialog.tsx | 16 +++------------- src/components/FilamentRow.tsx | 16 +--------------- src/docs/3d-mode.md | 2 +- 4 files changed, 6 insertions(+), 36 deletions(-) diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index d5ad631..49d032f 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -222,18 +222,14 @@ export default function AutoPaintTab({ ); // Calibration dialog state - const [calibratingFilamentId, setCalibratingFilamentId] = React.useState(null); const [calibrationDialogOpen, setCalibrationDialogOpen] = React.useState(false); - const handleOpenCalibration = React.useCallback((id?: string) => { - setCalibratingFilamentId(id ?? null); + const handleOpenCalibration = React.useCallback(() => { setCalibrationDialogOpen(true); }, []); const handleCloseCalibration = React.useCallback(() => { setCalibrationDialogOpen(false); - // Clear the ID after a short delay to avoid visible state change - setTimeout(() => setCalibratingFilamentId(null), 300); }, []); const handleApplyCalibration = React.useCallback( @@ -469,7 +465,6 @@ export default function AutoPaintTab({ filament={f} onUpdate={updateFilament} onRemove={removeFilament} - onCalibrate={handleOpenCalibration} /> ))}
    @@ -1232,7 +1227,6 @@ export default function AutoPaintTab({ open={calibrationDialogOpen} onClose={handleCloseCalibration} filaments={filaments} - initialFilamentId={calibratingFilamentId} layerHeight={calibrationLayerHeight} firstLayerHeight={firstLayerHeight} onApply={handleApplyCalibration} diff --git a/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx index cdacec1..df4a492 100644 --- a/src/components/FilamentCalibrationDialog.tsx +++ b/src/components/FilamentCalibrationDialog.tsx @@ -69,7 +69,6 @@ interface FilamentCalibrationDialogProps { open: boolean; onClose: () => void; filaments: Filament[]; - initialFilamentId?: string | null; layerHeight: number; firstLayerHeight: number; onApply: (updates: CalibrationApplyUpdate[]) => void; @@ -150,21 +149,12 @@ export function FilamentCalibrationDialog({ open, onClose, filaments, - initialFilamentId, layerHeight, firstLayerHeight, onApply, }: FilamentCalibrationDialogProps) { - const initialSelection = useCallback(() => { - const id = - initialFilamentId && filaments.some((filament) => filament.id === initialFilamentId) - ? initialFilamentId - : null; - return new Set(id ? [id] : []); - }, [filaments, initialFilamentId]); - const [step, setStep] = useState('select'); - const [selectedIds, setSelectedIds] = useState>(() => initialSelection()); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [maxLayers, setMaxLayers] = useState(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers); const [calibrationLayerHeight, setCalibrationLayerHeight] = useState(layerHeight); // Free-typing drafts for the numeric inputs; clamped to the committed value on blur. @@ -367,7 +357,7 @@ export function FilamentCalibrationDialog({ const reset = useCallback(() => { setStep('select'); - setSelectedIds(initialSelection()); + setSelectedIds(new Set()); setMaxLayers(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers); setMaxLayersDraft(String(DEFAULT_CALIBRATION_PRINT_OPTIONS.maxLayers)); setCalibrationLayerHeight(layerHeight); @@ -378,7 +368,7 @@ export function FilamentCalibrationDialog({ setMergeReads({}); setIsSaving(false); setBaseChoices({}); - }, [initialSelection, layerHeight]); + }, [layerHeight]); useEffect(() => { if (open && !wasOpenRef.current) reset(); diff --git a/src/components/FilamentRow.tsx b/src/components/FilamentRow.tsx index d0ccc63..5855ce2 100644 --- a/src/components/FilamentRow.tsx +++ b/src/components/FilamentRow.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Trash2, Wand2, BadgeCheck, FlaskConical, ArrowRightLeft } from 'lucide-react'; +import { Trash2, Wand2, BadgeCheck, ArrowRightLeft } from 'lucide-react'; import { HexColorPicker } from 'react-colorful'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { Filament } from '../types'; @@ -22,14 +22,12 @@ interface FilamentRowProps { filament: Filament; onUpdate: (id: string, updates: Partial>) => void; onRemove: (id: string) => void; - onCalibrate?: (id: string) => void; } const FilamentRow = React.memo(function FilamentRow({ filament, onUpdate, onRemove, - onCalibrate, }: FilamentRowProps) { // Local state for the input value to allow free typing const [localTd, setLocalTd] = useState(filament.td.toString()); @@ -253,18 +251,6 @@ const FilamentRow = React.memo(function FilamentRow({ - {onCalibrate && ( - - )} - {/* Calibration Badge */} {isCalibrated && (
    Date: Sat, 4 Jul 2026 10:03:53 +0300 Subject: [PATCH 44/81] Polish the auto-paint filament list UI - Filament rows reveal their secondary actions (convert-from-TD, wand estimate) on hover/focus instead of showing two icon buttons per row; open popovers keep their trigger visible. - Every row gets a fixed-width calibration status chip: the colored confidence label when calibrated, a dashed "Estimate" chip with a per-channel tooltip when not, so mixed profiles stay column-aligned. - Add Filament and Calibrate Filaments share one line. - The profile action strip keeps a stable width (delete is always rendered, disabled without an active profile) and gains subtle group separators. --- src/components/AutoPaintTab.tsx | 61 ++++++++++++++++++--------------- src/components/FilamentRow.tsx | 28 ++++++++++----- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 49d032f..a37ed89 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -399,6 +399,8 @@ export default function AutoPaintTab({ +
    + {/* Import */} - {/* Delete */} - {activeProfileId && ( - - )} +
    + + {/* Delete — always rendered so the strip width stays stable */} +
    {/* Import feedback */} @@ -469,27 +472,29 @@ export default function AutoPaintTab({ ))}
    )} - - - {filaments.length > 0 && ( +
    0 ? 'grid grid-cols-2 gap-2' : ''}> - )} + + {filaments.length > 0 && ( + + )} +
    {/* Max Height Constraint */} {filaments.length > 0 && ( diff --git a/src/components/FilamentRow.tsx b/src/components/FilamentRow.tsx index 5855ce2..a004759 100644 --- a/src/components/FilamentRow.tsx +++ b/src/components/FilamentRow.tsx @@ -135,8 +135,13 @@ const FilamentRow = React.memo(function FilamentRow({ } }; + // Secondary actions stay hidden until the row is hovered or focused; an + // open popover keeps its trigger visible via the radix data-state attribute. + const revealOnHover = + 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100 transition-opacity'; + return ( -
    +
    {/* Color Picker Popover */} @@ -201,7 +206,7 @@ const FilamentRow = React.memo(function FilamentRow({ - {/* Calibration Badge */} - {isCalibrated && ( + {/* Calibration status — fixed-width slot so rows stay aligned */} + {isCalibrated ? (
    - - {confidenceLabel} + + {confidenceLabel} +
    + ) : ( +
    + Estimate
    )} From c8bb0ae18543101c61ae70b5a9b1b72d312da381 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 4 Jul 2026 10:06:00 +0300 Subject: [PATCH 45/81] Keep filament row action buttons always visible The hover-reveal made the convert and wand buttons hard to discover; show them permanently, keeping the aligned status chips. --- src/components/FilamentRow.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/FilamentRow.tsx b/src/components/FilamentRow.tsx index a004759..f685683 100644 --- a/src/components/FilamentRow.tsx +++ b/src/components/FilamentRow.tsx @@ -135,13 +135,8 @@ const FilamentRow = React.memo(function FilamentRow({ } }; - // Secondary actions stay hidden until the row is hovered or focused; an - // open popover keeps its trigger visible via the radix data-state attribute. - const revealOnHover = - 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100 transition-opacity'; - return ( -
    +
    {/* Color Picker Popover */} @@ -206,7 +201,7 @@ const FilamentRow = React.memo(function FilamentRow({
    -
    + + {/* Proportional stack bar: left = build plate, right = top */} +
    +
    + {autoPaintResult.transitionZones.map( + (zone: TransitionZone, idx: number) => ( +
    + ) + )} +
    +
    + 0 mm (plate) + {autoPaintResult.totalHeight.toFixed(2)} mm (top) +
    +
    + +
    {autoPaintResult.transitionZones.map( (zone: TransitionZone, idx: number) => { const isCompressed = @@ -941,46 +984,38 @@ export default function AutoPaintTab({ return (
    -
    -
    - - {zone.filamentColor} - - {isCompressed && ( - - compressed - - )} -
    -
    - {zone.startHeight.toFixed(2)}mm →{' '} - {zone.endHeight.toFixed(2)}mm - - (Δ - {zone.actualThickness.toFixed(2)} - mm) - - {isCompressed && ( - - ideal:{' '} - {zone.idealThickness.toFixed(2)} - mm - - )} -
    -
    + + {zone.filamentColor} + + {isCompressed && ( + + compressed + + )} + + {zone.startHeight.toFixed(2)} →{' '} + {zone.endHeight.toFixed(2)} mm + + + Δ{zone.actualThickness.toFixed(2)} +
    ); } @@ -1022,41 +1057,18 @@ export default function AutoPaintTab({
    -
    -
    Calibration
    -
    - {( - autoPaintResult.confidenceFactors.calibrationQuality * - 100 - ).toFixed(0)} - % -
    -
    -
    -
    Coverage
    -
    - {( - autoPaintResult.confidenceFactors.filamentCoverage * 100 - ).toFixed(0)} - % -
    -
    -
    -
    Compression
    -
    - {( - autoPaintResult.confidenceFactors.compressionImpact * - 100 - ).toFixed(0)} - % -
    -
    + + +
    {autoPaintResult.confidence < 0.7 && (

    @@ -1072,6 +1084,20 @@ export default function AutoPaintTab({ Optimizer Performance + + {autoPaintResult.optimizerMetadata.cacheHit && ( + + + Cache hit + + )} + {autoPaintResult.optimizerMetadata.converged && ( + + + Converged + + )} +

    @@ -1102,20 +1128,6 @@ export default function AutoPaintTab({
    -
    - {autoPaintResult.optimizerMetadata.cacheHit && ( - - - Cache hit - - )} - {autoPaintResult.optimizerMetadata.converged && ( - - - Converged - - )} -
    )}
    From 0290459ad82500f4aeed2fb69579b15a74d6c6c5 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 4 Jul 2026 10:16:15 +0300 Subject: [PATCH 47/81] Drop decorative icons from the auto-paint results section Remove the sparkles/badge icons from the Transition Zones, Result Confidence, and Optimizer Performance headers, the lightbulb emoji from the calibration tip, and the sparkles from the Suggest next filament button (the loading spinner stays). Cache-hit/Converged colored dots become quiet bordered text chips, and the overall Result Confidence gains a progress bar matching the per-factor tiles. --- src/components/AutoPaintTab.tsx | 35 +++++++++++++++------------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 14cd498..a0dcf98 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -5,13 +5,11 @@ import { Button } from '@/components/ui/button'; import { Plus, Trash2, - Sparkles, Save, Download, Upload, FilePlus, Pencil, - BadgeCheck, Loader2, FlaskConical, } from 'lucide-react'; @@ -931,7 +929,6 @@ export default function AutoPaintTab({
    - Transition Zones @@ -1043,12 +1040,7 @@ export default function AutoPaintTab({ {autoPaintResult && (
    -
    - - Result Confidence -
    + Result Confidence @@ -1056,6 +1048,16 @@ export default function AutoPaintTab({ {(autoPaintResult.confidence * 100).toFixed(0)}%)
    +
    +
    +
    +
    +
    {autoPaintResult.confidence < 0.7 && (

    - 💡 Tip: Calibrate your filaments for better accuracy + Tip: calibrate your filaments for better accuracy.

    )} {/* Optimizer Metadata */} @@ -1080,20 +1082,17 @@ export default function AutoPaintTab({
    - Optimizer Performance - + {autoPaintResult.optimizerMetadata.cacheHit && ( - - + Cache hit )} {autoPaintResult.optimizerMetadata.converged && ( - - + Converged )} @@ -1143,10 +1142,8 @@ export default function AutoPaintTab({ disabled={isNextBestComputing} onClick={() => requestNextBestSuggestion(filaments, imageSwatches)} > - {isNextBestComputing ? ( + {isNextBestComputing && ( - ) : ( - )} {isNextBestComputing ? 'Finding suggestion...' From 8c0d04eeabd7e96427413170d04bf0eba3b0a081 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 4 Jul 2026 15:13:52 +0300 Subject: [PATCH 48/81] Share the session JND fit between calibration preview and save The measure step previewed hiding distances with the default JND while Save refit a session-wide JND, so Accurate mode could save different values than it displayed (and the "session JND" badge was unreachable). The session fit now runs once in a debounced background pass after the entered reads settle, with a "Fitting session JND" indicator; the live per-filament previews consume the cached fit and Save reuses it (falling back to a fresh session fit only when nothing is cached), so saved values always match the preview. Also rewrite the remaining stale README sections that still taught backlit-scale TD entry and the removed photo calibration wizard: the Auto-paint core concepts, quick-start steps, calibration walkthrough, and the HueForge TD section now describe hiding distances with conventional TD as a converted input (~10x the HD). --- .agents/calibration-phase2-review-issues.md | 8 +- README.md | 48 +++--- src/components/FilamentCalibrationDialog.tsx | 156 +++++++++++++++---- 3 files changed, 153 insertions(+), 59 deletions(-) diff --git a/.agents/calibration-phase2-review-issues.md b/.agents/calibration-phase2-review-issues.md index f7bc5d7..eca7dcc 100644 --- a/.agents/calibration-phase2-review-issues.md +++ b/.agents/calibration-phase2-review-issues.md @@ -14,8 +14,12 @@ > 2. ~~Band-edge JND acceptance~~ — `fitSessionJnd` rejects solutions at > `JND_FIT_MIN`/`JND_FIT_MAX` and falls back to the default JND; covered by a > regression test with an out-of-band synthetic session. -> 3. Cosmetic dead "session JND" badge branch in the live measure view — accepted -> as-is (the badge still applies to saved calibrations). +> 3. ~~Cosmetic dead "session JND" badge branch in the live measure view~~ — +> later upgraded by a codex PR review (preview and save could disagree when +> the session fit fired) and fixed properly: the session JND is now fitted +> once in a debounced background pass after the reads settle, the live +> preview consumes it (badge reachable again), and Save reuses the cached +> fit so saved values always equal previewed values. > > Verified after the fixes: 228 tests, tsc, eslint green. diff --git a/README.md b/README.md index 1c3ae97..c907955 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ Another minimal test you can try yourself: the included Transmission Distance (T 1. Upload or drag an image into the preview area. 2. Switch to the **Auto-paint** tab in the 3D controls panel. 3. Click **Add Filament** and configure your filaments: - - Use the **calibration wizard** (calibrate icon) to measure accurate TD values, or - - Enter TD values manually. + - Use **Calibrate Filaments** to measure accurate hiding distances from a small printed wedge, or + - Enter hiding distances manually (the convert button accepts conventional backlit TD values). 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. @@ -107,34 +107,34 @@ This is especially useful for complex multi-color prints where you need to under ## Auto-paint -Auto-paint is an automated layer-generation mode that replaces the manual palette/swatch workflow. Instead of reducing an image to a fixed number of colors and manually tuning per-color slice heights, you define a set of **filaments** (each with a color and a Transmission Distance) and let the algorithm compute the optimal layer stack automatically. +Auto-paint is an automated layer-generation mode that replaces the manual palette/swatch workflow. Instead of reducing an image to a fixed number of colors and manually tuning per-color slice heights, you define a set of **filaments** (each with a color and a hiding distance) and let the algorithm compute the optimal layer stack automatically. ### Core concepts -- **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 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. +- **Filaments**: Each filament has a hex color and a **Hiding Distance (HD)** value (in mm) — the depth of material at which the filament visually hides what's beneath it when viewed front-lit, the way finished prints are actually seen. Dark/opaque filaments hide quickly (e.g. 0.05–0.1 mm); light/translucent filaments need more depth (e.g. 0.5–0.7 mm). When you add a filament without specifying an HD, Kromacut estimates one from the color's luminance and saturation. If you have a conventional (backlit/lithophane) Transmission Distance from a spool sheet or TD test print, the convert button on the filament row turns it into a hiding distance (a conventional TD is roughly 10× the HD). +- **Beer-Lambert optical blending**: The algorithm simulates how light transmits through stacked filament layers using the Beer-Lambert law: `transmission = 10^(-thickness / HD)`. 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 reaches the selected opacity endpoint. **Compact** uses 80% opacity, **Detailed** uses 90% (one HD), 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) -1. **Define filaments** — Add your filament colors and Transmission Distances in the Auto-paint tab. Use the color picker and TD input for each filament row. +1. **Define filaments** — Add your filament colors and hiding distances in the Auto-paint tab. Use the color picker and HD input for each filament row (or convert a conventional TD value). 2. **Filament ordering** — By default filaments are sorted by luminance (darkest on the bottom, lightest on top). With **Enhanced color matching** enabled, the algorithm evaluates orderings to find the one that best reproduces the image's color palette. -3. **Transition zone calculation** — For each consecutive pair of filaments, the algorithm simulates Beer-Lambert blending layer-by-layer until the color converges. The first (darkest) filament gets a foundation zone thick enough to be ~95% opaque (`TD × 1.3`). +3. **Transition zone calculation** — For each consecutive pair of filaments, the algorithm simulates Beer-Lambert blending layer-by-layer until the color converges. The first (darkest) filament gets a foundation zone thick enough to be ~95% opaque (`HD × 1.3`). 4. **Height compression** — If the ideal height exceeds a user-set **Max Height**, all zones are uniformly compressed. The UI shows which zones are compressed and by how much. 5. **Virtual swatch generation** — The transition zones are sampled at each layer-height increment to produce a sequence of blended colors (virtual swatches). These drive the 3D preview and the height map, where each pixel maps to the layer whose blended color best matches. ### Filament Calibration -Each filament row in the Auto-paint tab includes a **calibration wizard** to help you determine accurate Transmission Distance values: +Kromacut measures hiding distances with a camera-free printed wedge — no light meter, no photos, no color picking: -1. **Click the calibrate icon** next to any filament to open the calibration wizard. -2. **Print test samples** — The wizard guides you through printing 3-5 samples of different thicknesses (e.g., 0.3mm, 0.6mm, 1.0mm, 1.5mm, 2.0mm). -3. **Measure luminance** — Use a light meter or camera to measure how much light passes through each sample, or visually compare against reference colors. -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). +1. **Click Calibrate Filaments** below the filament list and select the filaments to calibrate. +2. **Pick bases** — Quick mode uses one base read per filament; Accurate mode repeats the read over two or three bases so Kromacut can measure per-channel (RGB) hiding distances directly. +3. **Print the wedge** — Download an STL (any printer, one filament swap) or a multi-material 3MF (AMS-ready). Each filament prints as a row of patches from 1 layer up beside a fully opaque reference rail. +4. **Read back one number** — For each print, report the first patch that looks identical to the reference rail. Kromacut converts the reads into measured hiding distances with a confidence score. 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. Auto-paint uses calibrated red, green, and blue TD values for both preview blending and transition-zone thickness, so calibration can change the generated stack height and swap plan. +Calibrated filaments display a confidence badge next to their HD value. Higher confidence = more accurate optical simulation = better print results. Auto-paint uses calibrated red, green, and blue hiding distances for both preview blending and transition-zone thickness, so calibration can change the generated stack height and swap plan. ### Advanced Optimizer @@ -224,7 +224,7 @@ The result card shows: |---|---| | **Hex** | Suggested filament color. | | **Est. ΔE** | Estimated reduction in blend-aware average ΔE if this filament is added. A rough relative estimate — not a calibration confidence rating. | -| **TD** | Recommended starting Transmission Distance, borrowed from the nearest existing filament by color distance. Adjust after printing a test patch. | +| **HD** | Recommended starting hiding distance, borrowed from the nearest existing filament by color distance. Adjust after printing a test patch. | | **Captures** | Percentage of image pixels whose blend-aware color error would improve with this filament. | | **Isolation** | How far this color sits from existing filaments in perceptual color space (0–1). Higher means it fills a more distinct gap; lower means it overlaps territory already covered by blending. | @@ -236,8 +236,8 @@ Click **Add to filaments** to insert the suggestion directly into the filament l 1. Load an image into Kromacut. 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. +3. Click **Add Filament** and configure each filament's color and hiding distance to match your real filament stock. + - **Tip:** Use **Calibrate Filaments** to measure accurate hiding distances from a printed wedge. 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. @@ -252,17 +252,19 @@ Kromacut now supports exporting directly to `.3mf` format. This file format pres 3MF Export -## Transmission Distance (TD) — what it is and how to use it here +## Hiding Distance (HD) and Transmission Distance (TD) -Transmission Distance (TD) is the concept HueForge uses to produce perceptual intermediate shades by stacking translucent filament layers: instead of relying purely on opaque color pigments, TD models how light transmits through thin layers of filament and how stacking different colors (and varying thickness) produces new perceived colors. For a full conceptual description see the HueForge blog: https://shop.thehueforge.com/blogs/news/what-is-hueforge +Stacking thin translucent filament layers produces perceptual intermediate shades — the idea HueForge popularized with Transmission Distance: https://shop.thehueforge.com/blogs/news/what-is-hueforge + +Kromacut models the same physics but stores a **Hiding Distance (HD)**: the depth at which a filament visually hides what's beneath it under front lighting, which is how finished prints are viewed. A conventional TD (measured with a backlit lithophane-style test print) is roughly **10× the hiding distance**; the convert button on each filament row accepts a conventional TD and converts it for you. ### Two workflow modes in Kromacut: -**1. Auto-paint mode (Automatic TD processing)** — The recommended approach for TD-based prints: +**1. Auto-paint mode (Automatic optical blending)** — The recommended approach for layered-color prints: - **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. +- **Filament-based workflow** — Define your actual filaments (color + hiding distances) and let the algorithm compute optimal layer stacks automatically. +- **Camera-free calibration** — Print a small wedge and read back the layer count where it turns opaque; Kromacut fits measured per-channel hiding distances from the reads. - **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. @@ -275,7 +277,7 @@ See the [Auto-paint section](#auto-paint) above for full details. - Small adjustments to `base slice height`, per-color slice heights, and `layerHeight` change the produced layer numbers and perceived blends. - Iterate with actual filament on small test prints — translucency and perceived mix depend heavily on filament brand, color, and print settings. -**Recommendation:** Use Auto-paint mode for TD-based prints. It handles the complex Beer-Lambert physics and optimization automatically, producing better results than manual approximation. +**Recommendation:** Use Auto-paint mode for layered-color prints. It handles the complex Beer-Lambert physics and optimization automatically, producing better results than manual approximation. ### TD test image (for manual mode experimentation) diff --git a/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx index df4a492..57543c0 100644 --- a/src/components/FilamentCalibrationDialog.tsx +++ b/src/components/FilamentCalibrationDialog.tsx @@ -430,9 +430,10 @@ export function FilamentCalibrationDialog({ setMergeReads((prev) => ({ ...prev, [key]: value })); }, []); - // Per-filament calibration computed live from the entered reads. - const computed = useMemo(() => { - const entries = selectedFilaments.map((filament) => { + // Complete read inputs per filament (merge reads excluded — they never + // affect the fit, only the saved record). + const readInputs = useMemo(() => { + return selectedFilaments.map((filament) => { const targets = calibrationTargets.filter( (target) => target.filament.id === filament.id ); @@ -460,15 +461,8 @@ export function FilamentCalibrationDialog({ maxLayers, } : null; - return { - filament, - targets, - input, - result: input ? computeFrontlitCalibration(input) : null, - jndSource: null as 'default' | 'session-fit' | null, - }; + return { filament, targets, input }; }); - return entries; }, [ selectedFilaments, calibrationTargets, @@ -478,6 +472,83 @@ export function FilamentCalibrationDialog({ maxLayers, ]); + // Session JND fit, computed once in the background when the entered reads + // settle. Preview and Save both consume this cached result so the values + // shown are exactly the values saved. + const sessionKey = useMemo( + () => JSON.stringify(readInputs.map((entry) => entry.input)), + [readInputs] + ); + const [sessionJndFit, setSessionJndFit] = useState<{ + key: string; + jnd: number; + jndSource: 'default' | 'session-fit'; + } | null>(null); + const [isFittingJnd, setIsFittingJnd] = useState(false); + const latestSessionKeyRef = useRef(sessionKey); + latestSessionKeyRef.current = sessionKey; + + useEffect(() => { + const ready = readInputs + .map((entry) => entry.input) + .filter((input): input is NonNullable => input !== null); + // The JND search needs at least two multi-base filaments; anything less + // always resolves to the default JND, so skip the expensive fit. + const multiBase = ready.filter((input) => (input.reads?.length ?? 0) >= 2); + if (readInputs.some((entry) => entry.input === null) || multiBase.length < 2) { + setSessionJndFit(null); + return; + } + if (sessionJndFit?.key === sessionKey) return; + + const key = sessionKey; + const timer = window.setTimeout(() => { + if (latestSessionKeyRef.current !== key) return; + setIsFittingJnd(true); + // Defer past a paint so the "fitting" state renders before the + // multi-second search blocks the main thread. + window.requestAnimationFrame(() => { + window.setTimeout(() => { + try { + if (latestSessionKeyRef.current !== key) return; + const session = computeFrontlitCalibrationSession({ + filaments: ready, + maxLayers, + }); + setSessionJndFit({ + key, + jnd: session.jnd, + jndSource: session.jndSource, + }); + } finally { + setIsFittingJnd(false); + } + }); + }); + }, 800); + return () => window.clearTimeout(timer); + }, [readInputs, sessionKey, sessionJndFit, maxLayers]); + + const activeJndFit = sessionJndFit?.key === sessionKey ? sessionJndFit : null; + + // Per-filament calibration computed live from the entered reads, using the + // fitted session JND once it is available. + const computed = useMemo(() => { + return readInputs.map(({ filament, targets, input }) => ({ + filament, + targets, + input, + result: input + ? computeFrontlitCalibration({ + ...input, + jnd: activeJndFit?.jnd, + jndSource: activeJndFit?.jndSource, + }) + : null, + jndSource: activeJndFit?.jndSource ?? null, + })); + }, [readInputs, activeJndFit]); + const allReadsValid = computed.length > 0 && computed.every((c) => c.result !== null && c.result.ok); @@ -488,30 +559,42 @@ export function FilamentCalibrationDialog({ window.requestAnimationFrame(() => { window.setTimeout(() => { try { - const session = computeFrontlitCalibrationSession({ - filaments: saveEntries.map((entry) => { - const readsWithMerge: FrontlitCalibrationRead[] = entry.targets.map( - (target) => { - const opacityLayers = parseLayerInput(reads[target.key])!; - const mergeLayers = parseLayerInput(mergeReads[target.key]); - return { - baseColor: target.base.color, - opacityLayers, - mergeLayers: mergeLayers ?? undefined, - }; - } - ); - return { - ...entry.input!, - reads: readsWithMerge, - opacityLayers: readsWithMerge[0]?.opacityLayers, - baseColor: readsWithMerge[0]?.baseColor, - }; - }), - maxLayers, + const saveInputs = saveEntries.map((entry) => { + const readsWithMerge: FrontlitCalibrationRead[] = entry.targets.map( + (target) => { + const opacityLayers = parseLayerInput(reads[target.key])!; + const mergeLayers = parseLayerInput(mergeReads[target.key]); + return { + baseColor: target.base.color, + opacityLayers, + mergeLayers: mergeLayers ?? undefined, + }; + } + ); + return { + ...entry.input!, + reads: readsWithMerge, + opacityLayers: readsWithMerge[0]?.opacityLayers, + baseColor: readsWithMerge[0]?.baseColor, + }; }); + // Reuse the previewed session fit so saved values match the + // preview exactly; fall back to a fresh session when no fit + // is cached (e.g. quick mode). + const results = activeJndFit + ? saveInputs.map((input) => + computeFrontlitCalibration({ + ...input, + jnd: activeJndFit.jnd, + jndSource: activeJndFit.jndSource, + }) + ) + : computeFrontlitCalibrationSession({ + filaments: saveInputs, + maxLayers, + }).results; const updates: CalibrationApplyUpdate[] = []; - session.results.forEach((result, index) => { + results.forEach((result, index) => { if (!result.ok) return; const entry = saveEntries[index]; updates.push({ @@ -527,7 +610,7 @@ export function FilamentCalibrationDialog({ } }); }); - }, [computed, reads, mergeReads, maxLayers, onApply, handleClose, isSaving]); + }, [computed, activeJndFit, reads, mergeReads, maxLayers, onApply, handleClose, isSaving]); const renderSelect = () => ( <> @@ -878,6 +961,11 @@ export function FilamentCalibrationDialog({

    Enter the first patch number that matched the rail for each filament/base print. + {isFittingJnd && ( + + Fitting session JND… + + )}

    From e9e89fbf6c4a240807d1297fea05de6f44be813d Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 4 Jul 2026 16:09:10 +0300 Subject: [PATCH 49/81] Document that re-calibrating a filament replaces its stored value --- src/docs/3d-mode.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index fe89d22..e6cb25d 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -55,6 +55,8 @@ Calibration is camera-free: you print a small wedge and read back one or more op Because the reference rail is the filament's own fully-opaque color, you are only judging whether a patch matches the rail right next to it — a comparison that stays reliable across lighting and screens. The measured hiding distance is a material property, so it transfers to your real prints regardless of which base you calibrated against. +Each filament stores a single calibration. Calibrating a filament again **replaces** its previous value rather than averaging into it, and only the reads you enter in the current run are saved — so to combine several bases into one measurement, select them together in a single **Accurate** run instead of calibrating the same filament twice. Calibrating a different filament only updates that filament and leaves your other calibrations untouched. + ## Filament Profiles Auto-paint profiles store reusable filament sets. From e7109082e88b6dad13c5d5670072e0fd19e18c5a Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 4 Jul 2026 16:15:56 +0300 Subject: [PATCH 50/81] Block calibration save while the session JND fit is pending A user could enter the last read and click Save before the debounced session fit landed, saving values fitted with a JND the preview never showed. Save is now disabled (labelled Fitting...) from the moment an eligible session fit is pending until it lands, and the save fallback computes with the default JND exactly like the preview - so saved values equal previewed values in every path, not just when the cache is warm. Also codify the calibration STL contract: the file deliberately contains multiple independently watertight shells that slicers union into one printable part (a true CSG fusion of the rounded prisms is not worth the complexity; the output is print-validated). Module docs, dialog copy, and the test name now say so instead of claiming a single fused solid. --- src/components/FilamentCalibrationDialog.tsx | 75 ++++++++++++-------- src/lib/generateCalibrationPrint.ts | 7 +- tests/calibrationPrint.test.ts | 4 +- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx index 57543c0..f506371 100644 --- a/src/components/FilamentCalibrationDialog.tsx +++ b/src/components/FilamentCalibrationDialog.tsx @@ -488,18 +488,26 @@ export function FilamentCalibrationDialog({ const latestSessionKeyRef = useRef(sessionKey); latestSessionKeyRef.current = sessionKey; + // The JND search needs at least two multi-base filaments with complete + // reads; anything less always resolves to the default JND. + const sessionFitEligible = useMemo(() => { + if (readInputs.length === 0) return false; + if (readInputs.some((entry) => entry.input === null)) return false; + const multiBase = readInputs.filter( + (entry) => (entry.input?.reads?.length ?? 0) >= 2 + ); + return multiBase.length >= 2; + }, [readInputs]); + useEffect(() => { - const ready = readInputs - .map((entry) => entry.input) - .filter((input): input is NonNullable => input !== null); - // The JND search needs at least two multi-base filaments; anything less - // always resolves to the default JND, so skip the expensive fit. - const multiBase = ready.filter((input) => (input.reads?.length ?? 0) >= 2); - if (readInputs.some((entry) => entry.input === null) || multiBase.length < 2) { + if (!sessionFitEligible) { setSessionJndFit(null); return; } if (sessionJndFit?.key === sessionKey) return; + const ready = readInputs + .map((entry) => entry.input) + .filter((input): input is NonNullable => input !== null); const key = sessionKey; const timer = window.setTimeout(() => { @@ -527,9 +535,12 @@ export function FilamentCalibrationDialog({ }); }, 800); return () => window.clearTimeout(timer); - }, [readInputs, sessionKey, sessionJndFit, maxLayers]); + }, [readInputs, sessionFitEligible, sessionKey, sessionJndFit, maxLayers]); const activeJndFit = sessionJndFit?.key === sessionKey ? sessionJndFit : null; + // True while an eligible session fit hasn't landed yet (debounce + search). + // Save is blocked during this window so saved values always match preview. + const sessionFitPending = sessionFitEligible && activeJndFit === null; // Per-filament calibration computed live from the entered reads, using the // fitted session JND once it is available. @@ -553,7 +564,7 @@ export function FilamentCalibrationDialog({ computed.length > 0 && computed.every((c) => c.result !== null && c.result.ok); const handleSave = useCallback(() => { - if (isSaving) return; + if (isSaving || sessionFitPending) return; setIsSaving(true); const saveEntries = computed.filter((entry) => entry.input && entry.result?.ok); window.requestAnimationFrame(() => { @@ -578,21 +589,17 @@ export function FilamentCalibrationDialog({ baseColor: readsWithMerge[0]?.baseColor, }; }); - // Reuse the previewed session fit so saved values match the - // preview exactly; fall back to a fresh session when no fit - // is cached (e.g. quick mode). - const results = activeJndFit - ? saveInputs.map((input) => - computeFrontlitCalibration({ - ...input, - jnd: activeJndFit.jnd, - jndSource: activeJndFit.jndSource, - }) - ) - : computeFrontlitCalibrationSession({ - filaments: saveInputs, - maxLayers, - }).results; + // Compute exactly what the preview showed: the cached session + // JND when one exists, otherwise the default JND (the only + // no-cache case left is a non-eligible session, e.g. quick + // mode — Save is disabled while an eligible fit is pending). + const results = saveInputs.map((input) => + computeFrontlitCalibration({ + ...input, + jnd: activeJndFit?.jnd, + jndSource: activeJndFit?.jndSource, + }) + ); const updates: CalibrationApplyUpdate[] = []; results.forEach((result, index) => { if (!result.ok) return; @@ -610,7 +617,16 @@ export function FilamentCalibrationDialog({ } }); }); - }, [computed, activeJndFit, reads, mergeReads, maxLayers, onApply, handleClose, isSaving]); + }, [ + computed, + activeJndFit, + sessionFitPending, + reads, + mergeReads, + onApply, + handleClose, + isSaving, + ]); const renderSelect = () => ( <> @@ -884,7 +900,7 @@ export function FilamentCalibrationDialog({

    {format === 'stl' - ? 'Single solid. Print one copy for each filament/base read using the matching swap line below.' + ? 'One printable tile. Print one copy for each filament/base read using the matching swap line below.' : 'Colors + bases baked in for AMS / multi-material - all selected reads in one print.'}

    @@ -1149,9 +1165,12 @@ export function FilamentCalibrationDialog({ Back - diff --git a/src/lib/generateCalibrationPrint.ts b/src/lib/generateCalibrationPrint.ts index f859832..b5df49f 100644 --- a/src/lib/generateCalibrationPrint.ts +++ b/src/lib/generateCalibrationPrint.ts @@ -9,7 +9,12 @@ * to the darkest filament; dark filaments use a lighter base for contrast). * * Two output formats: - * - STL: one tile, single solid. The base/wedge split is by height, so a single + * - STL: one tile per file, emitted as multiple independently watertight + * shells (base slab, orientation foot, each patch, the rail) that touch or + * overlap. Slicers union coincident/overlapping closed shells on import, so + * the tile prints as one part; this multi-shell contract is deliberate (a + * true CSG fusion of the rounded prisms is not worth the complexity and the + * output is print-validated). The base/wedge split is by height, so a single * filament change (base -> color, above the base) prints it on any machine. * - 3MF: one or more tiles, each as a base object + a color object, with parts * assigned to real profile-filament slots (no invented base), for AMS printers. diff --git a/tests/calibrationPrint.test.ts b/tests/calibrationPrint.test.ts index 23ca587..8a81807 100644 --- a/tests/calibrationPrint.test.ts +++ b/tests/calibrationPrint.test.ts @@ -38,7 +38,9 @@ const baseOptions = { gap: 1.5, }; -test('generateCalibrationStl writes one box per base/wedge/reference patch', async () => { +// Contract: the STL is a set of independently watertight shells (one per box) +// that touch/overlap; slicers union them into one printable part on import. +test('generateCalibrationStl writes one closed shell per base/wedge/reference patch', async () => { const { generateCalibrationStl } = await loadGenerator(); const blob = generateCalibrationStl(baseOptions); const buffer = await blob.arrayBuffer(); From fad7c60cc4b59ef60dafe4f8106f584d9e885c4b Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sat, 4 Jul 2026 22:52:04 +0300 Subject: [PATCH 51/81] Deactivate a filament's calibration when its color is edited Calibrated per-channel hiding distances are specific to the material and color they were measured for, but channelHds and the calibration badge only checked the scalar HD - so editing a filament's swatch kept the stale calibration active and auto-paint silently optimized with measurements from the previous color. FrontlitCalibration now records the calibrated swatch color, and a new activeFrontlitCalibration() treats a calibration as active only while the filament still has that color. channelHds, the FilamentRow badge, and the auto-paint calibration-quality metric all go through it, so a color edit falls back to color-estimated HDs and the badge shows "Estimate". The change is non-destructive: reverting the color reactivates the stored calibration. Legacy records without a stored color are backfilled with the current color on load (protection going forward) and otherwise treated as matching. Adds unit coverage for deactivate/reactivate, the load backfill, and the color stamp; live-verified through the dialog. --- CHANGELOG.md | 2 +- src/components/FilamentRow.tsx | 12 +++---- src/lib/autoPaint.ts | 5 ++- src/lib/calibration.ts | 41 ++++++++++++++++++++- src/lib/profileManager.ts | 11 +++++- tests/hidingDistance.test.ts | 66 ++++++++++++++++++++++++++++++++++ tests/profileManager.test.ts | 34 ++++++++++++++++++ 7 files changed, 161 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b34f856..4b59933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Added -- **Frontlit filament calibration** - Replaced the photo-based TD calibration with a camera-free workflow. Pick one or more filaments, choose the base layer each prints over (defaults to your darkest filament; dark filaments can use a lighter base for contrast), then download a calibration wedge (single-solid STL for any printer, or a multi-material 3MF for AMS) that prints 1..N filament layers over that base alongside an opaque reference rail, and report the layer count at which each filament/base patch first matches the rail. Quick mode keeps the single-base scalar calibration; Accurate mode repeats the read over multiple bases to fit measured RGB-channel TDs directly, stores the read set (plus optional merge points), and fits a shared session JND when enough multi-base data is present. Kromacut converts those reads into a frontlit Transmission Distance using a perceptual just-noticeable-difference model, with a live predicted-vs-reference preview and confidence rating. The 3MF assigns parts to your real profile-filament slots (no invented base filament) and lays each tile out as an independently-arrangeable object. The old backlit photo wizard, its image sampler, and stored RGB measurements are removed, and legacy stored RGB calibrations are stripped on load; recalibrate any previously calibrated filaments. +- **Frontlit filament calibration** - Replaced the photo-based TD calibration with a camera-free workflow. Pick one or more filaments, choose the base layer each prints over (defaults to your darkest filament; dark filaments can use a lighter base for contrast), then download a calibration wedge (STL for any printer, or a multi-material 3MF for AMS) that prints 1..N filament layers over that base alongside an opaque reference rail, and report the layer count at which each filament/base patch first matches the rail. Quick mode keeps the single-base scalar calibration; Accurate mode repeats the read over multiple bases to fit measured RGB-channel TDs directly, stores the read set (plus optional merge points), and fits a shared session JND when enough multi-base data is present. Kromacut converts those reads into a frontlit Transmission Distance using a perceptual just-noticeable-difference model, with a live predicted-vs-reference preview and confidence rating. The 3MF assigns parts to your real profile-filament slots (no invented base filament) and lays each tile out as an independently-arrangeable object. A calibration is tied to the swatch color it was measured for: editing a filament's color deactivates its calibration (the row falls back to a color-estimated hiding distance) and reverting the color reactivates it, so auto-paint never blends with measurements from a different material. The old backlit photo wizard, its image sampler, and stored RGB measurements are removed, and legacy stored RGB calibrations are stripped on load; recalibrate any previously calibrated filaments. - **Preserve color separation (Auto-paint)** - Enhanced color matching can keep each distinct 2D image color mapped to a distinct printable color when the stack exposes enough printable colors, preserving gradients that would otherwise collapse onto a flat surface. It is mutually exclusive with Height dithering because both modes change the same printable height map. - **Auto-paint optimization progress** - Enhanced matching now reports approximate search progress while the background optimizer runs. - **Auto-paint test and benchmark coverage** - Added deterministic stack goldens, layer-invariant regression coverage, realized CIEDE2000 quality-budget tests, and an on-demand benchmark for fixture profiles using the same printable-stack mapper as the optimizer. diff --git a/src/components/FilamentRow.tsx b/src/components/FilamentRow.tsx index f685683..83adabc 100644 --- a/src/components/FilamentRow.tsx +++ b/src/components/FilamentRow.tsx @@ -7,11 +7,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import type { Filament } from '../types'; import { estimateHidingDistanceFromColor } from '../lib/colorUtils'; import { + activeFrontlitCalibration, channelHds, computeProfileConfidence, getConfidenceLabel, getConfidenceColor, - sanitizeFrontlitCalibration, FRONTLIT_TD_SCALE, } from '../lib/calibration'; @@ -38,11 +38,11 @@ const FilamentRow = React.memo(function FilamentRow({ filament.name ?? `Filament ${filament.color}` ); - // Calculate confidence for this filament - const sanitizedCalibration = sanitizeFrontlitCalibration(filament.calibration); - const calibrationMatchesTd = - !!sanitizedCalibration && Math.abs(filament.td - sanitizedCalibration.tdSingleValue) < 0.05; - const activeCalibration = calibrationMatchesTd ? sanitizedCalibration : undefined; + // Calculate confidence for this filament. A calibration only counts while + // the swatch still matches the color it was measured for; editing the color + // deactivates it (and channelHds falls back to color-derived HDs), so the + // badge and the optical model stay consistent. + const activeCalibration = activeFrontlitCalibration(filament); const confidence = computeProfileConfidence({ calibration: activeCalibration, diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index dbc5cc9..79fa2a8 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -16,6 +16,7 @@ import { type ScoringContext, } from './optimizer'; import { + activeFrontlitCalibration, channelHds, computeProfileConfidence, type CalibrationRgb, @@ -1698,7 +1699,9 @@ function calculateAutoConfidence( if (filaments.length > 0) { const confidences = filaments.map((f) => computeProfileConfidence({ - calibration: f.calibration, + // Only count a calibration whose swatch still matches its color; + // a color-edited filament falls back to the uncalibrated baseline. + calibration: activeFrontlitCalibration(f), transmissionDistance: f.td, }) ); diff --git a/src/lib/calibration.ts b/src/lib/calibration.ts index 62b0153..a5d3bea 100644 --- a/src/lib/calibration.ts +++ b/src/lib/calibration.ts @@ -57,6 +57,14 @@ export interface FrontlitCalibration { channelSource?: 'heuristic' | 'measured'; /** Whether the JND was the default constant or fitted across the session. */ jndSource?: 'default' | 'session-fit'; + /** + * Swatch color (hex) the wedge was calibrated for. A calibration only + * applies while the filament still has this color — editing the swatch + * deactivates it (see activeFrontlitCalibration) instead of silently + * blending with measurements from a different material. Absent on records + * saved before the field existed; those are backfilled on load. + */ + filamentColor?: string; notes?: string; } @@ -243,10 +251,40 @@ export function sanitizeFrontlitCalibration(value: unknown): FrontlitCalibration } else if (candidate.jndSource !== undefined) { return undefined; } + if (candidate.filamentColor !== undefined) { + if (typeof candidate.filamentColor !== 'string' || !hexToRgb(candidate.filamentColor)) { + return undefined; + } + sanitized.filamentColor = candidate.filamentColor; + } if (typeof candidate.notes === 'string') sanitized.notes = candidate.notes; return sanitized; } +/** + * The calibration that currently applies to a filament: a valid frontlit + * record whose calibrated swatch color still matches the filament's color. + * Editing the swatch deactivates the calibration (measurements belong to the + * previous material) and reverting the color reactivates it. Records without + * a stored color (pre-field) are treated as matching. + */ +export function activeFrontlitCalibration(filament: { + color: string; + calibration?: unknown; +}): FrontlitCalibration | undefined { + const calibration = sanitizeFrontlitCalibration(filament.calibration); + if (!calibration) return undefined; + if (calibration.filamentColor === undefined) return calibration; + const calibrated = hexToRgb(calibration.filamentColor); + const current = hexToRgb(filament.color); + if (!calibrated || !current) return undefined; + return calibrated[0] === current[0] && + calibrated[1] === current[1] && + calibrated[2] === current[2] + ? calibration + : undefined; +} + // ============================================================================ // Opacity-read calibration // ============================================================================ @@ -560,7 +598,7 @@ export function channelHds(filament: { calibration?: unknown; }): CalibrationRgb { return ( - sanitizeFrontlitCalibration(filament.calibration)?.td ?? + activeFrontlitCalibration(filament)?.td ?? deriveChannelTds(filament.color, filament.td) ); } @@ -712,6 +750,7 @@ export function computeFrontlitCalibration( : undefined, channelSource, jndSource: input.jndSource ?? 'default', + filamentColor, notes, }, }; diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts index 2906c13..437b5a8 100644 --- a/src/lib/profileManager.ts +++ b/src/lib/profileManager.ts @@ -51,7 +51,16 @@ export function sanitizeProfileFilament(value: unknown): Filament | null { if (typeof value.brand === 'string') filament.brand = value.brand; const calibration = sanitizeFrontlitCalibration(value.calibration); - if (calibration) filament.calibration = calibration; + if (calibration) { + // Backfill the calibrated color for records saved before the field + // existed: on load the current color IS the color it was calibrated for + // (no edit has happened yet), so this activates color-mismatch + // protection for legacy calibrations without discarding them. + filament.calibration = + calibration.filamentColor === undefined + ? { ...calibration, filamentColor: filament.color } + : calibration; + } return filament; } diff --git a/tests/hidingDistance.test.ts b/tests/hidingDistance.test.ts index b23eca8..d24d386 100644 --- a/tests/hidingDistance.test.ts +++ b/tests/hidingDistance.test.ts @@ -93,6 +93,72 @@ test('channelHds ignores invalid calibration shapes and falls back to derivation assert.deepEqual(channelHds(filament), deriveChannelTds('#ff0000', 0.4)); }); +test('editing the swatch color deactivates a calibration', async () => { + const { channelHds, activeFrontlitCalibration, deriveChannelTds } = await loadCalibration(); + const calibration = { + opacityLayers: 5, + layerHeight: 0.08, + firstLayerHeight: 0.16, + td: [0.068, 0.354, 0.373] as [number, number, number], + tdSingleValue: 0.373, + jnd: 2, + baseColor: '#000000', + confidence: 0.8, + basis: 'frontlit' as const, + calibrationDate: '2026-07-02T00:00:00.000Z', + filamentColor: '#00b8c4', + }; + // Same color → calibration active, measured channels used. + const matched: Filament = { id: 'f', color: '#00b8c4', td: 0.373, calibration }; + assert.ok(activeFrontlitCalibration(matched)); + assert.deepEqual(channelHds(matched), [0.068, 0.354, 0.373]); + + // Color edited → calibration deactivated, falls back to color-derived HDs. + const edited: Filament = { ...matched, color: '#ff0000' }; + assert.equal(activeFrontlitCalibration(edited), undefined); + assert.deepEqual(channelHds(edited), deriveChannelTds('#ff0000', 0.373)); + + // Reverting the color reactivates the stored calibration (non-destructive). + const reverted: Filament = { ...edited, color: '#00b8c4' }; + assert.ok(activeFrontlitCalibration(reverted)); + assert.deepEqual(channelHds(reverted), [0.068, 0.354, 0.373]); +}); + +test('a calibration without a stored color still applies (legacy compatibility)', async () => { + const { activeFrontlitCalibration } = await loadCalibration(); + const filament = { + id: 'legacy', + color: '#00b8c4', + td: 0.373, + calibration: { + opacityLayers: 5, + layerHeight: 0.08, + firstLayerHeight: 0.16, + td: [0.068, 0.354, 0.373], + tdSingleValue: 0.373, + jnd: 2, + baseColor: '#000000', + confidence: 0.8, + basis: 'frontlit', + calibrationDate: '2026-07-02T00:00:00.000Z', + }, + } as unknown as Filament; + assert.ok(activeFrontlitCalibration(filament)); +}); + +test('computeFrontlitCalibration stamps the calibrated swatch color', async () => { + const { computeFrontlitCalibration } = await loadCalibration(); + const result = computeFrontlitCalibration({ + filamentColor: '#ffd400', + opacityLayers: 6, + layerHeight: 0.08, + firstLayerHeight: 0.16, + }); + assert.ok(result.ok); + if (!result.ok) return; + assert.equal(result.calibration.filamentColor, '#ffd400'); +}); + test('migrateLegacyFilamentTd scales uncalibrated tds and re-syncs calibrated ones', async () => { const { migrateLegacyFilamentTd } = await loadProfileManager(); const { FRONTLIT_TD_SCALE } = await loadCalibration(); diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index 474f602..498f069 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -290,3 +290,37 @@ test('stored v1 profiles migrate tds once on load', async () => { if (!existingStorage) Reflect.deleteProperty(globalThis, 'localStorage'); } }); + +test('loading a calibrated filament backfills the calibrated swatch color', async () => { + const { sanitizeProfileFilament } = await loadProfileManager(); + const frontlitCalibration = { + opacityLayers: 7, + layerHeight: 0.08, + firstLayerHeight: 0.16, + td: [0.49, 0.51, 0.5], + tdSingleValue: 0.51, + jnd: 2, + baseColor: '#000000', + confidence: 0.9, + basis: 'frontlit', + calibrationDate: '2026-07-02T00:00:00.000Z', + }; + // Legacy record (no filamentColor) → backfilled with the current color. + const legacy = sanitizeProfileFilament({ + id: 'f', + color: '#ffffff', + td: 0.51, + calibration: frontlitCalibration, + }); + assert.ok(legacy?.calibration); + assert.equal(legacy!.calibration!.filamentColor, '#ffffff'); + + // Existing filamentColor is preserved, not overwritten by the current color. + const stamped = sanitizeProfileFilament({ + id: 'g', + color: '#111111', + td: 0.51, + calibration: { ...frontlitCalibration, filamentColor: '#ffffff' }, + }); + assert.equal(stamped!.calibration!.filamentColor, '#ffffff'); +}); From 2b2286a224cf918f2c07d263c47e29aeeaa7e573 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:14:30 -0500 Subject: [PATCH 52/81] Add TileSettings and GridSettings interfaces for multi-image tile configuration Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/types/tileSettings.ts | 216 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 src/types/tileSettings.ts diff --git a/src/types/tileSettings.ts b/src/types/tileSettings.ts new file mode 100644 index 0000000..8177a34 --- /dev/null +++ b/src/types/tileSettings.ts @@ -0,0 +1,216 @@ +// Shared settings type for multi-image tiles (issue #35) and single-image mode. +// See tmp_multi_modularization.plan.txt, Step 1: single-image mode is modeled as +// "one tile whose override is always empty" against a global TileSettings. +// +// Field placement follows the "Seems good"-approved UI proposal on issue #35: +// only panel 2 (Borders) is explicitly "per-tile overridable like everything +// else" (plus panel 6's per-tile skip/force-include override), so those are +// the only fields on TileSettings. Panels 1/3/4/5/7/8 (Split, Border fit, +// Labels, Colors, Outer border, Plate check) describe the whole assembled +// grid, not any one tile's content, so they live on the separate, +// non-inheritable GridSettings. +// +// Below, definitions are grouped by UI-proposal panel (1-8 in order), each +// panel's sub-settings in the sequence they're used, followed by the two +// aggregate containers (TileSettings, GridSettings) and resolveSettings. +import type { Adjustments } from '../lib/applyAdjustments'; +import type { ThreeDControlsStateShape } from './index'; + +// Panel 1 (Split): explicit M x N, or a target tile size in mm/px (the other +// unit is always echoed back so the mapping stays visible). +export interface SplitSettings { + rows?: number; + columns?: number; + tileSizeMm?: number; + tileSizePx?: number; + unit?: 'mm' | 'px'; +} + +// Panel 2 (Borders): Top/Left/Right/Bottom, each independently overridable +// per tile, distinct from the whole-assembly BorderSettings (panel 7) below. + +// Where a border edge's height boundary falls, for sources that need one +// ('filament' always washes out layers below a height; 'layer-color' samples +// whatever color is already painted at a height instead of specifying one). +export type BorderColorHeight = + | { mode: 'median' } + | { mode: 'mm'; valueMm: number } + | { mode: 'percent'; valuePercent: number }; + +// A discriminated union because each color source needs different follow-up +// params: a flat hex needs nothing else; a filament choice needs a height to +// paint up to plus how many layers below it to wash out; sampling the +// existing layer color needs only the height to sample at. +export type EdgeBorderColor = + | { + source: 'hex'; // hex always adds layers above the border, so no height param is needed. + color: string // flat hex color, e.g. '#ff00ff' or '#f0f' + } + | { + source: 'filament'; + filamentId: string; // filament ID to use for the border color, e.g. 'PLA:Black' or '' + heightMm: number; + // how many layers below the border to wash out (0 = none, 1 = one layer, etc.). + // if washout layers is > 1, and height mm < the height of the print, we'll display a warning that there will + // need to be two swaps per layer for n layers while the border is being printed. + washoutLayers: number + } + | { source: 'layer-color'; height: BorderColorHeight }; // simply use the color of the existing layer at this height, no new color is added. + +export interface EdgeBorderSettings { + thicknessMm: number; // thickness of the border in millimeters, can be 0 to disable the border on that edge. + color?: EdgeBorderColor; // if not specified, defaults to black. +} + +export interface TileBorderSettings { + top?: EdgeBorderSettings; + left?: EdgeBorderSettings; + right?: EdgeBorderSettings; + bottom?: EdgeBorderSettings; +} + +// Panel 3 (Border fit): how adjacent tile edges key together physically. +// A discriminated union because each joint style has its own shape params +// (e.g. a T-pattern's joint thickness has no equivalent on a flush butt joint). +// Non-flush members are all required (no partial/inherit within a joint) — +// `fitShrink` is a percentage in [0, 99.99] shrinking the mating surfaces for +// print-fit clearance. +export type BorderFit = + | { type: 'flush' } + | { + type: 't-pattern'; + jointThicknessMm: number; //total mm normal of the edge for the tab to fit into. The tabs will go in n/2 mm in, and n/2 mm out, so the total thickness is n mm. + tabWidthMm: number; // width of a single tab that sticks out of the edge. + repeat: boolean; // whether the tabs repeat along the edge or not. If true, the tabWidth will be repeated along the edge, with a gap in between each tab. If false, there will be a single tab in the middle of the edge. + fitShrink: number; // percentage in [0, 99.99] shrinking the mating surfaces for print-fit clearance. typical value will be `nozzle_dia/image_width_mm * 100`. + } + | { + type: 'sawtooth'; + toothWidthmm: number; // width of a single tooth that sticks out of the edge, tip to tip. + toothDepthmm: number; // depth of a single tooth that sticks out of the edge, tip to base. + fitShrink: number // percentage in [0, 99.99] shrinking the mating surfaces for print-fit clearance. typical value will be `nozzle_dia/image_width_mm * 100`. + } + | { + type: 'jigsaw'; + jointThicknessMm: number; // total mm normal of the edge for the tab to fit into. The tabs will go in n/2 mm in, and n/2 mm out, so the total thickness is n mm. + tabWidthMm: number; // width of a single tab that sticks out of the edge, tip to tip. + fitShrink: number; // percentage in [0, 99.99] shrinking the mating surfaces for print-fit clearance. typical value will be `nozzle_dia/image_width_mm * 100`. + }; + +// Panel 4 (Labels): orientation/ordering marks, hidden once assembled. +// Position of the mark on the tile face, as a 3x3 grid of anchors. +export type LabelAlignment = + | 'top-left' + | 'top' + | 'top-right' + | 'left' + | 'center' + | 'right' + | 'bottom-left' + | 'bottom' + | 'bottom-right'; + +// A discriminated union because 'none' carries no mark params, while every +// other style shares the same depth/size/placement shape. +export type LabelStyle = + | { type: 'none' } + | { + type: 'orientation-arrow'; + depth: number; // how many mm the arrow is inset from the tile base. + totalWidth: number; // how many mm the arrow is wide (tip-to-tip). + alignment: LabelAlignment; + } + | { + type: 'sequence-index'; + depth: number; // how many mm the number is inset from the tile base. + totalWidth: number; // how many mm the number is wide (tip-to-tip). + alignment: LabelAlignment; + } + | { + type: 'row-column'; + depth: number; // how many mm the coordinates are inset from the tile base. + totalWidth: number; // how many mm the coordinates are wide (tip-to-tip). + alignment: LabelAlignment; + }; + +// Panel 5 (Colors): 'global' quantizes one palette across the whole image so +// tiles reassemble seamlessly; 'local' lets each tile get its own best-fit +// palette (fewer filament swaps per plate, at the cost of seams). +export type ColorMode = 'global' | 'local'; + +export type QuantizeAlgorithm = 'posterize' | 'median-cut' | 'kmeans' | 'octree' | 'wu' | 'none'; + +export interface QuantizeSettings { + algorithm?: QuantizeAlgorithm; + weight?: number; + finalColors?: number; + selectedPalette?: string; +} + +// Panel 6 (Skip tiles): background-only tiles below this content threshold +// are dropped from export by default (per-tile TileSettings.skip overrides this). +export interface SkipTilesSettings { + enabled?: boolean; + contentThresholdPercent?: number; +} + +// Panel 7 ("Outer border"): outer-border/frame settings, usable from +// single-image mode too: "trace the outer edge of whatever geometry is +// given" (one model in single-image mode, the assembled grid in multi-image +// mode). [Issue #45] This is that same edge/frame tool. Shares +// EdgeBorderColor with panel 2 — same hex/filament/layer-color choice. +export interface BorderSettings { + enabled?: boolean; + color?: EdgeBorderColor; + thicknessMm?: number; + heightBehavior?: 'full' | 'fixed' | 'top-bottom'; + fixedHeightMm?: number; +} + +// Panel 8 (Plate check): target plate size every tile (borders/joints +// included) is validated against before export. A discriminated union so +// 'none' (no check) can't carry dimensions, and 'validate' can't omit them. +export type PlateSizeSettings = { mode: 'none' } | { mode: 'validate'; widthMm: number; heightMm: number }; + +export interface TileSettings { + /** Core (no panel): per-tile image adjustments. */ + adjustments?: Partial; + /** Core (no panel): per-tile 3D/print controls. */ + threeD?: Partial; + /** Panel 2: per-edge borders against this tile's neighbors. */ + tileBorder?: TileBorderSettings; + /** Panel 6: force-include/exclude this tile, overriding the grid's content threshold. */ + skip?: boolean; +} + +/** Whole-assembly settings with no per-tile inherit concept — one value per grid space. */ +export interface GridSettings { + /** Panel 1. */ + split?: SplitSettings; + /** Panel 3. */ + borderFit?: BorderFit; + /** Panel 4. */ + labelStyle?: LabelStyle; + /** Panel 5. */ + colorMode?: ColorMode; + /** Panel 5. */ + quantize?: QuantizeSettings; + /** Panel 6 (grid-level threshold; TileSettings.skip is the per-tile override). */ + skipTiles?: SkipTilesSettings; + /** Panel 7. */ + outerBorder?: BorderSettings; + /** Panel 8. */ + plateSize?: PlateSizeSettings; +} + +/** + * Naive stub merge: override wins per top-level key, override's absence + * falls back to global. Real null/inherit semantics (deep per-field merge) + * land later; this is intentionally shallow for Step 1's stub form. + */ +export function resolveSettings( + global: TileSettings, + override: Partial +): TileSettings { + return { ...global, ...override }; +} From 369d53034509e2e51899c175bd7ce14dcb706438 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 6 Jul 2026 19:10:04 +0300 Subject: [PATCH 53/81] Fix filament calibration confidence updates --- CHANGELOG.md | 1 + src/components/FilamentRow.tsx | 5 +++-- src/lib/autoPaint.ts | 23 +++++++------------- src/lib/filamentUpdates.ts | 27 +++++++++++++++++++++++ tests/autoPaint.test.ts | 38 +++++++++++++++++++++++++++++++++ tests/hidingDistance.test.ts | 39 ++++++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 src/lib/filamentUpdates.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fdba36..46e3cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint Max Height** - Auto-paint now plans, scores, previews, and exports the same layer-aligned stack. Height caps round down to a valid printable layer boundary, so a generated model no longer exceeds the requested maximum by adding a final whole layer. - **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 calibration confidence** - Color edits that deactivate a calibration now re-anchor the filament's estimated hiding distance to the new color without deleting the stored calibration, and result confidence now scores the filament sequence actually printed after variable-length optimization omits unused filaments. - **Auto-paint edge cases** - Blank seeds now resolve to stable cacheable values, optimizer cache keys include all target clusters and tuning inputs, and exceptionally tall stacks stop at the intended 500-layer slice-data limit. - **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. diff --git a/src/components/FilamentRow.tsx b/src/components/FilamentRow.tsx index 83adabc..0eeeaf0 100644 --- a/src/components/FilamentRow.tsx +++ b/src/components/FilamentRow.tsx @@ -6,6 +6,7 @@ import { HexColorPicker } from 'react-colorful'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { Filament } from '../types'; import { estimateHidingDistanceFromColor } from '../lib/colorUtils'; +import { colorEditUpdateForFilament } from '../lib/filamentUpdates'; import { activeFrontlitCalibration, channelHds, @@ -87,11 +88,11 @@ const FilamentRow = React.memo(function FilamentRow({ useEffect(() => { const timer = setTimeout(() => { if (localColor !== filament.color) { - onUpdate(filament.id, { color: localColor }); + onUpdate(filament.id, colorEditUpdateForFilament(filament, localColor)); } }, 200); return () => clearTimeout(timer); - }, [localColor, filament.id, filament.color, onUpdate]); + }, [localColor, filament, onUpdate]); const handleNameBlur = () => { const trimmed = localName.trim(); diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 79fa2a8..93ad108 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -1549,12 +1549,7 @@ export function generateAutoLayers( .filter((filament): filament is Filament => filament !== undefined); // --- STEP 6: CALCULATE CONFIDENCE METRICS --- - const confidence = calculateAutoConfidence( - filaments, - imageSwatches, - printedFilaments, - compressionRatio - ); + const confidence = calculateAutoConfidence(imageSwatches, printedFilaments, compressionRatio); const result: AutoPaintResult = { layers, @@ -1673,16 +1668,14 @@ export function autoPaintToSliceHeights( * 2. Filament Coverage: How well the filament colors cover the image palette * 3. Compression Impact: How much the result was compressed from ideal * - * @param filaments - Input filaments with their TDs * @param imageSwatches - Image color palette - * @param sortedFilaments - Filaments in their optimal order + * @param printedFilaments - Filaments in the actual generated stack * @param compressionRatio - How much compression was applied (1.0 = none) * @returns Confidence score and detailed factors */ function calculateAutoConfidence( - filaments: Filament[], imageSwatches: Array<{ hex: string; count?: number }>, - sortedFilaments: Filament[], + printedFilaments: Filament[], compressionRatio: number ): { confidence: number; @@ -1696,8 +1689,8 @@ function calculateAutoConfidence( // Average confidence of all filament calibrations using actual calibration data let calibrationQuality = 0.5; // Default baseline for uncalibrated filaments - if (filaments.length > 0) { - const confidences = filaments.map((f) => + if (printedFilaments.length > 0) { + const confidences = printedFilaments.map((f) => computeProfileConfidence({ // Only count a calibration whose swatch still matches its color; // a color-edited filament falls back to the uncalibrated baseline. @@ -1714,8 +1707,8 @@ function calculateAutoConfidence( // Secondary: filament count caps the maximum achievable coverage. let filamentCoverage = 0.5; // Baseline - if (filaments.length > 0 && imageSwatches.length > 0) { - const filamentColors = sortedFilaments.map((f) => rgbToLab(hexToRgb(f.color))); + if (printedFilaments.length > 0 && imageSwatches.length > 0) { + const filamentColors = printedFilaments.map((f) => rgbToLab(hexToRgb(f.color))); // For each image color, find nearest filament color (weighted by pixel count) let totalDeltaE = 0; @@ -1744,7 +1737,7 @@ function calculateAutoConfidence( // Cap by filament count — even perfect color matches are limited by // how many distinct layers can be stacked - const filamentCount = filaments.length; + const filamentCount = printedFilaments.length; let countCap = 1.0; if (filamentCount === 1) countCap = 0.5; else if (filamentCount === 2) countCap = 0.7; diff --git a/src/lib/filamentUpdates.ts b/src/lib/filamentUpdates.ts new file mode 100644 index 0000000..399e450 --- /dev/null +++ b/src/lib/filamentUpdates.ts @@ -0,0 +1,27 @@ +import type { Filament } from '../types'; +import { sanitizeFrontlitCalibration } from './calibration'; +import { estimateHidingDistanceFromColor, normalizeHexColor } from './colorUtils'; + +/** + * Preserve a stored calibration while keeping the scalar HD aligned with the + * color currently being edited. A reverted color reuses the measured scalar; + * a different color falls back to the color-derived estimate. + */ +export function colorEditUpdateForFilament( + filament: Filament, + color: string +): Partial> { + const update: Partial> = { color }; + const calibration = sanitizeFrontlitCalibration(filament.calibration); + if (!calibration) return update; + + const nextColor = normalizeHexColor(color, ''); + const calibratedColor = normalizeHexColor(calibration.filamentColor ?? filament.color, ''); + if (!nextColor || !calibratedColor) return update; + + update.td = + nextColor === calibratedColor + ? calibration.tdSingleValue + : estimateHidingDistanceFromColor(nextColor); + return update; +} diff --git a/tests/autoPaint.test.ts b/tests/autoPaint.test.ts index 0a52dac..4a24d9e 100644 --- a/tests/autoPaint.test.ts +++ b/tests/autoPaint.test.ts @@ -575,6 +575,44 @@ test('enhanced repeated-swap search keeps the printable red-to-pink transition', ); }); +test('auto-paint confidence scores only the printed optimized sequence', async () => { + const { generateAutoLayers } = await loadAutoPaintModule(); + const calibratedWhite = { + id: 'white', + color: '#ffffff', + td: 0.51, + calibration: { + opacityLayers: 7, + layerHeight: 0.08, + firstLayerHeight: 0.16, + td: [0.49, 0.51, 0.5] as [number, number, number], + tdSingleValue: 0.51, + jnd: 2, + baseColor: '#000000', + confidence: 1, + basis: 'frontlit' as const, + calibrationDate: new Date().toISOString(), + filamentColor: '#ffffff', + }, + }; + const unusedNearWhite = { id: 'near-white', color: '#fefefe', td: 0.5 }; + + const result = generateAutoLayers( + [calibratedWhite, unusedNearWhite], + [{ hex: '#ffffff', count: 100 }], + 0.08, + 0.16, + undefined, + true, + false, + { algorithm: 'exhaustive', seed: 9 } + ); + + assert.deepEqual(result.filamentOrder, ['white']); + assert.equal(result.confidenceFactors.calibrationQuality, 1); + assert.equal(result.confidenceFactors.filamentCoverage, 0.5); +}); + test('auto-paint slice data never returns more than 500 layers', async () => { const { autoPaintToSliceHeights } = await loadAutoPaintModule(); const tallResult = { diff --git a/tests/hidingDistance.test.ts b/tests/hidingDistance.test.ts index d24d386..b1cbc0a 100644 --- a/tests/hidingDistance.test.ts +++ b/tests/hidingDistance.test.ts @@ -7,10 +7,12 @@ import type { Filament } from '../src/types/index.ts'; type ColorUtilsModule = typeof import('../src/lib/colorUtils.ts'); type CalibrationModule = typeof import('../src/lib/calibration.ts'); +type FilamentUpdatesModule = typeof import('../src/lib/filamentUpdates.ts'); type ProfileManagerModule = typeof import('../src/lib/profileManager.ts'); let colorUtilsModule: Promise | null = null; let calibrationModule: Promise | null = null; +let filamentUpdatesModule: Promise | null = null; let profileManagerModule: Promise | null = null; async function loadViteModule(modulePath: string): Promise { @@ -35,6 +37,8 @@ const loadColorUtils = () => (colorUtilsModule ??= loadViteModule('/src/lib/colorUtils.ts')); const loadCalibration = () => (calibrationModule ??= loadViteModule('/src/lib/calibration.ts')); +const loadFilamentUpdates = () => + (filamentUpdatesModule ??= loadViteModule('/src/lib/filamentUpdates.ts')); const loadProfileManager = () => (profileManagerModule ??= loadViteModule('/src/lib/profileManager.ts')); @@ -124,6 +128,41 @@ test('editing the swatch color deactivates a calibration', async () => { assert.deepEqual(channelHds(reverted), [0.068, 0.354, 0.373]); }); +test('color edits preserve calibration but re-anchor inactive scalar HDs', async () => { + const { estimateHidingDistanceFromColor } = await loadColorUtils(); + const { colorEditUpdateForFilament } = await loadFilamentUpdates(); + const calibration = { + opacityLayers: 7, + layerHeight: 0.08, + firstLayerHeight: 0.16, + td: [0.49, 0.51, 0.5] as [number, number, number], + tdSingleValue: 0.51, + jnd: 2, + baseColor: '#000000', + confidence: 0.9, + basis: 'frontlit' as const, + calibrationDate: '2026-07-02T00:00:00.000Z', + filamentColor: '#ffffff', + }; + const filament: Filament = { + id: 'white', + color: '#ffffff', + td: 0.51, + calibration, + }; + + const edited = colorEditUpdateForFilament(filament, '#000000'); + assert.equal(edited.color, '#000000'); + assert.equal(edited.td, estimateHidingDistanceFromColor('#000000')); + assert.equal('calibration' in edited, false, 'the stored calibration should remain reversible'); + + const reverted = colorEditUpdateForFilament( + { ...filament, color: '#000000', td: edited.td as number }, + '#ffffff' + ); + assert.equal(reverted.td, calibration.tdSingleValue); +}); + test('a calibration without a stored color still applies (legacy compatibility)', async () => { const { activeFrontlitCalibration } = await loadCalibration(); const filament = { From 1782d2c00557dd8640e14bc107e7300f0c435cce Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 6 Jul 2026 19:36:19 +0300 Subject: [PATCH 54/81] test: add calibrated frontlit profile fixture --- CHANGELOG.md | 1 + .../8_Colors_Calibrated_Frontlit.kfil | 303 ++++++++++++++++++ tests/profileManager.test.ts | 38 +++ 3 files changed, 342 insertions(+) create mode 100644 tests/assets/filament-profiles/8_Colors_Calibrated_Frontlit.kfil diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e3cd1..c20bfb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to Kromacut are documented in this file. - **Preserve color separation (Auto-paint)** - Enhanced color matching can keep each distinct 2D image color mapped to a distinct printable color when the stack exposes enough printable colors, preserving gradients that would otherwise collapse onto a flat surface. It is mutually exclusive with Height dithering because both modes change the same printable height map. - **Auto-paint optimization progress** - Enhanced matching now reports approximate search progress while the background optimizer runs. - **Auto-paint test and benchmark coverage** - Added deterministic stack goldens, layer-invariant regression coverage, realized CIEDE2000 quality-budget tests, and an on-demand benchmark for fixture profiles using the same printable-stack mapper as the optimizer. +- **Filament profile import fixture** - Added a schema-v2 frontlit-calibrated 8-color `.kfil` fixture with import coverage to guard against accidental hiding-distance rescaling. - **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. ### Changed diff --git a/tests/assets/filament-profiles/8_Colors_Calibrated_Frontlit.kfil b/tests/assets/filament-profiles/8_Colors_Calibrated_Frontlit.kfil new file mode 100644 index 0000000..daf203f --- /dev/null +++ b/tests/assets/filament-profiles/8_Colors_Calibrated_Frontlit.kfil @@ -0,0 +1,303 @@ +{ + "id": "c2e9e401-d1bf-49c8-9166-1bdc487ffd64", + "name": "8 Colors Calibrated Frontlit", + "version": 2, + "filaments": [ + { + "id": "p9c63ms", + "color": "#ffffff", + "td": 0.5077096875055095, + "calibration": { + "opacityLayers": 7, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.4908894086367234, + 0.5077096875055095, + 0.49943726291595697 + ], + "tdSingleValue": 0.5077096875055095, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.9587611519194583, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.797Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 7, + "mergeLayers": 5 + }, + { + "baseColor": "#00b8c4", + "opacityLayers": 7, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default", + "filamentColor": "#ffffff" + } + }, + { + "id": "plvjtmc", + "color": "#000000", + "td": 0.05, + "calibration": { + "opacityLayers": 1, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.05, + 0.05, + 0.05 + ], + "tdSingleValue": 0.05, + "jnd": 2, + "baseColor": "#6300c5", + "confidence": 0.467990454631796, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.806Z", + "reads": [ + { + "baseColor": "#6300c5", + "opacityLayers": 1 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 1 + } + ], + "channelSource": "measured", + "jndSource": "default", + "filamentColor": "#000000" + } + }, + { + "id": "98z555k", + "color": "#ff7eb4", + "td": 0.519286177194662, + "calibration": { + "opacityLayers": 7, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.519286177194662, + 0.23963829238245016, + 0.41680651754298187 + ], + "tdSingleValue": 0.519286177194662, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.96, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.819Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 7, + "mergeLayers": 5 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 5, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default", + "filamentColor": "#ff7eb4" + } + }, + { + "id": "w8cncoa", + "color": "#f7d000", + "td": 0.44542466216175114, + "calibration": { + "opacityLayers": 6, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.44542466216175114, + 0.3595734312582509, + 0.0673046271932013 + ], + "tdSingleValue": 0.44542466216175114, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.7999999999999999, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.838Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 6, + "mergeLayers": 5 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 4, + "mergeLayers": 3 + } + ], + "channelSource": "measured", + "jndSource": "default", + "filamentColor": "#f7d000" + } + }, + { + "id": "vsn9q6u", + "color": "#d83400", + "td": 0.39476260956525944, + "calibration": { + "opacityLayers": 6, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.39476260956525944, + 0.11055878203215688, + 0.06967022943285602 + ], + "tdSingleValue": 0.39476260956525944, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.7812423269108548, + "basis": "frontlit", + "calibrationDate": "2026-07-04T15:13:24.473Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 6, + "mergeLayers": 5 + }, + { + "baseColor": "#f7d000", + "opacityLayers": 3, + "mergeLayers": 2 + }, + { + "baseColor": "#ff5ec4", + "opacityLayers": 3, + "mergeLayers": 2 + } + ], + "channelSource": "measured", + "jndSource": "default", + "filamentColor": "#d83400" + } + }, + { + "id": "azwg1yp", + "color": "#6300c5", + "td": 0.3743629711219067, + "calibration": { + "opacityLayers": 4, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.22211060078297443, + 0.06830463482833873, + 0.3743629711219067 + ], + "tdSingleValue": 0.3743629711219067, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.7999999999999999, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.868Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 4, + "mergeLayers": 3 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 5, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default", + "filamentColor": "#6300c5" + } + }, + { + "id": "xyyxysq", + "color": "#00b8c4", + "td": 0.2715230168634275, + "calibration": { + "opacityLayers": 6, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.0819169025740069, + 0.2715230168634275, + 0.13120519312247034 + ], + "tdSingleValue": 0.2715230168634275, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.8804030538435403, + "basis": "frontlit", + "calibrationDate": "2026-07-04T15:13:24.508Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 6, + "mergeLayers": 4 + }, + { + "baseColor": "#ffffff", + "opacityLayers": 4 + }, + { + "baseColor": "#6300c5", + "opacityLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default", + "filamentColor": "#00b8c4" + } + }, + { + "id": "upcjpfe", + "color": "#04bb00", + "td": 0.37055468605919367, + "calibration": { + "opacityLayers": 5, + "layerHeight": 0.08, + "firstLayerHeight": 0.16, + "td": [ + 0.1553450909824613, + 0.37055468605919367, + 0.07053705952903433 + ], + "tdSingleValue": 0.37055468605919367, + "jnd": 2, + "baseColor": "#000000", + "confidence": 0.7946766727762713, + "basis": "frontlit", + "calibrationDate": "2026-07-02T15:07:54.898Z", + "reads": [ + { + "baseColor": "#000000", + "opacityLayers": 5, + "mergeLayers": 4 + }, + { + "baseColor": "#f7d000", + "opacityLayers": 4, + "mergeLayers": 4 + } + ], + "channelSource": "measured", + "jndSource": "default", + "filamentColor": "#04bb00" + } + } + ], + "createdAt": 1783281850273, + "updatedAt": 1783281850273 +} \ No newline at end of file diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index c4d1f4b..dbdb5f8 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { createServer } from 'vite'; @@ -374,6 +375,43 @@ test('v2 profile imports are not re-scaled (double-import idempotence)', async ( assert.equal(stored!.filaments[0].td, 0.4); }); +test('calibrated frontlit fixture imports without rescaling measured hiding distances', async () => { + const { importProfiles, parseProfileFile, CURRENT_PROFILE_VERSION } = await loadProfileManager(); + const raw = readFileSync( + resolve( + process.cwd(), + 'tests/assets/filament-profiles/8_Colors_Calibrated_Frontlit.kfil' + ), + 'utf8' + ); + const parsed = parseProfileFile(raw); + + assert.ok(parsed); + assert.equal(parsed.length, 1); + + const source = parsed[0]; + assert.equal(source.name, '8 Colors Calibrated Frontlit'); + assert.equal(source.version, CURRENT_PROFILE_VERSION); + assert.equal(source.filaments.length, 8); + + const sourceTds = source.filaments.map((filament) => filament.td); + const result = importProfiles([], parsed); + + assert.equal(result.imported.length, 1); + assert.equal(result.imported[0].version, CURRENT_PROFILE_VERSION); + assert.deepEqual( + result.imported[0].filaments.map((filament) => filament.td), + sourceTds + ); + + for (const filament of result.imported[0].filaments) { + assert.ok(filament.calibration); + assert.equal(filament.calibration.basis, 'frontlit'); + assert.equal(filament.calibration.tdSingleValue, filament.td); + assert.equal(filament.calibration.filamentColor, filament.color); + } +}); + test('stored v1 profiles migrate tds once on load', async () => { const { loadProfiles, CURRENT_PROFILE_VERSION } = await loadProfileManager(); const existingStorage = Reflect.get(globalThis, 'localStorage') as From 0b76801858f0cce5e2e35f86730f9df502d11d6d Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 6 Jul 2026 21:31:41 +0300 Subject: [PATCH 55/81] update gitingore --- .agents/calibration-improvements.md | 234 ------------------ .agents/calibration-phase1-review-issues.md | 174 ------------- .agents/calibration-phase2-color.md | 228 ----------------- .agents/calibration-phase2-review-issues.md | 123 --------- .agents/hiding-distance-migration.md | 207 ---------------- .agents/next-best-color-td-fix.md | 174 ------------- .../2026-07-02-logo-test/findings.md | 89 ------- .../2026-07-02-logo-test/image.png | Bin 68424 -> 0 bytes .../2026-07-02-logo-test/print-photo.jpg | Bin 496982 -> 0 bytes .gitignore | 4 +- 10 files changed, 2 insertions(+), 1231 deletions(-) delete mode 100644 .agents/calibration-improvements.md delete mode 100644 .agents/calibration-phase1-review-issues.md delete mode 100644 .agents/calibration-phase2-color.md delete mode 100644 .agents/calibration-phase2-review-issues.md delete mode 100644 .agents/hiding-distance-migration.md delete mode 100644 .agents/next-best-color-td-fix.md delete mode 100644 .agents/print-findings/2026-07-02-logo-test/findings.md delete mode 100644 .agents/print-findings/2026-07-02-logo-test/image.png delete mode 100644 .agents/print-findings/2026-07-02-logo-test/print-photo.jpg diff --git a/.agents/calibration-improvements.md b/.agents/calibration-improvements.md deleted file mode 100644 index fab283c..0000000 --- a/.agents/calibration-improvements.md +++ /dev/null @@ -1,234 +0,0 @@ -# Calibration redesign — print-and-read-back - -Status: design / detailed outline. Not implemented yet. Supersedes the earlier -photo-based outline in this file. - -## Decision: scrap the photo-based calibration - -The current system ([`src/lib/calibration.ts`](../src/lib/calibration.ts), -[`src/components/FilamentCalibrationWizard.tsx`](../src/components/FilamentCalibrationWizard.tsx)) -asks users to photograph backlit patches and sample RGB. It is being **removed**: - -- Hard to make accurate (lighting, white balance, monitor, single-pixel samples). -- Bad UX (cameras, color pickers, manual RGB entry). -- It measures *backlit* transmission, but prints are viewed *frontlit*, so the - result is then mangled by a magic constant - ([`FRONTLIT_TD_SCALE = 0.1`](../src/lib/autoPaint.ts#L395)). - -## New shape (the big outline, with decisions made) - -1. User selects one or more filaments from a profile and hits **Calibrate**. -2. App generates a **calibration print** (STL *or* 3MF + instructions) to download. -3. User prints it. -4. User **reads numbers off the physical print** and types them back into the app. -5. App fits a frontlit TD per filament and writes it onto the profile. Auto-paint - and the preview use it directly — no more `FRONTLIT_TD_SCALE`. - -Locked design decisions (from user): - -- **Read-back = read a number off the print.** No camera. The user's eye reads a - *threshold*, which is robust to lighting and monitor calibration (the exact - weaknesses that sank the photo flow). -- **Base = fixed black.** Wedges print over a black base, matching the common - frontlit base. No base-color picker in v1. -- **Format = user chooses** 3MF (multi-material, AMS-friendly) or STL (+ swap - instructions, universal). - ---- - -## The core mechanism: opacity threshold vs. an adjacent reference - -The single most valuable, eye-readable quantity for a frontlit print is **how many -layers it takes for the filament to fully hide the black base** (its opacity -point). One number per filament pins the optical curve far better than the current -global 0.1 fudge. - -Why this is robust where color-matching/photos are not: the user is not judging an -absolute color against a monitor. They compare each wedge step to a **fully-opaque -reference patch of the same filament printed right next to the wedge**, and report -the first step that is *indistinguishable from the reference*. Relative -"same/not-same" judgments against an adjacent physical reference are reliable under -almost any lighting. - -``` -Filament A wedge (over black base): Reference - layers: 1 2 3 4 5 6 7 8 (~20 layers, - [▓] [▒] [░] [.] [ ] [ ] [ ] [ ] | fully opaque) - black showing through ──► full color [█] - -App asks: "Find the first patch that looks the same as the reference block." -User types: 6 -``` - -**One read only.** A second "mid-fade" read was considered and dropped: a partial- -transmission point has no sharp visual cue — there's nothing to compare it against, -so the eye can't place it accurately (the whole reason the opacity read works is the -adjacent fully-opaque reference). The opacity threshold is the single number. - -### Print geometry (per filament) - -- A row of square patches, patch *i* = *i* layers of the filament, *i = 1..N*. -- All patches sit on a shared **black base** (enough black layers to be opaque on - its own, e.g. 4–6). -- One tall **reference patch** (same filament, ~20 layers / guaranteed opaque) - adjacent to the wedge. -- Unambiguous orientation + indexing: leftmost patch = 1 layer; add an asymmetric - corner marker so the user can't read it backwards, and show the matching layout - diagram in the app. (Tiny embossed digits don't print reliably — rely on - position + the in-app legend, not printed numbers.) -- Patch size ~12–15 mm so steps are easy to compare by eye. - -### From a number to a TD (the fit) - -- **Thickness must use the real layer-height model, not `layers × layerHeight`.** - Auto-paint's first printed layer is `printableFirstLayerHeight = max(layerHeight, - firstLayerHeight)`, every layer after is `layerHeight` - ([`autoPaint.ts`](../src/lib/autoPaint.ts#L648)). The calibration generator, the - TD fit, and auto-paint must all share this one conversion or the TD is biased. - - In the calibration print the color wedge sits **on top of the black base**, so - its layers are all regular `layerHeight`; the black base's bottom layer takes - the first-layer height (for printability, doesn't affect color thickness). - - `d_opaque = opacityLayers × layerHeight`. Store `firstLayerHeight` + - `layerHeight` with the calibration so the number stays interpretable if print - settings change later. -- The read maps to TD through a **per-color "effective opacity transmission" `T*`** - derived from a perceptual just-noticeable-difference (JND), not a hand-picked - number. Two decoupled steps: - 1. Solve `ΔE00(blendSrgbChannel(0, filament, T), filament) = JND` for `T*`. - Depends only on the filament's own swatch color + the JND (ΔE00 is monotonic - in T → unique; bisection). `T*` is *not* a function of TD. - 2. `TD = -d_opaque / log10(T*)`. - This is the same `TD = -d/log10(T_opaque)` form as before, but `T_opaque = T*` is - *computed per filament* instead of guessed — so a bright filament (small `T*`) - gets a large TD and a dark one (large `T*`) a small TD automatically. The blend - and ΔE00 are the exact functions the optimizer already uses. -- **JND is a standard human-vision constant** (CIEDE2000 JND ≈ 1; use ~1–2.5 to - absorb lighting/observer spread). It is NOT fit to any filament — per-filament - optics are captured by the read itself. Brand/batch/compound variation needs no - constant change; changing filament just means re-reading that filament. -- Model boundary: single-TD Beer-Lambert assumes absorption, no scattering, so the - read pins the *endpoint* accurately but intermediate-layer color may drift — that - drift is Phase 2's job, not this fit's. -- Confidence = simple function of whether the resulting TD lands in a plausible - range. No more per-channel least-squares from RGB samples. - -### Per-channel TD — derive RGB channel TDs from the single read - -Auto-paint consumes calibrated per-channel TDs -([`USE_CALIBRATED_CHANNEL_TD`](../src/lib/autoPaint.ts#L396), -`calibratedTdChannels`), so we keep that path alive by **computing the single TD -and converting it to RGB channel TDs** using the filament's own swatch color. - -Physical basis: over a black base, the visible color settles channel-by-channel. -The brightest channel is the *most transmissive* (largest TD) and therefore the -**last** to reach full color — so the opacity the eye reads is governed by that -channel. That makes the brightest channel the natural anchor for the measured TD. - -Proposed conversion (deterministic, reuses the existing heuristic shape from -[`estimateTDFromColor`](../src/lib/colorUtils.ts#L39) = `1.0 + luminance*5.8`): - -1. Per channel, raw TD shape `rawTD_c = 1.0 + c_norm*5.8`, where `c_norm ∈ [0,1]` - is that channel of the filament swatch. -2. Anchor on the brightest channel: `k = TD_measured / rawTD_{argmax c}`. -3. `td_c = k * rawTD_c` for R, G, B. `tdSingleValue = TD_measured`. - -This preserves the color-driven per-channel *ratios* while pinning the overall -magnitude to the measurement. The exact ratio model is a knob to validate against -the 7-color frontlit set; the structure (single read → channels) is fixed. - ---- - -## Generating the print - -Extend [`generateCalibrationPatchesStl.ts`](../src/lib/generateCalibrationPatchesStl.ts) -(already builds patch rows) to add: the black base layers, the reference patch, -the orientation marker, and a 3MF variant. - -- **3MF:** reuse the app's existing multi-material 3MF export path. Materials = - black base + each selected filament. Slicer auto-assigns; minimal instructions. -- **STL:** geometry only + generated text instructions ("load black, print to - layer X; swap to filament A; …"). Universal, manual swaps. -- **Multi-filament layout & swap burden:** one wedge+reference block per filament, - tiled in a grid. Single-extruder users would face many swaps for a combined - print (black + N colors). Decision: default to **one block per filament file** - for non-AMS (each = black + 1 color = a single swap), and offer a **combined - tiled file** for AMS/multi-material users. The "let the user choose format" - decision pairs naturally with a "combined vs per-filament" toggle. - ---- - -## UX flow - -1. **Profile UI → Calibrate.** Multi-select filaments (checkboxes). -2. **Configure:** layer height (from print settings), max layers N (default ~10–12), - patch size; base is black (fixed); pick 3MF/STL and combined/per-filament. -3. **Generate & download** + show printable instructions (viewing conditions, - how to find the opacity patch vs the reference, orientation). -4. **Enter results:** a list of the selected filaments, each with an "opacity - patch #" input (and optional second read). A live predicted swatch updates as - they type so they get immediate feedback. -5. **Save:** frontlit TD written to each profile; preview + auto-paint update. - ---- - -## Integration & data - -- [`src/lib/autoPaint.ts`](../src/lib/autoPaint.ts): **remove `FRONTLIT_TD_SCALE`**. - Measured TD is already frontlit-correct. For uncalibrated filaments, re-baseline - the default frontlit TD (today's `estimateTDFromColor` heuristic was implicitly a - backlit value scaled by 0.1 — that baseline must be re-derived). -- [`src/types/index.ts`](../src/types/index.ts): replace the calibration shape. - New `FrontlitCalibration { opacityLayers, td: [r,g,b], tdSingleValue, - layerHeight, firstLayerHeight, basis: 'black-frontlit', printedAt, notes? }`. - Remove `measurements`, `whiteReference`, and all RGB-sample fields. - -### Breaking changes are fine — no deprecation, aggressive removal - -The user has OK'd breaking changes with **no migration notice**. So: -- **Delete** `FilamentCalibrationWizard.tsx` entirely. -- **Gut** `calibration.ts`: drop the Beer-Lambert RGB-sample fit, white-reference - handling, per-channel least-squares, confidence-from-CV, import/export of the old - shape. Keep/replace with just: the opacity→TD fit, the channel conversion, and - generator helpers. -- Persistence ([`profileManager.ts`](../src/lib/profileManager.ts), - `useProfileManager.ts`, `.kfil`/`.kapp`, localStorage): old `CalibrationResult` - fields are simply **ignored/stripped on load** — no conversion, no notice. The - filament keeps its plain TD; calibration just reads as absent until recalibrated. -- Rewrite `tests/calibration.test.ts`; drop fixtures tied to the old flow. -- Audit other call sites of the removed exports (`predictTransmission`, - `calculateTDFromMeasurements`, `validateWhiteReference`, etc.) and delete dead UI. -- Docs (`src/docs/*`) + `CHANGELOG.md`. - ---- - -## Phase 2 — color accuracy (combos) - -Designed in [calibration-phase2-color.md](calibration-phase2-color.md): the Phase-1 -opacity read repeated over 2–3 different colored bases per filament measures the -per-channel TDs directly (replacing the `1 + c*5.8` heuristic ratios), and the -cross-base consistency of those reads pins the JND as one global fitted constant. -Camera-free, same tile geometry, same "match the rail" judgment; pruned to -printable combos using the Phase-1 TDs as originally intended. - ---- - -## Decisions resolved - -- **Read-back:** single opacity-threshold number, read against an adjacent opaque - reference patch. No camera. (Mid-fade second read dropped — not eye-readable.) -- **Base:** fixed black. -- **Format:** user picks 3MF or STL; plus combined (AMS) vs per-filament files. -- **Per-channel TD:** compute single TD → derive R/G/B channel TDs from the swatch - color (conversion above). -- **Layer heights:** thickness uses the shared `max(layerHeight, firstLayerHeight)` - first-layer model; store both heights with the calibration. -- **Migration:** none. Breaking change, old calibration silently stripped. - -## Still to pin - -- **JND value** (1 vs ~2.5): a viewing-robustness choice, *not* a filament fit. - Sanity-check against the 7-color frontlit set that the resulting prints look - right; do not derive it from those specific filaments. -- Default max layers N and patch size for the wedge. -- The per-channel ratio model (validate the `1.0 + c*5.8` shape frontlit). - diff --git a/.agents/calibration-phase1-review-issues.md b/.agents/calibration-phase1-review-issues.md deleted file mode 100644 index 2e3a6b7..0000000 --- a/.agents/calibration-phase1-review-issues.md +++ /dev/null @@ -1,174 +0,0 @@ -# Phase 1 review — issues to fix before Phase 2 - -> **Status: ALL RESOLVED (2026-07-02).** All six issues below were fixed in the -> follow-up changes (calibration sanitizer + load-path stripping, first-layer-aware -> base height, STL swap instructions, `basis: 'frontlit'` rename, per-row calibrate -> button with reset-on-open, changelog cleanup). Verified: 224 tests pass, tsc and -> eslint clean. Kept for history; Phase 2 is unblocked. - -Review of commits `354f736` (frontlit opacity-read calibration) and `bd4fa71` (Load TD -Test button removal) against the plan in -[calibration-improvements.md](calibration-improvements.md). The core fit -(`solveOpacityTransmission`, `computeFrontlitCalibration`, `deriveChannelTds`), the -dialog flow, the 3MF slot mapping, and the round-trip tests all match the plan and are -in good shape. The issues below are ordered by severity; #1 and #2 are blockers for -calling Phase 1 done. - ---- - -## 1. HIGH — Old persisted calibrations are not stripped on load, and now bypass the frontlit scale - -The plan requires: "old `CalibrationResult` fields are simply **ignored/stripped on -load**." That stripping was never implemented — the commit touched no persistence code. - -Three load paths pass `filament.calibration` through untyped/unvalidated: - -- `loadProfiles()` — [profileManager.ts:17-30](../src/lib/profileManager.ts#L17-L30) - (localStorage `kromacut.autopaint.profiles`) -- `importProfiles()` filament validation — [profileManager.ts:165-168](../src/lib/profileManager.ts#L165-L168) - (`.kfil` / profile JSON import; only checks `id`/`color`/`td`) -- `loadAutoPaintPersisted()` — [App.tsx:108-142](../src/App.tsx#L108-L142) - (localStorage `kromacut.autopaint.v1`, `parsed.filaments` used as-is) - -Why this is actively harmful, not just cosmetic: the old `CalibrationResult` shape -(`{ measurements, whiteReference, td: [r,g,b], tdSingleValue, confidence, -calibrationDate }`) survives a JSON round-trip and **structurally satisfies the two -runtime checks that matter**: - -- `scaleFilamentForFrontlight()` ([autoPaint.ts:411-419](../src/lib/autoPaint.ts#L411-L419)) - skips the `FRONTLIT_TD_SCALE = 0.1` fallback for any truthy `calibration`. -- `calibratedTdChannels()` ([autoPaint.ts:402-409](../src/lib/autoPaint.ts#L402-L409)) - only checks that `calibration.td` is a finite-positive array — which the old backlit - fit also produced. - -So a user with an old photo calibration gets their **backlit** TDs (~2–6 mm, which the -old pipeline multiplied by 0.1) fed into the simulation as if they were measured -frontlit TDs — roughly 10× too transmissive, silently, producing drastically wrong -layer stacks. The changelog says "recalibrate any previously calibrated filaments" but -nothing in code enforces or even detects it. `computeProfileConfidence()` and the -FilamentRow "calibrated" badge also accept the old object (it has `confidence` + -`calibrationDate`, and the old flow also set `td = tdSingleValue`, so the -`calibrationMatchesTd` guard passes). - -**Fix:** add a single validator (e.g. in `calibration.ts`: -`sanitizeFrontlitCalibration(value: unknown): FrontlitCalibration | undefined`) that -accepts an object only if it matches the new shape — cheapest reliable discriminators -are `basis === 'black-frontlit'` plus finite `opacityLayers >= 1` (old shape has -neither) — and returns `undefined` otherwise. Apply it to `filament.calibration` in all -three load paths above. A defensive `basis`/`opacityLayers` check inside -`calibratedTdChannels`/`scaleFilamentForFrontlight` is cheap extra insurance since -worker inputs come from persisted state. Add a regression test: a profile JSON carrying -an old-shape calibration loads with `calibration === undefined` and the filament falls -back to the `FRONTLIT_TD_SCALE` path. - ---- - -## 2. HIGH — Calibration print geometry ignores first-layer height, so patch layer counts are wrong when sliced - -`baseHeight()` ([generateCalibrationPrint.ts:88-90](../src/lib/generateCalibrationPrint.ts#L88-L90)) -is `baseLayers × layerHeight`, with a comment claiming "The slicer applies its own -first-layer height when printing, so this is just baseLayers worth of regular layers." -That reasoning is inverted: because the slicer's first layer is taller, the Z grid is -`firstLayer + n × layerHeight`, and a base built from regular layers only does NOT land -on that grid. - -Defaults make it concrete: `layerHeight 0.08`, `firstLayerHeight 0.2` (and the embedded -3MF project sets `initial_layer_print_height = max(layerHeight, firstLayerHeight)` = -0.2, [generateCalibrationPrint.ts:571](../src/lib/generateCalibrationPrint.ts#L571)). -Slicer layer tops: 0.2, 0.28, 0.36, 0.44, … Base top: 5 × 0.08 = **0.40 mm — mid-layer**. -Every wedge patch top (0.40 + i × 0.08) is then also mid-layer. The slicer will -realize each patch with a color layer count that differs from the nominal patch number -(off by one, or with a partial base/color boundary layer), so the number the user reads -back does not correspond to `opacityLayers × layerHeight` of filament — the exact bias -the plan warned about ("Thickness must use the real layer-height model, not -`layers × layerHeight`"). - -**Fix:** make the base absorb the first layer: -`baseHeight = max(layerHeight, firstLayerHeight) + (baseLayers − 1) × layerHeight`. -Then the base top and every patch/rail top land exactly on the slicer grid (0.2 + 4×0.08 -= 0.52; patches at 0.52 + i×0.08 ✓), and each color patch is exactly `i` regular -layers, which is what `computeFrontlitCalibration` assumes. Update the stale comment, -the `calibrationPrint.test.ts` expectations, and add an assertion that the base top and -patch tops are integer multiples of `layerHeight` above `max(layerHeight, -firstLayerHeight)`. Note the wedge/rail math itself needs no change — only the base -thickness. - -This matters for both formats: the 3MF embeds the matching first-layer height itself; -for STL the dialog should tell the user which layer height AND first-layer height to -slice with (see #3). - ---- - -## 3. MEDIUM — STL flow lacks concrete per-filament print/swap instructions - -The plan specifies generated text instructions for STL ("load black, print to layer X; -swap to filament A"). What shipped is one generic sentence ("base, then swap to the -color above the base" — [FilamentCalibrationDialog.tsx:523](../src/components/FilamentCalibrationDialog.tsx#L523)). -Two concrete gaps: - -- **No per-filament base reminder.** The Base step lets the user pick a different base - per filament, and the TD fit uses that choice (`resolveBaseColor` → - `computeFrontlitCalibration.baseColor`). At print time nothing shows which base goes - with which filament; printing over the wrong base silently produces a wrong fit. -- **No exact swap point.** After fixing #2 the swap is well-defined: pause/swap after - layer `baseLayers`, i.e. at Z = `max(layerHeight, firstLayerHeight) + - (baseLayers − 1) × layerHeight`. Show the layer number and Z, plus "slice at layer - height H, first layer F" so the user's slicer settings match the geometry. - -A per-filament line in the Print step ("Red: base = Black, swap after layer 5 / -Z 0.52 mm") is enough; a downloadable .txt is optional polish. - ---- - -## 4. MEDIUM — `basis: 'black-frontlit'` is hardcoded even for non-black bases - -`computeFrontlitCalibration` always stores `basis: 'black-frontlit'` -([calibration.ts:224](../src/lib/calibration.ts#L224)) while also storing the actual -`baseColor`, which the base-picker feature lets be white or anything else. A -calibration over white then carries a self-contradictory record. This is a persisted -user-data field in a brand-new format — rename it **now**, before it ships, rather than -migrating later. Suggestion: `basis: 'frontlit'` with `baseColor` as the sole source of -truth (keep `baseColor` required rather than optional). If #1's sanitizer keys on -`basis`, update both together. - ---- - -## 5. LOW — `initialFilamentId` preselect is dead and would be stale if wired - -The only caller is `handleOpenCalibration()` with no id -([AutoPaintTab.tsx:490](../src/components/AutoPaintTab.tsx#L490)), so per-filament -preselect never happens. Even if an id were passed, the dialog captures -`initialFilamentId` in a `useState` initializer at mount -([FilamentCalibrationDialog.tsx:107-109](../src/components/FilamentCalibrationDialog.tsx#L107-L109)) -and only re-reads it in `reset()`, which runs via a 300 ms `setTimeout` holding a -possibly stale closure — racing AutoPaintTab's own 300 ms reset of -`calibratingFilamentId` to null. Either delete the prop and the AutoPaintTab id -plumbing, or fix properly (sync selection in an effect when `open` flips true). - ---- - -## 6. LOW — Stale changelog entry for the deleted photo sampler - -`CHANGELOG.md` unreleased "Changed" still lists **"Calibration image sampler"** -(circular brush overlay) — a refinement to the photo wizard that this same unreleased -version deleted. Per AGENTS.md changelog guidance (entries classify against the last -release), remove that bullet; the frontlit-calibration "Added" bullet already covers -the replacement. - ---- - -## Reviewed and explicitly OK (no action) - -- Fit math: `T*` bisection over any base, `TD = -d/log10(T*)`, brightest-channel anchor - in `deriveChannelTds` — all match the plan; round-trip tests recover synthetic TDs - within integer-read quantization. -- The base-picker (vs the plan's fixed black base) is a sound generalization — the solve - handles arbitrary bases and errors out on low contrast with a helpful message. -- Keeping `FRONTLIT_TD_SCALE` as the *uncalibrated-only* fallback rather than deleting - it outright is a reasonable reading of the plan's "re-baseline the default." -- Overlapping closed shells in the STL (orientation foot into base, patches butting the - rail): slicers union coincident shells within one solid; fine for a standalone - calibration part. -- 3MF slot mapping to real profile filaments in profile order, no phantom base, no - `` block — matches the refined design and is test-covered. -- All 219 tests pass on the current branch. diff --git a/.agents/calibration-phase2-color.md b/.agents/calibration-phase2-color.md deleted file mode 100644 index 0d11b15..0000000 --- a/.agents/calibration-phase2-color.md +++ /dev/null @@ -1,228 +0,0 @@ -# Calibration Phase 2 — color accuracy via multi-base opacity reads - -Status: core implementation complete; metamer-pair validation strip still open. Builds directly on the Phase 1 -frontlit calibration (see [calibration-improvements.md](calibration-improvements.md), -implemented in `354f736` + the review fixes). - -## Goal - -Phase 1 pins each filament's overall optical magnitude (scalar frontlit TD) from a -single opacity read over one base. Two things remain heuristic: - -1. **Per-channel TD ratios** — currently derived from the swatch color via the - `1 + c/255 × 5.8` shape ([`deriveChannelTds`](../src/lib/calibration.ts)), anchored - to the measured scalar. The ratios were never measured; they drive every stacked - color blend the optimizer and preview compute, so they are the dominant remaining - error source for intermediate/blended colors. -2. **The JND constant** — [`OPACITY_JND = 2.0`](../src/lib/calibration.ts) defines - "indistinguishable from the reference rail" and sets the global TD scale - (`TD = -d/log10(T*)` where `T*` depends on the JND). It was never validated against - physical prints (the Phase-1 "Still to pin" item). - -Phase 2 measures both, stays **camera-free**, and changes **only the calibration -side** — the optimizer and preview already consume per-channel TDs -(`calibration.td`), so better numbers flow through with zero pipeline changes. - -## Core mechanism: the Phase-1 read, repeated over different colored bases - -Phase 1 already generalized the opacity read to arbitrary bases (base-picker + -`solveOpacityTransmission(color, jnd, baseColor)`). Phase 2's entire measurement is: -**read the same filament over 2–3 different bases.** - -Why this measures per-channel TDs: the opacity read is governed by the _slowest_ -channel to converge, and which channel that is depends on the base. - -- Yellow over **black**: R and G must build up from 0 → governed by td_r, td_g. -- Yellow over **blue**: the blue channel must attenuate 255 → ~0 → governed by td_b. -- Each additional base with a different channel signature adds an independent - constraint on the (td_r, td_g, td_b) triple. - -No new read type, no new geometry, no new judgment for the user's eye — still -"first patch that matches the rail beside it," which is the whole reason the -Phase-1 flow is robust. - -### The fit - -Per filament, given reads `{(base_i, n_i)}` at layer height `h`: - -- Model: per-channel transmission `T_c(d) = 10^(-d / td_c)`; blended color = - `blendSrgbChannel(base_c, filament_c, T_c(d))` per channel (the exact functions the - optimizer uses). -- Predicted read for a candidate `(td_r, td_g, td_b)`: - `n̂_i = min { n : ΔE00(blend(base_i, A, T(n·h)), A) ≤ JND }`. -- Fit: minimize `Σ_i (n̂_i − n_i)²` (or a smooth surrogate on log-TD) over the - channel TDs, **regularized toward the Phase-1 anchor structure** (brightest-channel - anchor + heuristic ratios as the prior) so 2 reads still give a stable answer. - Deterministic — coarse grid + local refinement, no randomness. -- With **1 read** (Phase-1 case) the fit degenerates to exactly the current - `deriveChannelTds` behavior. Phase 2 is a strict superset; single-base calibration - keeps working unchanged. - -Integer reads quantize each constraint, so prefer overdetermination (3 bases) over -exact solving, and keep `maxLayers` large enough that reads land mid-wedge (the -confidence logic already penalizes clipped reads). - -### Optional secondary read: the adjacent-step merge point (the Phase-3 detector) - -On the same wedge, the user can optionally also report **the last patch that still -looks different from its neighbor** (walking up the wedge, the point where adjacent -steps become indistinguishable). Still a same/not-same judgment between two -physically adjacent patches — same trust model as the rail read. Observed in -practice on the first physical Phase-1 print: large visible steps in the first few -layers, then diminishing returns well before the rail match. - -Why it matters: the exponential model makes a _fixed quantitative prediction_ about -where this merge point sits relative to the rail-match point. Per channel, -step-to-step change ∝ `r^n(1−r)` while distance-to-rail ∝ `r^n` (with -`r = 10^(-h/td)`), so neighbors merge when the remaining gap to the rail is still -≈ `JND/(1−r)` — e.g. ~3× JND at h=0.08, TD≈0.5. Diminishing returns per layer is -therefore **expected** and does not by itself indicate scattering. - -What _would_ indicate scattering (a longer-than-exponential tail): merge points -landing systematically earlier — relative to the rail match — than the fitted -exponential predicts, consistently across filaments. The merge read costs nothing -extra to print, is optional at entry (never blocks saving a calibration), and is -stored alongside the opacity reads purely as model-form evidence. It can also serve -as a soft extra constraint in the TD fit when present. - -### JND: fit it from the same data instead of hand-tuning - -For one filament read over two bases, the implied TDs must agree — and -`T*(A over B1)` vs `T*(A over B2)` respond _differently_ to the JND (the ΔE00 -geometry along each blend path differs), so cross-base consistency identifies it. - -- Treat JND as **one global parameter shared across all filaments and reads** in a - calibration session (it is a human-vision constant, never per-filament — same - principle as Phase 1). -- Fit: outer 1-D search of JND over a sane band **[1.0, 3.0]**, minimizing the total - cross-base fit residual from the per-filament fits above; many (filament × base) - reads vs. one parameter → well overdetermined. -- Fallback: if the session has too few multi-base reads or the residual curve is - flat, keep `OPACITY_JND = 2.0` and report that in the UI/notes. Never silently fit - JND from a single filament. -- Once fitted, store the session JND and use it for subsequent single-read - calibrations too. - -This absorbs the unfinished Phase-1 validation task ("sanity-check JND against the -7-color frontlit set"): the first real Phase-2 calibration session of that filament -set _is_ the JND measurement. - -## Combo pruning (which bases to print each filament over) - -Auto-pick 2–3 bases per filament from the user's own profile filaments, pruned with -the Phase-1 TDs exactly as the original plan intended: - -- Drop pairs where `solveOpacityTransmission` returns undefined (no contrast → no - read possible; already handled/errored in Phase 1). -- Drop pairs whose **predicted** read (from current TDs) is 1 (base hidden - immediately — zero information) or > maxLayers (never converges in the wedge). -- Among survivors, greedily pick bases maximizing channel diversity: score each - candidate base by how differently it weights the three channels for this filament - (e.g., which channel the predicted read is governed by), and pick a set covering - different governing channels. Black (or the darkest filament) stays in the set as - the anchor read. -- The user can override the auto-picked set (same Select UI as Phase 1's base step, - now multi-select per filament). - -Note: bases only contribute their _surface color_ (they print opaque), so an -uncalibrated filament can serve as a base — no calibration-order dependency. - -## Print & UX - -- **Geometry:** unchanged tile generator. Phase 2 print = one tile per - (filament, base) pair instead of one per filament. Combined 3MF tiles or one STL - printed per pair (still exactly one swap each); the per-filament swap-instruction - list from the review fixes extends naturally ("Yellow over Blue: base = Blue, swap - after layer 5 / Z 0.52 mm"). -- **Dialog:** same four steps. Base step gains multi-select; Measure step shows one - read row per (filament, base) with the same live predicted swatch. A "quick" - single-base mode (= Phase 1 behavior) remains the default for one-off filaments; - "accurate" multi-base mode is the Phase 2 addition. -- Keep the read count honest: 5 filaments × 3 bases = 15 reads is the practical - ceiling for one session; default to 2 bases (anchor + one contrast base) and let - the user opt into 3. - -## Data shape - -Extend `FrontlitCalibration` **backward-compatibly** (the sanitizer from the review -fixes must keep accepting Phase-1 records): - -```ts -interface FrontlitCalibration { - // existing Phase-1 fields unchanged; opacityLayers/baseColor describe the - // anchor read for compatibility - ... - /** All reads used for the fit (anchor read included). Absent = Phase-1 single read. - * mergeLayers = optional adjacent-step merge point (Phase-3 evidence). */ - reads?: Array<{ baseColor: string; opacityLayers: number; mergeLayers?: number }>; - /** 'heuristic' (Phase-1 ratio model) or 'measured' (multi-base fit). */ - channelSource?: 'heuristic' | 'measured'; - /** JND actually used; already stored. Add where it came from. */ - jndSource?: 'default' | 'session-fit'; -} -``` - -Sanitizer: `reads`, `channelSource`, `jndSource` optional; validate elements if -present. No migration needed — Phase-1 records read as anchor-only. - -Confidence: multi-base calibrations with consistent reads score higher; a filament -whose reads _disagree_ badly under the best fit gets flagged (that disagreement is -also the scattering signal — see below). - -## Phase 3 gate (scattering) — decided by Phase-2 data, not folded in - -Phase 3 = upgrading the blend model from single-exponential absorption to something -scattering-aware (Kubelka-Munk style). **Deliberately NOT included in Phase 2**: - -- It adds ~2 parameters per channel per filament; fitting that from a handful of - integer reads is ill-conditioned without evidence the extra form is needed. -- The blend function sits on the optimizer hot path; KM composition is more - expensive — only pay if the data demands it. -- A model-form change shifts every stack prediction (goldens, previews, existing - calibrations churn). Do it once, with evidence, not speculatively. - -Phase 2 instead **collects the decision data as a byproduct**: - -1. Cross-base fit residuals: multi-base reads that no per-channel TD triple can - reconcile (beyond integer quantization) → absorption model at its limit. Surface - as reduced confidence + a note, per filament. -2. Adjacent-step merge reads (above) landing systematically earlier than the fitted - exponential predicts → long-tail/scattering signature. -3. Metamer-pair validation disagreements concentrated on stacks involving specific - filaments (white/pastel filaments are the likely offenders — TiO2 is nearly pure - scattering). - -Gate: if ≥2 of these signals agree across the 7-color set, design Phase 3 (likely a -single scattering-fraction parameter per filament, not full per-channel KM, fitted -from the same reads). If not, Phase 3 is unnecessary and the exponential stands. - -## Explicitly out of scope (v1 of Phase 2) - -- **Scattering model itself** — detection only, per the gate above. -- **Camera/photo anything.** Still banned. -- **On-screen color matching.** Still banned (monitor problem). - -## Validation & acceptance - -- **Synthetic round-trips:** generate reads from a known (td_r, td_g, td_b) + JND, - run the fit, recover within integer-read quantization (mirrors the Phase-1 test - style in `tests/frontlitCalibration.test.ts`). -- **JND identifiability test:** synthetic multi-filament session with a known JND; - the outer fit recovers it; a flat-residual session falls back to 2.0. -- **Physical acceptance — metamer pairs:** after calibrating the 7-color set, have - the app print a small strip of stack _pairs_ the model predicts to be - indistinguishable (e.g., "2 layers A over B" next to "1 layer A' over B'" with - predicted ΔE00 < JND) plus a few pairs predicted clearly different. Count - agreements by eye. This is a yes/no ordinal judgment (camera-free, monitor-free) - and directly tests what auto-paint relies on: stacked-blend predictions. - -## Open questions / to pin during implementation - -- Default bases per filament: 2 vs 3 (read burden vs. fit conditioning). -- Fit details: exact objective surrogate, regularization weight toward the - heuristic prior, grid resolution. Must stay deterministic. -- Whether the session-fitted JND should retroactively rescale existing Phase-1 - calibrations (they stored their JND, so it's computable — probably yes, with the - stored `jnd` recording what was actually used). -- Where the metamer-pair validation strip lives in the UI (part of the calibration - dialog vs. a separate "verify calibration" action). diff --git a/.agents/calibration-phase2-review-issues.md b/.agents/calibration-phase2-review-issues.md deleted file mode 100644 index eca7dcc..0000000 --- a/.agents/calibration-phase2-review-issues.md +++ /dev/null @@ -1,123 +0,0 @@ -# Phase 2 review — issues to fix - -> **Status: RESOLVED (2026-07-02).** All four items fixed: the session JND fit moved -> out of the per-keystroke path into `handleSave` (live typing now costs ~60 ms per -> filament; merge reads no longer retrigger computation), the dead ternary is gone, -> the measure step gained Base/Match/Merge column headers, tooltips, and an amber -> warning when merge > match, and the design doc tracks the metamer strip as open. -> Verified: 227 tests, tsc, eslint green. -> -> **Residual follow-ups — RESOLVED (2026-07-02):** -> 1. ~~Synchronous save freeze~~ — Save now sets an `isSaving` "Fitting…" state, -> disables the button, and defers the session fit past a paint -> (`requestAnimationFrame` + `setTimeout`), resetting state in `finally`. -> 2. ~~Band-edge JND acceptance~~ — `fitSessionJnd` rejects solutions at -> `JND_FIT_MIN`/`JND_FIT_MAX` and falls back to the default JND; covered by a -> regression test with an out-of-band synthetic session. -> 3. ~~Cosmetic dead "session JND" badge branch in the live measure view~~ — -> later upgraded by a codex PR review (preview and save could disagree when -> the session fit fired) and fixed properly: the session JND is now fitted -> once in a debounced background pass after the reads settle, the live -> preview consumes it (badge reachable again), and Save reuses the cached -> fit so saved values always equal previewed values. -> -> Verified after the fixes: 228 tests, tsc, eslint green. - -Review of the uncommitted Phase 2 implementation (multi-base opacity reads, measured -per-channel TDs, session JND fit) against -[calibration-phase2-color.md](calibration-phase2-color.md). - -**Overall: the implementation is faithful to the design and the math is right.** -Multi-base reads fit measured channel TDs with the heuristic as prior (single-read -degenerates exactly to Phase-1 behavior); the JND session fit uses a center-mode -residual for identifiability, requires ≥2 multi-base filaments, and has a -flat-residual fallback to 2.0; reads/channelSource/jndSource persist -backward-compatibly through the sanitizer; base auto-picking prunes by predicted -read usefulness and channel diversity; merge reads are stored as evidence only. -Tests cover the multi-base round-trip, JND recovery (within 0.25 of a synthetic -truth), and the fallback. 227 tests, tsc, eslint all green. - -One significant issue and a few small ones: - ---- - -## 1. HIGH (UX/perf) — the session fit freezes the UI for seconds on every keystroke - -`computed` in -[FilamentCalibrationDialog.tsx](../src/components/FilamentCalibrationDialog.tsx) -calls `computeFrontlitCalibrationSession` synchronously inside a `useMemo` whose -deps include `reads` and `mergeReads` — i.e. it re-runs on **every keystroke** in -the measure step. Once ≥2 filaments have complete multi-base reads, that call -includes `fitSessionJnd`: ~20 JND candidates × (full channel-TD fit per eligible -filament), where one fit is ~500 score evaluations × reads × a 36-iteration ΔE00 -bisection. - -Measured: the synthetic session test (3 filaments × 3 reads) takes **4.3 s** in -node; a realistic 3×2 session is on the order of 1.5–3 s. The freeze lands at the -worst moment — while the user is typing the *last* reads (and typing a merge value, -which doesn't even affect the fit, retriggers the whole thing). AGENTS.md: -long-running work must keep the UI responsive. - -Suggested fixes, in order of preference (can combine): - -1. **Don't refit on keystrokes.** Debounce the session computation (~400 ms) AND - exclude `mergeReads` from the fit input entirely (merge values are stored at - save time; they have zero effect on the fit — build them into the reads array - only in `handleSave`). -2. **Move the session fit off the main thread** using the existing worker pattern - (`useAutoPaintWorker` precedent) if debouncing alone still feels janky. -3. **Cheapen the fit**: cache `opacityLayerThreshold` results keyed by - (td-triple, base, jnd) across the JND grid; cut the bisection to ~20 iterations - (layer-resolution accuracy needs far less than 2^-36); skip re-running the - per-filament *display* fit when only the JND search is exploring. - -A pragmatic minimal version: keep per-filament fits live (each ~25–75 ms, fine), -but run the **JND session fit only on demand** — when the user leaves the last -input / presses Save, with the badge updating from "default JND" to "session JND" -at that point. Everything stays deterministic. - -## 2. LOW — pointless ternary in `computeFrontlitCalibration` - -[calibration.ts](../src/lib/calibration.ts): -`const tdSingleValue = reads.length > 1 ? fit.tdSingleValue : fit.tdSingleValue;` -— both branches are identical. Replace with `const tdSingleValue = fit.tdSingleValue;`. - -## 3. LOW (UX) — the "merge" input is unexplained in the dialog - -The measure row shows two bare number inputs with placeholders "match" and -"merge". The docs explain the merge read, but the dialog doesn't — a first-time -user won't know what to type there or that it's optional. Add a `title` tooltip -(e.g. "Optional: first patch that looks the same as the patch before it") and/or a -tiny column-header row above the inputs. Also consider a soft validation hint when -`mergeLayers > opacityLayers` (physically odd: neighbors can't merge after the -rail match) — accept it, but flag it visually. - -## 4. NOTE (not a defect) — metamer-pair validation strip not included - -The design's physical acceptance artifact (printable strips of stacks predicted -indistinguishable vs. clearly different) is not in this change. Fine to land -Phase 2 without it, but it is the third Phase-3 gate signal, so it should be -tracked as the remaining open piece of Phase 2 rather than dropped. - ---- - -## Reviewed and explicitly OK - -- Single-read path returns exactly the Phase-1 result (prior = heuristic channel - TDs anchored to the T* scalar); no behavior change for quick mode. -- `tdSingleValue = max(channel TDs)` for multi-read fits — consistent with the - brightest-channel-anchor convention. -- JND fit: center-mode residual (interval mode would be flat within quantization - intervals → unidentifiable); ≥2 eligible filaments required; improvement - threshold before accepting; deterministic coarse+fine grid in [1.0, 3.0]. -- Session JND, when fitted, is applied to the same session's single-read filaments - too, and recorded via `jndSource: 'session-fit'` — matches the design. -- Sanitizer extensions are strict (bad `reads`/`channelSource`/`jndSource` reject - the whole record) and Phase-1 records without `reads` still pass. -- Base auto-pick: prunes no-contrast and predicted-read-1 / >maxLayers pairs, - anchors on the darkest useful base, prefers channel diversity then mid-wedge - predicted reads; accurate mode defaults to 2 bases, capped at 3. -- Disagreeing multi-base reads (rms > 0.75 layers) reduce confidence and append a - note — the Phase-3 residual signal is surfaced as designed. -- Uncalibrated filaments' predictions for pruning use `td × 0.1` mirroring - `FRONTLIT_TD_SCALE` — consistent with auto-paint's fallback. diff --git a/.agents/hiding-distance-migration.md b/.agents/hiding-distance-migration.md deleted file mode 100644 index b4fea21..0000000 --- a/.agents/hiding-distance-migration.md +++ /dev/null @@ -1,207 +0,0 @@ -# Hiding Distance — rename, single-meaning storage, and per-channel model - -> **Status: IMPLEMENTED (2026-07-03, by Claude).** All sections landed, including the -> folded-in nextBestColor work (§4). Verified: 240 tests (12 new), tsc, eslint green; -> 24 auto-paint goldens regenerated; the calibrated-path regression guard -> (`tests/assets/autopaint-calibrated-baseline.json`, captured pre-change) proves -> byte-identical output for the 8-color calibrated profile. Implementation notes vs -> plan: `effectiveFrontlitTd` was unnecessary (single-meaning storage covers it); -> `.kapp` test fixtures stay authentic v1 files with migration applied in the -> fixture readers; the `profileManager→calibration→colorSpace/colorDifference/colorUtils` -> import chain gained explicit `.ts` extensions for plain-node test/script loading; -> one nextBestColor ranking test updated (linear light legitimately changes the -> winning candidate, and its old assertion contradicted its own comment). -> -> **Post-implementation self-review (2026-07-03)** found and fixed two issues: -> (1) the ×0.1 migration now rounds to 4 decimals — raw doubles like -> `1.1 × 0.1 = 0.11000000000000001` would have shown verbatim in the HD input; -> (2) migration gates re-keyed to fixed version constants (`TD_MIGRATION_VERSION` -> in profileManager, `>=` schema check in App.tsx) so a future v3 bump cannot -> re-apply the ×0.1 to v2 data. Goldens regenerated once more after the rounding -> change (a 1e-18 td delta had flipped one optimizer scenario). Final state: -> 240 tests, tsc, eslint green. - -Status: implemented. -**Supersedes** [next-best-color-td-fix.md](next-best-color-td-fix.md) (never started — -its defects are either auto-resolved by this migration or folded in below). - -## Decisions (user-approved) - -- **Name:** "Hiding Distance" (HD), unit mm — the depth of material at which a - filament visually hides what's beneath it. Display/docs rename only; the storage - field stays `td` (`.kfil` compat is a user-data boundary per AGENTS.md). -- **Storage becomes single-meaning:** `filament.td` always stores the *frontlit* - hiding distance, for calibrated and uncalibrated filaments alike. The current - dual meaning (backlit-community scale for uncalibrated, frontlit for calibrated, - reconciled by a runtime ×0.1) is the root cause of the nextBestColor bug class - and dies here. -- **Conventional TD entry:** users can still type a spool-sheet / MakerWorld-test - TD; the UI converts it (× `FRONTLIT_TD_SCALE = 0.1`) at entry. The factor is - empirically validated: the user's physical calibration of 8 filaments measured - HD ≈ old backlit TD × 0.1 across the set. -- **Uncalibrated filaments get heuristic per-channel HDs** (approved sub-decision). - Today they blend channel-uniform (`[td,td,td]`); the `deriveChannelTds` color - heuristic is only used inside calibration fitting. After this change every - filament blends with channel HDs — measured (calibrated) or derived from the - swatch color (uncalibrated). This is the biggest behavior change in the bundle: - uncalibrated blend outputs change and goldens must be regenerated. - -## 1. Data migration (the critical part — treat as a persistence boundary) - -New semantic: `td` = frontlit HD. Old uncalibrated values (~1–6) must be scaled -×0.1 exactly once. - -- Bump `CURRENT_PROFILE_VERSION` 1 → 2 in - [profileManager.ts](../src/lib/profileManager.ts). Migration rule applied on - every load/import when `version < 2` (or missing): - - filament **without** valid calibration → `td *= FRONTLIT_TD_SCALE` - - filament **with** calibration → `td = calibration.tdSingleValue` (defensive - re-sync; already equal in practice) - - stamp `version: 2`. -- Apply in **all three load paths** (same trio as the legacy-calibration - stripping): `loadProfiles` (localStorage `kromacut.autopaint.profiles`), - `importProfiles` (`.kfil` files in the wild are version 1 — must convert on - import), and `loadAutoPaintPersisted` in [App.tsx](../src/App.tsx). - - ⚠️ `kromacut.autopaint.v1` has **no version field**. Add one to the persisted - shape (e.g. `schemaVersion: 2`); absent ⇒ old ⇒ migrate filaments as above, - then persist with the marker. Do not key the migration off heuristics like - "td looks big". -- New exports write `version: 2`. Importing a v2 file must NOT re-scale - (regression test: double-import idempotence). -- Do not clamp or "sanitize" values during migration beyond the existing filament - validation — convert as-is. - -## 2. Runtime model — delete the ×0.1 layer, add channel HDs everywhere - -- **Move `FRONTLIT_TD_SCALE` to [calibration.ts](../src/lib/calibration.ts)** and - export it; it survives only as (a) the entry-conversion factor and (b) the - migration factor. Delete `scaleFilamentForFrontlight` from - [autoPaint.ts](../src/lib/autoPaint.ts) and both call sites (~lines 1430, 1545) - — post-migration, `td` is already frontlit. -- **`autoPaint.ts:1633`** (`totalTD = Σ f.td × FRONTLIT_TD_SCALE`): note this - currently scales raw tds of ALL filaments — for calibrated filaments that is a - latent double-scaling bug today. Replace with `Σ f.td` (now uniformly HD) and - check what consumes it for a behavior impact note. -- **Shared channel helper** in calibration.ts: - - ```ts - /** Per-channel hiding distances: measured when calibrated, else derived - * from the swatch color around the scalar HD. */ - export function channelHds(filament: { color: string; td: number; calibration?: unknown }): CalibrationRgb { - return sanitizeFrontlitCalibration(filament.calibration)?.td - ?? deriveChannelTds(filament.color, filament.td); - } - ``` - - Replace `calibratedTdChannels` (autoPaint.ts:406) with it; drop the - `USE_CALIBRATED_CHANNEL_TD` flag. Audit that every blend/transition consumer - (`blendColors`, `calculateTransitionThickness`, transition zones, optimizer - scoring context, worker payloads, optimizer cache keys) now receives channel - HDs for uncalibrated filaments too. Cache keys already include color+td, so - derived channels are covered. -- **`estimateTDFromColor`** ([colorUtils.ts:39](../src/lib/colorUtils.ts#L39)): - re-baseline to return HD directly — `(1.0 + luminance × 5.8) × FRONTLIT_TD_SCALE` - (≈0.10–0.68). Rename to `estimateHidingDistanceFromColor` (keep an alias export - only if call-site churn is a concern; prefer full rename, it's internal). - Call sites: FilamentRow wand (:176), `useFilaments.ts:19` default for new - filaments. -- **`computeProfileConfidence`** uncalibrated plausibility bands rescale ×0.1: - `1.0–5.0 → 0.10–0.50` (0.5 score), `0.5–10 → 0.05–1.0` (0.3 score). -- **FilamentCalibrationDialog `estimateChannelTds`** (:207): the `filament.td * 0.1` - becomes just `filament.td` (or the shared `channelHds` helper — preferred). -- **Calibration flow itself is unchanged** — it already produces and stores HD. - -## 3. UI - -- **Label rename:** "TD" → "HD" wherever shown (FilamentRow input suffix, - calibration dialog select list + fit card "TD 0.51mm" → "HD 0.51 mm", - next-best-color suggestion card). Tooltip on the input: "Hiding distance (mm): - depth at which this filament hides what's beneath it. Measured by calibration, - or estimated from color." -- **Input range:** current blur clamp is `0.1–100` — black's measured 0.05 can't - even be typed. New clamp ~`0.01–2.0`, step 0.01. -- **"From TD" conversion affordance** on the filament row (small button or popover - next to the HD input): user enters a conventional/backlit TD value, field stores - `value × FRONTLIT_TD_SCALE`, marked uncalibrated (clears calibration like any - manual edit). Label it plainly, e.g. "Convert from TD (lithophane/backlit - value)". This is the community-facing bridge — keep it discoverable. -- **Per-channel visibility (read-only):** show `R/G/B` HD values (2 decimals, mm) - — for calibrated filaments on the existing calibration badge tooltip labeled - "measured"; for uncalibrated on the HD input tooltip labeled "estimated from - color". No editing of channels (manual scalar edits already clear calibration — - keep that semantics). -- New-filament default (`useFilaments.ts`) comes out on HD scale automatically via - the re-baselined estimator. - -## 4. nextBestColor (folded in from the superseded plan) - -Post-migration, its raw `f.td` usage becomes correct automatically (the old plan's -defects #1 and #2 vanish). Remaining work: - -- `recommendedTd` (:386) needs **no conversion** — it borrows the neighbor's td, - which is now HD, and the added filament's uncalibrated path expects HD. Just - verify and update the card label. -- **Blend in linear light**: `blendRgb` (:107) lerps gamma sRGB; rework to lerp in - linear light matching `blendSrgbChannel` semantics (linearise/delinearise - helpers already exist in-file), and fix the stale "matches autoPaint's - blendColors" comment. -- Keep scalar-HD curves (no per-channel in this heuristic — planning tool, keep it - light). No changes to candidate generation/scoring/ranking. - -## 5. Tests - -- **Migration:** v1 profile (uncalibrated td 4.0) loads as 0.40; calibrated - filament untouched; v2 double-import idempotent; `kromacut.autopaint.v1` without - the schema marker migrates once; mixed profile correct. Extend - `tests/profileManager.test.ts`. -- **Calibrated-path regression (the key acceptance guard):** auto-paint output for - an all-calibrated profile (use - `tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil`) must be - **identical before and after this change** — calibrated filaments' channel HDs - and scalars are untouched by the migration. -- **Uncalibrated behavior change:** goldens change intentionally (channel HDs + - no more runtime scaling — net optics identical in magnitude but now - color-shaped). Regenerate via `npm run test:autopaint:update`; re-check - `autoPaintQuality` budgets still pass. -- **Estimator rescale:** `estimateHidingDistanceFromColor('#ffffff') ≈ 0.68`, - `('#000000') = 0.1`. -- **channelHds helper:** calibrated → measured triple; uncalibrated → derived - triple anchored at the scalar. -- **nextBestColor:** new `tests/nextBestColor.test.ts` — linear-light blend spot - check (50% transmission of white over black ≈ linear midpoint ~188 sRGB, not - 128); suggestion for the calibrated fixture returns `candidate.td` on HD scale - (~0.3–0.6); recommendedTd ≈ nearest neighbor's td. - -## 6. Docs & changelog - -- `src/docs/` (`3d-mode.md` filament/TD sections + calibration walkthrough, - `settings-and-controls.md`, `troubleshooting.md`, `quick-start.md` — grep for - "TD" and "Transmission Distance"): rename to Hiding Distance with a one-line - bridge ("previously shown as TD; conventional lithophane TD values can be - entered via Convert from TD — they're ≈10× the hiding distance"). -- `CHANGELOG.md` (unreleased): under **Changed**, one bullet covering: the rename, - the single-meaning storage + automatic one-time conversion of existing profiles - and `.kfil` imports (call out `version: 2`), the Convert-from-TD entry, and - per-channel blending for uncalibrated filaments. The latent - calibrated-double-scaling fix at autoPaint.ts:1633 goes under **Fixed** only if - it changed released behavior — it's within the unreleased calibration work, so - fold it in. -- AGENTS.md "Domain Notes" TD bullet: update the terminology so future agents - aren't taught the old dual meaning. - -## Acceptance - -- Old `.kfil` (v1, uncalibrated td 4.0) imports as HD 0.40; exporting produces v2; - re-importing that is a no-op. -- `8_Colors_Calibrated_New` profile: identical auto-paint output pre/post change. -- New filament defaults, wand estimates, and next-best suggestions all land in - ~0.1–0.7 HD range. -- UI shows "HD"; typing a conventional TD via the converter stores value×0.1. -- Full suite + regenerated goldens + tsc + lint green. - -## Out of scope - -- Renaming the `td` storage field or `FrontlitCalibration.td` (data compat). -- Editable per-channel values. -- Per-channel curves in nextBestColor. -- Any calibration-flow changes (it already produces HD). diff --git a/.agents/next-best-color-td-fix.md b/.agents/next-best-color-td-fix.md deleted file mode 100644 index 949e7ad..0000000 --- a/.agents/next-best-color-td-fix.md +++ /dev/null @@ -1,174 +0,0 @@ -# Fix next-best-color TD scale handling (post-calibration cleanup) - -> **SUPERSEDED (2026-07-02) — do not implement.** Execution never started; this -> plan is replaced by [hiding-distance-migration.md](hiding-distance-migration.md), -> which makes `filament.td` single-meaning (always frontlit hiding distance). That -> migration auto-resolves defects #1 and #2 below, and its §4 carries over the -> remaining work (linear-light blend + tests). Kept for the defect analysis. - -Status: superseded. - -## Context - -`src/lib/nextBestColor.ts` (the "Suggest next filament" heuristic, run in -`nextBestColor.worker.ts`) predates the frontlit calibration work and still treats -`filament.td` as one homogeneous quantity. Since Phase 1/2, `td` means two -different things: - -- **Uncalibrated filament:** backlit-community-scale TD (what users type from spool - sheets / TD test prints, ~1–6 mm). Auto-paint multiplies it by - `FRONTLIT_TD_SCALE = 0.1` before simulating (`scaleFilamentForFrontlight`, - [autoPaint.ts:415](../src/lib/autoPaint.ts#L415)). -- **Calibrated filament:** measured *frontlit* blending distance (~0.2–0.6 mm), - consumed as-is. - -`nextBestColor` receives **raw** profile filaments and never distinguishes the two. -Verified against the user's real calibrated profile -(`tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil`, frontlit TDs -0.37–0.56 mm). - -## The three defects - -### 1. Simulation mixes incommensurate TD scales - -`blendRgb`/`buildBlendCurve` use raw `f.td` -([nextBestColor.ts:205](../src/lib/nextBestColor.ts#L205), :221–222, :323–324). - -- All-calibrated profile: curves are frontlit-consistent by accident (raw = frontlit). OK. -- All-uncalibrated profile: curves are ~10× too transmissive vs. what auto-paint - will actually do (pre-existing bug, predates calibration). -- **Mixed profile: nonsense** — a calibrated 0.4 mm filament and an uncalibrated - 4.0 mm filament are blended on the same curves as if commensurate. - -### 2. `recommendedTd` output is on the wrong scale for calibrated profiles - -The suggestion borrows the nearest filament's raw `td` -([nextBestColor.ts:379-386](../src/lib/nextBestColor.ts#L379-L386)) and -AutoPaintTab adds the suggested filament **uncalibrated** with that value -([AutoPaintTab.tsx:1204](../src/components/AutoPaintTab.tsx#L1204)). The pipeline -then multiplies it by 0.1: - -- Nearest filament uncalibrated (td 4.0) → suggestion 4.0 → effective 0.40 ✓ -- Nearest filament calibrated (td 0.40) → suggestion 0.40 → **effective 0.04 mm, - ~10× too opaque**. This is the broken case for every calibrated profile — the - user's new normal. - -### 3. Blend runs in gamma space; auto-paint blends in linear light - -`blendRgb` ([nextBestColor.ts:107-111](../src/lib/nextBestColor.ts#L107-L111)) -lerps raw sRGB values. Its comment claims it "matches autoPaint's blendColors", -but auto-paint moved to linear-light compositing (`blendSrgbChannel` in -[colorSpace.ts:30](../src/lib/colorSpace.ts#L30)) in this release cycle. The -comment is stale and the curves are systematically darker mid-blend than the real -pipeline's. - -## Fix - -### A. Single source of truth for the effective frontlit TD - -Move the constant + add a helper in **`src/lib/calibration.ts`** (a leaf module the -worker can import without dragging in autoPaint's optimizer — the reason -nextBestColor inlines its color math): - -```ts -export const FRONTLIT_TD_SCALE = 0.1; - -/** Scalar frontlit TD for simulation: measured value if calibrated, else the - * backlit-scale user entry scaled down. Mirrors auto-paint's - * scaleFilamentForFrontlight. */ -export function effectiveFrontlitTd(filament: { - td: number; - calibration?: unknown; -}): number { - return sanitizeFrontlitCalibration(filament.calibration) - ? filament.td - : filament.td * FRONTLIT_TD_SCALE; -} -``` - -Update `autoPaint.ts` to import `FRONTLIT_TD_SCALE` from calibration.ts (delete its -local copy; keep `scaleFilamentForFrontlight`'s behavior identical — it can use the -helper for the scalar). No behavior change in auto-paint; regression-covered by the -existing legacy-calibration test. - -### B. Normalize TDs at the top of `nextBestColor` - -```ts -const filamentTds: number[] = filaments.map((f) => effectiveFrontlitTd(f)); -``` - -Everything downstream (pair curves, `estimatedTd`, candidate curves, winner -curves) then runs on commensurate frontlit values. Note `buildBlendCurve`'s span -(`3 × fgTd`) becomes ~1.2 mm instead of ~12 mm for uncalibrated filaments — that is -the fix working, not a regression. - -### C. Emit `recommendedTd` on the uncalibrated (backlit) scale - -The suggested filament is added *uncalibrated*, so its stored td must be what the -×0.1 path expects. Convert the borrowed effective value back: - -```ts -const recommendedTd = effectiveFrontlitTd(filaments[nearestFilamentIdx]) / FRONTLIT_TD_SCALE; -``` - -(≡ unchanged value when borrowing from an uncalibrated filament; ×10 when borrowing -from a calibrated one.) Keep borrowing from the nearest filament by ΔE — it -captures material similarity; `estimateTDFromColor` remains the wand's job. - -### D. Blend in linear light - -Rework `blendRgb` to lerp in linear light and return sRGB, matching -`blendSrgbChannel` semantics. The linearise/delinearise helpers already exist in -this file (used by `rgbToLab`/`labToHex`) — reuse them rather than importing -colorSpace, to keep the worker bundle self-contained (or import from -`colorSpace.ts` if the bundle impact is nil — implementer's choice; colorSpace is -tiny and React-free). Fix the stale comment either way. - -Scalar-TD-only curves remain fine for this heuristic (see non-goals). - -## Tests - -There is currently no `nextBestColor` test. Add `tests/nextBestColor.test.ts` -(vite-ssr loader pattern like `frontlitCalibration.test.ts`) covering at least: - -1. **Scale normalization:** two profiles describing the *same physical filaments* — - one uncalibrated (td 4.0), one calibrated (td 0.40 + valid `FrontlitCalibration` - with matching channel TDs) — produce the **same suggestion hex** for the same - swatches (curves identical after normalization). -2. **Recommended TD scale:** with a calibrated profile, the returned - `candidate.td` is on the backlit scale (≈ borrowed frontlit value ÷ 0.1), so - that `effectiveFrontlitTd({ td: candidate.td })` ≈ the neighbor's measured - frontlit TD. -3. **Linear-light blend:** `blendRgb`-equivalent output matches - `blendSrgbChannel` for a couple of spot values (e.g. 50% transmission of white - over black ≈ linear-light midpoint ~188, not gamma midpoint 128). - -The `8_Colors_Calibrated_New.kfil` fixture can serve as realistic calibrated input -for test 1/2 if convenient. - -## Docs & changelog - -- `CHANGELOG.md` (unreleased): under **Fixed** — suggestion TD now accounts for - calibrated (frontlit) profiles instead of recommending a 10×-too-opaque value; - suggestion blending matches the linear-light optical model. Per AGENTS.md, if it - reads better, fold into the existing next-best-color bullet's section instead of - a standalone entry (the feature shipped in v3.1.0, so **Fixed** is correct). -- `src/docs/*`: only if any doc states how the recommended TD is derived (check - `3d-mode.md`'s suggestion paragraph); the user-visible behavior ("recommended - starting TD") is unchanged in kind. - -## Non-goals (explicitly out of scope) - -- **No renaming of TD anywhere** (UI labels, fields, docs). The - "blending distance" naming discussion is a separate, user-gated task. -- No per-channel TD curves in the suggestion heuristic — scalar effective TD is - sufficient for inventory planning; keep the module light. -- No changes to candidate generation, scoring weights, or ranking. -- No changes to auto-paint behavior — step A must be a pure refactor there. - -## Acceptance - -- `npm test` green (including the new nextBestColor tests), `npx tsc -b` and - `npm run lint` clean. -- Manual sanity: with `8_Colors_Calibrated_New` loaded, "Suggest next filament" - returns a candidate whose TD is ~3–6 (backlit scale), not ~0.4. diff --git a/.agents/print-findings/2026-07-02-logo-test/findings.md b/.agents/print-findings/2026-07-02-logo-test/findings.md deleted file mode 100644 index 6113e2f..0000000 --- a/.agents/print-findings/2026-07-02-logo-test/findings.md +++ /dev/null @@ -1,89 +0,0 @@ -# Print findings — Kromacut logo test (2026-07-02) - -![alt text](image.png) -![alt text](print-photo.jpg) - -First real auto-paint print with the measured frontlit calibration -(`tests/assets/filament-profiles/8_Colors_Calibrated_New.kfil`, 8 filaments, -accurate-mode 2-base reads, JND 2.0 default). - -Images: `image.png` — the app's 3D preview projection; `print-photo.jpg` — photo -of the physical print on a dark mat (downscaled from the 22 MB camera original, -which stays untracked next to it). - -Print setup: normal mode (NOT flat paint — no transparent carrier; the grey rim -in the photo is a border/brim, not a carrier). Transition opacity: default -(0.8 unless changed). Layer height 0.08 mm. - -## Overall verdict - -- **Hue placement is accurate across the whole wheel** — green→yellow→orange→red - transitions and the purple family land where the preview says. The calibrated - endpoints are doing their job. -- **Systematic desaturation/wash:** the print is uniformly lighter and less - saturated than the preview, worst in dark saturated colors (cyan → pale blue, - violet → lavender, red → orange-ish). - -## Specific failures (user-observed) - -1. **Orange over pink did not shift toward red** as the preview predicted — it - stayed orange. -2. **First layer of cyan over purple is not the predicted darker blue** — it - reads as light, washed purplish. - -## Analysis - -### Weak-channel TDs are heuristic floors, not measurements (primary suspect) - -Both failures are thin layers over a saturated, differently-hued base, failing -the same way: the model predicts the top filament's complementary channel kills -the base almost instantly; physically it does not. - -- Cyan `#00b8c4` fitted `td = [0.068, 0.354, 0.373]`. `td_R = 0.068 mm` ⇒ one - 0.08 mm layer transmits only ~7% red ⇒ purple's red should die ⇒ dark blue. - Reality: red survives ⇒ true `td_R` is substantially larger. -- That 0.068 was never measured: cyan's swatch R = 0, so the `1 + c×5.8` - heuristic pins the channel at its floor, and the black + white base reads - constrain it weakly — the fit's regularization left it at the prior. Same for - orange `#d83400`'s G/B channels (0.137 / 0.103) interacting with pink. -- **Actionable:** recalibrate with complementary-hue bases that stress the weak - channel directly — cyan over a red/orange base (pins `td_R`), orange over a - cyan/blue base (pins `td_G`/`td_B`). Accurate mode's manual base override - supports this today. More generally: the auto base picker prefers channel - diversity but chose black+white here; consider whether it should specifically - seek a base that is bright in the filament's darkest channel. - -### Partial-opacity stacks (contributing, magnitude unknown) - -Auto-paint stops stacks at the transition-opacity target (default 0.8), so -regions show ~20% of the underlying stack. The calibration pins the opacity -*endpoint*; the *interior* of the curve is assumed exponential per channel. If -real filaments scatter, partial stacks pick up unmodeled white backscatter → -wash. Cheap probe: reprint a section at transition opacity 0.95 and compare -saturation gain against the preview's own change. - -### Emissive display vs reflective print (irreducible) - -The preview on a dark background on a monitor emits saturated light a reflective -object cannot return; deep blues/cyans suffer most. Even a perfect model looks -more vivid on screen. Sets a ceiling on "matching the preview". - -## Phase-3 gate ledger (scattering evidence) - -Signal 3 (stack prediction failures): **one entry, partially implicating.** The -wash direction (extra lightness, weak complementary-channel kill in thin layers) -is consistent with scattering, but confounded with the heuristic-floor channel -TDs, which absorption-model recalibration with better bases may fix. Signals 1 -(fit residuals) and 2 (merge reads) from the calibration session remain -negative. **Gate still not triggered** — retest after complementary-base -recalibration. - -## Follow-ups (in order) - -1. Recalibrate cyan (red base) and orange (cyan/blue base); reprint the affected - regions or small stack patches; recheck failures 1 and 2. -2. Transition-opacity 0.95 comparison print for the wash magnitude question. -3. Build the metamer-pair validation strip (still-open Phase 2 item) — it tests - exactly these cross-color stack predictions in a controlled way. -4. If failures persist with well-pinned channels → real Phase-3 (scattering) - evidence; revisit the gate. diff --git a/.agents/print-findings/2026-07-02-logo-test/image.png b/.agents/print-findings/2026-07-02-logo-test/image.png deleted file mode 100644 index ea2b7cbea2d69fd0bce6cefa4422b6df781d7f49..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68424 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 U+a0v7;11SxB`w8rc~kWN0UJ6N6#xJL diff --git a/.agents/print-findings/2026-07-02-logo-test/print-photo.jpg b/.agents/print-findings/2026-07-02-logo-test/print-photo.jpg deleted file mode 100644 index 7eaf0312d6f427394d164c74439e3777d7d7a46d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 496982 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
    u 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�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 z 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�_k0PjzG=5__WF0rzazv8KS=~v}JgO+3 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�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�fuvb2yiM9lKvkQN0QBQOZ;?4h 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

    Uj)Z1D6r{RbR4?|9D6fUr| zcC(zssj#Qm)mX$9Zj=Ge;{vj(MIi{TR1NiWeez+MH5DWujutL=vwRVpfEV3>C))uB ziNNE&H4-Qb!i*4;dykeB*CdpHweCm50HZ1?VnIEuViy{DDznyisKY%0?)2Ji1h|dRw!BH-v9tWJf7yatX5vzA8>G4^}l~i zQ|VxLz;ox<4o;yr5E?cW?bS2Rzj z<%-NMm~YLFFdTS7GpIe+mwx!8OCcJl1F${un28lcaa9fI-_H;zC0Fqq;0?-Mfxj5S zL*eM5xx4S2;cNkQ#{IEK)3to?8FfhdSC+*n6j`u+#&9H!^>4Aj#i$G001>v>+D_Y5 zVu7~DVTV%zwfbNdQXgvEUidcLnkNNW?oK;_R6Q^do(~?_+N~>U9=N(|za6uy&A$G) zKqOcfj0w8r90GyTKOFD=&g6Pva}M|P#&;|Q`eN=IRd0m2JLyq=g9=bV*zsRHKXm=j zN3IgKci-WPD3CcgN7f{)>;kB|vTKZUAPC8%b9KQufSb2dYLiV0bg=uI^v6jR!7j&BSiD){BA7a=p4Yj;nm_?Y_wzUffPszO8oYlRMJ1s|zBuiM zNu!-tR{C~T1oP{Zng;0OiR4heEuVV$U_6qjfFz7rzv4LGAiXx7K#j;I!M(8~B)X6T zR~P!@ypn3SC425F@D?tW(X}xPxTPoa!!Px8yC@6mR8Ib$xRRH7Qmm<4J8_--#V(blpe`b-N&3R z_C;U0%Rtf~XwQ_L{Wt6mOaL7CheE9y8{fw%{M3aQFf1zg@9KXHD8;HmLy$om7Bnl1 z#*LzlBvM80H65?h;gPb!c$?atD!XbukFdYX=Y&NV$_a#NBYN*v zclO0wg5wFPn2Yms^usYmflOhw0p}yX{bb-rne{4>w;z^3lLn2|BnVh2W9GfR>kMV& zTS6AN+mn^?I(dOQ1ub{>7D?}nhBwq5mNf^;0NsV~Jw86zR%oHsBHL*kl6JA1gmOb7 zI~es49xzU#DEA+ZMlP9Ul)RD%)GwCJo2`X=k%4Wkn&utKol%@F1atVS)2sL>KdSZSJ%_+wZ+y)nXgXsh2GzfA_&}MZiP|J! zD#e4g<9+`C4AU1_I(W1wU0~2Zzm_bqMDQS4JTeyClV{=nhGd3N-8$+U*`fOUc)+PH z%_GQ3Qgv>7li%x%y*kULTU9J-j=&AO`t#^E%?T|kte563T8;8KuUy5{i<3$MBUb+a zmEX_41N8kmvg!(T3Z?Ez{{UZ1ih~#%wxV`WGSNFpDP%)r0C%8!eK9&|3w1tcRdRmu z7hd=jMktaQW7K5ws}Jqq*T#i=WV(pdbpcVtnyX+q_WkI=$? z8XY?VA8T&s+x=l$dcL6by+2XZ7i;B9@<^|#+xJ^RRl-`CR|k*fq26?>lcewbn~LhiE0Tg#wbjR)7` z+{~N$Q%Co-`^|MMGK-T>W!6EYN+mPl0Sb; zp=Nb?+90AB!c`M`o7d~~$#nfLqG2w~H#WSGFN^KYQ>xK2c#QfJPJuvqkEs3ndu0lk zI%vovRDjr4`>X!z=soZuCyFD99T+0VYDw7cZoYXzJkabJm?G)(9tU7Pc%4+Q<7?dl zZ5mY-QOED&n%ARQV`hznPqVaQ$yMwWcQt1J04?w`ZweO*M5(bSonPo;^z$+WeF8uq zd%7pn=Zx-+6onO(&{likUjG0a$@J>*7Gfh0=GtklNUb_V&b!=I<7DAChCj~N`5)3TGdaz8Jw z60VD6g+lgE{zuc;UQo`)K9$~5v`V4{?XbU%00*_?UJWrvrM0Z5N`G|?{JzJ7^TLtC z)3S&2u~5P>!jp6_(}VAn>0piAERr$=Viiff$m~0x_1hqW2-an#Qp@HYtoA>R{uyVe zkysr>oekQKp;htq_~InSE}_WNt-QAT_OgEu&k-HE+gV*yv8`VC3)C{hI`ryGhHp6y zYR3ClwrWU8(q@pAVnVlIM+W@`=0X%s7C?^LJTPt45AQ2`j!)t6%GwF>%%OI4pMx?}|Vo0&L z9lOx_?}^sN@HvE(w%=Nf?29Ae=D@!})b$+;GQ{zdEKRA41F3B7?bz4D7jJ9Gw6k?RR8gwO3&yOvwo5j9{`kuodUs@fT687T4OB_q{dn)(jLX!j zFI}n?5@>ya-&Xry?|dqe^-dN|qXm;nkhVzG!1U(wQS{L?QoOPg;jpE>jop#(^u&di zr)QAqVo(yNf$*B60~YedTBtvPseWlxRa|$?$R-GfPhPRK@EEkd;D@J zKqFZlP=ZQ;jNB9HUYy`qkAf4yUCR?Cec?dS^aFqQoF)>O)DmJq&tG&a9^XY=v{Oo} zBbeiOoPuluK^u;E8`t54LIyoPmDf)yTP+IS#d~ev-#NOcnH$AWj7ODGLWU&o_~Z&I z!>q7(04W4p-{!N?iAL;+j_ittEeI4 zfU2Zp#2Tws^nHf*K=jP*CWT^S4B(T+fqQ=bS*A(kXva*%7?n#uhAf`s->|SdY<+hN z2>NH7q%%s6!qPQ5gB@HGwRvp!+;+XDI+^+yyawtS(ny}xI%)(RkK=yFAr#_1nF(MU zwOy{{pVPm5n#CtqJZ*Y)Fcc53{{Y>ESNVfYAPX9@X+|2T9}shLw2Bw--@qunV?CJ zQzVSmqf?OE>NH9A?SD)QJIoQLZAiL7P@ri55I^6olZtw&97oeep?ta%wQBeH-#)oT zondaBWKph0J{bzOC;{~T(e=r@#?p9UKn%ng0oj1#;6Kh6@new@<1r*fe2IQzvXVVX ztImwFJg6BO_hP_rq?17U5G!tWz@&1=6sBk%ON6OSZKd^sypKWVtdWUUSYy;uNe$Wb zYOjXhJ@GX7lezHUMX~fcdtbwo@yei6JZHo6_=_6H_d_oH*8ZE1d@G^p8>rV4bqb@B zKpNPex(CN^hW?o_^c19eN|3wmFaAdUe+;~%#9(SQGXj=a8q;^c8iv^lo3}zpQU`yt`MD6+M`sDSA|DPjlnnfkD=r6#r|VVawDZz8-3aoFEo9go;OL< zM`E@0|$6n2HLh(8fYE( zvCjAO7>Zg#T}WG{Yc|^fXXZnFaG+i$gRnBqPptfdOcAR%h*$>VeK$30Y%LmRS2 zG^kZNN8JX6j{g82_`3ClD;e>@{+xh?1X(;*=W+DFd1HwxEo{*sZBKhK?L+B_EN4j6 zKGRDX(VvFSp1Ld;5?TIWR9Ci06N$k z{6}je8Dpr%yDah`Eq#bLe*1p!5F5TjRtG8vrp0T`qZCjFk zsC{#tfA(jG#ZrYs7p>&8-{pJz`eQWfGyvQ{Bfk->aIcX-vOwI~JP)2s%AuI)oB30* zp}@1pG45!AsC+ab#-F`ntKsqY#gWX0A>!%Mnp6ce0N=N+_$0ot zA$b>8g;GGS1p)LKLRzEn)CnVP1wcES@9TG19n*C2FpxaJlxtX&8<^c}Yp^%Jz6WRO zU&N3La$K?45CO0U#Ahn1uZWJ`KC^1Vl|>RrH|_Z2N2GL;H}esatS%ZLviGA~b6&gP zO{0QyJc}S%0R>bF6i5^W^SQ@@UkJ%58i8L71F&Lt1pGE5+zbd%n99917GqQgVe#y7 z+o(mchBz-bzENj!9OzPTz48C72Q3koavWirQKq#aIHaMf}zt04* z#jFUYNvbvB3eh!Ve?#9YdS?hCGBjpV9IpsL7A)Vt?y_+qi(AlLNKmoZZAOLn;|N~` z6{P9fNc5or*;r9wd>-STIm#?xr}HlMgUaV(%f9cc7F3l2qp7_zS1^#EKJ|?0-(tO&8BpdHXe||Q~AtJPBk0Bae zK~|z~wSIq3o&_?KrZPcct6)KDzANH+%8(=y&I1H3^AOvxj1(}DM$nJdxKDSVthc=sa z8jMx2BzK|P(!RbfA0~KN}<(qa8#`Y0=~cN z2_ej3o=pG&>$UG)hbQZe6kp6prAZ3JFPnuv_4C35PH97tpf!R81@#|F`ei!#VKci& z6G9m3DSIJOtMZNOd%imle3Vfvtcq<=)wS#aHFyI> zH<`KLO&jB#@CyW;G#u=w)88~O-XKw?jR7SBl_hMFZoNC?U4wNSG-Buy7s{fmk8jfh zof%S|Jx5QLzqf<=etAZbd_czB1p!9@5TJ4N#FPS{rj#Fch&sD&aEN47n7zhRV|!lU zRtF=bXssAh;fP?$H(Km|zrH8s)CmBn+hJFaFNS(@UBb#-+IV$Bg;Dq4Z*1)^;g-}E zHf@J;KgamU7LlRSg^zY5PzSL0{{Vbv1n{HQvLRz)wm#KgJS`rcoDEhP?Nv{p-{Hu~ zB`b>+d;|IA6;%G1AJenkcB9`p;qZeb$8dHC-0xcvx6=5-KUVX>3@!H|O$ulOxhJr* zxjf!1B!gO4OYAjoUHR?)@Q%?Wv5gTa5v?-qQ>X$-OwiM6`V)pM&y9UxdRNcYXr9PGh3ZCr{#cDN9I-=fKHRO8~*?w z3=%3uVWVLov80kKeT{u_8UFzJhIS%BXMM%{=83wwBP2=cXaYr%$6|A}OVR+)$N{P@p5lo55T*NwGX-kkwe8NdkzI1GYDd<4-)ZsS3fEtYb>q z+zK2m*4YMkbEGPeR2@K8b&n7K04-O(QBZ2q;Oqlf;0}K*X`udEMhta|1$V8D5PpX$ z5;){CUrdbb2CpjJZD((;AX-vb62iIf^ZI3?l|(K8V%Xta0)7#W4hrP;FPS+JO1x6gQ9MTXNo0#T&&{B+*?#Y}NO|0)UK33JUgN z&Jjw?8FdOYZoUTtGON=lVgT)3y|6TrOS4;=0dIN(07WK{!HK36MeK2xW-qLbyK+VW z{THcY?ZFku*#20dyrcpFBw$u{>6$a)2Q7OSJ-cn|&M1yWjhM$u+iGvq0!yP=VX2sM zcn6!TX8!=$GKX7dBS<`Z*QWb?FchI{VA{4Enm_M4N<*b6>_Iio1(2B-zTomsQY9Li z)#|J=?_VqgH5Qs6u@`rH9@y&%c%9b7Ysn;8JLgD>OkdSj8U>YF)9iNV0cK}1Sqo%& zMFH>_5-RAUZO^t*6*RTp!*T`vbCJTR7C}237q%{~AOX0mrW;^9WNf4r73ONhMKThq zK&|&U1F%L=+czWE3Yh>gDvvR~H^5@<$e~h#jXVwa!l9B~WRxJ^l+j=Dk^_qbweALX z!B02H!mTTRNeR2FqBaNH>649KQV_DLlUCdheB&XI5bi)5fCkrsH4MNO+=Ey{$h1UY zsUu_Q^~42%QCx6H?SWJWreje{6*gVuR6mepS%tXN!v{|;h8knkZp{trI7H1IDGW*bqqf+JHu8c9JcEGDJXcVw zy>Wz_UE=^Q*YA^ZF4Qn?eKDO-$w=g=7jNY^Ti_Ksg*hg;-+RQ~GemASqnz$ZCHG^= z&TlmxM6nnJ+%W|Cg?#glk)-Js4*T<+8$3=$>Ai`pMY49Q2LQJ3x{>Xd((tR96;%Du zt~VaJA{A+imNjI1;CT#@s!B$^SILiww5Hy=EivOR`cR`AfE z3PImcAFgL7jsd{Cm1+nG+ZBjR9fX>Wh70diy|h}`YAI96EI zt$n?Ic`|m>!3UgWW@=XR4%dJSsnP;~2XVFF05g5+7rl%w0061KQCwi~$pnk8{@4@= z;1x1P7%;F2=M{#eB~(@X#wlRRpbk0TlY|nY_9M{uz+Lp+R`r~ujz_~AT86b04`aS^ zglD2yd4(8y@&XdfkAnx90(E8*_kFOL||vbaMD)Pz-(Xc*X;21LuGV zz3%Vu#R^Zy2?n;R&I)U_A8Z1Vv)NEhdUhD6V?(}Dm3Kxwd-7RVK2 zwlwY?lt!eGFPzSk2<<}H>A z$I~O!LSvkibsE=o9IwX${XB6Jz&fJXHrtP$W_@cR5t;{L2HDKM6k17O#0FaBzt(ch zrBO!m{siF@kyuS{Y}IvKW|;bjiUsiXIpWU4D5Xb4eM>O;D{=MKw`&|F19>>oP(8@!Hr+A?X|Y!@XE3>T*c{_ z0Dv#v{@NbfbC8}Ul2%qadfdu=8{GrXd}0)dTra&F^Lso(vb$X z2T|l#U_CPPERys@E1w)15va2Ecki&{Zuvfz6ELJrz@}@kJ6S!30_a&tReugY?j+a7 z{{Y5Qsp;T{`Y<~@R7jADv0aD8>*Ij*t3X+6yH(&<@AAiWPX|B)Q2=TN{#W$C$}~v9 zyea`o)$RWB#bQXqYXs=>t$p6v-D=2%1EEcd3KR`ena!XpIaD#fTcztHj%jl_r6%H3#_PWa5gkft-oFV8IfU< zX4ZV#b~XCt;E@DEBxMh(yk_Bd?SQ=;5XmH-B*X}ixZ3v|C?8Go!>Z|1p=io9olMq4 zZDqR)>^)C@Hs3RJ@c7+j}R#!>ZT&d^sssK_+r8$>R#q0 zBSJM0E`?RsfBVbyQA5<2#LcHs({e3<2LKDRx5MR$(+T>B-YDc$*3?Z>n)Ub%{--FX zMb)aTmZWKL>dm<)u&zD-0Q;Pj`Z|uWR-_7KB)B)Lo&NwT_X2AlpPllV~i;Qo}zL3K@Pe(ICK+u>*E1{o!1 z>13HgDT&8Ui>ip{ayyg0{{GpVbq&%-p{(Jdr4wlm?jCAa1+*`t4r}z^_}- zIQv2R7~x=JeFjNXs~zj^o;l-@UmQ-FG2_G2Yeg~<<|cr%>*-&a%k>>xP`ej0X)&`d z_o>LLByc&*##s7?ED^O;sjc=E^uBN>IR2?v0v*WrH0EepnaPb0{V6h|tsyS8F}hKOG0e1y@$Q%fZ5 zqN9ngU~C%tYU93C2**w1%`2f*^D+)W{{YAF#_(pD5d@|*CsWHPBXLVW`+5&-0T5Y{ z!HEE3w@k*j1L3|TXQ`SNiaR7QVdmLJHNL~cwGdI8n}I0RF+z%C(WsVHTaLmw)sw{?BOwrZlA0&N4qsC4G>jZV!t}~qk$ft zIHd97Rb5Pll(On1?Xcpx=OG7Dv4ki;s;oTSuWH-memLeg=sX5uJXZ?I9-9uk@_Y8% z{;~efZBxV+IJ`%(si+lU!*A$E(0kxzjmTDvEQU%TdAJv2Vf(zlPg1MK(Ek8mMXNcx zy8H&;D>=o-PU`Ec$8zAHJRuiKJ z^J&)mg5QN*6P*<}q91Ewy0nu+pWf#xiaN!PG>oU0Q1j5Mu1~%E$;2@iC4`5>jhj_9 z*cC*4Nx<%>tdU?|om4FVj26X_X2|Y*)$q=OSt04VgQ1-y3m&t$UU&2yZHVfsur+Si zszONwov8YE`r+`(kr{FEFn z#jyw1X?Y{ z5;oEl9{XY-K`@RYW?0w|;85gq`C~kdB+Sl-#H}j6!9WK{6es~_{pCY+qCSnhZAmJt zuBhEk_72C`5rWsm*0M2>N8ynmvYSz_VZC?GnPeLJWLgSKNM4BrLH__G{Qa{%GR9n% zR%$BE%WuE=`CwXM7%e0cvqal!aYV5M@qLZ&?mciwI*G`6&-Efupqkl6-6#UKkYCccMFU zSx91zRNb_ux>Y9J5f%xDgqBUk6GZ}`3+$kPnH{00Q!xW7lP8vsVGkH(|K^0n#_h8W%j=f7l)0pH4 z(o(X5dAkSd0N>Y-PU-=Tq(@c}g(Y^aM37G&-%JZ6;zvsYF;VL(poLYfwCoAs`=6d? z(8RK|ilz{(s4m=ppl>H6iqbJU;s#AZHT%CWeg*>bPGcw>I@+iMa5?=>0r5nEQGCy& zAo7KxMQ(l_@F_8Y8xj!);2Id~z+C1_c0c|DEU8+u?zNVJIEbutiG6MNXN^YXxCnkZvP)VS&OJ2fZ_cA-alvGJ@@ z?F=B%C2Ru6w&0Im=Q>c(E5}Wt^xS-syA?OU=kfQ-sVz?H8DOqMF~1O2kOKBMsM+W;A;Yn(n318;DMZ$38=_IfW%qvRlQmG?S?tp7gtXVXmO=n zY5;*?k-;1H2NEeuyH16WqHJiJAPxPuz#Z76F2*I;63utp@CW6-1tX77NbK$ARtV9S z^Rk`x6~G|1F8%QWwQ$D#5uG@%`2-7>SYe znKhs3g1`}5l0f=pCh+4&U>N9a3+DUW?nlDDnK-0Hv*xy6?LK8S&H7*AwgU!PWF0_2 z!_4j%Py+jHz`eHh=Mkk>Ms%|3-``cFPyl}a04%>x*Rn?R%B>-%wXI-o0RVBR0)29B zV~oUNI-CtpW6E01e5>`q={k8}nDp`myVT6L#a|qI@pvNX;g_gZbEl>rX5VnP{_@ci z)JP(U9L!G9Y1OQn6ad_wb0?kxM-n40lNacJ&LdL^U&N0>K~v0pyGAzW&()bxa|44kDH4fQJFUAP(m`Sf!S7 z()py2-Ig1Yc_+3NO;a&C^r$45&)wT^mDW&XQ#vCHAc8c8vtVv(u)_T-^wy0_UTeR- zV#QZ~^Mzu0BRXvWBm}-ltG|&s&7skl?!^nx6sl|r{Qm$9g1$6O3PO@cq$ovDv~1ZV zpZ?WfwN}}jO43NNNZNI{)ZiWc&#rU4ui`NUq-WH}K%=*(>}4J$SQ1$#1eDoB3ji9b z?b~lmq1?iS84Qvjk*OPCWA(3617FKCI_usu#jyhJtncy|;#k7Oh&%~7c?$}v+P2$k z?rz3%u}Iu8vP*1x*dONuqcS`$3nGA|DfJg*an9ov?8JIr{`fkAj?u>ss}u+RUG}ez zS<*T5EE1t`r%hb`{r46?ct9zTN>ZsJ~f3_Nn~IYK9D@7&ie}I71`28rvCum z8ns=qvU&KNV;wJ>%*si0;=Va19ucp-tWuCUz_(V)Wez1$#3*G^TYL0If7>$lGarejFqXlekF#cnV3##byP5x9sj z`-8t5d_Tqq77Xa0(ry4@w|npOyt=9)yT>8(F$=LR7zdJeqwvh6q-R!@HMJi0ImL{# z8ZK2pCyT(SL*f|;cU?de-LL+RTc_$HjxM4pBXi=ARfE!@7es(JJLYdFkiN2`PQ-y{ zZZ^h})ikgKloT8LK{vO@^~j`i5M~HYo3zzeUieEav5j6*paVyC-}kmWF`WZbB7h0l zSs2kWFN*BJBOvXj*|!FP?f7kD+bSfnmQ56)t5=|^zHyOZXsEeTNVYr0FbW9dwcY(YVn{($jx<#bZg(#23U;`RFP@p|>(R9t#30%4{jSC+U!t4R# zea^#S&Ua9=bW)SyKT6`%uH&^HclG+@5!HOG4G=uHd-{xkGSDOt3X^8@oIB-K=-+UwsaF~TK<$Y2|9DF<=f-yWD*VqTItR7v8OD@{d60B`jt5Garz zI3PFS>M`{zP+-&n7EOz-zY;Pb6depS{oU@3k8j_mbUHl5s~4k03^7FPtl?-4wNau^ z0{eIRWm-vOMUYKpBhW!$2pmxM-zuWXraeS#b{^*hih)9)1&7`vzr!f~Lp*4PM=KqL zQg2BA0JD)~Dt0$`1>BIDsD)jCJPYFu*>{{S2U zdZ&;O`gc6{tW%_m6i;$E7{q9r1*6{N7W*z$fE(aP(pQ6SX3Ugv(}0g0MMShcg8Brm!Bj0i|#3acIY zJZ+2OGLlL2UdJsv#$%7dB@3tZ2V%gVrr#d_01OGRF$7}4gAck0GzVkp@XlgdHzDZ> z1gN?v{bHR78fgq(t%2kBRh$)AP%JjwA6zPvJhCEcP&XUt1GQk;V4@uW zbnXQXdCDCN5DFX3wXwZEryz)CM$)HoM#pTYS0S2E-V#J~fK@J!zW2@qD;}*is12Ix z{uqVzZ@6u^<0>p7JvzlCu@ke6&qKLi69}EXlJv#Y;ubw)cDJPgK8V2uH1e6wF zqjo3078+ya((${rpVS0xN6bDr5(J9|x6LrlVgtopY6p>ZR+H4_L6-Rsn z;)k5cAS4rJxyKG6Qb!IC0}5nqUrhx`Q$p*uGJv(4cf2g4V8ixM>9BCyolc*>@!mANieSzD*rZFBPc~tst za63StK_rv7z*kEU2bgR~J7;me{@8%gz~{EXcpZ+NEY=4b@qh^+ZU_gAWMCD5 zG)UiTzii;gNU?o}*nxK2p7Y2q4>wy7cwIB^M<@uS1-+t?is` zhO0OqUa_oHRY9*PB=_~pN(8QGPA<0 z%0d3ZKs+txl!grvmZ>B^*bd9_%ziJ3L%Q0A`=k54?*cC|Dg_d3ayxqEV^gRL-0xS$ zKUnoRVzi-0kZqmQ!sbIGn$#>Q$fa`X{v26&_MTEh?T7K->PYW{1FVRENJSKOmoMJzP*mk_Tq->}<3G`rocaJkqLw zSwY+1Z}WlCL#5q_kgmeiao(>Ew^eBo#a7?~O>LbwPzz{f0Y}|alZgvQL;wldxC7VY z+XA7$kTZFP#qWZDT!1DQK!9E&8#0+LRiPx`!WrO1#L6t(41x0lcluj7ETJTN;! z8Pufg)wvhXwc`m}QSh`Y01l(u=bh9*0_%;Eq}@Q|4}U?4B=JVkD#or*o3XQ>e)s}N zW&rp_U6>78w>D3|e1v74#7yM4v#9Vgy-S%CGT2}NJNgdz%hD_Kf<asPs7o zm?4Tzhp3iW2DBki1J2^l(=t%%BMN{=qkGM8MyVdAERmrWTYi17G4#mlH9D6TScdO~ z2u7bIvH)1$7eh7l1~z%tW&$=FIRLTmu=v(1nYzBD^(_czBY-FX2cYZ+<&hmJi>0Cq zNveS-wcvAwR1G7YL1-fOAZ#(d&+8@WBXlUbBx`L#^==3a>%VMR(IQ(YEHoa0GtbTODXDDOD4TUGbkG=-*1rHwn9${A%74%F(jtj93kdt zi^i%ITkkiiKeL(jEQXjDAl{;Q_R2JPT}FWKt3gme93ATYD<>*wJ}s96fHKJ=JaR@~ zXYzz6W*6V#&&w6j;wVd-W3s5&HbC&sdTCYFHQw(VA1NM(zwBY_`mp^>l9+%pF=qiZ zi#*UA4DT6qE5x8Ok-($36nVYw{l-VAK_^zRn?P6oB-tPA)(0$~%d@bP{{V3aCaXw0 zQ2Bm&Nu!!5Rz94tiHJH+GBEb^-)w~Q0LQ{pp+=T!vd8>w^ulF$RzC{VlX|=S1_hi= z(@!9uVwV#jKYEVi;mzbDm7WP1KE4lbt`ka=ehDcxPCE57#7d(tPa1*({{X}uetdi|G07UU$v22VQ(t#`{{X8wbdDw}Wb&aD zUy^;7-m5fpvIy3B9TcMtDrFZ}wxACtxXjX*S-NtKsMeGm3;zJS-vS~tBRVbVRj>x+ zP~Y{(Gqdz!BnQEXi%~lZ@A2CxAyn}InN?22i>4#ny<{9R%@Qgf3-3_ohjaA6=s=lR zhtPzdDc|Q=PKp6LEL@~&&g7a4^F8}y86;`TtLSGor@6eeek8N_2z8dBplzs(3M)Li zfs#8=!5r`_eTTkJq(}HEZz^c$R7hpSW`g}4DldKf9x)s=KVa-&ZG_7uB+ybf-$UQJ~gB~~j) zG^%g4eD}>O;q>A~CFPxy`fR)@CzE{blhgdN!z3jfY1Yi&+Q(sj`6$uEXH1&(k^mcO zCvT-+*8-*C>myE<7<@9M$m~w;{{H}td`@L!GfL)xRNq0|SmgWh^Q`F$^va3|)L-ud z#{K?zYMB$vX6uSq?J?7#`pLi#OHLKM^ei_NP!qOmenHL zAqMs*kKd`wx+Gn1hvGWNC}Il`)Cn}EyOKG@jv%l|_7C+mn`&kq$@b#?+uIKNEnRLPsoyUYxLj+VrriA1}yewJrS87_>7>81VIM1AW`yw#52=Sw4y2icr$9 z05MlnwXIuk+hJQ|xySCdN)nskkd&nm$ z$uosAIaMJEE3%LCxj8AOmJbRBQQ_*;7!X*E=zD$R@V{)cOEi;6-9+V?wL4bDnYlmb z+VXvJD!7RmTYX{f$OH|K@4f|yc;yY#$jGdqGGCR5Ab@`ZzotpjSejYpwlW=LlXeLp z?f(GU%k>jQCrYs=iE2DneIv}qho#V}1KgaHC#i;Fg<44f0EXOcz&|?tFsiyiJ1U8z zQh>2n8bh1lS8hLbkn2)NBoXx+5W7Yiz+h{)z~rSQO6e)_w2-1f#=^GtuhSXeW`aUU zGAU$r2FVA%!()FOp{Fu+PK3Z1Dgh^(?+|w<(~5I=PY+WxLIih~KwHSGWD*xu zSANIgkVQ)d4+Ti6_X8!`?_~Wk9$g?;ok$v}@K3+X@xcm@5-{=wLTIoX4Y>Es5cJD4 zst{?lSsat1?E4L{iTKbSqf!PDMmBn)2n3T};>H*2HNuP}Dj0x5`cd5a{o|3+f0oiC zvE^oavI!dxrT#vc$EW^TS$1?_%BHCE&*y>9{WwmEJVIZ|e|zJP4~CsNlf*tZ-Hih9 zJl*|ntK-_SVGQ7xqaXnIg#FQ6d~&hAnL^Arg<7-@NV9kGKaK>1(H^i!5GsY!Prl%D z^Ujh_8IU}Ka3g1NWkpr}KTI&i8%!d3HI10E_a=$w(|iD{M4@ZMH9$3%JJCLsu&5dJM;MM;m-| zcUIj$P?6apOJY=?EeZgQzCXT1&kPSw8ubiC2D3^)Abe{mmsN9gX!4CNX40ccAKEP6 z9-jEk)qgE2=qe|JPKR3*%f0=5eR2~_8QGM#HY1}*$C?M&SJNvv>F1hhBJmX-O|_6% zA+x=Fx8r;Va~!=o$s&iHi^xKo3Oy)yy;~l}@JA4Z3akGBSfd$FJg&+QeUHoIlj%^* zJg}Y}h%H`FKRkKiSkai>1<*8 zwRNTh5KWzq{2w>d0*U1kL|a$@@-`dk=eKS0Fm&kJ4~S{?0(95|jjxlv)%s&a)ySSA zYe>Yg1Z4}krUs9_s6Y3e7fjtqa{*J~#X^Hzy025o@eT0)jI@^Y5{=KgJ3R)z}`uj z5l2iEhglesH#-49d`Lfr5PF$pVp;lx)%WiE_xO`nj`K=OM+&Zjb@*b6p$tIPe#WT! zjF(DZsAnU?A}e)A9M`bt-!7dx^-@a2z=TJs!pH`o6dkXVe1YEzSmAhjXyq~LGN5G| zg&)2yxiv@ja!F*gdPI)JMxnU#8`$>r`ubukh-GrYk=FE}9B;w)=WG(Qq)Lk_ko&N$ zmam_yeQ-4N3(+@FPgx9}7*_x*fow%4{u*SCQ9a&U((JJ|(P^bc^qqlklow>4d zP|ZBi2m`-_hQfov?04I8e+=oO{$0=xp?t_#lxkK4RbQ^;5$*4Q&EY{q#u5irEFy8e zf_Lq)s~I=&;blP2AynP$)e*&e5=W*}t?5XSc(dAZ=IA#>OJFa5&MatP!~4!3XooH&H1*6S*zncxqX# zLiVfsqPNMzq^MF~NoucsxcOjo%&QIRapq+NT`&M~`|pj^$|M0J^O9_~k>=)&cI4oS z3A(nAriiqK#m0-dH@$ppW{6irZ7aNL``xhA`~p5cZ>8W$M*eYh!_$;lV*mhdg_}3? zzn`GV$07>^H(&tZAN#@#v5}&1y1^1M?X{7oZpZb`ns}Ngjy+x@wU!D6ucykt90TB? z0pse)Vm!!2TWlzL)&3ZhDP)bDglYsR6cKe0emn1v5rjpqWpglbz~FCt-#mUeu9lA0 z+DQW0Xp`J`;L!SUfDyE$iD5uu-haA#P#(MO^UAvP^2(218nHK`!rklvBf2K#nFV%2^F6 zF3Y(TP3}!M*C(PV<3|o$z3)PoZuj5xIb|pLbXu1- zr2zn;((HF2k~`TSmQ07g3&KGpV4);)M3eLX02P!k36&z8p^OUFqNtE7w)95-06d)J zDJekeHiu>0_is&)<$$23mkM>*wgZz!fxn&>lR*pN*1FV4^DTOQc?BM1wbe$5?UoZV zh3XKJQT(=Os1@}3;AkwpT%?^y6+*_5r+t9n)seYAx0CCo{%5P6SxX5W%Q(}gzdA=9 z_Q*8PBfQQ_m*&p@0Kf5-eyB~;s+f@(M=P;30Nnh)6O=y8uuz5`B< z9b6H<#cIC3m=%@MDFBJVPz#w6NE(O+x$VYFrjkixc+`T$NoCSWlD{ehpZ@11;>g`T zgDV)Ld+@ayv&&bhtlfS7!L%)hk4MvBUo4z>UMB44SLKD{9u!-RPQ-*3cH8~^FnC?0V)~O%t2%#U8bm>&L@YrH2K7t;zsB$xWQDW-My>>nKzSyQ#oeH%mCj7rnKA7LkH;l^~C{tDf z`9)Ql`CuwUrZ;0`e{aJ706bSqU>aSG0(mL`8&(IAv@B{tL?>`4pP!}_{;!pE3f}(! z&P8x{lr*L`DgahsO>wpT4l9HuMbh)lk$dGM)QGaE42Z*8o)4vut^o_hWn~q^FlyM1 z_U8enU|EtN%(hB~``UY7=Z_>)$00pP8Ydz}W&|tm$ghd~d*r2+9z}4WRdN} zRtG_Lb@LPGJ8#;sp$e&m*vN|LKniZnp5F|ls(fa4Wg&%W;#h< zhik=D3?x-F%igbQ-!r;~mAo+8IZ(#Udz>RgvVgR&A}Aq(jstsd_~N9jh;+LGMIZc! zrYK!-`LAO^&mRmg4wR>AV4;BQZof~<3UYi|$(BRmu@+oi50*_)OsdST(SzpFcdu-5 z55 zIT|&qt3Tb}0acVo_fD-xYuFFn;aHnXT_~W%ub)rPD0(Tp{NEGYjtDSf6D}p z(DCz@NZZ9v5GpVv3#vYxagr(v5;q&ss=~^^ z4VtQr_Pn90PLbF;fJpD(;xNu5c~(WWL$;E42fp5!FadqY(_-zb6|@xq0thDPbLoLe z84HjDG3F$HHn*;1k-w+u!_PBdlaFS7u<; zyHF$7{a|Rq0tpvEq7-m!$K<`yTBuy4vjTyI<(NfFvp<3oD!fO4sp~_oj?k@ z;GE}4aWEFzC1CjtgdFz=|x3-{rC7B5(1;mt5FyVSc6JPt@ri9@*~j*6}G+X z9GKNZf!Lg)GDf7(c0GvO0U4aBO|;$N?4YPU_P|LZhy&q=?$~X~{4fCkwE%ZG%DRV{ zw+6lN7B_r*j3lL!ZC19x6chpvm{>RZV(#0I#~2$zs=ALm_RiI>O@O6EU@?%!gblA_ z@c#f9mVWo;hQKxl9;Z32*ps9d-_rp_Htn}z=x~_WD7zrueID4m7g4-D!6*t5yNQlwt|9y5b~_ui)qH5C8=M<)@y&Bx0E zmwo)8ZS9?{mf%P%ePO9&KG@8|5gWCZtKm?i{j&L?c z*+3)mk-7Yi1odhFaYvjwlOjRvYhL+h7B2y;%KonCvW3$6X@X ziS(}dJ(;&B>43#SpbyUi)zLMBfOq!JP@vHx1Dc4h!#TLF!vItN09b0+14i%y)$S~D zjsOEu+#c6~RGVF}Y_;aUYz5qKKh83ZO}^WAz=W>lLk=XUcnR! z+zdk0c*bK&0a0gzf%TGg05z|A*!Ie|PSXu-AO^2-u*r2AG%A8DRx`}+8paJSNVBs zK3%!a>ZL5`#epMw89?$*S6~{vxBa6426?)LcZ|mw8}}{n%Z7l;RtrHyvlF?zW1ee0}?RtF#`0ltyQrYVn-_Milc2s zy*?iP*axX$*2r5xTRpbDu)8lt=wmv(mi?&UCe*6=o3XpC@r0=o6krCSzM*9I!a8WY zXITIQIV*oIGeam0y*!6yLBYE9%x^Y=th`;1@t2Nx$Yz&I5;q)I(*YuJ5B0($0Mr9- ztL2kUrmeLdN^73jk3WkWI0`I)LWQppMQs@UMG#Ae;hWH1TThb&F)WZ%Mdz?X*YFU&io9disn5Hs4}*Z8Q>62 ziao|rojQbw5dLCL%h+Bt-Bb8R_a?~~mFl4oX#s(8=Vl+;NaFpmSE8S&k4H}&lSu5N z!$mE~+vaixf?lq45C(q?d6@yBL%uKbDJM`~MF1KKFWRjAGMyA|r38|sX&?c~7IgEO zS~C760CXK%&wD#%R+W_wqNtX-iLecvSbAhgOr$QbXC^{GQsvmtKXji@#2mDQ z$m<&t0_;a~-2C#rLqRlP^x~C30{COM)RWr?>1A1_J1T_)vyFouY*z!20PTonhA1Qo zpvSORW9GZ;2gARw!#XaVyfza9pdBX74O`XyI2jZK^(h$|6Ynu2YUA|CqP{9PEXE}j zMD{+vzf9BDBqEt)CBNiP1AV_!n3-ByW@F}U`v5N~)WsV0W+irL)`GcOE$g=Z?0)M4 zjzm2sg;()z+%cu0umE>C6-0PejmSAYfEH`xl%6KgtcOgB2`2YEWRRGdQ%3&)@*Do@ z@GaF)iYS;jj-uS2z-@vL3#sa8K8sOj)Yq@?jFYjRU?E4 znqk}GNcap3T#6n87lAY-hlW;)9yj#kzZIFfd0WI*b&k+dj>Gr*<1vvMO-jil8f{xw zs6W2hI%rL@pXzKqNWJUnfv2lkff36h86;pC^;uoG`D{LzuhW>@NL5FK1qW4@CcQle zrc0;@y+Z{fQ&j=If4}#d`c9GwqhTzhfOL5Ul4z4(y6@>>mN-msHHDF#Zin{Q_^bI0 z0$84Q9s>o501JDX^*-l(b+}`brDxPttgt(A#dkQ5(8&g?z1Lgsz^erQ7#3AY5ev|L z*s|^Gzr!OD5p^2S5vI)^NdEw=C5k%*Eu|EBNdr@R`wo6s({*r8py=@QC-#k*y-)A_ zU}=}EPNo)ElSFFD5(Bup7q`oQh|E&-<-!J#2IPhQ!Rxl)Om*twGbGOsl*Ujls-dfmSf0i`eIT7gup8*w!C!0Q>JNn~v`k;5} zV~wQg)Kjx?F6bY9v!=07;T%mO#Z0$~X&DdG-GQIh&}c`kxLd z1d2$esswueIiSlCwDAvywgZ8EyMB1Zf0m2FngYZS@>kyV)9g3jz2J9B@x-d>ZWu8j zx80%1_!>VfPM#ywp?JoYMJx$Bs*UgU&UlYXWMTq%qf3oDr?iiUnl>@$Xvhm{DRuE<)PZ9Ca?-w#?cu7(SIlp6 zZ@1t!>@rYSP1GVx$^QTn2bdrr*&gElqXRq4vC34aRzOq$Yr}6}jo;5N6P-F*f~At} zyAA&Ue?yBPEb?g3f0yKw_q_+_lxaG8PM#!X@h`-7G|W9c0Ne4(5$0IY(l9m2wT;UY z`g67~ODhi&2W3FxLY5ravTyg!B$Fv(@xFl@4I~ZrG(Q2q%NM7R*mkB07z5msx6hG~ zTc`pqrH!;Q#7?Ck>_ztNTYSz;y!}R^vk>+Tp5p9$O`G{+bn*w9^a;x;Pzd72#{M|+ ze?i%|Z!;lLu=3ahA5H%NJfo*E4xSc)M~c?02;hCtPrV!ZWnLn&9+n7>r}7FOW?{CU zts3<|41hrjq=1cbQ>u;1-xu#gE7EmmqM9O2(F0NY(Y1nf`X6)Za5HrhERx!zlgy09 z$#3zFQ>KC_BohfFksTyWKXcQ5P&duqzoCf9HPP?-ZlIQ69dW6$eSPMW_q1c=h?@4FUdH zDdH+M=^*z6ANP&nD;HCvLIzcpKe$~JN8|Y9S}_bjBOP|h;tQfUpgw;>G0T5dV{{?i zLX+4LRDL^t>nK0W%?wnrqJ~i$+VSx8KK|z^)3_6BNCdqkWHg9x%sYa64%=p9DUz>T zluIxd<=_6ZR*45kBA%VrH6Bv8xzpVIH~HXAVU8tP54m5Eqs3N-=`p}MbZE7K_H zVDQ|DjoP-uNnMZEZ;fIzG~Q$i03-__YQAyc>X)aUMCv2)Rbymcb|UEfi6`O)1e2!{ zEO4W#b(rcbz$qJ1v%uK>afp%nmpw5GrHX-cKqiI!s~AEcfhKto9UW;7`pFgv_5!)d zq+l>ra0n!`3K~YLHS5Uya1~65#z&9DL}RiORd#pp&*PUJJkoVhbf!>>0A(y`My9>~ zWPGwLOcE7G)(9l(;ENy;`Ta49Yh@CUlicsepvWJiB0ip9>Y)e9RoCy( zH`|@|{{S_T>E=JoDkBBdJkkMm&7Yw-ol3{z$>GULNjhq@onLp2^07S7F#}X`s@WPv zd1!!rHx_b)@Lm{1wCk)Ag}=Pm9RB#k@h0ioXtfZ8vamaWb!X$o`JsAA-F3*sBu3iF zJ6&zt)b_~8@%dRJ*QaJA*kfntHy95b#z_g40F41)q3V}wQS#g8oO*}qE>=>yRQNz{ z04?`Nw@QWP;hABN|0yXm;$fn&wi> z7yF0aH}YHfjFPd`trnC89Sb^w827K22j43@#V=9e#ic}GgfXdL?Xd5?8YkM zD@~}{`}XI!KDeDULJ1>jfC$7os}ogqezpEE1Zzl9O1NOPr|%O-%00Kt{Z6Ne5Xer8 zrEb81N8&|&GVd{xF{UMpLNFwsGlFZsJ^uh4JyUdmyO2z;ASj5|5q3QHJJ-tr=`s(z_B*U0lNmp zpO$Qf2>NJxy&^YR6lu1$L8(Y24*uU9&C|%RNW}zfjtYYy7e~|3`}VvDmJb~OR`yU2 zu-o_Rk0C81tp#Nsc0JAh9f!*cD3KSzT{5Fzl-nOu>MKq2-!ye_#IUr{Cr}QU%IX)W zmGiN0ikcpuh#-|uP>L8zNTLWJDBM+d>~AO#Yj}u_)zzd$SJbho8i*C&U_M!qLm8uG z(Hy5rg$BD;U3!V&>fUaiM~XnYL0VQJhLXE`5-ZaHP1Pg3F%)Q`3bqL?xY)5CyK#}~ z6&jWmYG9Tta%-^kyZVf>sp=wBR$02vqH6<|dmr3(9Fbo;?UHGxEM}xKF|gon4c(5w z*sq2IiP|uXh$FqUR>zv#zD_cZB3TqhN%OmlUAPtLxccM5jWm(6oez88x9f_gUa3(7 zQmU}7Hv7bUe>@62=sH*+5k#*TCt8|gWDtG!+Z7;3m_}KqL3_l#hUD+gz?=@M(BBA@ zZAacW*!J)A#y@FI{HOBeiz{hr3O6bU7HalZBLPgMHOQcNTc>Uf{ySlsZkl1KBk!eRH+L7-fnl(W#XsZHeEVyVZMSx^XUv zL~<4yH&)+*59xm_2kKM+Q*rDKtoazW4yfG^UCzm`P@ zi5sVf87#V1@9$XS)Yr?LS6Nba>3riugkVJwZnw%BZAi)qMpXrs0@(X{NzT0)yrZ11_QJx{hr`EUU$be%@j z8=+fY@3s_?d;*UWvjJ-$K5pB72ajA}q9u|-tEiy>5J(s3N#>1=pgT8-DJ>+nq=_7# zUU&8VaQF)kQQ^DlU_Sbr7ejBB`}<+(P?ULD4vPp~g^0(m$81TurI@3`($;P%S{r5bC{QWTGOJS%MxRoC zID{)mSjNd44M5$R$cw_>6o3G;5~>ug^?RL&-0nxF_>xNohLK&YBr?rm#q_d*{#%{{RfCoP}Te&Sf&kenkt#-R;|NT+!6aJIFOg6_9E#c2>;To}0>?iO zmQuCIT_an&2uH5pt`OZs4bv7zbZUJiqifVfaDI4M&AUku6&MtxRnQml!qUql%OjG_ zCwD=)Cwj@lu7c5U3EGd8)nChO1oI?eY}5I;-1GkOsIkQqjUtmPmC-`9_d9#yOC>2r z15kPd?nQ0Sr})doylo-swFU-1x(T5~Q9N)5t$N*LR$?3k1O~CO!6`tts|5NE z*{OPzl^Dq36t|m22j5o1+K=U#UOx_YjaHWEfOS{B*W-eNN_DoSQNTYzcoSVJ+KDA^ zdd)GI(WaL`b!h2X%@sB`6}M`w_9rA$Q9z@6q6w=wz8MN$(T$r>Zm=&D<}FBo7HXnRGN^!3sbl)UES} zPKqv6g&SSN`sC#? z`3!PyZJBhC5Z=Iiv7%`rZ5*wu6hC#3Yxe&DIJ3u0+iBlrCvY$yrj2E74P@^^{_*Yj z;xsZSke1n42Kby2mLL>fMI1&fNi0Y=zkC7(fSw|O&7RptNZugoX@Xl{bM5lQ37{+9 zt-Ut=-U#9{2RWb_Kw zyD&T+2fhXRZlxokG!4{5yr#0DjSvm?tH~i6=r)ul&tP!~RV*z}!+cgO0+t4b!`}d# zAiD$p^0f?%!5czVgC{rXwho`DJwAy7D-d=JEPIN{h6Izx_iuniOcPlk1HnJtSkjda z2q8!SY%0xnz|t#3iB%vZ9kv5%-|L(M7V@sM$-VFc>ndEntwzm$zg#N%NF*oSBh9~V zabygH9s#qbiuTSMxOy6W4+ne&a!$b7`qf@pG)oxdI^L^05na9gv5}&dWe&+2fxTH4 z1%5dai%X=C53g@r1!17jVBZYwC0GXA^~aExc6G2=@HgAzfU2v7a&~Vp9M%ICR!xqc z$9>!7ivUKDc#cs)7C_v5Gme+lpy>;BtGNFFIm*i9B#cCNz~p=|IH@M8$rsKt(<@u) z<7(#=wb+mD@yBfC(hvjpuI~GKVNEMY08-`Ec2>Qs+ak6e$=n;?F2dsu&A8gD5=2#y zQ-TinggvH}D9T@g59U2Ho=DmRN~XJZAXz`-{p7d=4gv3sKxHAyp;Ko0I18`HM`5@* z#kEn>Ukqr}g&?Tkabp$9W%L7YYy+rj1O=hZ_xRw{=V52%hFKO;0>-OAY`yEg7@9@% zt7z;^_rU1m6U!WGAIwGf-x%#972QWPb|Vwa7_g#!aH#&J8r<$%kB$OZ@eYx(JY@q4 z6u`ajXKKfrPQ58Y#_R{zzC`&!6b<*lG96CTss-#9kAT4>o>g6qcf~89gQV}i0(GCd zD*@G%vag6h?rU-Dg^jtU*1QWea$#(ZsQP2F1U3m|9mWH!tKn9FTpzqg7>*f0BwdU4 zz+4s6XC7$UDB~>X!Pdr;bUpnrCJ6`7o-oMwRafg+p64vFzlEeal_arS-)sr)7h|04 zg@%P74#fAtq}U`74;WZ7ub5V@53T?gt9k>|30h7@_y`^tC57i&_*|V`gi#!q7=O`m#wf;EFfDvRB_4UA= z_Tb?=k-yUmY1QQv4!~X3aa}xZfI%<0HhuAQ&?oT1#DVa^>%Iq3VYuJh0&r;UTkVc? zg#v}u^NPD!UB|Ws1Zf}&Tt-6O{{YDPWSt7pBoXXzfIE_Nl&K^YKGlGrjt&_Z6S3N` z(I<|0!WKPL_M*FXI0_El+09#TewZ}+9sM(p-uA%Zx2VUKRgtYwC$QruX87%ff(^mD z`e6=)foebuH(y)@ZkY+O^%#Y=+qN>Qh4XV;3MgrJHhfdf+6p zStEP~RXP}fu;k|lY5)_@t_0j2==S%{Ec6EZU>!AOoP+SrQqhp=!pE-oq?;GLXCIyi z)XcPq;I8`x_;=0`lSHxH)?O}<$ugiNm>u`V>LUtBMrr_pYJonORJ@`5w?nrX(?-H* z*pfq7<7`3>prLDS`0U@;15l+qR~Q;oPf0*o2XR7&*COF3m5QlfWx`47T$I~Lp}*bu zemNNBF|Z*{qrk7n0^_7*MGQ2D+S{CwJ`IIyCvTQ(Ny5bLzy=`h-k#Z#cQU5BDz{{D zz5|H~0ejW%HUlJF$iOk!P`_;9;uwo*D|-?~Xh>2>2{tGSu1UZ`1bP`>&ii_NPw$Lr z9pjA|0Sattuf6MtW_aR3pityi$DC=0h*jF}&@s~jYK@D@ zqPvm#3^GVrkdQ)yrmt%KGBU*}kqgqREvlWi!kQ_(H`TFGKq>U~+d64HexqKJbrD1l z)3z9)L#fLWqk~_2*X4m+MCVZ&j_d7^Sy5U~x)w9SlLLh54i7-H%k3E5xX^w>ZYqnHpKv*9rP)8o$9B7lo6bKA9P(3r5+Rm_a zj@`Y#Je)Sp#Bc?WeK0EM=`n3=SC0|<{67q*Pt-C?=a7XwS3fLAo5c#U4ODMP>^ls1 zWjdZn^p!RO(6jz97p0F*t$KnXpsG3g<>s0wk~q~yrUdsSbM)gfO+1VYgoxOS8~xGk z@BVRCv>_nS^}x~*MuQuaaC;617Ek!c`$wc`iZ_g>fh(|?U941 zZ6aid0zT@2b^GYFHJhc{vo+V4=LcE-{pYv!65TdpaBE5n(~i*_sB}J zYZJL$E#xiJj)29J?MFA0X87N!W z-+#z@eDVZWk!953it1s%AF-M`ZwIBBCXD#Af(jBXLFDo`Hhv!*%Eg`}Yb*}Qw%6Q# zxuc~43*-`3!_`jYG zpf`@;-J=XERRBmEReZ6M6Ch$KTT;x_r!2Py|^AVe4ME zUo2*hc~*!Dl2R5YZ>}VXqF?31YlS&#tw4V31CXM+v#BACn;+Zl@yZKjK20zynG}2R z`~Lt~8c2~@h_^cechWD6?QbL{S5~81O;7{(e_RFWqIb~l)Fo{+qR92z_r5XdmVIC| zE|5sFE{8YryhLVA(AZu*&vW==M2X?58&_x4#0#<50rLIU10Y#Cg@OoIT#eIF57Cm<8Pi`c*+p17*|VHTFIeNDMXCBxX^<8{_h*8fuUtis>8U{ zR`@4j@~!>wE{uEv-YN+t02Nb98V!y7N9J=G2_YRY2Xa|#^Lzk5eBql;upBEf*ag@C zHXnxn06aZ2H%K~}l&-kci0odQ*pcoyybkJs>E0xcM<{;qKX9JxJ$JvRXzIFdn9dp* zLanS|o(T!&Z<(t2@!K&p$nnJ!79}~xz%__ret@lc|BjP`NV1TlM`oWGPs_)}pJOSfN z7@8NDoq8i3-LdEAhJA`(N*jzf&V2MnaJiNYkp%$LL7;VCoi5ogO(*+LdZFJj9DX zmfoLCp&<*=M6y?kQgB@e^0JP={eGBch=-bUF*6cf*OI^7P5v1PXX+$Vs0|V^YO%SZ zK3iUTe__D~+72Ok85&4r*0r|FYw|ipp1^T|=ORX8*EfqBBa5N11Ygwc_~n$s-87D2 zI@l_yVZZ{(9^}yYUS#S7iPPwkW&z(;f!@23>$&}$(qs_y;vH-}KnP>K4mbH(`HTsF zE~>xQRVf#k>22tZzuq2~rFVu&GP=E!v*Z~`Gcs56I@yp5o z0LemC%eZl9t5>A9J-kGM!}cD@CDG#8XAi<^2b_?tRV#pFo|;q_LF*E|It( zZTQund_`+#(gx~5wgBzFK7xnG9U&$*kywZH{^Cih-`jtW!!vkHbvaEj&``tD{{TD- z^xa5`Cv}fs^vW%@6tlVd6ZQ7abdoA~fQY*)=^k$X0Ouebq6i9DVS0$?;q8~{Bw^M& zq*G^7+KWE+(i z$+{!ui4;CO9Za5MEouv1tN}O**9_>;t-8OEFtT^MuUwk5bu3j5l7Ndm)$A~-Z84Hq zEN%Cy{59N)KV7}EUsAm^9X8T0^9?CRjYkWn%WC6tK=~hRf-fY618HQ0mQg~Cpj}_X zY%irlM|#3WLq3|Jd=HI`Ms|r{iWk;YYNNOfsO&wc44Ie2jM|d!Hh)rD@`{~C7*5C? z8rjM7D7yTs5+G-v!&`}Dy*Br0P1@WHV{e)2acefT2y*eQvw%b27#Y zkRC9)%7!O_WBh(N7wL(YsaU#bC3zXuN=A2cxS?LQ2iGUmLQ_MxhFZe0jPKa6KKs@f zCrKC+DUL-XhZdv|JiCv8uXFRv@|$3?tI#xj=83({_V4t_Ei7bo&(tB0!*?ptFDbs( zJ@>DN!xk>7MD|k9u9ac;lDm&iclqSdlC7$01(##Cs{H^TmTD10D$CT!?y{CTV?-CA z>m-xfh*9uwYz+D(idkep35l&ke`wW1@gpVS*##T=mtcEcUTEp{%IqDZ5tj(s zn%;)z<&$ken5?R?@pLY({{TMOK{7A`QDm0UYz-RjJ_3i&5tJ;XuA-IZW4DxkwVbR> zOw{iLV$=h^qM%>kGK?b7OEWH_bc5!tjRVE<0X?t;$qT&UWnm8NwZw+aU)HfN#FZpi zz(BMVweAHE#9-2jOhDRKmdE2?TrsdM;kzp9p>1@7ZT;{ZD2xe@nb`moY+dd@8}`T7 zH(C6@O)pfDA}a(gXaVMG`jgMDW?-PJ#;i$F$B*4+sggNV$r*YKBZfj&Vi6J zG?Jr8Ba+qkQRVXS{lwni4sZ&zaV*4i!f1_3#;Zpl4UXSFrsz-na{K(IaQ0LouVT$+IX(dd1B_s(LGbn!HI zqyuaL*@-(7>p*e-ar$EY6UiehIbS0My{y)j^so*VpkgHm4#cu48`jT9Fs)ielg}piyb6=2NRlyS zjU3Zz73SNIO`Cs~0?Is*1f^|dOki>RB@`WbK7C~;1!BFodhSr zF@;jrNp~!F_S^6SzAArGa$_hJ3uy%D1!$AMucv-7`e}3|lR0wYl}hrfxGner{{Y=& z;)!F08T6HEYX?zYMOFKL11O`rn7qu4s(kED?8f)2yv`CvMhv80;1OqjyEEChGS)Rfd~MS zbjN;!fpz#|$gd)VMLLg}wD%WM*Q3UGcS7(uNRM zPlnEsuzfV1Ywfu|Abz-}R4Nse_-u9@=^y7|iZsUCux>SQNUibRLm1>#b%>Fw$YHl6 zRd>J3Dyr)+f{9@pf^BT;saP*dh1MX?pO zUSlIP)6EzKKK44APrvCtz0G(r&d7dbQU>!=T#H!ijEIGT z=W;>$oMS6%nDnyAA5Ww!*OyY*K43ZCx%3$)3;e-o8bYD19mO#ni5|EPAyrfYV9wwY zwXhrPP4=Oa4kUGsk}WDhVlL=k8Bp30)!huL3h2{&8?%1AV)UIm^%HnXv6p``pIyG_ zuIJO&0%+8&8}6dmUnaKO_uDGwMuobNp<)iGL2h49HD4dCVd@WvwVOYsRuEg4qSaHv<&jKkL8N_9z z7gSE?<~|rGtVrt2tQdkp@9T;fylk6ozN5&m`^H@?R5KM*$@i=U>RIKTwa22hFOY5Y`QG@5jVsDV`iGglEc0I+0LeZL%3DDt41J6(k?)HuJ7d`57uBDU+e(N|+BBrO^<$fa3AwXvpx&x3rJU}fz}Wn-Nh?TSSZpD%z9uZtxQW@fwA?*k(rQ4?8UZK zE$Qe!AFeltzPDCHs{wmo9OAqyp-7cZ*QK`j;3|Yk0AuC^9Y=xwb3+Xc8x4$uQ7gF{ zk@DLTIjyzs6e&=^ZUt;Bj&MaA7F2CkK{UIay}no+s~oE#X(@)|Z@hjuiKqsoLJbS_ z_>aatUzfO&9a^{3W%6)ZAl|d>?_wCbf8KhXH}dz1hWkz=ZQa2Cs6w2 zA(#pTkBR+p8HTH4WB|53hqh7HPhv2{_+)Sa-;2evACO4zXFE>d-o)~KFb&9# zZp9Cg7}|VR zj!p>Xl_gbK7zNbd_<(soKhG$WHkPt&>rwSRvTW?CL;dmssXl|f{D8od7G@@tA7NPV z#AhtJz@;op8uyeD8$sk?qbAOf)WQ_Spt;dv(F;U8Iq#trKNCICF#|coN zl^5k*x98jEkyy|ac?r^6aJ<*Iz6BwLFba( zgz#@wHPsxNuBP+7dRF5dwCRq3H^{)sQbQOj^{^|rIY9^tl@6nSu1!EJFV_#54*cWF zOCJy*MLba2>i{qcHYV?|+OR5YUaq#|Ez@-gADz%jN|hnT!)g?NAC^P~Aai??YYOWD zd)@Kd9wqTrqM%&SUH<@FUkUUEDs+?0ZHSD@$5qwc;T7=XNMtgzc-Az5xUKI001w9` z#4%D$ez@QJjsR=yJ7Y=H%N&Im16}ULd~g)WD=Ld>91?MC(FH*szL?Zl(vS!p&J}T5 zNUA-~0Eo0rtxGF*Bb)_PZcU#;ah*WD7pL6kD=1fH1h@9l;{k0&C}Zj`YR)9FW9DY{ zyeRfdJMISCU7RWc`PqoowE=(%x{w%qsOIy#5vOzNc)&v~5N!7L#~`5wUp4PQ;1bE< z1Tl?QyWal*44%M(yk5sRnkQlb;2ddSWGl5;8+Y7b1TFz!r%5!-M%dC6Ye1!FSoJuF zpwhg76}h{tC2!2fgIEC(hnY%)bw@26K0I<2L*y5?rbVPXQ!%5p;f<>Q0IJGB+PnV% zj0x2eHm`i{<9>T%bLJ+u!1l#OmLONl1FeDhVX{?$BE2!0RHCq9XZw9I3D#`zdz=8u zrl=o##*q{#Wjyi^AmW9#+Q{_IlV&$q0MP=C?T2&&;eQO}H(^IS;3C1Tz9#^vx4l|x z0}xxbGH9?Fh#l)Vs+Ir~UZ(*x*{!jZS@Uh)j&m-NeE$H9c?4ndSwc#M^*{^a&=tqfJoZxS?z!t z_V>y<=oCP@zg$HttIW1Ag|p7*2tOn6!m4C#A%fp#HH@*2H2~VGn_=<5P^wp-J?sF^ zFR1P*k9_7DM>c&knRFU$usDTV-iYG{?a9B61|X1Y0XqOV!n>Z|EN2u!-j5ZERQBUK zh_lET9I2?Cz^`8U+v9M&^+DIiY`EvSN6>@$v;+T4Xo?sgTC z{*hwdWI|4mPVI$Jv>lk(suVF+AmZu0&Im%cQmScDX#8_mTPbjcFp8_?8g@TC3_6S_ zO7YJ5tE1Irm?|3Fw_)(b>EJRraKLRtxhFZ}lc$xQLJ1>L2Y=bXjG)KWTQ!5e;=c@| zNJ?l`B(c5s`sBPO>IrhhxikSbSm9PNv0clk8~R{qMM^67Bb&cMar$1GLuIXYCfi|I zi3E|IV1ZXcxjDKy2T+=%l!{s+tg{XV-LeR?2SIc-8{Bb`Rq+XO+*#*qt}>z~2*k32 zd2dVXGrS8ixePzz|3FkB6>XsOjU1 zR9OQH7pJ{*edzc3=69Li2+KE-Z%7`!{{T(#rDEz0jaO%F>)haQiCr{-RpukQj9#); zIs|CN6NAN$-w)3_OGgp10y})pG4n?rfOXw^0gxEPtP8s;M|YrdKYsbrM6aoofvg=W zf91dK@}R{Kk!8JFe4rjLzsDe&bnCNV(C1<6g$|Zkw4R+t=|M{WAdR+(6w*Z>9#cRI zpa#R~zH@Zq7{eAPQ94H@kFT$w$;^(b44{HVfOa*_*QPr-mN7QLfd~OLx2MQuL|KqJ znHZKIc=~%~Sro_OB8qMG9qPM$?-nw#f{qdBlB98&l)Ao_YhD)k+rB|b8FlJot5Ki; z*!$t|nI#aah9;|iHoUQchL9IjY%5q^52c@KIRtG)1r=S+_!sHrcwUpsF!KvuJJ=sQ zJwcaFgl$?X*neQZ%%4y5h>PO&6KZl9_SR43Ukq?zkUGXrr4<+(0ta*N{&1+VLVQgO z`hNAEdBVLEy;HG)kSYrrjURlXv@yg%7Qkt0;?Hlu3WG; zGa8LX?MT|F=9d15{{Y-N^xdy=dlTuM)I1QmFriVWfBesXhx*7Wzfi%ea(tuvMgfvZ zI)#E3AjKPOIMh!+fX!cMOAJ#~M9ZXUV%uv!4$3&M^O=?s?OraWUTfFbV@oN9L}_8# ztO7pq?ayoj6pnoSTWANlHD4YDdSt{d&hj`VN1^9>+Pi<8nU-P#vw4`2e?8B>RzDFW z+_8kVkl<`<)8S(R{bNh~pk+{wDfEv+#rk|Z^XZw`YAGn7Q2{3Q=lAu^PZ=b~EPFEw z1ni=&&3Enf%x)&?ufo_~qhhRH+kTiAYnnCc8Okm0@BQcZ@#8Uxo@NbN2xDOS`q$Gm zx)~!#~DQ?H}J!d=7Z*02hHZ5wgV;x`HLu4*vkZ z(=|cYI*5_uLcKc|4H%+shd#gxuUqFcNz_IpXO$XbT9^S;Pi?(0$1xE(SnEhu0ZF9a zzdp5qdXAn?=46aUBXTr|BmTg9AACsOIC?mkn~@my<|7!{lOcAbPO!7Gk+A75dTK;>AV6%yG}K7X%#X*z<#!x5}t0d@VywffM` zqXURnPjvI0Q&T0^j zo25NuF$R(+0NLAp#eVz!GIZomDV604>UA+9_x}JlfFxFqQp_~iAUCP+mnuB2WK@wD zGDk!ZKTn1!4Lgz)mml1F0&nYQ^7P3nNi>rziryf7{dT(-`JZeH1dzr$g|fsKX8!<) zCb#t7@&O77lUn#FbtzG^8vXrFJWA4Z?WGDSHrKa${LT)SrWDM#_-Ayn8T#0o zflCa;4n48@d7c@3u&Zi89=86`ex9cRVrUDqX>DWs*!|y^%zi%Elcj;qsDNM(EymaR zX6~W)WspW?kySyDfCmF_mHXs+xnHYPnU0nU5Rke?l@<8`@!tR<2QD5a8bB&;fbK8k zGTl0e6iqCxE4nZ(UR7oUQNI5F0OKa*`lSu1DyR%g8>Jir@BXrC#02U5%%y`7UGJO? zQx2XvT*lriBejLSB#tQe+>!9JnV^*-mM1DnRnkWTd#(HW;>jDM)s+FxrT+kr9On7w z>7WvHN+aqbV!zi=Muwqv@HRd9^uWQ@ygf%#)XuR=ku-#=GioD#3GQ*a;o`Awo)wNT zv{Y|Z>-88;mM%kTiV{e%Oyc&h!|}^>@<5OVCdz;GwEg3`-{uD2;{pe%5%n>$M96}b ziyhl)$2~NI&WzH8Qp`^J^tW$n-{qYQQa4qp2uVzUX_2Ta6UisA+c>jyu|$t01}4W9 z!42>u@*cSbSFG{+X?l3)SffbFzOlT9$-k`+q3xMuiTY_w+JcA*=pWb6T|$c zP{kSLnbd1wZ1PV0RsLTbdu#QP%>1L4;kVHua+!KBM^zF@HevVi_m5~@~ znk{58x+RDkd@DPpLDuyv(`t@GBPfSpQq#aCBe=hUu&^Em^58e3w zgXR()JZ5%{9ICvUOpZARVSb!mN2rm^r5{O9NF_kr4TV>2vD2tnH;l zRD}*t7q{WJ*Ez_Sq;(1efcUgN=mdo|+?%hV!a1Ge>52kOO|T9(=Ki~WSnz3`I)f69 zR3fvTKwocNx%lTt)iU)l%_|{jLYu1<#>3R&KThjBv3M&3M$GJ`u7y}yK9%)2RitS8 zf>lV3X;g;qSLP$Pu*p2C;u7H=Lm_Vr0Yi^ZTvX~(E~R9RSg_f{4tAr+@4g6pIJ`Mo zKp=oY76+lP#=WpQ7mN_^uf*4=*`U`|@bACAcDm`>H*i#o8oL_j^cloFN`YxmIrFdH zCu8_+oz(Q=ILMc2l1&l~j-Ewr@ceKL5wpiM=4A+jNLOv>iUbbzh7PTr4^3_1N_3Em zWG<_r>&M_R(gy3gr;;HV5yoYDAwsII;9sW$<&%w|SXJ~9FeDMqtG)fdv{q25(+H%K z#*iX}Ce(s;QK9u8j!&qao?`A!w1<;r0B%Y7S^8plvB1-+r->O-1?@wHJAHmwU&Lrs%tnL2#3E39wn~$? zb6|WkN=Fe08GvCNhDx$omd$+t-{X=IiPcP>`|`6@P$ZBv*Py(jA~?K2sUw)tDah`` zecy(yujh{{GSbN7LoT3qs@zx4PI8%gR8?AV7!nTv@qd;WWia%q)JCdgieN(#d|9d@ zxxc0ZLXnQL7oNl;mO-e2WD=t2*Qd5jKaLDyJ#45zZU&L5zJK+cVU0R>E@T#bo9#p2 z;zn33U(EEu@2G%jET9PXr38cSY+tqazz{46Gz{fTEN!;kS6vX*>^~e1BaVKdS@obA zl~(kIBa!gq3{e|0POLg`O>PFGO{d~5axW+KKc+MwPA=%szo{4Vz?#SklJa@4%RCBL%f$RcGHHsD}`rmvC zy(7bBK?I&3mOnEWe<-qQfF!efQ688PZBndgp|!Uv4?6$_4*ca4tUWWp=_Q!F%D|fj z?!Bn*woK>(+>%Z3HYTh5emLm_j*_p3eo?8Iaa)@D90aL)h9Or|Hsz~({{TPd97tLg zR+^(MHAX+Yi=$juZ=L{-LK4J)qzY44$Oq;}*AatCC6yHsN&B_3*}5K_df-+`7Fc?C z5(EV`7Io%Ju6`6~VDTO>-9C~~5LBIkqDVFHC+m$2az`0E_o%m5eKh40?0C`kH(#wlWE1|dan-0*vHcKG};VVJh@4KgbNi>AY6 zQhy7jMGAxsU@@)B0e>Bh53Wu*qlRmhd{DL&D`^+K9y=N}kat$kB!re#ZsawBI}_O7 zKK$W$U0OpSw{lz^BXP(z?ms*oXNKKOwGV)+Azupq=r>!QNaK82-gX`#npSXu8~*^Z z1ps}jh66_H7MR_H-Xf1Muwx+J!)x1Z_T2hjEMUzOD>PA^OjI{KS>MxY7`oZUqIU{O z+UODc_-8OL3P`}HHoCalhxz<)EYQmW$)TO3BTSS{GLe5Df0iukDjja5O|$Q@V0XR# zE0XFRWqsn)Yp`ASp|`^tB)Eyv(mgO5cc2B6b=&g5((!c@K^!1C)#6Ho5#}5ntN#E7 z#h;^f>Z0i#qm0Snu|L?Ls3D0Ryu|UwW?6Mi&nRa#q}if2pndy&F=lY=Y?D`+tm9j1|%YG6vElBkqsi<&=(C zx_+wS3)HTgUlB-9#Ba9TZ|!5az)7QanH6M>g=z&J_8V_s4luZuLa)@jYjg^w_5>-= zK3-qL3~^{B)mRWInD7noJ$C%@=j%+JOujaSjz(rGi{)NwhiX5W$}?*xVgOgMq;@{I zW*Qs-vM~e7NTNsf_xNQc2y}%2j|?yXfI$Ea{)GJTWDb`PgwYX113-rKyn;*x?3AZ+ zFK}zQ%kyF0kf!Qa%4>h_8;;`t0LDURlf)-j8xXa*JDe`As@x(L*b{B+GOI+f zL^_>fP|*|1EfPB#y{~VsNm?@6qDNiXthWPd{eHQoQT+E>?H~TyD3$yuzyWr;18(^q znljROVM74c3$-X3Nu%1x$Olh5N7TPj36?t~f*gZJ>=quN*S+Kngrxwi6qhPjzoq4% zD-3`U<3x92Ji5oVj4LsU85C($asvtknmv#Bz-NkRR^^3OMU7Ti z$p}SCAh!PiFGJk@bD78UTI6>$!&3ve1JmLDG34-!Xyi8p5?aA-b``b+aK-az`lNaU^BW1k&JAT zfU{MZa#70(GUPoTic#kJ5jwnrZtz|2Z{HdPg1Q8(D& z01-j%z8Gea004pp;Cd6bB(dl!tVObdX@Q`3!mMml<BZ{_pPuZ$l;``09n=FItHm0 zqVa8a|?6 zli30`R35EINIQz(zi)<5#D$`hQnO6vfhS-+e}1?^O(|&{6Qq;5{{XBS)+YrSB%$4X zy)ZCRR)-w>j3-HQix`WA+?|i9zg(hwk>UFy%a z1y&O#fEjIxBF@;!)2fp5+Myc-`_@4q*QRt#A#`U})%k- zNfL_;e7Co!Y{;Y+C5a2^g@^{p1+Q_OBNA>64(HPX&_IUd0g50uLiC#2{jVjc8x5!u zcRsk9hP}lD&uq6+s|xr9MxfVvNFxCtcaA_~RkN_v0sQffu9&j4V_K39rTUDMl>h<_ z`kbYPfKg^3n_Y!^Rst@TYSgPklbxWu1wp8j*l&eo9uXUz{W-;sMvaM6^1!Kdf=h`> zt{H_9{_-yD@2v7p6guQ8%1I+__&IWX7>bHlJexMd13Mj6zni(oXc|2snQV*Z@GZE~ z7W?ch*#7-;Bq#&dzB`W+FAy|f z16nVCjx1q7S61_b!R>)Mqv$b#WTQd7FN2REl6NOa^&wV(AEqHY8g2VvSs7vvuX4au z9f!v!B!R1H1A*y*Y&GcQae-NDbMV0DKn;Vc#o^VY`D}yhfH45rQV6T`!ogNC{{SoG zcffgJ@Vq${l!*n>nAm(Cx(hG__vZ=IK!9()3$RUmh6DISTd1{OZ_^d`zf3CY;psZH zUdHyXO2lDjZSaCs6%?x-UBMW;``^AiMw;|$`0t9umN-xYn;%?tdf0(n5)LNh;{~Ro zJ~#o_PZmMW&~(^4`eEO(+Wl}a;<&(K&JFB+@k^8C09FMoyQ$cA&ils5_}0e=?Z)P? zfo93$k$~REdcnhNei&ETtPA0)sROfF?~tENN65!*i6X1_dC^Vbk`FwOYy}z8$tBMO z{BQwqTbmt@HQL#_v&XJXs)MAiqBu9kH;PayQ0D$P6)w^J#?sxr1`#W|yNymrt}ky~ zjG+RpwXxic0%o&bRp3`R8enr2h$FpE^CJUE6lqASsT+1XljuGeknbCKg}o?lPqpM& zh?`~ejgPms1I}7WDYmhD`g+!07p<1a+!kKr+aVmUAZ5}O4&>JOz&d~mm1Y-Q?SMjw zq$)VAfd2q_tEYg8f;P+#7m!&@bjz?EiQJrFGIdT>ysvi7-TwgAL0Lpf;n3^XV^_a= z$;BjU>?Nivz~mF}k#PPdkf%zGkNCqXi?tN2DTwdi0T*1(R5rx?2RUXDL-%zv8v}YD zzHsP`&^%x(0CgS7W0~X+JareCgFsg~Lm3!LsdZh|fxcKszx%Ix6jF}l@4v?KJvl&( zWZ^izFFFQk%QaY|M2iCgZ7J~e8`Mu5lZ_LiFjftx{{SBNBfk-g0b2v_+OmwrVsaP@ zp>@q3*aA?k_h?rudu0SGD+Lcged2SvL}*maB9AH5S%vO+`1;_CP5ikkq>fLoJfGie z1Zd`I3$WR@(p!4{GSE$~LnMGLttzi?mIxbD9}|nRazU0!SR(=Ey<}B2x6;@9k zxYNea^&v}GUIEBk_?|1-B!D4nw9= zM}@AL_tZz=Gfc72DD@j43Pzf}?{05y{jv=6803`B+Xp1lEm`&+nOAa=Bf}4xBVpJ6 zL{KFDSs9FLDgxn^lkS1{eUGl&{POTS$pZ+gNaOybx|)L#$L0lPM?{Mn1wKa`>IZwR zz7?6YOA~l@#;j^y4R!~;WoK3XVZhW5o3rHrkM|;V803Op zpg4@60b}H`vHDrY>pGR4RFw?C1PpfqyAQ7z@x+S7RM~xlb_Zf_)L-vxi^jbS{t$GO zh5O)81AXZF>~MwZV^}3Hv#9|`-R-yMkm;OAJU3?8P_N(b<(gBaNaTs9Agh9O-y85x zvBVJe|zWKwpxC?2Au>UTqr=+KwdWk;kF))L`yRT zD;|E`u?X1lNXjYo5UreV?x}gWdRdP)O zxF)##e;l7w!Yb3NPZ2MAzfQ*%KA zjA5>a4Vz;+p_+YFA#i*s`S&N#3m>tO@P}0{rB)F|r8QrtsrRpzKUX=9T|GM=G4oKX z?lvD?{jf8TU{-Y+l(vz7+szNA_`+i(WYOpEH}`Bu&K?d2g`a zH1yLtbx<;C{+((rF3-6AJ{U(s)2B*`LTdu(p2ya|Pi*Pf8BhgO6-=wS2Fc&w^aBk2 zSx0vWFMe;!@A3D^_-h|VC6Y@r5*2LU7EiYzVN`-v>sgG%2UJ~df084|o~ zq-h5Bq9}ik-uPqcDP_~873r8)q1*nwu_k?G%E;2YolJd6+@Gxv``9fc)eI8E3}r7+ z19EQf^7>>8EYGRDg-O&#r+p1SEkFmI>ls;8*3v{4k*?7Q_?btYXR9j~QrsH`13Is1Bn` z19U+o*Pt8x#$}CKT@KHw73f(0)gS9B@;v=Z9a3td7zX5%-1=Gkusf)UVx42@nOP?Q z(A+8RKTns>Ezot1({&QKD4jy>8=4_#n(dBbwaCh~p_q%Uukpp|^!TY5T_k{{ z-v)rJ1J-op1eCcRI1(MfBFWtSd*cHfN+4ekM652w(cD@29A<^$=_QN;5m*^-V_?t& z=6D{MfT@xgyhZ?RsYc`2518KrVOZdba-kVn{4uF$g#;4b)@zSi^~pl<{hskm(6rIB zD@wd+U{1jNj{g7=nR;mlh*-wV<%W`Y`gh7BIDoXS9b9;Ep#I9Ee+|#q0i>VD6D@2c z40MY-1Hts%3;1S#5qgFmEszI4_<_Lrewf+?vc~Niy+*+7J-%J=!m&M&29xDbPWRsZ zu!Gai=cJM&87nIQg7~g?_#4Xg1Hy_xk8DvWt#5QlKRV2vS5lJ5L{XKdq+a1judQa7 zRyg{XldMJo)VGEQ-fHrXg^}{Whfl6FS((>J-<{ZRx2YDV_H$eH9Y0Xe&akQKBxZR4 z*$@ILK=uK>tKpLIQ1t8qtg2XrAKVE90q_@p6YY}GQOz=_ArK1HN07hg>5)kyYI_dy z#-uK-{NE3m!pq`IAeqEys7eFhZMgN@HS~YW4w@#CSy0Hw#TB=PG@Bl#?{EBbJkZH9 zY@LjaFKYDsD+8((>S0n5!BM49&?dIC?0NURjJ-U`)<+ahuOM&(DWDtU`uk?v>H2kz zUKH{AMN^~@RgIEw>Icfb@=pr_(4JrDffK zqw?Ot`3%N{OA?YiD2Bw2=eO&Yh_Ihbz3tkjV>`4J>B#jftz|jJ)j+P!PuvUn2kw0>s$_k4mq@F+_$|VyZ}t0KS)~ z3pnc&GqJnUHymA%YummNorv)4n!mkSt~fM!v8pSxvW1fP0PnYQcKmX2h?023+KO5i zDlUbKuYPfxQ}qN!ij2z2wrZ$v`r$V_2>40IA`fno*w2-QPBkB7Y zLj>JGVGsm)SkTmW1b!#;7#Ql1!z_*AY2+^VTMwuL{&}nH>6$K@#AVuAd}AL z(yX75#EbgzEKn$RjSSA%Y6KHPx%~zsti3#p1xBZ0x~zpe z*WxjyX&fYy62XWeMl>IxTe>)U^>N2NuTU(Z1SCu=v@ ze*N>KW@n#3uxrYvwckF}KA1t5Tc~MS)Lq7>Zy_Z7_sUGMySv0hQR>g#H zx&$9g3xtYZiREcgB#5kQ4(!E9B(U_Nzy3C7Qm?HD2XjmonY}+OaTIMaAJt?ES;B&8 zwBG*!JHJXfX%!=kvcup`laMX>4RyC*wfNv?RA{bVnU9Dhq&9U09=*GdrZcIRs0UEt zuSlXU$p_bK#p#{;bwH9RVQ!+h^&dg_RyXxy=#0_2`5XqJ=HJx%cCU^CE{I+-@d6Zr z%749Vk$;EFJE=nmSo8#19*0q`b$kfhD2~9Hd?fKv!;4=iQ^4Pm#>d|z!fvHdk^(8P z8iml`^1!cCFp&hYE|V?F$_?ojKo}+JB`6q8t~QLOnhHR(K>l~~#etwc0#1@iQV;2? zRi|-&r))hQrkp|HpjRM+Ap4+?@NeWWJF2Li)jE$9IwKZRXte`CdlE(-tu#uJ!v0wb zKc9hW2>MwY`eeFea61Q7aCtq3-|sKf7$oVEi7t!W^SgdEUr&Yt%3@*Rs*uDlncM#W z9rmv0jDpPOS9g>vg-|SR)I|V&cKkB`0L(y3sfmfyG#7oq90RuYD0@~$SU;8~Cybd? z0>`qR*bOgCf+UzL#tEPp@+^>3k1-=GJ<2EwYxAaBLnp z9l8D1EKPF*p>(0~G(GM%G)~umkVv8J;}S-cDS`6~{4wHqn8qGLh{w!E;dk`!hE{|) zVRp<*0zvP#{=0tf1g+yLwFLxQw%&HTC-A^h{xitWBS=ek{_c17!>Xm#GFcX+e7X5^ zoU;}VsG&s_vU~o<1?mah+=kmxhj|@7Suy0ZHJAN1x z>EwB4F4O4Tm(l~^n)NmvL9q=G{FOX?I9{P#Sb zF{%1VS|?cM)8#7X%XRE|7mpe{^zp?UL6{RoH?nGle2!5y>GVYM%;~RRhvDvAZF;MU z?S);0Oad!|L|XZU0&IJ4zrzyPYm}(uk2kNUz7)A>&9D}vl(4%W`}trG24IcVKa>Nv z{zUo@Oab0lUL}YUtQ@%9f%@fPRgPqRG0LhMKqNFA4%K{E_5T1EzB{i^Cr{zUqE|@^ z1@kQeC?8Gt_za4tM><0+u?u7<@dBesdj`4pua;(#P|5%ddA+OHZ~NyO1y+#}kA|hU zW4Jr>lyvG0emt?uTU8&q$)QBlz<5}$Qz12*fliErF04vWov8o@7tWCloUl!$kJ?A zYtlC-{N%Mrp<0gTVnx}!Wja+^fMQPKxEOpg%J3M-kQ=kyR~ZBJ><3O)Q@1yHlwd?0k~98xKp%^mrIa(;fe z$*n_1$l-`z4FKR}Xj;Hfox@^L_A1S9N*%P+^A1|hYxuwcXQS@RR~#b%Vb z5(;JJ&pAXZ+GdZ;7&C*p{{X!1zo>qqSoH#JLlVGQCw3%{d-z}@%wz!R0I1k-K3`0{ zJsIjcX*z=}Dxt~*ECKtZ-Fj_b9Gb?cWLZpXL}N~qwS!;h^TZ`xNd!kjVOz2EAD#l3 zq*(-YWRbF)yyDFxXnQoKh#U7mnZqk9=rvR=fIa1%HMo<7!GcXkErV;*&LkU&AaSQ zlXbS-XF3J@ui{6|t@B>_!X|GKfF%GQbnp+Z0U|i`kX}H`ZaAaq@b~x1JfT?(E0^1-38&!%mh|J|Io_%r8i45v!s8?rnQt;l^4S0FiG|md26@6Z;wAyQTKOdU4R6ZDk(`k4^8nN*GbUGtT-p1-#A(!sS(&M(Yw09;6R0ftLwWY z9kl0B3Z>18+rD$KP@py5#1Bt==2q~7gXPpf7rqB8E0!(^ByC=}009k>7<_2^XGN!2 zW*%D?>5n6%a=S!L8x`B(@W3k!YPyLM6I%nu-Twf6vO3O+t?$z>9GWc=kOmx)$F?_6 zu=MNU#OpC8{6e%VwG0TT)}-nd2KF~N8&fW~ELn|>M{}JH(f~~<*zc5|&pQNi%h0e;{cy9>`!k@5-e(gW7HnbpiO`~Rs>tJLB7?4?>W-Dp2M~RtGcCcSM)ekcuuV9 z8uy}k`d}mwe`f-#Fcp6vOb65~u>;V0j51!3HC}S5jgiLY>~MD-@JQN=6(qA8BCs9; zpq3=4?0bXy;!g3BLW&2u$$_XKsFcC0a)%dfoD9SR=-c7e%!g1102)vQ9YeS|*RRZe_ph#Z-Cc>? zZ--C{6y0~*09w&dE9Zcr3JA`TKp-ix?~cWh6cAJqV4Mj6SWp;hh2P5?O(l{x?TSU& zzHoE{Yush)aMy-glbDGy+Ha}eDW{yDW4#vR;IGeU(z2FbHw6<@MM;HM21blnp zQK%jMI2M=F3i_C1@yimyYkh|`>y{`iI?1X300EPZITEaZ7}xLHDhz!q(YXolz`%=m zpUen`qW*)nD|wg-1+J>EG$H<`xfmckfH}YOlM=>~fQlBy5WW4d0z)H$6>I^$b{R;3 z@W>V~YwuXXV2@h`BCFF9N7Cb23;?nRYwhvCpc(*CwOPEHMdHjBB`vJ(Jx{&k_+nZt ze1uy8>5UV{9hNY68xw%CvYOBo71_w|TUIJa#I&<&DmDNfbEYLHSU`BkG&v%(yr$uD zINgA{`Cv*VG8P8)pS!&a#_)^*`dqjIiCJ-RU13s3J(gY8rh7oSAP9K=o{Q7rhJ$B(2h`rGc?i z>4F%YP)+@Yg@Hg`{{SqHi1Hl;G}CZK=QYt@kz-aZX6PP0>*jDePX(d^w2J+4QG;(p zC>7=7Ch0S{Sfgt`UC%hox)ipt{Wb#s0OWm#rUE^rwWEVz4e)uomki~P=1pt{X^uf4 zW1u?)qA1^`gyg$}V|xJm=BVM8*c0I{kXfH#0owBcxWQp= z#BrVDJFs@3!)v>zLn?aS)Lx48A5}ln1ij)Y^y2< zb&QuGN~<=<+}G4#CsiW_0V$O^JkazW*}$XJ?nMRryl=(gpa|?A&)TbRrd)LKD2U#Y zz?ufnI1y-xI$eoo8mMwf;?6WH5RfeNpLBVoZh5m`mTBuCjbQ?rLe|H-AIN4YXA#QUXo8>te|P@! zilnTih$U@_4R%jEWgwYhgsLa?HGSf2FT>xR^CqzF4BG`-0_aw0MVG7a)+D_OJgc$z z;B<_t8#7HK<*-*3YQPK9BTO-hC%rEKqZIP~?(KZPGm`eX%zC?cqxj<3gT#~U<(w1P<* z{{RDDJR@{)$vlV~s2YjUOnkAYYxd;UW_3pn(f%OV6JT0yD2 z#DmAM%A>R~F=9eUKnrz$>hggsrfC(NmUG|Kdu3&eg2q-Mlnub%&u^!E&L(3fy@sMZ zqtny(>7JaE2qPv>Jk}&~O_FP1ejNOUL4|y=89<~hu)ExU-ZF(?(#lBH$FT36sVFOv zZ@nq|oBn_2I%x#bv|^Qg0N4)S6UG$sL?$fktjeO4n+i7jxAVV-AcLmx<&ob?1p+lZ zvvK}Ue#13&GHNl26-uuFZdlhO{C_O-&n}WK|-<+1&nrv4v!mxP5F8kO>Cs_AeW#5hRTiNWs|M1A7`Z z@$L1%Y2tYLWKDY;me2Pwt?SeC&WczSgne2%jRd!bMyDiL7r^a{5Hm!qL$kS|@XGX> z97xX^H4Q=7o_#OB`S@UG^G6(h8W%ij9gnxCC+UwLP*E9BD{BE*4ez~Q#;@U&b8aF4 zs4Z;6kiLW1aqp27l01!}3LnINIqJ1pS<*vuq;4d^s)O7Kv9uZbW(tTXroFgQV%0o285VU&0?nTvC z%f38_l9Y@vHi4OsYTW)q8b=FqsPh%wPxju`^!)Nf>I7_P*}nU6_#dtm&jf5_NXz&@ zfUMgRYjg0s#a9UoM1Z4p<8wyr`(1rLIeBFT;gB-9jeN)s!p|R%@0=~u#*-avS=a~C zqWKmV@%dmc^(E?V^>Xa>$*cMQ0Gvb~98VPSs5M5oo+_fp2}RHV;cS7oy=35)FA*E4Lg>co z_tSQF^**@$WPMa!L-hSbgUCb#D_vJ@yA6&Of;nW02wTgrEc6K^*dE(e{12uOu*gZ<s zQhdaLy^o3fFsy{hEQ%SnQL~=Fe(xFKVH@?46lkK8LI*pz+}(cp7-UTnBxlb-T&O&f zER5PZHI&Hn%pxo`gwwUP(E4wXWsXN~fX1SRyV9eY@A&>1(90?@WF|HP`e2_m^JCv( zh2GmAZvOyZ-QXUg zH$@&Jl2q;i1kQo$K?Dk}c~aK7j3zD6{V zD>!x~THgDgPs;&XNkcmw2)pb6HFy4Uq{k#^KxHQEf8r{?0g8$yXqb^0lKjVTdEUEl zar%Cp-@)^%G=Q%(7WS`@1pM%hXq%}5q-BmrRT}oPD*i(9CMceABeF=c4;-Juv!&`K zpTkyALh3~TZhMpQyh4DMa<3g#(e3N--vOh7Q0Xe6P~fVcQ^EZI036pU8T!WYT17>x zl0a=!U_T0>=~%AJq%>&B8i1;-Y_R|meiy!IpY0XumR*AxZCd%2?_z)Z?}wCNW0R&Z zC;?l=G{yTUHB3H4V#@}USZt|MY_|>j-weqhc6E~A)!~L+!)!MCeKV%&m#XSujS9y4 zllp{glmNe$6uL~IV@*X9Q9;RO>?`-qRwizpSO;Z|mh`US$lxEH{c}8WmUmdHj>_aw z*xmabhHGw+BNkaaZl9+F_=;!{8vWe zE{Du_+qT`eAC6zBZw7XON18#Ujfx;q{A%!ZQ~2)>P;~`F0d*TCY~R%1*C>O)(lbpX zEMR~@zSmo2B@+&n)<8V&t`SeDKaT$ZhCJWZks^t8$W$(XKe&Pa09c>P%BcJ@?5(GA zx5>a>pQ@5JNuqBFqH9(H{KAO*MhPWmI$4CQK?g{!$*@Vn9l{uP(yd_W=VAI+)aANk z7f%OMB+_Y;DFZ4Mu_Ir9g?>LAA!bOrUZf{Z=0!UnFK~N%af{Xo;gO-1MJ6jNTh{b{ zFTiGK=W^B~QG7ApkM6SZcrv@l%1nZ(Zigej6kv&hgjOoV_Zu$-PuB#}#V1iR>SjXz zR$eRqN8|eEMc0TTXvD2@8ulM{zBON#H&q-eO2a0nV8LB>RsoObFeM}Z05nN7p-?J? z6gD0GPW<8V+e38dK@sXy-(WfUleom46+%Xe`jG~*c`SWC7Ba~Moh-3Dk*h-}qH5?h zjy-lHeQ+egS?AUj>32GII}0cG>y_!}JvUmeu_K7q^hh-Zt299E>zoIP3$INXI#HTd zeZ_Be=qkQr8^!V20F@!(4VnW|jYR!7$RZKI4C;bb6bKoBQ>Z~(vGGX zG|H>txF)O>+P(HS@gBJaWP_?_5j2qo1gn}3wl@4fJa^Q{3o_Y2>WTfew*LSi595G~ zrYPPJKqa)24%cUMhA|M5QKpf+psyF{xw`F>WQmoxbqsYWCj7KXzjL+?ZPd=6)Mj#3 zH6Km9#NZzZlr)7xB54E=Jl+2QA(ernF(mH3AVLbJt%~2=IC{8YMnba5r$ey?MJcnS zdx9%}d|3ohKZKqlxCtVVyV+1p4%_~jKpkUvqFqX@3Q`KaHLxF3dC@#jN8>UmWnHWL zw_Uxv?~>_=OJ2qO=#`FPpPg-|Pw=e_phCcZYQG}WNg+tI#v_rjhhOcj}RDRJjxvsK_1c^jw< zD^pV`L-%an!}rO?5k(6i45fv$7va>90I#pejG8J<;#c(+DmtuyM#I0)D=hItG_46z ztf2sPqd+YmVtK$)rZ9@g4cl>Caxdlh=5~*J8@DP4YNLOj(>6&TQaYHv&zGnUVUYNL z@v=pce*yH#o~cZbM(M3mwUNs&p!pMC=K)q0>Sbim6ol4GH4ClJ#NbtuX|8q%(hy9K zdkXdRtg5e%Jj>Ihz7&t)qPO1O^oFi?1L=dNNLo|;%gD+_1V*k1*pq#M{4gOOP5o7m zT7-HwO;wuwF~*rXHf0JlJr}<@uxJ74vHIeUn8*M{1YUr3Rg>}k?T@KK z2qK$pju9pbCR+=nE~2M#u@(!j((rDQ1EI)*KOA75G-80U#)*ED`2=rq12<*I=v z*wOiCLcm01pv)XK5zT>BUq8bn@Vqes$y&vOe&l|K3ed(G8DwaYP=E-GZVh(lbEN%o z3dEC-5oeA8Af2WQ^Ul~2f^dlR zN1E?g2eB7lGw{xT>c*s!RgS?<;NSZEvTYxSv+fugAYRwEg$%}UJ7Z;^HI^~5Do(W0$F!40_D$LMgTogoVlcOVPZ-)sey z)fC2{I0DC}s~29lSX6qH5Or8yKmdE>*fOVj6|3JhbkLrm5QSGES$_2pJ@4bT26EbH z#*V*s^&6%g{{TEtS(HQoVhLhz*0=mIjD(#GY=Q=M4&mW47ApYeBwgcDq#!$fC7mXZl1b${9jh56{ zzD=A2a+2#`6GolX0r$76zg$;WRVb_oyE;zCZhss)b_~h@d(~rLIKN@Ob9C~?TyQfG7%V{44{z5i4DzD}MqtW8 z)vBoW+~rdzgv?7Uf-)GWunmFhlMYO0!dZN+sGHe#{Qdp0gtAIRYz%>KPWz4d%A-F_ zll1BvOpK{vB_A&&$u z8&FXqfo6uP2WundGSERERU|N|k|0rw7UOFqkC6N@sZ7FkA|whKK`ICLdt?O7t&yVv zSXPUwHL?70!0=@ZYz}rmEwbHsf_+o;<%2hvsT&}=pl^Mv@$|@v#L&dZQBb|}CgXnp z00EA|DF}&3+Pv-ntLEzLSs5aM)GSfHv0<-$_n*BN|Rf}#-AK{2re8YJs0S7t{gbSN8pFp@7}K$N(&^K01dr=f0l7|RZ@Yq zo^qA zb^!d3Y#^x-TDW!|gMfa!<3vGIZB_x?&^z&19yt6UT2@&J0f85{s5v4l`eI8$dF(JM zQZ|iEOCILQ78~L4;N{+U<|au{nOl>xs|y{4R@Z#pRlMNpR~+rzCUuGrscT`j6m~+aH`C^$!N7H9c=Ro;UaCE~ z7x`qA?no8CW0gl#0MauRB884e>4~J0Q%N#i5&B>hEL(7`Un{I`p->3)7c5X20}c)L z_Nu@N#?A)qLac4@&Ur&1vc-jNe}0vKo*|72fqJOz$Ri$o5y~D0C0vpN0jLY_?~-QP zfULhxS}4rRR*s_kuYHHh0JKpV_H+uu%Tc8@b&7#QA#hnw-avfc92yzRIs;m_E5-=O zSxMBC38LH|Y=K?Wu+p?U{{XCa3n8Rsp6AVj1@cNg1>z{HwDbg1^g=>%XQReS#9dma0FVUQ$_NT}3{1AnG)1jKh5 zLsee+#wc_em4ge|kG=l+z$p=!k~Hd3U~#J^9d--=v-hhgtUxOtm=13y`go^ULV zBkCh`VWz0o+Z~8MAJ-ObGRP^bPTp_B97ExhNr78Xz^!fwzsobRx4XXk^MKA{2o#nq z8+_&{-e9K5y8UJ)2sv=(e=Oq2Q+pY)_0k;F+2Ww z&g@AlH$2dxg6WR`0QZ5!2Cb<3VwN0M5P$C&s6TbL9PNOibhnvq_pIWgfxdfV$n7m^ zBn1>bGJFrcSRCWcyItTm4pUvO`b~E4h!ohaPZ$od23rT~it*^!8^26qS#=rHet`cI3reVLAxQnMh9&}YWe3d1XvV3dA#BaZb<(CdBNL7Sg%gw1M1?GfB;ZmlkoWCv`-`I zcGTzBU@su6rXuU*vsYMvNn=qYlgG9cXM#fBMzsf?0sjCw-V=eTMf#fC;fv!tk_p%k zT+-4q!l6!%_1xf3chaPJ$ok$}zzhs&q>;;ZCltY;EfJ}?H+6zZf2^8Lr5g~uU>K=M zgs?TaH_qH(j^cHZXkXnY7pzt@+R-G7J%%&Ks+^ry!R>(a*0yxEW3hizf)IhUz3s{M z$%4vJUdE~*dW`9ji-jhLl~7kE0PNlLzBh^j;dhbG|O>W~ve z)%#<3!H{chiMj)GfUzqv2{Y-8ki;+EKM%tP@{IBmx`ABw_s*RN3ii6Ys6TWMT(b27 z61qaUApPNW?_3-Jp?q!#p|$K=-`HR%$jZr7z|cRANUK9OvUQu^e5Y8TM;yAA*q<+T z9{sn#;~9RSmLaSItFZU>#1bG-?##|f=VE`r$J4oHVnG0$fD7UP>IgfL>wxXlXk+1@ zIXks%1|)Kc1F>sv2XammQ7ov)NPoIWs~BO0WFmmnMGnAVbEA03ScX$tf#H5QBZ1@| zGk~#^WL;?&Tw}2k!fbi$Z`!xq`|*nE>j`MskjG(t@0^1De?U5DBGOANo0YpDk6)4b zW^SG2Ms-u=Z~?B_sz+#gJrhP*)rFIL6TtcAXJn72q5&sm(mC}$=O_!)ER0E%4s|tO zAD(eCMdb5tz5P4>{jqvk7)W#mO<=8#+i#a7{{T`q20b8)E4>5X-ymh_z>i!_Zb2k_ zcgevhC{{HZnCT7w02}Y|$S$I~M1?=(eX9>kCx*Ut;Q17tuhigbl2(LxHK-||2G{O= zNy#d_QL}x<2FSmr1&T?fB)MiDVn?qwbB|PA6sWy43wn`wQF3EhT7pPE@%WFrJ^gWd zcJS3iq<}$S2XY4Ajb$d4WJK1M7PTAu$J61R;)RV(SW75t;4kpoDkQK4>S@lV8@2X6 zpIXaG@JkY|nk!yo-1k2W=4j`QUsi&uK;D4#`IDJrh!xo0kV!TNAHSXjS^_1|o9}w~ z0Q^s;__~&)GO=~nyRG@h&)1#t<5?#~h&7WS2Apt>^-of-Ar$2@>LcL0g{+wz;`;pzZ_~qb-r-CLjxAedV*IC%< z2jE4YmwXJ2lJw<~n@h6T9Fet*S2|%)M6oohTYmol-fxse)CORs$_m8jT^vIXDxXHCNs>N>9KO zUHr#9`8-n1Pwna`ECJ5Bzc$B1$^}ZF2rJGZs z(|aQBO%L(DQavigqdRCcPYZi}YW}%bo>>?-NT5@1$lV@wow z{{W8PrWhHbjpRDxM;bMEBVtF>1VTuf#ll%{Vg0_iCY8E{DEHx|n)v?!p@C>7StXo4 zg@u8u;1CGg*j-?qJX)M0{lYvt<6=p#VPA%24v4N1e4uT8uP@VeArhbZfC3gWc_)+k zeFg+#SisU%gH@IX@csH_r*`StWL+fbuowGB74-aZ%Jm63FXhm!uv;33Z$sNAdT?W9 zlF^lQIa6YdSMT2gx|no?k~>snU3b7grY}sa7Abr}7Bl4}dT+K>I+*%pXk$?+Dhbp! zus<3k{XOv#7w{WQi=rSLE$&Goz9(~(1q_}ij>UR{#Ia*%z2Cq4$?ATbI7bXvZhZkC zSaS@mfY8K@&5ogrGre4b`NH+pKOC?O0jr~fT#>e{{ZaRpv3!bYY`Kt zi>)k@vc<}hEsFa5awQ4}W)u{F!;AG8B$Q1ghgXJ_18sM>*nK|?4GOyDGe;8d=F)cn zZhm9^U~awDAEod^w}_9dpD>}t{(lU9ouXJ}ZEQM)-2vlBADK1u%>EU!N=Ym%o%uZc z{{YFrXu5d}iz}&Zc36MH%~|~LuT3XWAykOzBm@Ux-oCrzq`g5R6edP4S&$uxu1~`I z@%6#c{h=}Gzmt&MK$jQEW3eaKYp;$3Zcd&@8<`mGUWecJ&OcEbM;wUMWJwKGQSD># ztXUO$$)uJ|P{?f2<6+0js|J{fXJ%li6l&+pM<1Wl2wt7Kb{eFC+ff#8w%mL6yl7fB zmO$?43h6+P)9}L4bjqYKcj_fTwgA!^$EW^3 ziNd7>@W<0UB}pMzs}9F%{49L(!=!KJld4xpklaKKj~3sU`izc<7E&NA@hbpGT4!75 z*zNe_zR~MM)0PNh14#-F+;{vije1$8zq9y*EjK|LyZTJU&-^TJQOkyz9#4W_eJ2pum4gEjX zWSoC1Fp*bD2&|;?Z-2`gXxXKI5Vg={wWeWPe5-uvzpK_K^E!k~s$17hYzL*+!1Xj1EW` zJgob8Y*2y4@A~1KsU!gY7*#K)Z94&fGx?0w9I>p>#9$Ju$Vt{un^xCd{c(CerY@Jm zVbb`mtvpEGtz-a`^!vMDXYiINA}pZGD{KpFS9=H2=zh*tK8Yl7qJIqmDz4y#unzvi zZ^tRpcoFq6zgg4k>2;4YnkRxqk6~WG`sA0am&1_j){-&P9c_E>_@Ax-Nk>i{0;97E z4iDTk4#(^D!jlPSpTcF0B-{WGZUuYSg8yUZchH$oRWHwy3DF>M%!j@-q=_x7t+oYs;0BZ z+RYD$+c_g56p}O}QPel$m=Dhay%f-~iD#-B)a|e)>5r`we<6@_Bn6|;73AC+-$}sh z{JVr`H8qmU`e8=#A}i(b3$Bvi+P-VALAb!DQhZbq=~U7MJy9S~s_pB7HAH2!2SVKw z-p2m`Ofv}*Rq=H?s4J_pcjx8~a3iS`MpMJOkwxvj0cY3~fhV1xsb-OMi3nxww`?*? zEYU2Hvld1QyNjX6(uM{^nCjL;Cfb{gAOJpi7}K#zW%RDwZ-GvtLOMvwOzmk>17CYl z+u>fh(R9x1;+3eFh}DG(J0G6ck<$J~C~Aod*pHW~8EG7NO6w^aWYUYLiyQt!0)}}t zbd1&D!e{_ND^yt5xi|8z*$9~$Ql;!UP(UJ)fj7msAT|ho%tLH2Q5o|Fn{kAWETn!yJM%_T2p_VWpPZQ2Ul9mUQ9!Vr@GCeGYHg-V3DFI6C zLG6?=D1`Maz?DQRM!&d@Kl_{c;-FOV0y?g^O%@yg-S428N`Xj)Y~WbL5d#QWFhhElyFMK4NaMj>Q!MQCGW z0qt2Cj-+WCK_;W9?XV|v_*OuCBWeLdH@wJ>gg%kqeot?E8B}bUMbIKu3{7!oinq=8(Ld*eKf z1g@y2NWjpxHm_a1_xRxt(3M%E0K{3FNG@nA9fKc-IP(;eaw3Q+lfzMF#-atA`2Kjd z2{N)B+-XC$-yZk+d*ymrOdUy!TqZc_&91P-ZDZHh(AE_sk$O`sOt(C85R!swse=LQ zWR3l@bF|#HlG;@KM<0b&S>TX$4-~49Ng9h*`^L`zdeQtc4y!83FNjMqB`kg5IsEW6 z$23d3M7==pg7|!CB~O?U=uP}{4AFVQ4bg0h^yL2loV%-dfeL6SccEl2C-cD~h67Wp zh6I(W(s=x6VGOc>hCL`59UD;O*J1c%r7|N|h-L9q>jV2v^?!lG1A#0iRZ$#_4&s0p z&*z3nUOt_lQL?~*eF(e!u%?~LfAaFLo;KFUNU%r%gWtE}atbOp3Ko{0%Gb3Iu4pd| zjHSsE0&MfB@(;%(K_wecik?!-*bc)0$W+FGPWo4$?KQakFXxGDoJ2_)Shscd+t)1B zBS=y!rO+4CYQK;9!Ee*MyEdm*7R)%Yz68u{RbG?^JQI6<#&g7i$Rx32Puus#bWXZH z?!<0=c>P8X>LRr;&=?9E?_Rh?%N*bk!CEQX~zSu};zMV+W@J~=p% zL3d^<%e4{Q-}u1jL$O&SD7*dc^fn#I{{R_7#AE(kJ0whPwQfr&uTP%e41{!E(CKBi z4e%LPllpcvC?|9Wat{9hPbkVxL~)2pD$1-Cm~Y(P{{S(OQd3W?1+oAGnmD>y=3vqX z4P#qh<=Y-NXi%ZuDO4Ujz?)6WIXXliL3P3@IS#+~{r1s<7aiBacE3N!VB_7zL{x zxcyEU7#6lr8i5*3jrqTP2a-<_n1V%{Vn4h6VW~|bF<=IT1mAnx(|^|%YgZ1h8Kj`yj7GXDxdUpyt`x~A>NGPoh?h+}Sz&w+uj_~Tc_!4cg0V&CV}EpA zZ~O0vs+tfAZ#Lt9Hrddtsg^J3EjWVj6D6 z0mTf649b$Tvj!wkJBr7WS9FRbQZ!%O?oRf7`1)ZJU`P?h#`RGIY<&;naRsKNB#=VA_V-PdFD7wW&sDiYD@@YXjVUF(hNGhK#6U2_46MzPJ>a*+@XaeE$G; z@8j)`l0Q$PXxRfP1y68(A1noZ+f8k^n~*)R-X&NA5CPU!yMinH;(t5M(lI}i_l-DMnY*t+X6^XBAZ%NimbIvZ_>yBMZNkt0TLEL$n;2IjuFuFBV@ z_}^|m0|84G3JTQ@M|^fk6}3|ZvO^yK0KKsZ(yLWxzj2kGZlkGS^1C}5Yi+f9pMF0K z0?NU4s7RO{RHW)$MOKlO041*W9PQf`9hJ`HiYk2w!WGF?R|M^(4sbehts zMhOCn39h!V-o5z3v7JZCti*jVsZmX<%y}Kb_WIrwvK=4E4##>J7g&5)Osc55NELkEO~$q&G$4b;ebbJD%Euw3s};%H zx8aGZSYJ|WMjZC60vV*z2;5(o{#dMmU(DN!usP0WLKFgZe|G%gj<*iHgZHB~MJJA@f!PQuuL~M5YU^k3Ss9j4yDej{X0#2>yWny)Rc$yTk z1XFg%f>>SA9AHqD(QFj4J5k>hh{}dnC5Jb)Z}{Vw%0884(%X6g@$|^DQw7)_J{T63 zFq%RzV?yZn_V&r?C8^0+k45((Px0J&ufpzJr}gNo6eUZmqmERzt@HPF*w zdJJ}AAyPEay@B5U089vm^4S-S=oN_vk;W8}qYM<-VOxu_lPb!5+#gH}Aql8Rr;jaq z4SA^6+_p)a^A%54`I2$ zEeQiqBaX+MSI`kiHr>T>oCwqo;ctIz;Gmt(l|Aq{-B#dwW7{ZFHrtcmIzoj3-(C7_ zMoqz>YKOiDD6&C66O0H$$2$>#9}EL~k-dx*!_IzK49Mc?SY%iRbLEZ+SNcCgH|vY zh+r&P_rO%4Wv|+?lxtua^eOImz(X&!b6v8=9Z>6645O9rezk-|k~ZXgaMh-xUfc1D zum{)E8G)e0RgJI^t&_Ek;{G8Qz~kE=MT^u2$81qx-E-}M!M3BEgofBz&ZEt3+W-RV z3M==C6bQ~Mxf#?;bHBcGT^6&qae!i=cR<3ceC3cSY8xC~TvqtYvXv|U0Noq~ZHXV2 za0Q65zpil|_QI@51Cj55S~pNFPWj3y2@XBiI`*Q+W5=c`S_Fbf!U;mE3e#iA%NLN^LP+uYU_tO%9%P^*7;pZmvkjo8S0O%1XU7nFc^H}uQN;VfEBnJfSa_rz8a%wu0YjmYeM1_qD|5LH7J zqel1n8L`d49zl`kl|iHJNH5 zrRX&c`}^hu?jstB?l8fsNkm|hNv~3H9VEH}*?~3dku$f7&A}$XBQG=zok5N|Qlg0B zk9)rP2_bkN%0~MVIl!wUByFghJP-4eQ$t4*O3e=|SdJA>*!19_2WRbmg7#d?M(-K$$&9Jk2cHifS z@rF}6utXdJKJoqUjnY#i2>>iqgw@#Kex67zCnZ>RZ|&Onz&%Vz@QD?G2VxtO@9@aT zs~<`vTWJSj*n8lUa1lu-lYO$T2bMKiQ1{{Y~DJC{Em z*X`J5MOA`P5n{opwvo?z9{sW?QR?8Es6`}3px7IGU!dD1Buy$Kqa9B`SAXy^-I@rU z6_`NA*d6qX+Q{VMpw|^fYj3Z%5iX#tY$J`el^c=0oOleLab&R@4}X7Qi=#_k!nn?1 zAq>iNm)H~i$GFNKDH=%1EdmH_O}YO7yquZ?#;R~8U)LL`)J zYpS@b9CyG3j~uc_NBl+pIagNdA(42ELg=^O!t zQbt&zEUpU!vaq`+z8;PvGOvhfD($zgbBmxeGMzycXp$@G?|E2M_-P8R_e1{xBhTTs z5TSHPU8B1PN`J&2{{X*CAJX!I!+vkqD>2gQN%6>Q)z1F_4*A_kib%xBq`Ms^&e#5c zA6%kq6PRorF&5gnG&%fokm(&OiXh0q0qDE)`|p#6C4QdWh}3J?47~LlBoa_jTa&*R zffdxuMx;;xyFUY8zf2uVc!0~Xjv=HL6{~Z-eDdnC6FE2H(>QJj91n-X*CLA_g&-xv z54x3hZ;JZhMdEMeY_-M)qwFvB%}~PxaTbxwH~<6#mOE~Jg?)1>A&yiM`89yRH>Yd- z{u#+FE|er{+}#iQz-20hl}l2PNgD!x?;EFBgb|%!%3SJS?NB)1>$k%fst%JZER;^e zVzj>ujB#7YsP?Kgd}{*FQ36G_qEy)X$GQ0N^~HiIStO1}qyz@vfBNr%rN`2nbEBYh*V&J&2;){pfUk3pI`kq|1j2q+5J z*@)Zc$F>BHDWiEMeI#tFLeICie4%=DkHIX>L>geLc2DK$fhlzrRV77p#{U4H_`wt} z6fzkky>{A97wdl67mP!;qFk>7BCRh{z$U2KP}nT{>h-UlKS=@WdWKXMvI4@|xCHJ` z!2L5PQ0Gi7A>qr0+3IMH-%D2S^KB<`^ zD-?1&GHN>j2G{V;((}ObI3=8$W;R&zZ|ZL|hfst_MS((x0{wowf1CvA<9NsQ0n*2g z=iBwiG6Ne+8kQGK4`J93!!$)SU&^wOH8=x})D6vh@HYPdHxPU;36()%<}tS(_w>Ne zB&_p3mMcca`JogDizsbOFN+*+^S!>f?GKK!%^I=@i4;e-f1l%s(!$3?2*vdfs!eQv zW9fl2JHZM@%Bn#Jk1;j1{cq=uA}e(BI?>SKQG<2>{{AN#Xj%_%WdZZ*bjl*+>9d}gmqI8Eg5#9{meQ1e>~(+ zTlrTcIdNofz0v)5$6Ya$nlwf(8I~?|(A<&fx8c9MdX*wXkhs>0H38g^PwRnrz;217 zNUr1u#3#(VZay3D^7O|qC)vG1v=T;SPwrEGYV3WkzFgv3s8Z3p>b}$n{h!Adr-?}n z%%yZ|@|)5RufTpJ3rkPsD8M@c^oW6EM-Xr_#pC0KTM307lcMh-TYQ3 zzo+Uxc(L@}CD1yWi5IxM4J|B7<3p!ZNTpH0tKPx+n)z*y)35&kB}@jHHdk~102(9@ z{`h@!DMZj#BF+}bCu`sL^~)>5@wo8T;1J}0R8@$w&7!CVx=P*N`ZQ|{{VkXnJBT!0x>cW6c*cW&mX025((W@ zM)m?82gd^Obp|HsQlwiwhaJH{TB1Oo~K;0 zGk9c3i>XvlqDJ1^0rbR2>UyrAER5$`odG-FYWLiH@GaH9PYUT+kBI0=7iyzO;Ck3M z@yx~`u%Q+Xi)QpURqfmBi5z9!%@ZL)2h%omo+0}>S}PERyk8KV-mA(!f4#5-vZNBc zeK%7fG0|xV)3%PLP-`fG8GO%SD5R@VHaKS~<{03!Eni9DPr`j6pAf%upeK;*Ai5Cq9SB}5P%g~XeXH8{{XT#_~2=q!FH7t z6j!#5Nz_)zp??GTWM;Xw%L+D%B(rkEkIWpHF6@)A52S#~`v#$P{(d=Gd}xArz@mW4 z)x$9tztjr$z!D8|(x#X~-rNhUMU=+iw1>s9$k>1d50!p7IlrekWCE1v1Xva}1paw> z*TJ0@MiMtHLA~0Ie*4}O;o5tRI&mbDe@%SULu@{L;&lpR&`H=KUF&0YL*y|`Zv9w^ z=8YsW0A#-+@6O$?07&GPKA%?V0M>iA=mq{5MY`ElWD~BP4648npkKh>!ywViETsTq z?WKUR#aaCFokD+H!!qe8O6m)^0Gr$Ai!wP$h?dez<avOJc)iWh_fA)eVQ&Z{0sjCXf5E~YDB_+?5;l`Ak#0%QyYuq=1|(G+z)KmWy@;;g;9w>a zp+hngA=XOWFKYa7vdtstbE)dd-s*`tMWlsioS1;8K2<@eZ&bgS%+oA-O2)-j+QB>y z!uy=D>LW-?K+O)fQDd-CzWuk~B^xuhhSC23OK=CNHNE!UvMSO^)3h^qrQ@0<5uz&W z&8*ozlrJWRNL-Sk^;4;+-DU zjmwa0WDP*@2nXxG=aEaT-NcMbQ6L`V;qi4kfC6b!EqE8(^Tei(PrNBul0h4hUxxXk z!HqhW5rHAHc5RzZ#>Hbr2}F`eq>KdA1c3hl?rs18e)hl-%?iX2+PqRt)c}0jM!z29HoE_1UuLq@7n7Ac_x?o&eNv0k@Yd5d)?6e z@qpqe-Mkj4%Miv!+N!wq^~EZvJd9gaQ19*V?~+YEAtI`{ZrN_0SB~XqkC>1N>~<#o zcKq-Z)J>p0ms`ESQV;shm6kmy7CAJXSN{MlKv&3qxQ?+4W-MF#hsOT^3^H{Ds;G!Y z3IOEUCXIvb*nF~!JWU+(K_B%H1|T7@Hokp9ABG~7FoB_+-NheIP(Ap=Jf~GH=qqN8 z9zRToj@_dKs_GO--`fLHbkk1@^(wMFagtc7-=XK%{A5-)O)sU?0thwhWM;_v$LTtC z9W0Wotckt(Re+&mi@jNzX&fq zX|?Ju0TG8B@5%K4064MCBk7$T73Q_j+P>=d6gD?w;aIOLM(b=OQ@x+M+x}P_SS+G| z(xiO_V@P1M3ces%g@R8#&$VCI5Xo|QVOI2kxY(NK0!H#ncBn|QNuV#`fR8FN0;{C= z?}8R4U%EHFy-jVDmOza0#NW{j4PAmp*Z7~V2;uQi%0`sqSG`?ebW()}R}EtCd#}qm zkUU6ZV$3KVUqA8q;)qnE($bGGvqX$|iU>$xzH72gkSu)K3Qd z;Z&im)kqwI03Ej(3bhVtK^^eM##IrGC;==z39nz33`NxH)pzpaDALlzf;vS333?W6 zp4aR%A&{{k0DBAc$Bc;NA%WFbOfr(Hn!52FY!S7OU(W-I#8J_FO+ac&8y&YlhDt;0DilMj55<4=51zkugH8lZ$mIXR^Ox6Had1(keV(kIv<#&K4>P&>W0Pr~9Y;8xf#|YB~F$q*XlBo6FbS&1XL_el|iuY$E9t6147;=qoUMl5sj~IeYrmzM+P-k zAxY=Wz49`~AUbH|5~yYjwWw?b9sZ{hSi>qR%EhEzP6(hH$f5Gz=a3+x%ThAXyY3D4 z$-U_SJ&<=iQTD~s(Qd-|6i(wbhi;`KKqyB_qJZ3+-`8`LXVK>#BXT}DWk6VvqCuy6 z{@dFNME*34zy=5IuLBVP1=>PJ+X1mYpN0U4vN$5Q*xv^zReD*O;33@EBXjF` z`b0~vL~THdUtf+Z5LmYV00KF{?Rr;B0CiPgBhv|H5*FQ5l02ryOcH>wvA5SPs@KF} zsMS&9(*jZ(H$i)Cj|{pM!HpYT0OyqWVwjxAsZ{AFP5PYe3Wken9jgJP15fsavq}!* zsvTH<5_lXu+URm@S4V~lyB7epjtg=Ax}+=4xG zGV^tGc=f~IK8KQ+@I4sRUuFT)ketNU=@Lu9~QvhV0({Thy?08SUE#wm+vy(z~M!<)<7MA zBkxyi1ObaBscyg$PBw&c1%ODQw|ou>k=+!kjrRWBF~(c(qYD-fp!L869itQve|(y= zBVAp5@yZGWpOy2Bz#(L{ZRly=0UgWLg<2hkGqBPCBI}%1cU3W#B}uyNc(tm@+t3UG zMv^aV1HMqWWIzQKHD?rpq>uq8wg9rxfJxhowt}7fMQ2$?SjV)Pdl6l z3+g0|h3Q;U94w2%9f}3)4#NQ) z5HYzcx0v&Q(Uc#29k6}t8ZY#~>e21CD*Ec9fy2soIoCjX`r&&U8#jTRl67u$?&#{+<1bhy*; z&Qcc8sUx??Inl#Y^XciF+H1;5KKMd}B;nFyG{E1X#oT#;ZS$E7+l&Z# zwFhe4@tnnu)vEzbsXS*lnDd3m*nxlxG>Yr@gbwKBs@rmJfHJW;1iFE!ZU?`v1eH=IQ(B$3HR+R?-C|@1LYlho z?SaIZ36wAZ8Yb^kWmYLAQe-|=7tTrq`>Q8oU<0trG?pYg1UFT_1-e-ZLn21Nc%RcM zr*p^Qn6!j0tSzjC0D?Zgnadl-Rks57Cz3LS5HF}cgO82^y*jRgMRq=?5(wmAT}PBv z`}{F9EQIMi@239%JkxL;K zJ22Bi=z9M9Vlpa{?PGn1di1~y?XY#w)nI&koaUNBM0~CD@B28xR#~iz{{X~y&TkmG zNY~7KqtswGQPX&AtKzFZkXT;8p4iYrG_lkf#^bOb?~KZjl_6QJx4Jih9C)HK-urgI zxsz8?6dhVyV^#AQ-CcC@g+|myOBvk_ReVDIkf^%(B7z%&(i?}jPeNx+A&_s znxuHu5tyU1UZwW!XERxmM0uu8wt5|GO9ZNs*%VM*5l1BRn`Y8&i9g(aIkp)J zl+qVPkSy_B-G0tzhf$F=UXi_DBKXZN!9&jBONJDqakkfddJjwmGlX?iwcBQ@AfN67 zBh*NcMu}F{vW&NgWRbdwQH)=BY30Zxlh|MQ$@S7lI;6TlbLCJ6YqEa3e6Rw^De2TR z0IcH2zWdk1^Tq26D=m`xO&Z(}a(#WXmL+so4pb<%Cy#7l)5_}YL_n0R-qtz%GKQAr zPZ0oJ32c6Qd`?9jeD+u|UA@n@)M1*X0wiVt04VQ%zz*kZ8j!Ln@E zKAO;21osL#_;K~ZpwerSo;I_+di$K$(KIrIfx{4hyE^jCTMvi(^v+(ISs8kG(6fi& ze|X>gV|33L>D{&tBo^C^x7Z)Y-iEPZDF{Fqj?VS>0q^t&0ScIwL^fH45>Esk2k)Hjo_I`=__m+` zfVUcNf0yTzl0zqv%K*2edIEX<`s4KCC3TKf+_=_2whB9bJYfm;iAkg`OMzsrz3I)eu?Tyx4E=-2O;E#=(_;HgOD4YAh?Ptn=)!+wtSEyj8QtB+Fjoq8y z*Y(P*UM%4(J=JYM5_iAH430Wg5rsPVV&jedK11@w@hM&=jY}}qtR0kppV+`+h*5O; zW!L5;&{11`H~M6v0YkDQhkKgZ{lIo3~RF6tYfA zfYM0HvP44xtZsG$4%PeP4@DBRk}x0=4wgIvu=-Xslg7>B6cYfNEcb3~;&ieVifBrC z?6IoY0sB88g&{_HCV5yb&^5WOj!pWL5i#i`1t_M9|tJ4B7{notF1Mpu;Ch7#Ygi1}MIVO4%du_}&2uuTLqCK#a%% zQg>_L^2h0?ba>k=<-j$@4i6&S}9<`iP#c71#mwcoa-bI#>|us>Z`#7_xcRFlnE-m z0O_SbI<)RLIE>S3@UGS(Sjk)Izrb!jcoc!wMl!u8h$xbNb}x)nvDRw8DjUA$#eh$I z&ZIMc$x`$R_wSXSN0LAcL3M6Q*5F_7=e;UNjqBf^NzDYO_3Xu7jz9lE&EZ z7xqq9sfFCTD!Bv!8z`W6p|`+(83vG~ETMn^0!OL6hvR|Ju`@WZN~vH^2V?qi@0V$j zkC3*d+_4p?nicRA*ogv0@%ZlO11miZz$AT7wmUaW%2^P!qCo6yonZF(kIbAax}z4Y zDdrKMzD*Lx+pyp8KPBoQNc5;=kbywm0p9-r9PbI9Nt{MWX&9KJ8w0u9*kTyGeyDWf zDspu5xjX^z!YLdDVwe^hC`bw0P`caKeZIb#KAX&iqgH8+1ps}~w*LSVu=(ZP8X(Kn zsi<2>t--7EgXw?gCFf--wj{C`1)1L4uWeq1Z59e6{JaL4jW26p6Yv%XL zbp1;tN(5>`h6I(nBoHu*34fR!NQ97~i>>cNYx(TBdPrsJo#J;3Cx%KB!xBKR#PN{n z*Tax82f>W4tNPc()>aASjv10g-ZddTL19;X_rIcN-JGzZkQ0qh8Vm zVOpr}4(I2v%9;9YQ7Mnj5&%acR@G-$wGuvAG@UwQsz_E}>QLLzf0KVK3bW})RAqB4 zWetk*gR5imBN7-1bOA*x0#2=f1P1*wbAw6ZBN~hp1G4YA{r-67An`;YIwRA&HE!Nr zSM?tufzrs+%v)J!Yb3WO&g7r2PpH&Qkio?ZZr#3{AEqFVq8e{e*qh|t40UqSnMt-( z72mh`!YYxd`_ckLdXBBQy@B{vQg0P!nk_M~Qgu+S*Yw!^b3avc5~IwK{liA|_TSqn z1OEUoGYOEtiMGdzufnjVgQ*|MOB{e93QDs5=Om4r*a63`XGq9U{DppNn%=WQ>6$*E z5o>4ND3Pa>PDYwz&P&PBB-pw>6Nzki+)ugv{1)R8Jc z;Q&8*`Td`W&i-LRLg>tis+D}YN!TB)WTh15APH5ym8^S_^uQ)D#L7U_4Td7mZvOyW zg0VpTT!uzuR*uNCKpVV%u|JpVBpcWt6)eh7SA8O#%5{RgX~BHQZYA2 z_J15`rY#iF09xsz`qBJ9EG^U-EXkom0Cp9ofuZ?*GJw!ZT_KJ~u%l4oi3Z2#^v+(T zp=ORU3lA=)0NoMzY%oSqi4sP1veRih@z2(=dZmp>ju_aTWCkYn42owGIYd-M4!y+z zz3=oGVKKX+%cv-2)K{|q0CrS#-Ba}yG4RR)G|1KWSMJx!tK4Ei)}#{3#vO4m*?;1| zc~8I}t_0DW&Y%lLK0+)D*qnI8aKy4wyA!}X5&nqDESBR+0twvkPqE5}fk0I%EC5Lc z&4GVP{BRl>lq}K75pWk$vXC^J1Li8ld;4Nat<;bqT8_r%teW-u=NVPn9STT6OiOIc zNj>-+50)%W{&=KT3Ov9ATh#-8n~VpR6VtbbSQaFT6l>?}iz_jTB%lg_N5I*?bN(}< z@++M{m0>}S{2To-2@0toRwOahLG>FC&nh3pUl7Zts1g4F+1r7WK`gM67g7Qgn&9tW zk;=3(yph9RX}3|ZyVwto28i_W9b?Wu>Z#z}QTl#3203CeG$B|!GL1vcV|$_QYj14j zR-J~2RPezBJ5gdmB>tcGh8Y;Ahu|@NU(;Ysj%W_to5@1n1m%M`LNu_TTTgrrODv$d zAw`NC1AXyClDaWf))&2ApWo?)=Q9^Xh%%jkU8;ox@vKPXXaIF$JBr_bd;>Qb0b)6!tkWCk@`1oKIQ3?l=EmAQI z4cM#n`5causZ=mLaqpUEa=-vs><~41O08GV*Ch+n5CPI@w&&~HoC3`sgdzwSs}fuj zxUGlb_~0+%M#uq3*|tDzEq|kp8!DL2z-k(R`EEuiP&KQM5R*#Us=faJ3<7#^TA-B~ zK(g0XJ^uhNo(SPz0Fvg(04;6vIDA+UfkZS=hSjwb+=B3# zK<(dd_zfc@t9}p&fB*&ZPZ#n(T(m11va0}mPR+et^Y~_QF*2Z_7g!_<-o76^X}mPq zBzKXV`HcX}+iu644tTiuw9NJa3^w2P zbEbJ5*^n1DOONh1eD}aBBy7WzV3W1{zbxSB7$6BCETl0N$8U!J0EP&4dYe zapHy#Qa8C5_=`9kfl`)FUBKrl6$y-ND2pDu*F5ALX<`b4bVsn~DyPJM3fE20cJk1^ za1&3A6-9%3RFVL~(S?mjd$U5C$ggjnR%lp870;QMUYe>Mi1t3&E|@T$JuBikVM;(c zjsi^X%}->V^&U;*v;@DFRAy7AiK2b~0Djn7(ZF%$qQITW1Z+3L5>B4HKqG6fH$BDI z*8vsCAd_k~YCE0qctxBo*&@fbKZC2oNc`qahzEwc$)iZJX-0j2cJ7bE1D-UE!AS(7 zhX8|h$IEf@&d}|$>7(Ym<7^^CX)pf(Z+jFr8Ne9>up+MapiTuMks@MBhhu(L<9pxb z+Ym-xL``S`Bn?Mp7vIx3!c_}$PULf2{&=wpeZe>Nyb5&@9-F9SI$XxoNZcQAd*VpC zJTe$IpcvLRxg=IrW(s?^8x6mH`Pwui+ScG%+za2ITnpltk@3y|AUg{K zft+P52)b1JpmByJi%^@yRUyFe?zTLe>^A&xB%qd6T_a7KzH*AOyu)LoU>AdAf1F7p zI^K?;KjYgGi5fJ8X+nNU-PT*;+xE$b4 z!3#`pD=wr%6Oe1rez>$xo5~0lN3JoeTL9Vh?||9}iDK~^{zY$Kdwv-rrAJ~;R#x#H z&xQ~YwUb+VWS2;NG<5EMI2^#RqAJbU!6A6tdIWk5s!SOKZ5`IC-jBD?;c=lASr}T{ z78QRFQSXp1h73ksRaLir@FUb@1s2Bl_8qglLkoFdR@KM17=`F4ssn#~p@dU1Gk8{m z`@JxuvHp^Z4?y~yC|AI8+O=(@gRuDL6)hMlnrPOqTm(&a$QMyJuv;Rrpov>ha(-RD zxFM0?kVuTm{g@ge@x)>{$Rzl;$UobFuO%*$ScTbp5rL#>mNgWiR=zvobq)PZb!5=* ze)xpD`Mto~*8$Z`i>8I$00!p)K!Ule+qlBuwK?zD9{3(ZGZtzT0I)I!5+RRE8oS_c z#@qU2#RMk!INm0f5;9-0?|`t>)~9|w22tBR*I{Sh8I@KsRjG|p?Z+7)LhJ_Z+~p;V zAt)VKFZT=%dQt}`xxDMC12EVduUr8dQeSdFvx2$Q)J6lzBNw6N41F<4k#zvp2HzYE zQIrd=_g(!kAS~dkZ+jjn;BdDW*mfAvN#Qvq5v&!a?e0DB5_K?Q2|dOtjazSc4H;9c zupEugY%Qf}bp{^WQNc)+hH;_w>1ARYS+^MbOG zPQIe>4yR_)0J_Ce95E)FwlgatgFtHBd@)gVF(;2vfMl7Ti2zWcr#d-VD1t|*zHv$d z+An_F?VQ4zzFS}uA`;{&Qrr&M1qK2482%G!VQ31`=bT8>#F1A}P)!nhU^cQGFxrmC zA770qG7Sn%mIHDxbMeH`RG%>0@y=G%0OJ5XD-{Hl>`3S3j0bY0u@hFg2FZ(XyzLRUqM2*q!HMh7qoYjA$K)k9Dqj%XdmEt)s-#t)<^pi1&9 h^T3%KUmf$e$~2(KsZ&q>Ben%qCw8G+*MCe8|Ji78FQ)(i diff --git a/.gitignore b/.gitignore index 44757d0..9b6ef9b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,5 @@ src-tauri/target/ src-tauri/gen/ src-tauri/WixTools/ -# Full-size camera originals in print findings (compressed copies are committed) -.agents/print-findings/**/DSC*.JPG +# Agent workspace files +.agents/ From e37b7d1d700f2b23092c931d0d9455d3062a7015 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 6 Jul 2026 23:17:07 +0300 Subject: [PATCH 56/81] Fix auto-paint calibration cache state --- CHANGELOG.md | 2 +- src/components/FilamentCalibrationDialog.tsx | 3 +- src/lib/optimizer.ts | 35 +++++++++--- tests/optimizer.test.ts | 60 ++++++++++++++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c20bfb8..5abd5f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint Max Height** - Auto-paint now plans, scores, previews, and exports the same layer-aligned stack. Height caps round down to a valid printable layer boundary, so a generated model no longer exceeds the requested maximum by adding a final whole layer. - **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 calibration confidence** - Color edits that deactivate a calibration now re-anchor the filament's estimated hiding distance to the new color without deleting the stored calibration, and result confidence now scores the filament sequence actually printed after variable-length optimization omits unused filaments. -- **Auto-paint edge cases** - Blank seeds now resolve to stable cacheable values, optimizer cache keys include all target clusters and tuning inputs, and exceptionally tall stacks stop at the intended 500-layer slice-data limit. +- **Auto-paint edge cases** - Blank seeds now resolve to stable cacheable values, optimizer cache keys include all target clusters, tuning inputs, and active calibration state, and exceptionally tall stacks stop at the intended 500-layer slice-data limit. - **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/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx index f506371..07d32b8 100644 --- a/src/components/FilamentCalibrationDialog.tsx +++ b/src/components/FilamentCalibrationDialog.tsx @@ -34,6 +34,7 @@ import { import { cn } from '@/lib/utils'; import type { Filament } from '../types'; import { + activeFrontlitCalibration, channelHds, computeFrontlitCalibration, computeFrontlitCalibrationSession, @@ -672,7 +673,7 @@ export function FilamentCalibrationDialog({

    {filaments.map((filament) => { const selected = selectedIds.has(filament.id); - const isCalibrated = !!filament.calibration; + const isCalibrated = !!activeFrontlitCalibration(filament); return (
    + {optimizerAlgorithm === 'exact' && ( +
    + Exact base order will score about{' '} + {formatBaseOrderCount(exactBaseOrderCount)} base orders + before repeat refinement. +
    + )}
    diff --git a/src/components/FilamentCalibrationDialog.tsx b/src/components/FilamentCalibrationDialog.tsx index 07d32b8..2feaedb 100644 --- a/src/components/FilamentCalibrationDialog.tsx +++ b/src/components/FilamentCalibrationDialog.tsx @@ -75,6 +75,20 @@ interface FilamentCalibrationDialogProps { onApply: (updates: CalibrationApplyUpdate[]) => void; } +interface CalibrationTarget { + key: string; + filament: Filament; + base: Filament; +} + +interface CalibrationPrintPlan { + targets: CalibrationTarget[]; + printOptions: CalibrationPrintOptions; + layerHeight: number; + firstLayerHeight: number; + maxLayers: number; +} + const MIN_MAX_LAYERS = 4; const MAX_MAX_LAYERS = 40; const MAX_BASES_PER_FILAMENT = 3; @@ -170,6 +184,7 @@ export function FilamentCalibrationDialog({ const [isSaving, setIsSaving] = useState(false); // filament id -> chosen base filament ids (defaults are auto-picked). const [baseChoices, setBaseChoices] = useState>({}); + const [printedPlan, setPrintedPlan] = useState(null); const wasOpenRef = useRef(open); const darkestFilamentId = useMemo(() => { @@ -328,7 +343,7 @@ export function FilamentCalibrationDialog({ [filaments, selectedIds] ); - const calibrationTargets = useMemo(() => { + const calibrationTargets = useMemo(() => { return selectedFilaments.flatMap((filament) => resolveBaseIds(filament.id) .map((baseId) => { @@ -353,6 +368,17 @@ export function FilamentCalibrationDialog({ }), [calibrationLayerHeight, firstLayerHeight, maxLayers] ); + const currentPrintPlan = useMemo( + () => ({ + targets: calibrationTargets, + printOptions, + layerHeight: calibrationLayerHeight, + firstLayerHeight, + maxLayers, + }), + [calibrationTargets, printOptions, calibrationLayerHeight, firstLayerHeight, maxLayers] + ); + const activeMeasurePlan = printedPlan ?? currentPrintPlan; const firstLayerPrintHeight = Math.max(calibrationLayerHeight, firstLayerHeight); const swapZ = calibrationBaseHeight(printOptions); @@ -369,6 +395,7 @@ export function FilamentCalibrationDialog({ setMergeReads({}); setIsSaving(false); setBaseChoices({}); + setPrintedPlan(null); }, [layerHeight]); useEffect(() => { @@ -397,7 +424,8 @@ export function FilamentCalibrationDialog({ }, [filaments]); const handleDownload = useCallback(async () => { - const tiles: CalibrationTile[] = calibrationTargets.map(({ filament, base }) => ({ + const plan = currentPrintPlan; + const tiles: CalibrationTile[] = plan.targets.map(({ filament, base }) => ({ filamentId: filament.id, color: filament.color, baseFilamentId: base.id, @@ -409,8 +437,8 @@ export function FilamentCalibrationDialog({ if (format === 'stl') { // The wedge geometry is identical for every filament, so one STL is // printed once per filament (swapping the color above the base). - const blob = generateCalibrationStl(printOptions); - downloadBlob(blob, `kromacut-calibration-${maxLayers}layers.stl`); + const blob = generateCalibrationStl(plan.printOptions); + downloadBlob(blob, `kromacut-calibration-${plan.maxLayers}layers.stl`); } else { // Slots follow the full profile order so they map to the user's machine. const profileFilaments = filaments.map((f) => ({ @@ -418,10 +446,15 @@ export function FilamentCalibrationDialog({ color: f.color, name: filamentLabel(f), })); - const blob = await generateCalibration3mf(tiles, printOptions, profileFilaments); + const blob = await generateCalibration3mf(tiles, plan.printOptions, profileFilaments); downloadBlob(blob, `kromacut-calibration-${tiles.length}reads.3mf`); } - }, [calibrationTargets, filaments, format, printOptions, maxLayers]); + setPrintedPlan(plan); + }, [currentPrintPlan, filaments, format]); + + const handleEnterMeasure = useCallback(() => { + setStep('measure'); + }, []); const setRead = useCallback((key: string, value: string) => { setReads((prev) => ({ ...prev, [key]: value })); @@ -434,10 +467,20 @@ export function FilamentCalibrationDialog({ // Complete read inputs per filament (merge reads excluded — they never // affect the fit, only the saved record). const readInputs = useMemo(() => { - return selectedFilaments.map((filament) => { - const targets = calibrationTargets.filter( - (target) => target.filament.id === filament.id - ); + const grouped = new Map(); + for (const target of activeMeasurePlan.targets) { + const entry = grouped.get(target.filament.id); + if (entry) { + entry.targets.push(target); + } else { + grouped.set(target.filament.id, { + filament: target.filament, + targets: [target], + }); + } + } + + return [...grouped.values()].map(({ filament, targets }) => { const readValues: FrontlitCalibrationRead[] = []; let complete = targets.length > 0; for (const target of targets) { @@ -455,23 +498,16 @@ export function FilamentCalibrationDialog({ ? { filamentColor: filament.color, opacityLayers: readValues[0]?.opacityLayers, - layerHeight: calibrationLayerHeight, - firstLayerHeight, + layerHeight: activeMeasurePlan.layerHeight, + firstLayerHeight: activeMeasurePlan.firstLayerHeight, baseColor: readValues[0]?.baseColor, reads: readValues, - maxLayers, + maxLayers: activeMeasurePlan.maxLayers, } : null; return { filament, targets, input }; }); - }, [ - selectedFilaments, - calibrationTargets, - reads, - calibrationLayerHeight, - firstLayerHeight, - maxLayers, - ]); + }, [activeMeasurePlan, reads]); // Session JND fit, computed once in the background when the entered reads // settle. Preview and Save both consume this cached result so the values @@ -494,9 +530,7 @@ export function FilamentCalibrationDialog({ const sessionFitEligible = useMemo(() => { if (readInputs.length === 0) return false; if (readInputs.some((entry) => entry.input === null)) return false; - const multiBase = readInputs.filter( - (entry) => (entry.input?.reads?.length ?? 0) >= 2 - ); + const multiBase = readInputs.filter((entry) => (entry.input?.reads?.length ?? 0) >= 2); return multiBase.length >= 2; }, [readInputs]); @@ -522,7 +556,7 @@ export function FilamentCalibrationDialog({ if (latestSessionKeyRef.current !== key) return; const session = computeFrontlitCalibrationSession({ filaments: ready, - maxLayers, + maxLayers: activeMeasurePlan.maxLayers, }); setSessionJndFit({ key, @@ -536,7 +570,7 @@ export function FilamentCalibrationDialog({ }); }, 800); return () => window.clearTimeout(timer); - }, [readInputs, sessionFitEligible, sessionKey, sessionJndFit, maxLayers]); + }, [readInputs, sessionFitEligible, sessionKey, sessionJndFit, activeMeasurePlan.maxLayers]); const activeJndFit = sessionJndFit?.key === sessionKey ? sessionJndFit : null; // True while an eligible session fit hasn't landed yet (debounce + search). @@ -962,7 +996,7 @@ export function FilamentCalibrationDialog({ Back - @@ -1036,7 +1070,7 @@ export function FilamentCalibrationDialog({ ? predictFrontlitColor( filament.color, opacityLayers, - calibrationLayerHeight, + activeMeasurePlan.layerHeight, calibration.td, target.base.color ) @@ -1062,7 +1096,7 @@ export function FilamentCalibrationDialog({ setRead(target.key, e.target.value) @@ -1072,14 +1106,14 @@ export function FilamentCalibrationDialog({ className="h-8 w-20 text-sm" /> - / {maxLayers} + / {activeMeasurePlan.maxLayers}

    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

    setMergeRead(target.key, e.target.value) diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 93ad108..617953a 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -270,7 +270,7 @@ export function deltaE2000Lab(lab1: Lab, lab2: Lab): number { 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 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; @@ -284,7 +284,9 @@ export function deltaE2000Lab(lab1: Lab, lab2: Lab): number { ? hue2 - hue1 + 360 : hue2 - hue1 - 360; const deltaHue = - 2 * Math.sqrt(adjustedChroma1 * adjustedChroma2) * Math.sin((hueDifference / 2) * Math.PI / 180); + 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 = @@ -297,17 +299,17 @@ export function deltaE2000Lab(lab1: Lab, lab2: Lab): number { : (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); + 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); + Math.sin((60 * Math.exp(-(((meanHue - 275) / 25) ** 2)) * Math.PI) / 180); return Math.sqrt( (deltaL / lightnessScale) ** 2 + @@ -349,19 +351,14 @@ function realizedColorError(left: Lab, right: Lab): number { * @param thickness - Layer thickness (mm) * @returns Opacity value (0-1); calibrated TDs return the least-opaque channel */ -export function getOpacity( - filamentTD: number | CalibrationRgb, - thickness: number -): number { +export function getOpacity(filamentTD: number | CalibrationRgb, thickness: number): number { if (thickness <= 0) return 0; const channelTds: CalibrationRgb = typeof filamentTD === 'number' ? [filamentTD, filamentTD, filamentTD] : filamentTD; if (channelTds.some((td) => !Number.isFinite(td) || td <= 0)) return 0; - return Math.min( - ...channelTds.map((td) => 1 - Math.pow(0.1, thickness / td)) - ); + return Math.min(...channelTds.map((td) => 1 - Math.pow(0.1, thickness / td))); } // ============================================================================= @@ -495,9 +492,10 @@ export function calculateIdealHeight( // 0.05 = 10^(-thickness/TD) → thickness = TD × log10(20) ≈ TD × 1.3 // Dark filaments have low TD (e.g. 0.5mm) → foundation ≈ 0.65mm const firstFilament = sortedFilaments[0]; + const foundationTdChannels = channelHds(firstFilament); // Use the least-opaque (largest) channel hiding distance so every channel // reaches ~95% opacity. - const foundationTd = Math.max(...channelHds(firstFilament)); + const foundationTd = Math.max(...foundationTdChannels); const opacityThickness = foundationTd * 1.3; // 95% opaque // Ensure at least the base thickness (avoid unnecessary extra layers) const foundationThickness = Math.max(baseThickness, opacityThickness); @@ -506,7 +504,7 @@ export function calculateIdealHeight( filamentId: firstFilament.id, filamentColor: firstFilament.color, filamentTd: firstFilament.td, - filamentTdChannels: channelHds(firstFilament), + filamentTdChannels: foundationTdChannels, startHeight: 0, endHeight: foundationThickness, idealThickness: foundationThickness, @@ -548,7 +546,7 @@ export function calculateIdealHeight( filamentId: filament.id, filamentColor: filament.color, filamentTd: filament.td, - filamentTdChannels: channelHds(filament), + filamentTdChannels: transitionTd, startHeight: currentHeight, endHeight: currentHeight + transitionThickness, idealThickness: transitionThickness, @@ -640,9 +638,7 @@ export function ceilAutoPaintHeightToPrintableStack( const first = printableFirstLayerHeight(layerHeight, firstLayerHeight); if (height <= first + PRINTABLE_HEIGHT_EPSILON) return roundPrintableHeight(first); - const regularLayers = Math.ceil( - (height - first - PRINTABLE_HEIGHT_EPSILON) / layerHeight - ); + const regularLayers = Math.ceil((height - first - PRINTABLE_HEIGHT_EPSILON) / layerHeight); return roundPrintableHeight(first + Math.max(0, regularLayers) * layerHeight); } @@ -665,9 +661,7 @@ export function floorAutoPaintHeightToPrintableStack( // Return zero rather than silently exceeding the caller's maximum. if (maxHeight < first - PRINTABLE_HEIGHT_EPSILON) return 0; - const regularLayers = Math.floor( - (maxHeight - first + PRINTABLE_HEIGHT_EPSILON) / layerHeight - ); + const regularLayers = Math.floor((maxHeight - first + PRINTABLE_HEIGHT_EPSILON) / layerHeight); return roundPrintableHeight(first + Math.max(0, regularLayers) * layerHeight); } @@ -1044,7 +1038,9 @@ function mapTargetsWithSeparation( 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); + 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; @@ -1403,9 +1399,9 @@ function findBestFilamentOrderWithOptimizer( allowRepeatedSwaps, }); - const sortedFilaments = result.order.map((sf) => - filaments.find((f) => f.id === sf.id) - ).filter((f): f is Filament => f !== undefined); + const sortedFilaments = result.order + .map((sf) => filaments.find((f) => f.id === sf.id)) + .filter((f): f is Filament => f !== undefined); return { sortedFilaments, result }; } @@ -1498,7 +1494,6 @@ export function generateAutoLayers( sortedFilaments = orderingResult.sortedFilaments; optimizerResult = orderingResult.result; - } else { // Standard: sort by luminance (dark to light) sortedFilaments = [...filaments].sort((a, b) => { @@ -1565,7 +1560,8 @@ export function generateAutoLayers( // Add optimizer metadata if available if (optimizerResult) { result.optimizerMetadata = { - algorithm: optimizerResult.resolvedAlgorithm || optimizerOptions?.algorithm || 'balanced', + algorithm: + optimizerResult.resolvedAlgorithm || optimizerOptions?.algorithm || 'balanced', score: optimizerResult.score, iterations: optimizerResult.iterations, converged: optimizerResult.converged, @@ -1688,7 +1684,7 @@ function calculateAutoConfidence( // 1. CALIBRATION QUALITY // Average confidence of all filament calibrations using actual calibration data let calibrationQuality = 0.5; // Default baseline for uncalibrated filaments - + if (printedFilaments.length > 0) { const confidences = printedFilaments.map((f) => computeProfileConfidence({ diff --git a/src/lib/calibration.ts b/src/lib/calibration.ts index a5d3bea..227c735 100644 --- a/src/lib/calibration.ts +++ b/src/lib/calibration.ts @@ -597,10 +597,7 @@ export function channelHds(filament: { td: number; calibration?: unknown; }): CalibrationRgb { - return ( - activeFrontlitCalibration(filament)?.td ?? - deriveChannelTds(filament.color, filament.td) - ); + return activeFrontlitCalibration(filament)?.td ?? deriveChannelTds(filament.color, filament.td); } function frontlitConfidence(opacityLayers: number, tdSingle: number, maxLayers?: number): number { @@ -783,19 +780,27 @@ function fitSessionJnd( }); if (eligible.length < 2) return undefined; - const evaluate = (jnd: number) => - eligible.reduce( + const residualCache = new Map(); + const evaluate = (jnd: number) => { + const key = Number(jnd.toFixed(6)); + const cached = residualCache.get(key); + if (cached !== undefined) return cached; + const residual = eligible.reduce( (sum, input) => sum + calibrationFitResidualForJnd( { ...input, maxLayers: input.maxLayers ?? maxLayers }, - jnd + key ), 0 ); + residualCache.set(key, residual); + return residual; + }; let bestJnd = OPACITY_JND; let bestResidual = evaluate(OPACITY_JND); + const defaultResidual = bestResidual; for (let jnd = JND_FIT_MIN; jnd <= JND_FIT_MAX + 1e-9; jnd += 0.2) { const rounded = Number(jnd.toFixed(2)); const residual = evaluate(rounded); @@ -815,7 +820,6 @@ function fitSessionJnd( } } - const defaultResidual = evaluate(OPACITY_JND); const improvement = defaultResidual - bestResidual; if (improvement < Math.max(0.1, eligible.length * 0.04)) return undefined; if (bestJnd <= JND_FIT_MIN + 1e-9 || bestJnd >= JND_FIT_MAX - 1e-9) return undefined; diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts index 5bc572d..7a394df 100644 --- a/src/lib/nextBestColor.ts +++ b/src/lib/nextBestColor.ts @@ -5,8 +5,16 @@ import { channelHds, deriveChannelTds, type CalibrationRgb } from './calibration // Minimal color math — inlined to avoid pulling in autoPaint's optimizer dep. // --------------------------------------------------------------------------- -interface RGB { r: number; g: number; b: number } -interface Lab { L: number; a: number; b: number } +interface RGB { + r: number; + g: number; + b: number; +} +interface Lab { + L: number; + a: number; + b: number; +} function hexToRgb(hex: string): RGB { const h = hex.replace(/^#/, ''); @@ -19,31 +27,43 @@ function hexToRgb(hex: string): RGB { // IEC 61966-2-1 sRGB linearisation thresholds and coefficients. const SRGB_LINEARISE_THRESHOLD = 0.04045; -const SRGB_LINEARISE_SCALE = 12.92; -const SRGB_LINEARISE_OFFSET = 0.055; -const SRGB_LINEARISE_DENOM = 1.055; -const SRGB_LINEARISE_GAMMA = 2.4; +const SRGB_LINEARISE_SCALE = 12.92; +const SRGB_LINEARISE_OFFSET = 0.055; +const SRGB_LINEARISE_DENOM = 1.055; +const SRGB_LINEARISE_GAMMA = 2.4; // IEC 61966-2-1 sRGB → CIE XYZ (D65) matrix row coefficients. -const M_RX = 0.4124564, M_GX = 0.3575761, M_BX = 0.1804375; -const M_RY = 0.2126729, M_GY = 0.7151522, M_BY = 0.0721750; -const M_RZ = 0.0193339, M_GZ = 0.1191920, M_BZ = 0.9503041; +const M_RX = 0.4124564, + M_GX = 0.3575761, + M_BX = 0.1804375; +const M_RY = 0.2126729, + M_GY = 0.7151522, + M_BY = 0.072175; +const M_RZ = 0.0193339, + M_GZ = 0.119192, + M_BZ = 0.9503041; // CIE XYZ (D65) → sRGB inverse matrix row coefficients. -const M_INV_XR = 3.2404542, M_INV_YR = -1.5371385, M_INV_ZR = -0.4985314; -const M_INV_XG = -0.9692660, M_INV_YG = 1.8760108, M_INV_ZG = 0.0415560; -const M_INV_XB = 0.0556434, M_INV_YB = -0.2040259, M_INV_ZB = 1.0572252; +const M_INV_XR = 3.2404542, + M_INV_YR = -1.5371385, + M_INV_ZR = -0.4985314; +const M_INV_XG = -0.969266, + M_INV_YG = 1.8760108, + M_INV_ZG = 0.041556; +const M_INV_XB = 0.0556434, + M_INV_YB = -0.2040259, + M_INV_ZB = 1.0572252; // CIE standard illuminant D65 tristimulus values (normalises XYZ to [0,1]). const D65_X = 0.95047; -const D65_Y = 1.00000; +const D65_Y = 1.0; const D65_Z = 1.08883; // CIE L*a*b* cube-root approximation thresholds and coefficients (CIE 1976). -const LAB_EPSILON = 0.008856; // (6/29)³ -const LAB_KAPPA = 7.787; // (29/6)² / 3 — slope of the linear segment -const LAB_DELTA_16 = 16 / 116; // y-intercept of the linear segment -const LAB_INV_CBRT = 6 / 29; // cbrt(LAB_EPSILON) — Lab→XYZ cube-root threshold +const LAB_EPSILON = 0.008856; // (6/29)³ +const LAB_KAPPA = 7.787; // (29/6)² / 3 — slope of the linear segment +const LAB_DELTA_16 = 16 / 116; // y-intercept of the linear segment +const LAB_INV_CBRT = 6 / 29; // cbrt(LAB_EPSILON) — Lab→XYZ cube-root threshold // Threshold for linear RGB values in the sRGB de-linearisation step. // Derived from SRGB_LINEARISE_THRESHOLD / SRGB_LINEARISE_SCALE ≈ 0.0031308. @@ -56,13 +76,15 @@ function rgbToLab(rgb: RGB): Lab { ? s / SRGB_LINEARISE_SCALE : Math.pow((s + SRGB_LINEARISE_OFFSET) / SRGB_LINEARISE_DENOM, SRGB_LINEARISE_GAMMA); }; - const r = linearise(rgb.r), g = linearise(rgb.g), b = linearise(rgb.b); + const r = linearise(rgb.r), + g = linearise(rgb.g), + b = linearise(rgb.b); const fx = (r * M_RX + g * M_GX + b * M_BX) / D65_X; const fy = (r * M_RY + g * M_GY + b * M_BY) / D65_Y; const fz = (r * M_RZ + g * M_GZ + b * M_BZ) / D65_Z; - const f = (t: number) => t > LAB_EPSILON ? Math.cbrt(t) : LAB_KAPPA * t + LAB_DELTA_16; + const f = (t: number) => (t > LAB_EPSILON ? Math.cbrt(t) : LAB_KAPPA * t + LAB_DELTA_16); return { L: 116 * f(fy) - 16, a: 500 * (f(fx) - f(fy)), b: 200 * (f(fy) - f(fz)) }; } @@ -71,7 +93,7 @@ function labToHex(lab: Lab): string { const fy = (lab.L + 16) / 116; const fx = lab.a / 500 + fy; const fz = fy - lab.b / 200; - const invF = (f: number) => f > LAB_INV_CBRT ? f * f * f : (f - LAB_DELTA_16) / LAB_KAPPA; + const invF = (f: number) => (f > LAB_INV_CBRT ? f * f * f : (f - LAB_DELTA_16) / LAB_KAPPA); const X = D65_X * invF(fx); const Y = D65_Y * invF(fy); const Z = D65_Z * invF(fz); @@ -125,15 +147,10 @@ function linearChannelToSrgb(channel: number): number { // colorSpace.blendSrgbChannel (composite the transmissive foreground over the // background in linear light, then re-encode to sRGB). Accepts a scalar HD or // the per-channel R, G, B hiding distances auto-paint blends with. -export function blendRgb( - bg: RGB, - fg: RGB, - td: number | CalibrationRgb, - thickness: number -): RGB { +export function blendRgb(bg: RGB, fg: RGB, td: number | CalibrationRgb, thickness: number): RGB { if (thickness <= 0) return bg; const channelTd: CalibrationRgb = typeof td === 'number' ? [td, td, td] : td; - if (channelTd.some((v) => !Number.isFinite(v) || v <= 0)) return bg; + if (channelTd.some((v) => !Number.isFinite(v) || v <= 0)) return fg; const blendChannel = (bgC: number, fgC: number, tdC: number) => { const t = Math.pow(0.1, thickness / tdC); return linearChannelToSrgb( @@ -283,7 +300,7 @@ export function nextBestColor( // blend-line midpoints (which land within ~0.5 ΔE of their segment) don't // show up as a meaningful gap. const COVERAGE_FLOOR = 1.0; - const effectiveReachable = currentReachable.map(e => e >= COVERAGE_FLOOR ? e : 0); + const effectiveReachable = currentReachable.map((e) => (e >= COVERAGE_FLOOR ? e : 0)); const baselineTotal = effectiveReachable.reduce((s, e, i) => s + e * counts[i], 0); const baselineAvgDeltaE = totalPixels > 0 ? baselineTotal / totalPixels : 0; @@ -307,10 +324,13 @@ export function nextBestColor( // These thresholds are still on raw error, used only for scoring weights (not filtering). const sortedReachable = [...effectiveReachable].sort((a, b) => a - b); - const p90Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.90)]; - const maxReachable = sortedReachable[sortedReachable.length - 1]; + const p90Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.9)]; + const maxReachable = sortedReachable[sortedReachable.length - 1]; - interface LabCandidate { lab: Lab; hex: string } + interface LabCandidate { + lab: Lab; + hex: string; + } const seen = new Set(); const pool: LabCandidate[] = []; @@ -347,7 +367,14 @@ export function nextBestColor( // ------------------------------------------------------------------------- // Score every candidate. // ------------------------------------------------------------------------- - interface CandidateScore { lab: Lab; hex: string; weightedGain: number; rawGain: number; nearestFilamentDE: number; estimatedTd: number } + interface CandidateScore { + lab: Lab; + hex: string; + weightedGain: number; + rawGain: number; + nearestFilamentDE: number; + estimatedTd: number; + } const scores: CandidateScore[] = []; for (const { lab, hex } of pool) { @@ -355,7 +382,10 @@ export function nextBestColor( let estimatedTd = filaments[0].td; for (let fi = 0; fi < filamentLabs.length; fi++) { const de = deltaELab(lab, filamentLabs[fi]); - if (de < nearestFilamentDE) { nearestFilamentDE = de; estimatedTd = filamentTds[fi]; } + if (de < nearestFilamentDE) { + nearestFilamentDE = de; + estimatedTd = filamentTds[fi]; + } } const candidateRgb = hexToRgb(hex); // The candidate is a hypothetical uncalibrated filament, so model it the @@ -380,11 +410,14 @@ export function nextBestColor( } const improvement = effectiveReachable[i] - newReachable; if (improvement > 0) { - const w = effectiveReachable[i] >= maxReachable ? 3.0 - : effectiveReachable[i] >= p90Threshold ? 2.0 - : 1.0; + const w = + effectiveReachable[i] >= maxReachable + ? 3.0 + : effectiveReachable[i] >= p90Threshold + ? 2.0 + : 1.0; weightedGain += improvement * counts[i] * w; - rawGain += improvement * counts[i]; + rawGain += improvement * counts[i]; } } @@ -398,7 +431,10 @@ export function nextBestColor( const maxIsolation = Math.max(...scores.map((s) => s.nearestFilamentDE)); // Rank by weighted gain; report improvement from unweighted gain so improvementPct ∈ [0,100]. - const winner = scores.reduce((best, s) => s.weightedGain > best.weightedGain ? s : best, scores[0]); + const winner = scores.reduce( + (best, s) => (s.weightedGain > best.weightedGain ? s : best), + scores[0] + ); // ------------------------------------------------------------------------- // Build the result for the winning candidate. @@ -429,7 +465,10 @@ export function nextBestColor( let nearestDE = Infinity; for (let fi = 0; fi < filamentLabs.length; fi++) { const de = deltaELab(winner.lab, filamentLabs[fi]); - if (de < nearestDE) { nearestDE = de; nearestFilamentIdx = fi; } + if (de < nearestDE) { + nearestDE = de; + nearestFilamentIdx = fi; + } } const recommendedTd = filaments[nearestFilamentIdx].td; @@ -438,20 +477,20 @@ export function nextBestColor( console.group( `[NextBestColor] ${filaments.length} filament${filaments.length !== 1 ? 's' : ''} → ` + - `${imageSwatches.length} image colors | ${totalPixels.toLocaleString()} px | ` + - `${pool.length} candidates (${scores.length} scored)` + `${imageSwatches.length} image colors | ${totalPixels.toLocaleString()} px | ` + + `${pool.length} candidates (${scores.length} scored)` ); console.log(` Baseline avg ΔE: ${baselineAvgDeltaE.toFixed(2)} (blend-aware)`); console.log(` Suggestion: ${winner.hex.toUpperCase()} HD ${recommendedTd.toFixed(2)}`); console.log( ` Accuracy gain: +${improvementPct.toFixed(1)}% ` + - `(${pixelsCaptured.toLocaleString()} px / ` + - `${((pixelsCaptured / totalPixels) * 100).toFixed(1)}% of image improve)` + `(${pixelsCaptured.toLocaleString()} px / ` + + `${((pixelsCaptured / totalPixels) * 100).toFixed(1)}% of image improve)` ); console.log( ` Isolation: ${isolationScore.toFixed(3)} ` + - `(nearest filament ΔE ${winner.nearestFilamentDE.toFixed(1)} / ` + - `max ${maxIsolation.toFixed(1)})` + `(nearest filament ΔE ${winner.nearestFilamentDE.toFixed(1)} / ` + + `max ${maxIsolation.toFixed(1)})` ); console.groupEnd(); diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index dafb18f..4a52a6f 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -248,9 +248,7 @@ export function createSequenceScorer(context: ScoringContext): (filaments: Filam return (filaments) => { if (filaments.length === 0) return Infinity; - const sequenceKey = filaments - .map(filamentOpticalKey) - .join('|'); + const sequenceKey = filaments.map(filamentOpticalKey).join('|'); let palette = paletteCache.get(sequenceKey); if (!palette) { palette = buildAchievableColorPalette( @@ -359,10 +357,7 @@ function maxSequenceLength(filaments: Filament[], maxExtraRepeats: number): numb return filaments.length + maxExtraRepeats; } -function isBetterCandidate( - candidate: ScoredSequence, - current: ScoredSequence | null -): boolean { +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); @@ -377,11 +372,7 @@ function reportProgress( options.onProgress?.(Math.min(iteration, total), Math.max(total, 1), bestScore); } -function withProgressSpan( - options: OptimizerOptions, - start: number, - end: number -): OptimizerOptions { +function withProgressSpan(options: OptimizerOptions, start: number, end: number): OptimizerOptions { if (!options.onProgress) return { ...options, onProgress: undefined }; return { @@ -540,8 +531,7 @@ function optimizeBeamSearch( 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; + const totalIterations = filaments.length + (maximumLength - 1) * beamWidth * filaments.length; let iterations = 0; let best: ScoredSequence | null = null; reportProgress(options, 0, totalIterations, Infinity); @@ -689,7 +679,10 @@ function buildVariableLengthNeighbor( candidate[position] = eligible[rng.nextInt(0, eligible.length)]; } - if (isValidSequence(candidate, allowRepeatedSwaps) && !sequenceEquals(candidate, sequence)) { + if ( + isValidSequence(candidate, allowRepeatedSwaps) && + !sequenceEquals(candidate, sequence) + ) { return candidate; } } @@ -722,12 +715,7 @@ function runAnneal( while (iterations < maxIterations && temperature > SA_MIN_TEMPERATURE) { iterations++; - const newOrder = buildVariableLengthNeighbor( - currentOrder, - filaments, - maxExtraRepeats, - rng - ); + const newOrder = buildVariableLengthNeighbor(currentOrder, filaments, maxExtraRepeats, rng); if (sequenceEquals(newOrder, currentOrder)) { temperature *= coolingRate; onStep?.(best.score); @@ -848,9 +836,7 @@ function optimizeHybrid( // 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, maxExtraRepeats, rng); + restart === 0 ? best.order : randomInitialSequence(filaments, maxExtraRepeats, rng); const outcome = runAnneal( initialOrder, @@ -917,11 +903,7 @@ function optimizeExactBase( } const deep = optimizeDeep(filaments, scoreSequence, withProgressSpan(options, 0, 0.4)); - const exact = optimizeExhaustive( - filaments, - scoreSequence, - withProgressSpan(options, 0.4, 1) - ); + 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; @@ -986,14 +968,24 @@ export function optimizeFilamentOrder( }; const resolved = resolveAlgorithm(opts.algorithm, filaments.length); + const scoringContext: ScoringContext = { + ...context, + preserveSeparation: opts.preserveSeparation ?? context.preserveSeparation ?? false, + }; + opts.preserveSeparation = scoringContext.preserveSeparation; // 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 defaultSeedInput = canonicalOptimizerInput( + filaments, + scoringContext, + 'tier-comparison', + opts + ); const seed = opts.seed ?? stableHash32(defaultSeedInput); opts.seed = seed; - const cacheKey = canonicalOptimizerInput(filaments, context, opts.algorithm, opts, seed); + const cacheKey = canonicalOptimizerInput(filaments, scoringContext, opts.algorithm, opts, seed); if (opts.cachingEnabled) { const cached = globalCache.get(cacheKey); @@ -1004,12 +996,7 @@ export function optimizeFilamentOrder( } let result: OptimizerResult; - // 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, - }); + const scoreSequence = createSequenceScorer(scoringContext); switch (resolved) { case 'exhaustive': diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts index 1a3e7cc..3256a67 100644 --- a/src/lib/profileManager.ts +++ b/src/lib/profileManager.ts @@ -42,7 +42,8 @@ export function sanitizeProfileFilament(value: unknown): Filament | null { typeof value.id !== 'string' || typeof value.color !== 'string' || typeof value.td !== 'number' || - !Number.isFinite(value.td) + !Number.isFinite(value.td) || + value.td <= 0 ) { return null; } diff --git a/src/lib/regionWeighting.ts b/src/lib/regionWeighting.ts index 5e8d8db..dd224d9 100644 --- a/src/lib/regionWeighting.ts +++ b/src/lib/regionWeighting.ts @@ -21,6 +21,7 @@ export interface RegionWeightOptions { } const DEFAULT_CENTER_WEIGHT_STRENGTH = 0.5; +const MAX_CENTER_WEIGHT_STRENGTH = 0.999; /** A spatial weight lookup bound to a fixed image size. */ export type SpatialWeight = (x: number, y: number) => number; @@ -32,6 +33,11 @@ function nearestCenterDistance(width: number, height: number): number { return maxDistance > 0 ? Math.hypot(nearestX, nearestY) / maxDistance : 0; } +function normalizeCenterStrength(strength: number): number { + if (!Number.isFinite(strength)) return DEFAULT_CENTER_WEIGHT_STRENGTH; + return Math.min(MAX_CENTER_WEIGHT_STRENGTH, Math.max(0, strength)); +} + /** * Build a center-priority weight function for a fixed image size. Every term * that depends only on the size (fall-off denominator, normalization bounds, @@ -43,10 +49,11 @@ export function createCenterWeight( height: number, strength: number = DEFAULT_CENTER_WEIGHT_STRENGTH ): SpatialWeight { + const resolvedStrength = normalizeCenterStrength(strength); const centerX = width / 2; const centerY = height / 2; const maxDistance = Math.hypot(centerX, centerY); - const denominator = 2 * (1 - strength); + const denominator = 2 * (1 - resolvedStrength); const min = Math.exp(-1 / denominator); const max = Math.exp(-(nearestCenterDistance(width, height) ** 2 / denominator)); const range = max - min; @@ -149,6 +156,7 @@ function generateCenterWeightedMap( weights: Float32Array, strength: number ): void { + const resolvedStrength = normalizeCenterStrength(strength); const centerX = width / 2; const centerY = height / 2; const maxDist = Math.sqrt(centerX * centerX + centerY * centerY); @@ -162,7 +170,9 @@ function generateCenterWeightedMap( // 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)))); + const weight = Math.exp( + -((normalizedDist * normalizedDist) / (2 * (1 - resolvedStrength))) + ); weights[y * width + x] = weight; } diff --git a/tests/nextBestColor.test.ts b/tests/nextBestColor.test.ts index b54ba92..638d27d 100644 --- a/tests/nextBestColor.test.ts +++ b/tests/nextBestColor.test.ts @@ -1,17 +1,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { nextBestColor} from '../src/lib/nextBestColor.ts'; +import { nextBestColor } from '../src/lib/nextBestColor.ts'; import type { Filament } from '../src/types/index.ts'; - function filament(id: string, color: string, td: number): Filament { return { id, color, td }; } const BLACK = filament('black', '#000000', 1.0); const WHITE = filament('white', '#ffffff', 2.0); -const RED = filament('red', '#ff0000', 1.5); -const BLUE = filament('blue', '#0000ff', 2.5); +const RED = filament('red', '#ff0000', 1.5); +const BLUE = filament('blue', '#0000ff', 2.5); // --------------------------------------------------------------------------- // Edge cases @@ -34,7 +33,6 @@ test('returns null candidate when all swatches are already covered', () => { assert.equal(r.candidate, null); }); - test('returns null candidate when image palette exactly matches the filament set', () => { // Every image swatch is one of the existing filaments — nothing to add. const r = nextBestColor( @@ -76,7 +74,7 @@ test('blend-aware: far candidate wins when its segment covers the common color t [BLACK], [ { hex: '#888888', count: 1000 }, // common mid-grey - { hex: '#eeeeee', count: 1 }, // rare near-white + { hex: '#eeeeee', count: 1 }, // rare near-white ] ); assert.ok(r.candidate !== null); @@ -93,12 +91,12 @@ test('pixel count weighting: common color beats rare one when blend segments div const r = nextBestColor( [BLACK, WHITE], [ - { hex: '#606060', count: 1 }, // grey — on L-axis blend, covered - { hex: '#808080', count: 1 }, // grey — covered - { hex: '#a0a0a0', count: 1 }, // grey — covered - { hex: '#c0c0c0', count: 1 }, // grey — covered + { hex: '#606060', count: 1 }, // grey — on L-axis blend, covered + { hex: '#808080', count: 1 }, // grey — covered + { hex: '#a0a0a0', count: 1 }, // grey — covered + { hex: '#c0c0c0', count: 1 }, // grey — covered { hex: '#ff6666', count: 1000 }, // desaturated red — common - { hex: '#00ffff', count: 1 }, // cyan — opposite hue, rare + { hex: '#00ffff', count: 1 }, // cyan — opposite hue, rare ] ); assert.ok(r.candidate !== null); @@ -120,12 +118,12 @@ test('p100 weighting tips ranking: rare maximally-underserved color beats common const r = nextBestColor( [BLACK, WHITE], [ - { hex: '#606060', count: 1 }, // grey — covered by L-axis blend - { hex: '#808080', count: 1 }, // grey — covered - { hex: '#a0a0a0', count: 1 }, // grey — covered - { hex: '#c0c0c0', count: 1 }, // grey — covered - { hex: '#ff8888', count: 5 }, // desaturated red — moderate distance - { hex: '#0000ff', count: 1 }, // blue — maximum distance (p100) + { hex: '#606060', count: 1 }, // grey — covered by L-axis blend + { hex: '#808080', count: 1 }, // grey — covered + { hex: '#a0a0a0', count: 1 }, // grey — covered + { hex: '#c0c0c0', count: 1 }, // grey — covered + { hex: '#ff8888', count: 5 }, // desaturated red — moderate distance + { hex: '#0000ff', count: 1 }, // blue — maximum distance (p100) ] ); assert.ok(r.candidate !== null); @@ -147,8 +145,10 @@ test('improvementPct stays ≤ 100 when p100 weighting selects the winner', () = ] ); assert.ok(r.candidate !== null); - assert.ok(r.candidate.improvementPct <= 100, - `expected ≤ 100, got ${r.candidate.improvementPct}`); + assert.ok( + r.candidate.improvementPct <= 100, + `expected ≤ 100, got ${r.candidate.improvementPct}` + ); }); // --------------------------------------------------------------------------- @@ -165,7 +165,10 @@ test('improvementPct is > 0 and ≤ 100', () => { ); assert.ok(r.candidate !== null); assert.ok(r.candidate.improvementPct > 0, `expected > 0, got ${r.candidate.improvementPct}`); - assert.ok(r.candidate.improvementPct <= 100, `expected ≤ 100, got ${r.candidate.improvementPct}`); + assert.ok( + r.candidate.improvementPct <= 100, + `expected ≤ 100, got ${r.candidate.improvementPct}` + ); }); // --------------------------------------------------------------------------- @@ -181,8 +184,10 @@ test('isolationScore is in [0, 1]', () => { ] ); assert.ok(r.candidate !== null); - assert.ok(r.candidate.isolationScore >= 0 && r.candidate.isolationScore <= 1, - `expected 0–1, got ${r.candidate.isolationScore}`); + assert.ok( + r.candidate.isolationScore >= 0 && r.candidate.isolationScore <= 1, + `expected 0–1, got ${r.candidate.isolationScore}` + ); }); test('single viable candidate always gets isolationScore 1.0', () => { @@ -190,8 +195,8 @@ test('single viable candidate always gets isolationScore 1.0', () => { const r = nextBestColor( [BLACK], [ - { hex: '#000000', count: 10 }, // covered — skipped - { hex: '#ffffff', count: 90 }, // sole viable candidate + { hex: '#000000', count: 10 }, // covered — skipped + { hex: '#ffffff', count: 90 }, // sole viable candidate ] ); assert.ok(r.candidate !== null); @@ -209,9 +214,9 @@ test('blend-aware: both candidates produce a candidate with valid isolation scor const r = nextBestColor( [BLACK], [ - { hex: '#000000', count: 1 }, // covered - { hex: '#444444', count: 500 }, // common, moderate isolation - { hex: '#ffffff', count: 1 }, // rare, maximum isolation + { hex: '#000000', count: 1 }, // covered + { hex: '#444444', count: 500 }, // common, moderate isolation + { hex: '#ffffff', count: 1 }, // rare, maximum isolation ] ); assert.ok(r.candidate !== null); @@ -356,6 +361,15 @@ test('blendRgb applies per-channel hiding distances independently', async () => assert.ok(Math.abs(asymmetric.g - asymmetric.b) < 1e-9); }); +test('blendRgb treats zero or invalid hiding distances as immediately opaque', async () => { + const { blendRgb } = await import('../src/lib/nextBestColor.ts'); + const background = { r: 255, g: 255, b: 255 }; + const filament = { r: 20, g: 80, b: 140 }; + + assert.deepEqual(blendRgb(background, filament, 0, 0.2), filament); + assert.deepEqual(blendRgb(background, filament, [0.2, 0, 0.2], 0.2), filament); +}); + test('measured calibration channels change the blend-aware baseline', () => { // A calibrated teal whose measured channels ([0.35, 0.12, 0.12]) invert the // color-derived heuristic (~[0.068, 0.354, 0.373]): red hides slowest, not diff --git a/tests/optimizer.test.ts b/tests/optimizer.test.ts index 124ee54..500eaeb 100644 --- a/tests/optimizer.test.ts +++ b/tests/optimizer.test.ts @@ -119,7 +119,10 @@ test('optimizer progress is monotonic and completes for every algorithm', async onProgress: (iteration, total) => exactRepeatSamples.push(total > 0 ? iteration / total : 0), }); - assert.ok(exactRepeatSamples.length > 2, 'exact repeat refinement must report intermediate progress'); + assert.ok( + exactRepeatSamples.length > 2, + 'exact repeat refinement must report intermediate progress' + ); assert.equal(exactRepeatSamples.at(-1), 1); assert.ok( exactRepeatSamples.every( @@ -207,6 +210,18 @@ test('cache keys include all weighted clusters and optimizer tuning', async () = 'changed per-channel calibration TDs must miss cache' ); assert.equal(getOptimizerCacheStats().size, 4); + + const contextPreserveSeparation = optimizeFilamentOrder( + filaments, + { ...manyClusters, preserveSeparation: true }, + options + ); + assert.equal( + contextPreserveSeparation.cacheHit, + undefined, + 'context preserve-separation mode must miss cache' + ); + assert.equal(getOptimizerCacheStats().size, 5); }); test('cache keys distinguish active and inactive calibrated swatch colors', async () => { @@ -337,7 +352,10 @@ test('exhaustive search evaluates ordered subsets and drops a strictly worse fil }); assert.equal(result.iterations, 15, 'three filaments have 15 ordered non-empty subsets'); - assert.deepEqual(result.order.map((filament) => filament.id), ['white']); + assert.deepEqual( + result.order.map((filament) => filament.id), + ['white'] + ); }); test('repeats can close the RGB color path to reach the missing magenta blend', async () => { @@ -465,11 +483,8 @@ test('fast uses a narrow beam while explicit exhaustive remains exact', async () }); test('effort tiers are deterministic and preserve the previous tier best result', async () => { - const { - getExactBaseOrderCount, - normalizeOptimizerTier, - optimizeFilamentOrder, - } = await loadOptimizerModule(); + const { getExactBaseOrderCount, normalizeOptimizerTier, optimizeFilamentOrder } = + await loadOptimizerModule(); const mediumProfile = [ ...filaments, { id: 'green', color: '#42a85f', td: 1.6 }, @@ -511,7 +526,10 @@ test('effort tiers are deterministic and preserve the previous tier best result' assert.deepEqual(deepAgain, deep, 'same seed must reproduce the Deep tier'); const ids = deep.order.map((filament) => filament.id); - assert.ok(ids.every((id, index) => index === 0 || id !== ids[index - 1]), 'no adjacent duplicates'); + 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( diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts index dbdb5f8..1625fac 100644 --- a/tests/profileManager.test.ts +++ b/tests/profileManager.test.ts @@ -376,12 +376,10 @@ test('v2 profile imports are not re-scaled (double-import idempotence)', async ( }); test('calibrated frontlit fixture imports without rescaling measured hiding distances', async () => { - const { importProfiles, parseProfileFile, CURRENT_PROFILE_VERSION } = await loadProfileManager(); + const { importProfiles, parseProfileFile, CURRENT_PROFILE_VERSION } = + await loadProfileManager(); const raw = readFileSync( - resolve( - process.cwd(), - 'tests/assets/filament-profiles/8_Colors_Calibrated_Frontlit.kfil' - ), + resolve(process.cwd(), 'tests/assets/filament-profiles/8_Colors_Calibrated_Frontlit.kfil'), 'utf8' ); const parsed = parseProfileFile(raw); @@ -500,3 +498,10 @@ test('loading a calibrated filament backfills the calibrated swatch color', asyn }); assert.equal(stamped!.calibration!.filamentColor, '#ffffff'); }); + +test('loading a filament rejects non-positive hiding distances', async () => { + const { sanitizeProfileFilament } = await loadProfileManager(); + + assert.equal(sanitizeProfileFilament({ id: 'zero', color: '#ffffff', td: 0 }), null); + assert.equal(sanitizeProfileFilament({ id: 'negative', color: '#ffffff', td: -0.1 }), null); +}); diff --git a/tests/regionWeighting.test.ts b/tests/regionWeighting.test.ts index 1545620..9a14964 100644 --- a/tests/regionWeighting.test.ts +++ b/tests/regionWeighting.test.ts @@ -69,6 +69,16 @@ test('center and edge weights favor opposite image regions', () => { assert.ok(edgeCorner > edgeCenter, 'edge mode should favor the border'); }); +test('center weight strength of one remains finite', () => { + const center = createCenterWeight(4, 4, 1); + const values = Array.from({ length: 16 }, (_, index) => + center(index % 4, Math.floor(index / 4)) + ); + + assert.ok(values.every((value) => Number.isFinite(value))); + assert.ok(values.every((value) => value >= 0 && value <= 1)); +}); + 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; From 2a1167370857ec3d6993eb4b31a324d790d841ae Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Tue, 7 Jul 2026 23:03:12 +0300 Subject: [PATCH 59/81] Fix deterministic optimizer tie breaks --- CHANGELOG.md | 2 +- src/lib/optimizer.ts | 13 ++++++++++--- tests/autoPaintGoldenFixtures.ts | 24 +++++++++++------------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb1ef5..69880e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint Max Height** - Auto-paint now plans, scores, previews, and exports the same layer-aligned stack. Height caps round down to a valid printable layer boundary, so a generated model no longer exceeds the requested maximum by adding a final whole layer. - **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 calibration confidence** - Color edits that deactivate a calibration now re-anchor the filament's estimated hiding distance to the new color without deleting the stored calibration, and result confidence now scores the filament sequence actually printed after variable-length optimization omits unused filaments. -- **Auto-paint edge cases** - Blank seeds now resolve to stable cacheable values, optimizer cache keys include all target clusters, tuning inputs, preserve-separation mode, and active calibration state, zero-HD blend helpers agree on the opaque-filament limit, and exceptionally tall stacks stop at the intended 500-layer slice-data limit. +- **Auto-paint edge cases** - Blank seeds now resolve to stable cacheable values, optimizer cache keys include all target clusters, tuning inputs, preserve-separation mode, and active calibration state, beam-search ties use locale-independent ordering, exact base-order search discloses its estimated base-order count before running, zero-HD blend helpers agree on the opaque-filament limit, and exceptionally tall stacks stop at the intended 500-layer slice-data limit. - **Filament profile validation** - Imported `.kfil`/`.kapp` profile filaments now reject zero or negative hiding distances instead of letting invalid raw HD values reach the UI. - **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. diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index 4a52a6f..e62eb34 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -330,6 +330,13 @@ function sequenceKey(sequence: Filament[]): string { return sequence.map((filament) => filament.id).join('|'); } +function compareSequenceKeys(left: Filament[], right: Filament[]): number { + const leftKey = sequenceKey(left); + const rightKey = sequenceKey(right); + if (leftKey === rightKey) return 0; + return leftKey < rightKey ? -1 : 1; +} + function hasConsecutiveDuplicates(sequence: Filament[]): boolean { return sequence.some((filament, index) => index > 0 && filament.id === sequence[index - 1].id); } @@ -360,7 +367,7 @@ function maxSequenceLength(filaments: Filament[], maxExtraRepeats: number): numb 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); + return compareSequenceKeys(candidate.order, current.order) < 0; } function reportProgress( @@ -545,7 +552,7 @@ function optimizeBeamSearch( }); beam.sort((left, right) => left.score === right.score - ? sequenceKey(left.order).localeCompare(sequenceKey(right.order)) + ? compareSequenceKeys(left.order, right.order) : left.score - right.score ); beam = beam.slice(0, beamWidth); @@ -578,7 +585,7 @@ function optimizeBeamSearch( beam = [...candidates.values()] .sort((left, right) => left.score === right.score - ? sequenceKey(left.order).localeCompare(sequenceKey(right.order)) + ? compareSequenceKeys(left.order, right.order) : left.score - right.score ) .slice(0, beamWidth); diff --git a/tests/autoPaintGoldenFixtures.ts b/tests/autoPaintGoldenFixtures.ts index 4fcf653..d70638b 100644 --- a/tests/autoPaintGoldenFixtures.ts +++ b/tests/autoPaintGoldenFixtures.ts @@ -1,10 +1,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { - CURRENT_PROFILE_VERSION, - migrateLegacyFilamentTd, -} from '../src/lib/profileManager.ts'; +import { CURRENT_PROFILE_VERSION, migrateLegacyFilamentTd } from '../src/lib/profileManager.ts'; import { colorSwatchesFromJpegBlocks, logoFixturePath, @@ -18,7 +15,6 @@ 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; @@ -38,7 +34,9 @@ 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 & { + const parsed = JSON.parse( + readFileSync(profilePath, 'utf8') + ) as Partial & { version?: number; }; @@ -63,9 +61,7 @@ function readProfile(fileName: string, expectedFilamentCount: number): FilamentP } function rgbToHex(r: number, g: number, b: number) { - return `#${[r, g, b] - .map((value) => value.toString(16).padStart(2, '0')) - .join('')}`; + return `#${[r, g, b].map((value) => value.toString(16).padStart(2, '0')).join('')}`; } function pngSwatches(image: PngImage): Array<{ hex: string; count: number }> { @@ -84,7 +80,12 @@ function pngSwatches(image: PngImage): Array<{ hex: string; count: number }> { .map(([hex, count]) => ({ hex, count })); } -function scenarioSeed(profileIndex: number, fixtureIndex: number, enhanced: boolean, repeats: boolean) { +function scenarioSeed( + profileIndex: number, + fixtureIndex: number, + enhanced: boolean, + repeats: boolean +) { return ( 0x4b524f4d + profileIndex * 1009 + @@ -100,12 +101,10 @@ export function autoPaintGoldenScenarios(): AutoPaintGoldenScenario[] { { name: 'logo-png', imageSwatches: pngSwatches(png), - imageDimensions: { width: png.width, height: png.height }, }, { name: 'large-jpeg', imageSwatches: colorSwatchesFromJpegBlocks(largeIssueFixturePath), - imageDimensions: { width: 3888, height: 2916 }, }, ]; @@ -118,7 +117,6 @@ export function autoPaintGoldenScenarios(): AutoPaintGoldenScenario[] { name: `${profile.name} / ${fixture.name} / enhanced=${enhancedColorMatch} / repeats=${allowRepeatedSwaps}`, filaments: profile.filaments, imageSwatches: fixture.imageSwatches, - imageDimensions: fixture.imageDimensions, enhancedColorMatch, allowRepeatedSwaps, seed: scenarioSeed( From 185305b7018433ec0e986c7e8d0f952898611d47 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 9 Jul 2026 20:26:07 +0300 Subject: [PATCH 60/81] Update dependencies to resolve Dependabot security alerts --- CHANGELOG.md | 1 + package-lock.json | 672 ++++++++++++++++++++++--------------------- src-tauri/Cargo.lock | 75 ++--- 3 files changed, 372 insertions(+), 376 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69880e2..8ad70ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to Kromacut are documented in this file. ### Changed +- **Dependency security updates** - Updated the desktop runtime to Tauri 2.11.1, fixing an origin-confusion issue that could let a crafted remote page invoke local-only IPC commands on Windows, along with patched openssl, rustls-webpki, and rand crates. Development tooling moved to patched releases (Vite 7.3.6, Rollup, PostCSS, esbuild, Babel, and friends), clearing every Dependabot alert that is fixable without a Tauri major upgrade. - **Hiding Distance replaces TD** - Filament opacity is now expressed as a frontlit **Hiding Distance (HD)** in millimetres: the depth at which a filament visually hides what's beneath it, matching how prints are actually viewed. Stored values become single-meaning (profile schema v2): uncalibrated values from older profiles, `.kfil`/`.kapp` files, and saved app state are converted automatically (×0.1, one time) on load or import, and the old internal frontlit scaling layer is removed — this also fixes calibrated filaments being double-scaled in the recommended-height estimate. A convert button on each filament row accepts conventional backlit/lithophane TD values (≈10× the HD) and converts them on entry, the wand estimate now produces HD directly, and per-channel values are visible in tooltips. Uncalibrated filaments now blend with per-channel hiding distances estimated from their color (previously channel-uniform), so uncalibrated previews and stacks are more color-faithful; the next-filament suggestion simulates blends in linear light with the same per-channel hiding distances auto-paint renders with (measured channels when calibrated, color-derived otherwise) and recommends HDs on the stored scale. - **Auto-paint enhanced matching** - Optimizer scoring now follows the same layer-snapped, Max Height-compressed printable stack used by preview and export, scores realized print error in CIEDE2000 with a weighted p95 tail term, and uses complete target-color/cache inputs so repeated runs are deterministic. - **Auto-paint optimizer controls** - Replaced the older optimizer choices with deterministic effort tiers (Fast, Balanced, Thorough, Deep, and Exact base order), selector-based repeated swaps, transition-detail endpoints, and explicit stable seed handling. Selecting Exact base order shows the estimated number of base orders it will score before running. Legacy saved values migrate to the nearest current tier. diff --git a/package-lock.json b/package-lock.json index 9e0073a..2859075 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,13 +61,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -76,9 +76,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -86,21 +86,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -117,14 +117,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -134,14 +134,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -151,9 +151,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -161,29 +161,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -203,9 +203,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -213,9 +213,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -223,9 +223,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -233,27 +233,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -295,33 +295,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -329,14 +329,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -417,9 +417,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -434,9 +434,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -451,9 +451,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -468,9 +468,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -485,9 +485,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -502,9 +502,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -519,9 +519,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -536,9 +536,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -553,9 +553,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -570,9 +570,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -587,9 +587,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -604,9 +604,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -621,9 +621,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -638,9 +638,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -655,9 +655,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -672,9 +672,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -689,9 +689,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -706,9 +706,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -723,9 +723,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -740,9 +740,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -757,9 +757,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -774,9 +774,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -791,9 +791,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -808,9 +808,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -825,9 +825,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -842,9 +842,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2123,9 +2123,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -2137,9 +2137,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -2151,9 +2151,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -2165,9 +2165,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -2179,9 +2179,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -2193,9 +2193,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -2207,9 +2207,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], @@ -2221,9 +2221,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], @@ -2235,9 +2235,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], @@ -2249,9 +2249,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], @@ -2263,9 +2263,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "cpu": [ "loong64" ], @@ -2277,9 +2277,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "cpu": [ "loong64" ], @@ -2291,9 +2291,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "cpu": [ "ppc64" ], @@ -2305,9 +2305,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], @@ -2319,9 +2319,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], @@ -2333,9 +2333,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], @@ -2347,9 +2347,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], @@ -2361,9 +2361,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], @@ -2375,9 +2375,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], @@ -2389,9 +2389,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", "cpu": [ "x64" ], @@ -2403,9 +2403,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -2417,9 +2417,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -2431,9 +2431,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -2445,9 +2445,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -2459,9 +2459,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -3122,9 +3122,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -3393,9 +3393,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -3403,13 +3403,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3538,9 +3538,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3705,9 +3705,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4036,9 +4036,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4049,32 +4049,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -4433,9 +4433,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4744,10 +4744,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5229,9 +5239,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5249,9 +5259,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -5487,9 +5497,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -5547,9 +5557,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -5567,7 +5577,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5817,13 +5827,13 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -5833,31 +5843,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" } }, @@ -6248,13 +6258,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 61370cc..e145829 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -892,7 +892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2442,15 +2442,14 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.11.0", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2474,9 +2473,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2570,7 +2569,6 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -2632,7 +2630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -2642,7 +2640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -2669,19 +2667,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.116", -] - [[package]] name = "phf_macros" version = "0.13.1" @@ -2962,9 +2947,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3262,7 +3247,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "rkyv", "serde", "serde_json", @@ -3293,7 +3278,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3320,9 +3305,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -4011,9 +3996,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" +checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" dependencies = [ "anyhow", "bytes", @@ -4062,9 +4047,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -4083,9 +4068,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -4110,9 +4095,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4205,9 +4190,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -4230,9 +4215,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -4256,9 +4241,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.0" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -4274,7 +4259,7 @@ dependencies = [ "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf 0.13.1", "plist", "proc-macro2", "quote", @@ -4315,7 +4300,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5165,7 +5150,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From 67a39e62ea7813fad65f856ef9f580ffe5714960 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 9 Jul 2026 22:14:31 +0300 Subject: [PATCH 61/81] Add collapsible control groups --- CHANGELOG.md | 1 + package-lock.json | 201 +++++++++++++++++++++++++++ package.json | 1 + src/components/AdjustmentsPanel.tsx | 24 ++-- src/components/AutoPaintTab.tsx | 28 ++-- src/components/CollapsibleCard.tsx | 97 +++++++++++++ src/components/ControlsPanel.tsx | 85 +++++------ src/components/DeditherPanel.tsx | 201 ++++++++++++++------------- src/components/ImageResizePanel.tsx | 135 +++++++++--------- src/components/PrintInstructions.tsx | 28 ++-- src/components/PrintSettingsCard.tsx | 44 +++--- src/components/SwatchesPanel.tsx | 22 +-- src/components/ThreeDControls.tsx | 90 ++++++++---- src/components/ui/collapsible.tsx | 9 ++ src/index.css | 20 +++ src/lib/collapsedGroups.ts | 35 +++++ tests/collapsedGroups.test.ts | 84 +++++++++++ 17 files changed, 815 insertions(+), 290 deletions(-) create mode 100644 src/components/CollapsibleCard.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/lib/collapsedGroups.ts create mode 100644 tests/collapsedGroups.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad70ad..66079d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint optimization progress** - Enhanced matching now reports approximate search progress while the background optimizer runs. - **Auto-paint test and benchmark coverage** - Added deterministic stack goldens, layer-invariant regression coverage, realized CIEDE2000 quality-budget tests, and an on-demand benchmark for fixture profiles using the same printable-stack mapper as the optimizer. - **Filament profile import fixture** - Added a schema-v2 frontlit-calibrated 8-color `.kfil` fixture with import coverage to guard against accidental hiding-distance rescaling. +- **Collapsible control groups** - Every settings card in 2D and 3D mode (Adjustments, Resize Image, Dedither, Quantization Settings, Image colors, 3D Print Settings, Auto-paint, Color Slice Heights, and Print Instructions) can now be collapsed from its header to save sidebar space. Collapsed groups are remembered across sessions, and important indicators stay visible while collapsed: color counts, busy spinners, modified-settings dots, unsaved-profile markers, and quick actions like reset and copy. - **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. ### Changed diff --git a/package-lock.json b/package-lock.json index 2859075..cb57d41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", @@ -1252,6 +1253,206 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.16.tgz", + "integrity": "sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", + "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", + "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", + "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index e3aa684..f113fcd 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", diff --git a/src/components/AdjustmentsPanel.tsx b/src/components/AdjustmentsPanel.tsx index dd69e0d..e62baf2 100644 --- a/src/components/AdjustmentsPanel.tsx +++ b/src/components/AdjustmentsPanel.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import { Check, RotateCcw } from 'lucide-react'; export type SliderDef = { @@ -120,12 +120,14 @@ export const AdjustmentsPanel: React.FC = React.memo( }, [onBake, onCommit]); return ( - -
    -
    -

    Adjustments

    -

    Fine-tune image properties

    -
    + : undefined + } + actions={ -
    -
    + } + >
    {defs.map((s) => { const displayVal = values[s.key]; @@ -188,7 +190,7 @@ export const AdjustmentsPanel: React.FC = React.memo( ); })}
    -
    +
    - + ); } ); diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 31aff5e..7f80882 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import { NumberInput, Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { @@ -286,12 +286,24 @@ export default function AutoPaintTab({ return ( - -
    -

    Auto-paint

    -
    -
    - + + {isComputing && ( + + )} + {error && !isComputing && } + {activeProfileId && isDirty && ( + + )} + + } + > {/* Profiles Section */}
    @@ -1264,7 +1276,7 @@ export default function AutoPaintTab({
    )}
    - +
    {/* Calibration Dialog */} = ({ title }) => ( + +); + +export const CollapsibleCard: React.FC = ({ + id, + title, + subtitle, + headingLevel = 3, + actions, + collapsedSummary, + className, + children, +}) => { + const [open, setOpen] = useState(() => !isGroupCollapsed(id)); + const Heading = headingLevel === 4 ? 'h4' : 'h3'; + + const handleOpenChange = useCallback( + (next: boolean) => { + setOpen(next); + setGroupCollapsed(id, !next); + }, + [id] + ); + + return ( + + +
    + + + + + + {(actions || collapsedSummary) && ( +
    + {!open && collapsedSummary} + {actions} +
    + )} +
    + +
    + {children} + + + + ); +}; + +export default CollapsibleCard; diff --git a/src/components/ControlsPanel.tsx b/src/components/ControlsPanel.tsx index f502461..29dd8e8 100644 --- a/src/components/ControlsPanel.tsx +++ b/src/components/ControlsPanel.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { NumberInput } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import { Check, Loader, RotateCcw } from 'lucide-react'; import { Select, @@ -86,31 +86,36 @@ export const ControlsPanel: React.FC = ({ selectedPalette === 'auto'; return ( - -
    -
    -
    -

    - Quantization Settings -

    -

    - Configure palette and reduce colors -

    -
    - {onReset && ( - - )} -
    -
    + + ) : !allDefault ? ( + + ) : undefined + } + actions={ + onReset && ( + + ) + } + > +
    +
    - - +
    ); }; diff --git a/src/components/DeditherPanel.tsx b/src/components/DeditherPanel.tsx index 1a6a471..f3fc7ff 100644 --- a/src/components/DeditherPanel.tsx +++ b/src/components/DeditherPanel.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { Label } from '@/components/ui/label'; -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import { RotateCcw, Check, Loader } from 'lucide-react'; import type { CanvasPreviewHandle } from './CanvasPreview'; import { deditherRowProgress } from '../lib/progress'; @@ -218,12 +218,21 @@ export const DeditherPanel: React.FC = ({ }, []); return ( - -
    -
    -

    Dedither

    -

    Smooth dithered patterns

    -
    + + ) : !allDefault ? ( + + ) : undefined + } + actions={ -
    - -
    - -
    -
    - -
    - - {weight} - - + } + > +
    +
    +
    + +
    + + {weight} + + +
    + setWeight(value[0])} + className="w-full" + />
    - setWeight(value[0])} - className="w-full" - /> -
    -
    -
    - -
    - - {passes} - - +
    +
    + +
    + + {passes} + + +
    + setPasses(value[0])} + className="w-full" + />
    - setPasses(value[0])} - className="w-full" - /> -
    -
    +
    - - + +
    + ); }; diff --git a/src/components/ImageResizePanel.tsx b/src/components/ImageResizePanel.tsx index 4afca35..c6aa8f6 100644 --- a/src/components/ImageResizePanel.tsx +++ b/src/components/ImageResizePanel.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import { Check, Loader, RotateCcw } from 'lucide-react'; import type { ImageDimensions } from '../hooks/useSwatches'; import { @@ -59,12 +59,21 @@ export const ImageResizePanel: React.FC = ({ }; return ( - -
    -
    -

    Resize Image

    -

    Downscale pixel resolution

    -
    + + ) : !allDefault ? ( + + ) : undefined + } + actions={ -
    - -
    - -
    -
    - - - {percent}% - + } + > +
    +
    +
    + + + {percent}% + +
    + commitPercent(value[0])} + className="w-full" + disabled={working} + />
    - commitPercent(value[0])} - className="w-full" - disabled={working} - /> -
    -
    -
    - Current - - {imageDimensions - ? `${imageDimensions.width}x${imageDimensions.height} px` - : 'No image'} - +
    +
    + Current + + {imageDimensions + ? `${imageDimensions.width}x${imageDimensions.height} px` + : 'No image'} + +
    +
    + After resize + + {targetDimensions + ? `${targetDimensions.width}x${targetDimensions.height} px` + : '-'} + +
    -
    - After resize - - {targetDimensions - ? `${targetDimensions.width}x${targetDimensions.height} px` - : '-'} - -
    -
    - - + +
    + ); }; diff --git a/src/components/PrintInstructions.tsx b/src/components/PrintInstructions.tsx index 477ea7b..f2b55c9 100644 --- a/src/components/PrintInstructions.tsx +++ b/src/components/PrintInstructions.tsx @@ -1,4 +1,4 @@ -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import type { SwapEntry } from '../hooks/useSwapPlan'; interface PrintInstructionsProps { @@ -24,14 +24,16 @@ export default function PrintInstructions({ flatPaint = false, }: PrintInstructionsProps) { return ( - -
    -
    -

    Print Instructions

    -

    - Generated swap plan for your printer -

    -
    + : undefined + } + actions={ -
    - -
    - + } + >
    {/* Recommended Settings */}
    @@ -200,6 +200,6 @@ export default function PrintInstructions({
    - + ); } diff --git a/src/components/PrintSettingsCard.tsx b/src/components/PrintSettingsCard.tsx index 713e0fd..33576ef 100644 --- a/src/components/PrintSettingsCard.tsx +++ b/src/components/PrintSettingsCard.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { RotateCcw } from 'lucide-react'; @@ -127,14 +127,14 @@ export default function PrintSettingsCard({ ); return ( - -
    -
    -

    3D Print Settings

    -

    - Configure your printing parameters -

    -
    + : undefined + } + actions={ -
    -
    + } + >
    {/* Pixel size (XY scaling) */}
    @@ -205,7 +205,11 @@ export default function PrintSettingsCard({ type="text" inputMode="decimal" value={layerHeightInput.value} - className={layerHeightInput.error ? 'border-red-500 focus-visible:ring-red-500' : ''} + className={ + layerHeightInput.error + ? 'border-red-500 focus-visible:ring-red-500' + : '' + } onChange={(e) => layerHeightInput.onChange(e.target.value)} onFocus={layerHeightInput.onFocus} onBlur={layerHeightInput.onBlur} @@ -232,13 +236,19 @@ export default function PrintSettingsCard({ type="text" inputMode="decimal" value={firstLayerHeightInput.value} - className={firstLayerHeightInput.error ? 'border-red-500 focus-visible:ring-red-500' : ''} + className={ + firstLayerHeightInput.error + ? 'border-red-500 focus-visible:ring-red-500' + : '' + } onChange={(e) => firstLayerHeightInput.onChange(e.target.value)} onFocus={firstLayerHeightInput.onFocus} onBlur={firstLayerHeightInput.onBlur} /> {firstLayerHeightInput.error && ( - {firstLayerHeightInput.error} + + {firstLayerHeightInput.error} + )}
    @@ -248,8 +258,8 @@ export default function PrintSettingsCard({
    Smooth Meshing

    - Smooth connected color boundary edges with fast welded topology. - Turning this on disables Flat Paint. + Smooth connected color boundary edges with fast welded topology. Turning + this on disables Flat Paint.

    -
    + ); } diff --git a/src/components/SwatchesPanel.tsx b/src/components/SwatchesPanel.tsx index 2fad766..b29971d 100644 --- a/src/components/SwatchesPanel.tsx +++ b/src/components/SwatchesPanel.tsx @@ -3,6 +3,7 @@ import { RgbaColorPicker } from 'react-colorful'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card } from '@/components/ui/card'; +import { CollapsibleCard } from '@/components/CollapsibleCard'; import { X } from 'lucide-react'; interface SwatchEntry { @@ -57,13 +58,12 @@ export const SwatchesPanel: React.FC = ({ const closeModal = () => setOpenSwatch(null); return ( - -
    -
    -

    Image colors

    -

    Detected color palette

    -
    -
    + = ({ Updating… )} -
    -
    -
    + + } + >
    = ({
    )} - + ); }; diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index fa11b1c..0cd0379 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import ThreeDColorRow from './ThreeDColorRow'; import { Sortable, SortableContent, SortableOverlay } from '@/components/ui/sortable'; import { Button } from '@/components/ui/button'; @@ -60,7 +60,14 @@ export default function ThreeDControls({ persisted, }: ThreeDControlsProps) { // --- Filaments --- - const { filaments, setFilaments, addFilament, addFilamentWithProps, removeFilament, updateFilament } = useFilaments({ + const { + filaments, + setFilaments, + addFilament, + addFilamentWithProps, + removeFilament, + updateFilament, + } = useFilaments({ initial: persisted?.filaments?.length ? persisted.filaments : undefined, }); @@ -115,7 +122,9 @@ export default function ThreeDControls({ ); const [paintMode, setPaintMode] = useState<'manual' | 'autopaint'>(initialPaintMode); const [autoPaintMaxHeight, setAutoPaintMaxHeight] = useState(undefined); - const [enhancedColorMatch, setEnhancedColorMatch] = useState(persisted?.enhancedColorMatch ?? false); + const [enhancedColorMatch, setEnhancedColorMatch] = useState( + persisted?.enhancedColorMatch ?? false + ); const initialPreserveSeparation = persisted?.preserveSeparation ?? false; const [preserveSeparation, setPreserveSeparation] = useState(initialPreserveSeparation); const [maxRepeatedSwaps, setMaxRepeatedSwaps] = useState( @@ -133,9 +142,7 @@ export default function ThreeDControls({ // --- Optimizer Options --- const [optimizerAlgorithm, setOptimizerAlgorithm] = useState< 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact' - >( - persisted?.optimizerAlgorithm ?? 'balanced' - ); + >(persisted?.optimizerAlgorithm ?? 'balanced'); const [optimizerSeed, setOptimizerSeed] = useState( persisted?.optimizerSeed ); @@ -196,11 +203,30 @@ export default function ThreeDControls({ regionWeightingMode, smoothMeshing, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paintMode, filaments, enhancedColorMatch, preserveSeparation, maxRepeatedSwaps, transitionOpacity, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + paintMode, + filaments, + enhancedColorMatch, + preserveSeparation, + maxRepeatedSwaps, + transitionOpacity, + heightDithering, + ditherLineWidth, + flatPaint, + optimizerAlgorithm, + optimizerSeed, + regionWeightingMode, + smoothMeshing, + ]); useEffect(() => { - savePrintSettingsToStorage({ layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing }); + savePrintSettingsToStorage({ + layerHeight, + slicerFirstLayerHeight, + pixelSize, + smoothMeshing, + }); }, [layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing]); // --- Color Slicing --- @@ -313,7 +339,7 @@ export default function ThreeDControls({ const instructionFlatPaint = builtState ? builtFlatPaint : flatPaintActive; const instructionColorCount = instructionPaintMode === 'autopaint' - ? instructionAutoPaintResult?.layers.length ?? 0 + ? (instructionAutoPaintResult?.layers.length ?? 0) : instructionColorOrder.length; const isInstructionOverLimit = instructionColorCount > 64; @@ -524,17 +550,18 @@ export default function ThreeDControls({ {/* Manual Tab */} - -
    -
    -

    - Color Slice Heights -

    -

    - Drag to reorder, adjust sliders to customize -

    -
    -
    + + ) : undefined + } + actions={ + <>
    -
    -
    + + } + > + orientation="vertical" + >
    {displayOrder.length > 64 ? (
    -

    Too many colors ({displayOrder.length})

    +

    + Too many colors ({displayOrder.length}) +

    - The image has more than 64 unique colors. Please reduce - the image to fewer colors in 2D mode using the quantization - tools before switching to 3D mode. + The image has more than 64 unique colors. Please + reduce the image to fewer colors in 2D mode using + the quantization tools before switching to 3D mode.

    ) : ( @@ -593,7 +623,7 @@ export default function ThreeDControls({
    - + diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..9605c4e --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/index.css b/src/index.css index d071026..4097868 100644 --- a/src/index.css +++ b/src/index.css @@ -8,6 +8,8 @@ --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; --animate-bounce: bounce 1s infinite; + --animate-collapsible-down: collapsible-down 0.2s ease-out; + --animate-collapsible-up: collapsible-up 0.2s ease-out; } :root { @@ -203,6 +205,24 @@ } } +@keyframes collapsible-down { + from { + height: 0; + } + to { + height: var(--radix-collapsible-content-height); + } +} + +@keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + } + to { + height: 0; + } +} + /* Ensure box-sizing is border-box everywhere */ *, *::before, diff --git a/src/lib/collapsedGroups.ts b/src/lib/collapsedGroups.ts new file mode 100644 index 0000000..7df8877 --- /dev/null +++ b/src/lib/collapsedGroups.ts @@ -0,0 +1,35 @@ +const KEY = 'kromacut.ui.collapsedGroups.v1'; + +export function loadCollapsedGroups(): Record { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; + const groups: Record = {}; + for (const [id, collapsed] of Object.entries(parsed)) { + if (typeof collapsed === 'boolean') groups[id] = collapsed; + } + return groups; + } catch { + return {}; + } +} + +export function isGroupCollapsed(id: string): boolean { + return loadCollapsedGroups()[id] === true; +} + +export function setGroupCollapsed(id: string, collapsed: boolean): void { + try { + const groups = loadCollapsedGroups(); + if (collapsed) { + groups[id] = true; + } else { + delete groups[id]; + } + localStorage.setItem(KEY, JSON.stringify(groups)); + } catch { + // Storage unavailable; the in-memory React state still tracks the session. + } +} diff --git a/tests/collapsedGroups.test.ts b/tests/collapsedGroups.test.ts new file mode 100644 index 0000000..3ccd245 --- /dev/null +++ b/tests/collapsedGroups.test.ts @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Minimal localStorage mock for Node.js. Another test file in the same process +// may already have installed one (the property is not configurable), so only +// define it when missing and use whichever mock is active. +if (!('localStorage' in globalThis)) { + const store: Record = {}; + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + for (const k of Object.keys(store)) delete store[k]; + }, + }, + }); +} +const mockLocalStorage = (globalThis as { localStorage: Storage }).localStorage; + +const { loadCollapsedGroups, isGroupCollapsed, setGroupCollapsed } = + await import('../src/lib/collapsedGroups.ts'); + +const KEY = 'kromacut.ui.collapsedGroups.v1'; + +test('groups default to expanded when nothing is stored', () => { + mockLocalStorage.clear(); + assert.equal(isGroupCollapsed('quantization'), false); + assert.deepEqual(loadCollapsedGroups(), {}); +}); + +test('collapsing a group persists it', () => { + mockLocalStorage.clear(); + setGroupCollapsed('quantization', true); + assert.equal(isGroupCollapsed('quantization'), true); + assert.equal(isGroupCollapsed('adjustments'), false); +}); + +test('expanding a group removes it from storage', () => { + mockLocalStorage.clear(); + setGroupCollapsed('quantization', true); + setGroupCollapsed('quantization', false); + assert.equal(isGroupCollapsed('quantization'), false); + assert.equal(mockLocalStorage.getItem(KEY), '{}'); +}); + +test('multiple groups persist independently', () => { + mockLocalStorage.clear(); + setGroupCollapsed('adjustments', true); + setGroupCollapsed('dedither', true); + setGroupCollapsed('adjustments', false); + assert.deepEqual(loadCollapsedGroups(), { dedither: true }); +}); + +test('corrupt stored JSON falls back to expanded', () => { + mockLocalStorage.clear(); + mockLocalStorage.setItem(KEY, 'not json{'); + assert.deepEqual(loadCollapsedGroups(), {}); + assert.equal(isGroupCollapsed('quantization'), false); +}); + +test('non-object stored JSON falls back to expanded', () => { + mockLocalStorage.clear(); + mockLocalStorage.setItem(KEY, '["quantization"]'); + assert.deepEqual(loadCollapsedGroups(), {}); +}); + +test('non-boolean entries are dropped on load', () => { + mockLocalStorage.clear(); + mockLocalStorage.setItem(KEY, '{"quantization":true,"weird":"yes","n":1}'); + assert.deepEqual(loadCollapsedGroups(), { quantization: true }); +}); + +test('setGroupCollapsed repairs corrupt storage instead of throwing', () => { + mockLocalStorage.clear(); + mockLocalStorage.setItem(KEY, 'not json{'); + setGroupCollapsed('swatches', true); + assert.deepEqual(loadCollapsedGroups(), { swatches: true }); +}); From 2671f0f80edca5b94821b9c095a1fa83734d086b Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Fri, 10 Jul 2026 10:34:55 +0300 Subject: [PATCH 62/81] feat: 3D inspection view modes - The 3D preview toolbar now offers Shaded, Transparent, and Wireframe modes for inspecting surfaces, layers, and mesh edges. Wireframe uses thin, layer-colored feature edges, and the selected view is remembered without changing print settings or STL/3MF export content. --- CHANGELOG.md | 3 +- src/App.tsx | 11 ++ src/components/PreviewActions.tsx | 81 ++++++++ src/components/ThreeDView.tsx | 167 +++++++++++++++- src/docs/3d-mode.md | 1 + src/hooks/useThreeScene.ts | 11 +- src/lib/meshing.ts | 306 +++++++++++++++++++++++++--- src/lib/previewPrefs.ts | 24 +++ src/lib/previewRenderMode.ts | 189 ++++++++++++++++++ src/lib/previewWireframe.ts | 317 ++++++++++++++++++++++++++++++ src/types/index.ts | 3 + tests/export3mf.test.ts | 46 +++++ tests/meshing.test.ts | 90 +++++++++ tests/previewPrefs.test.ts | 51 +++++ tests/previewRenderMode.test.ts | 109 ++++++++++ tests/previewWireframe.test.ts | 196 ++++++++++++++++++ 16 files changed, 1570 insertions(+), 35 deletions(-) create mode 100644 src/lib/previewPrefs.ts create mode 100644 src/lib/previewRenderMode.ts create mode 100644 src/lib/previewWireframe.ts create mode 100644 tests/previewPrefs.test.ts create mode 100644 tests/previewRenderMode.test.ts create mode 100644 tests/previewWireframe.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66079d2..0c66870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to Kromacut are documented in this file. - **Auto-paint test and benchmark coverage** - Added deterministic stack goldens, layer-invariant regression coverage, realized CIEDE2000 quality-budget tests, and an on-demand benchmark for fixture profiles using the same printable-stack mapper as the optimizer. - **Filament profile import fixture** - Added a schema-v2 frontlit-calibrated 8-color `.kfil` fixture with import coverage to guard against accidental hiding-distance rescaling. - **Collapsible control groups** - Every settings card in 2D and 3D mode (Adjustments, Resize Image, Dedither, Quantization Settings, Image colors, 3D Print Settings, Auto-paint, Color Slice Heights, and Print Instructions) can now be collapsed from its header to save sidebar space. Collapsed groups are remembered across sessions, and important indicators stay visible while collapsed: color counts, busy spinners, modified-settings dots, unsaved-profile markers, and quick actions like reset and copy. +- **3D inspection view modes** - The 3D preview toolbar now offers Shaded, Transparent, and Wireframe modes for inspecting surfaces, layers, and mesh edges. Wireframe uses thin, layer-colored feature edges, and the selected view is remembered without changing print settings or STL/3MF export content. - **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. ### Changed @@ -63,7 +64,7 @@ All notable changes to Kromacut are documented in this file. - **Project license** - Kromacut is now licensed under `AGPL-3.0-only` instead of MIT so redistributed or hosted modified versions must stay open under the same copyleft terms - **Web metadata** - Improved the page title, search description, canonical URL, social preview tags, and web app manifest metadata for hosted Kromacut pages - **Filament profile extension** - Auto-paint filament profile exports now use `.kfil` by default while continuing to import legacy `.kapp` files -- **Smooth meshing performance** - Smooth mesh generation now uses one fast welded-grid algorithm with deterministic boundary-chain smoothing in a bounded sub-pixel envelope and fan-triangulated caps instead of contour tracing and cap cleanup, avoiding hangs and browser memory blowups on complex image layers +- **Smooth meshing performance** - Smooth mesh generation now uses one fast welded-grid algorithm with deterministic boundary-chain smoothing in a bounded sub-pixel envelope and boundary-preserving caps validated at slicer precision, avoiding inverted preview faces, open exports, hangs, and browser memory blowups on complex image layers ### Fixed diff --git a/src/App.tsx b/src/App.tsx index c2c3187..47320ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { AUTO_PAINT_TRANSITION_OPACITIES, type AutoPaintRepeatLimit, type AutoPaintTransitionOpacity, + type PreviewRenderMode, type ThreeDControlsStateShape, } from './types'; import ThreeDView from './components/ThreeDView'; @@ -41,6 +42,7 @@ import ProgressOverlay from './components/ProgressOverlay'; import DocsPage from './components/docs/DocsPage'; import { defaultDocSlug } from './docs'; import { loadCameraMode, saveCameraMode } from './lib/cameraPrefs'; +import { loadPreviewRenderMode, savePreviewRenderMode } from './lib/previewPrefs'; import { buildDocsPath, parseDocsLocation } from './lib/docs/navigation'; import { applyHomeSeo } from './lib/seo'; import { migrateLegacyFilamentTd, sanitizeProfileFilament } from './lib/profileManager'; @@ -252,6 +254,9 @@ function App(): React.ReactElement | null { const [mode, setMode] = useState<'2d' | '3d'>('2d'); const [docsOpen, setDocsOpen] = useState(() => parseDocsLocation(window.location) !== null); const [isOrtho, setIsOrtho] = useState(loadCameraMode); + const [previewRenderMode, setPreviewRenderMode] = useState( + loadPreviewRenderMode + ); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 const [exportStep, setExportStep] = useState({ @@ -738,6 +743,7 @@ function App(): React.ReactElement | null { smoothMeshing={builtModelState.smoothMeshing} isOrtho={isOrtho} flatPaint={builtFlatPaint} + previewRenderMode={previewRenderMode} /> {exportingSTL && ( { + setPreviewRenderMode(next); + savePreviewRenderMode(next); + }} onToggleCamera={() => setIsOrtho((v) => { const next = !v; diff --git a/src/components/PreviewActions.tsx b/src/components/PreviewActions.tsx index fbe504b..7b281db 100644 --- a/src/components/PreviewActions.tsx +++ b/src/components/PreviewActions.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { PreviewRenderMode } from '@/types'; import { RotateCcw, RotateCw, @@ -16,6 +17,8 @@ import { FileType, Box, Camera, + Check, + Eye, } from 'lucide-react'; export interface PreviewActionsProps { @@ -42,6 +45,8 @@ export interface PreviewActionsProps { flatPaintModel?: boolean; isOrtho?: boolean; onToggleCamera?: () => void; + previewRenderMode?: PreviewRenderMode; + onPreviewRenderModeChange?: (mode: PreviewRenderMode) => void; } export const PreviewActions: React.FC = ({ @@ -67,9 +72,85 @@ export const PreviewActions: React.FC = ({ flatPaintModel = false, isOrtho = false, onToggleCamera, + previewRenderMode = 'shaded', + onPreviewRenderModeChange, }) => { + const [previewModeMenuOpen, setPreviewModeMenuOpen] = React.useState(false); + const PreviewModeIcon = + previewRenderMode === 'wireframe' + ? Grid3x3 + : previewRenderMode === 'transparent' + ? Eye + : Box; + const previewRenderModeLabel = + previewRenderMode === 'wireframe' + ? 'Wireframe' + : previewRenderMode === 'transparent' + ? 'Transparent' + : 'Shaded'; + const previewModeOptions: Array<{ + value: PreviewRenderMode; + label: string; + icon: React.ReactNode; + }> = [ + { value: 'shaded', label: 'Shaded', icon: }, + { value: 'transparent', label: 'Transparent', icon: }, + { value: 'wireframe', label: 'Wireframe', icon: }, + ]; + return (
    + {mode === '3d' && onPreviewRenderModeChange && ( + + + + + +
    + {previewModeOptions.map(({ value, label, icon }) => { + const selected = previewRenderMode === value; + return ( + + ); + })} +
    +
    +
    + )} {mode === '3d' && onToggleCamera && ( + )} {mode === '3d' && onToggleCamera && ( + + +
    +