diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..94430f8a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+tests/assets/performance/**/*.3mf filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 53b9a3a7..da67f0e0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -98,7 +98,7 @@ jobs:
**macOS:**
1. Download the .dmg file for your Mac:
- Apple Silicon (M1/M2/M3): *_aarch64.dmg
- - Intel: *_x86_64.dmg
+ - Intel: *_x64.dmg
2. Open the DMG and drag Kromacut to Applications
3. Remove quarantine (required for unsigned apps):
sudo xattr -d com.apple.quarantine /Applications/Kromacut.app
diff --git a/.gitignore b/.gitignore
index 4cbec2b1..a8a2423e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,7 @@ test-results/
src-tauri/target/
src-tauri/gen/
src-tauri/WixTools/
+
+# Agent workspace files
+.agents/
+.profiles/
diff --git a/AGENTS.md b/AGENTS.md
index 03796648..370205af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,9 +13,13 @@ 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
+ (introduced in profile schema v2; v3 adds appearance evidence); 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 396752af..2908e32d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,57 @@
All notable changes to Kromacut are documented in this file.
+## v4.0.0 - 2026-09-10
+
+### Upgrade notes
+
+- **Back up important filament profiles before upgrading.** Legacy photo-based opacity-calibration records are no longer used and are removed on load; affected filaments should be recalibrated with the new wedge workflow. This does not refer to the new Stack Matrix photo measurements.
+- **Regenerate Auto-paint results before printing.** The updated optical model and layer planning can change predicted colors, stack heights, and swap plans. Check the new instructions against your slicer settings.
+- **TD is now frontlit Hiding Distance (HD).** Older uncalibrated values are converted once on load or import. Conventional backlit/lithophane TD remains an accepted input and is converted to HD; do not manually rescale already-migrated profiles.
+- **Preview colors remain predictions, not guaranteed print matches.** Calibration evidence and confidence help identify better-supported estimates, but results still depend on the filament, printing conditions, and lighting.
+
+### Added
+
+- **Stack Matrix calibration** - Print many fixed-depth filament recipes on one board, then photograph and align the result to measure their colors. Choose 2–8 filaments, recipe depth, capacity, and backing; Kromacut prints every combination that fits or selects a color-diverse subset using the HD model. The photo tools include orientation markers, rotation, precision alignment, a projected grid, and corrected previews, with explicit review for uncertain alignment and optional reference-marker correction.
+ Compatible matrices supply measured colors and bounded estimates for nearby recipes. Broader optical fitting must improve held-out measurements; unsupported combinations retain conservative estimates. Derived fitting leaves saved HD values, raw measurements, and physical export colors unchanged. Same-ID legacy imports cannot erase newer appearance evidence.
+ Saved boards remain attached to their owning profile and retain their recipes, regular and first-layer heights, and measurements. New boards follow the current 3D print settings. Plans survive restarts and can be re-downloaded; completed boards can be rephotographed or deleted. Desktop exports use Save As, and cancelled or failed saves do not register a new plan. Calibration 3MFs sanitize invalid characters in filament names.
+- **Palette Proofs** - Export a job-specific 3MF of reachable candidate stacks for selected image colors. Record the closest patch, ties, or **None**, with **Best available**, **Close**, or **Dead on** match quality. Draft results persist across restarts. **Continue targets** tests nearby untried challengers; **New targets** explores other colors, without padding exhausted rows with unrelated candidates. Proofs retain their original settings and can be reopened, re-downloaded while compatible, or deleted. Judgments provide bounded appearance corrections without changing HD calibration or physical filament assignments; broader fits require held-out improvement.
+- **Preserve color separation** - Enhanced matching can assign distinct image colors to distinct printable colors within a hard predicted ΔE limit. Strict mode rejects incomplete results; otherwise unmatched colors merge into valid surviving matches. Results distinguish **Exact optimum**, **Best found**, and **Partial palette**, report preserved colors and repeats, and block printing when no valid mapping exists. The search removes unnecessary filament repetitions. This option and Height dithering are mutually exclusive.
+- **Prediction confidence** - Printable colors report whether they come from exact recipe measurements, interpolation, fitting, or simulation. Matching considers evidence strength alongside predicted color error without weakening the separation limit or overriding Dead-on anchors.
+- **Printable feature-size preview** - Inspect detail at the selected effective extrusion width. The At-risk/Printable preview shows narrow regions and likely neighboring-color takeover. Optional **Omit at-risk colors from matching** applies that substitution throughout matching, preview, and export; isolated regions without a defensible replacement retain their original color. The same line-width setting controls height-dither block size. This is a printability estimate, not a substitute for checking the slicer.
+- **2D touch-up tools** - Added palette-safe Brush, Eraser, Fill, Text, and color picking. Text can be moved, resized, and wrapped; edits support undo while image adjustments remain non-destructive.
+- **3D inspection and color modes** - Added Color accurate, Shaded, Transparent, and Wireframe views, plus a Simulated/Physical filament-color toggle. Color accurate displays the selected swatches without lighting or tone-mapping changes; it does not guarantee a physical color match. These remembered display options never change exported geometry or materials.
+- **Carrier-free Flat Paint** - The experimental Flat Paint workflow now supports face-up prints without a clear carrier layer. Normal color-stack order is retained over backing, with visible blends aligned at the flat top surface and no artwork mirroring. Preview, exports, and instructions follow the selected orientation.
+- **Landing page and web routing** - Added a responsive landing page at `/` and moved the browser tool to `/app`, preserving same-origin saved data and `/docs/...` links. Includes returning-user launch preferences, themes, desktop downloads, search metadata, and direct-route support. Settings provides a direct link to documentation.
+- **Community showcase** - Six responsive project cards combine sixteen preview, slicer, and finished-print images: King of Hearts, Hobbits and Dragons, HOPE, Titan, Batmanga, and Naruto. Captions distinguish predictions from physical results and include creator credits, available source links, and full-size views.
+- **Collapsible controls** - Settings groups in 2D and 3D can collapse while retaining important status and quick actions. Their state is remembered.
+- **Filament templates and palette tools** - Added unofficial Bambu Lab PLA Basic supplier palettes and read-only filament templates with estimated HD values. Templates can be saved as editable profiles. Built-in, supplier, and custom palettes can be cloned; custom colors support names and persistent enable/disable flags. Older palette files remain compatible, and migration to the expanded built-in catalog retains custom palette and profile selections.
+- **HueForge spool import** - Import CSV or TSV spool libraries with flexible columns, quoted fields, multiline values, and a UTF-8 BOM. Names, brands, colors, and spool UUIDs are retained, with duplicate UUIDs made independently editable.
+- **Calibration documentation and community links** - Added an illustrated guide to HD, wedge calibration, Palette Proofs, and Stack Matrices, plus Reddit links in Settings and the README.
+- **Experimental multi-plate switch** - Added a remembered preview toggle and animation. It is explicitly unfinished and does **not** split images or change generated prints.
+- **Developer diagnostics and validation** - Optional desktop recording captures Auto-paint settings, evidence, progress, and final stacks without the source image or changes to optimizer selection. It is off by default. Offline tools create frozen diagnostic print bundles, replay predictions against preserved recipes, and evaluate withheld recipes, filament pairs, and boards. Reports distinguish model estimates from photographed evidence and disclose shared-photo limitations; these tools do not modify saved calibration.
+- **Regression and performance coverage** - Added deterministic stack fixtures, printable-layer and export checks, prediction-consistency and held-out-validation tests, reproducible optimizer benchmarks, and browser coverage for calibration, persistence, exports, and the landing page. Color-quality tests assess predicted colors at printable layer heights, not independently measured print accuracy.
+
+### Changed
+
+- **Frontlit wedge calibration and HD** - Replaced the legacy photo-based opacity workflow with camera-free printed wedges and adjacent opaque reference rails. Quick reads measure scalar HD; additional bases refine a constrained channel estimate. Partial sessions save only completed filaments, and editing a calibrated swatch temporarily disables its calibration without deleting it. STL and multi-material 3MF exports retain the settings used to print the wedge. HD storage semantics were introduced in profile schema v2 and are retained in v3, which also preserves validated appearance evidence.
+- **Auto-paint optical model** - Blending now uses linear-light sRGB and substrate-aware HD estimates. Wedge refinements remain local to tested bases; compatible Stack Matrix evidence can support richer fits within measured thickness ranges. Interpolation and measured-prefix corrections fade outside their support, with conservative estimates used beyond it.
+- **Auto-paint matching and search** - Scoring, preview, and export now share the final layer-snapped, height-limited stack. Only results matching the current layer settings are used for slicing. Matching evaluates predicted CIEDE2000 color error at printable heights, considers poorly matched colors and prediction confidence, and reuses the selected layers in the preview. New Fast, Balanced, Thorough, Deep, and Exact effort tiers replace older algorithm choices, with repeat limits, transition detail, stable seeds, deterministic beam-search tie-breaking, and search progress. Saved algorithm selections migrate to the nearest tier.
+- **Performance** - Reduced startup, 3D-tab, calibration, and optimizer overhead through worker offload, deferred preparation, bounded caches, and lower-allocation processing. Completed work is reused when inputs are unchanged, while deterministic stack and export results are preserved.
+- **Results display** - Transition Zones includes a proportional stack bar and compact height rows. Calibration confidence and optimizer status are easier to scan.
+- **Height dithering** - Replaced Floyd-Steinberg with an error-conserving Stucki kernel while retaining block sizing and edge protection.
+- **Dependency security updates** - Updated Tauri to 2.11.1 to address a Windows IPC origin-confusion issue, alongside Rust dependency and development-tooling updates. The production npm audit is clear; remaining npm findings are confined to development tooling.
+
+### Fixed
+
+- **Desktop file exports** - Filament-profile `.kfil` exports use Save As, handle cancellation without reporting success, and surface write errors. Large 3MF exports stream XML in bounded chunks to avoid desktop WebView read and string-size failures.
+- **Profile and palette persistence** - Failed storage writes no longer report success or replace working state. Imports validate hiding distances and duplicate filament IDs; legacy stored ID collisions are repaired without dropping filament rows, while ambiguous appearance evidence is discarded.
+- **Remembered print settings** - Saved Auto-paint settings load before persistence can replace them with defaults. Unsaved filament edits remain authoritative, Max Height and wedge layer height are remembered, and Reset Print Settings also resets Smooth Meshing.
+- **Max Height and foundation opacity** - Height caps round down to printable boundaries; compression and trimming preserve the foundation's opacity minimum, and impossible foundations are rejected.
+- **Auto-paint edge cases** - Center and Edge priority use actual image regions. Blank seeds, cache inputs, zero-HD limits, and the 500-layer ceiling behave consistently.
+- **Crop controls** - Crop overlays no longer block Save, Cancel, or the image-size controls.
+- **Smooth 3MF integrity** - Validate and, where needed, retriangulate smoothed faces at export precision to prevent pinhole open or non-manifold edges in large exports. STL exports were unaffected.
+
## v3.1.0 - 2026-06-18
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..eef4bd20
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
\ No newline at end of file
diff --git a/README.md b/README.md
index 053e9066..507a2825 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,15 @@
# Kromacut
-[](https://www.patreon.com/cw/vycdev) [](https://discord.gg/nU63sFMcnX) [](https://www.youtube.com/@vycdev) [](https://github.com/vycdev/Kromacut/releases/latest) [](https://github.com/vycdev/Kromacut) [](https://github.com/vycdev/Kromacut/releases) [](https://github.com/vycdev/Kromacut/releases/latest)
+[](https://www.patreon.com/cw/vycdev) [](https://discord.gg/nU63sFMcnX) [](https://www.reddit.com/r/kromacut/) [](https://www.youtube.com/@vycdev) [](https://github.com/vycdev/Kromacut/releases/latest) [](https://github.com/vycdev/Kromacut) [](https://github.com/vycdev/Kromacut/releases) [](https://github.com/vycdev/Kromacut/releases/latest)
Open-source HueForge-style tool for converting images into stacked, color-layered 3D prints.
+**Try Kromacut in your browser:** [landing page](https://kromacut.com/) · [open the app](https://kromacut.com/app) · [read the docs](https://kromacut.com/docs/overview) · [download desktop releases](https://github.com/vycdev/Kromacut/releases)
+
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 + 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.
@@ -26,7 +28,7 @@ A quick look at what Kromacut produces from a source image to a printable model:
| Sliced by Colors | Real Print |
| | |
-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 |
|---|
@@ -44,8 +46,8 @@ Another minimal test you can try yourself in the app header: the Transmission Di
- 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.
-- **Advanced optimizer** — Simulated annealing and genetic algorithms find optimal filament ordering for complex multi-color prints.
+- **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.
## Notable implementation details
@@ -64,9 +66,9 @@ Another minimal test you can try yourself in the app header: the Transmission Di
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.
-4. Enable **Enhanced color matching** for optimal results (auto-selects best algorithm).
+ - 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.
7. Export via **Download STL** or **Download 3MF** and follow the print plan.
@@ -107,45 +109,51 @@ 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)`. This physically models the color you see when printing thin semi-transparent layers on top of each other.
-- **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.
+- **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.
+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
-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** | 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. |
+| **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
@@ -154,7 +162,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
@@ -164,7 +172,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.
@@ -173,14 +181,18 @@ 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. |
-| **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. |
+| **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. |
+| **Preserve color separation** | (Requires Enhanced color matching) Treats the configurable ΔE value as a hard eligibility limit for distinct printable colors. The optimizer searches repeat tiers progressively and ranks candidates by preserved-color count, source coverage, preserved-match error, repeats, then stack length. Strict mode rejects a result unless every 2D color receives a unique in-limit match; non-strict mode drops unmatched colors by merging their regions into surviving preserved mappings. Mutually exclusive with Height dithering. |
+| **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. Mutually exclusive with Preserve color separation. |
| **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 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
@@ -190,7 +202,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.
@@ -214,7 +226,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. |
@@ -226,9 +238,9 @@ 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.
-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.
+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.
7. Use the **layer-by-layer preview slider** at the bottom of the 3D view to verify color transitions.
@@ -242,18 +254,20 @@ Kromacut now supports exporting directly to `.3mf` format. This file format pres
-## Transmission Distance (TD) — what it is and how to use it here
+## Hiding Distance (HD) and Transmission Distance (TD)
+
+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
-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
+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.
-- **Advanced optimizer** — Simulated annealing and genetic algorithms find the best filament ordering for complex images.
+- **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.
See the [Auto-paint section](#auto-paint) above for full details.
@@ -265,17 +279,17 @@ 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)
- 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/content/community/batmanga-finished.jpg b/content/community/batmanga-finished.jpg
new file mode 100644
index 00000000..4c1603c2
Binary files /dev/null and b/content/community/batmanga-finished.jpg differ
diff --git a/content/community/batmanga-kromacut-preview.png b/content/community/batmanga-kromacut-preview.png
new file mode 100644
index 00000000..3ca53ed2
Binary files /dev/null and b/content/community/batmanga-kromacut-preview.png differ
diff --git a/content/community/batmanga-slicer-preview.png b/content/community/batmanga-slicer-preview.png
new file mode 100644
index 00000000..9f3641a2
Binary files /dev/null and b/content/community/batmanga-slicer-preview.png differ
diff --git a/content/community/candidates/batmanga-2026-09-06/README.md b/content/community/candidates/batmanga-2026-09-06/README.md
new file mode 100644
index 00000000..f2c689e8
--- /dev/null
+++ b/content/community/candidates/batmanga-2026-09-06/README.md
@@ -0,0 +1,17 @@
+# Batmanga showcase candidate
+
+Saved at the user's request on 2026-09-06 before printing. The user subsequently supplied `20260906_214317.jpg` and approved adding the finished print with both screenshots to the showcase. The finished photo was then replaced with the user's cropped version from `D:/Downloads/20260906_214317.jpg`, copied without further edits. Exact copies are included at `content/community/batmanga-kromacut-preview.png`, `batmanga-slicer-preview.png`, and `batmanga-finished.jpg`. The archived screenshots remain unchanged; the uncropped photo remains at its original camera-file location.
+
+- `batmanga-kromacut-preview.png`: original Kromacut screenshot, copied without resizing or editing.
+- `batmanga-slicer-preview.png`: original Creality Print screenshot, copied without resizing or editing.
+
+Context visible in the screenshots:
+
+- Artwork title: Batman: The Jiro Kuwata Batmanga, volume 1. Credit: Jiro Kuwata / DC; [source image supplied by the user](https://m.media-amazon.com/images/I/81rOZq5ZgqL._AC_UF1000,1000_QL80_.jpg). This replaces the previously linked DC book page, which was not the user's image source. Print and screenshots by vycdev.
+- Kromacut model display: approximately 65.8 × 100.0 × 3.7 mm; transition summary shows 3.68 mm and 42 physical layers.
+- Deep optimizer, enhanced matching and Preserve color separation enabled; height dithering and Flat Paint disabled.
+- Creality Hi with a 0.4 mm nozzle; slicer layer height 0.08 mm and first-layer height 0.16 mm.
+- Slicer estimates: 3 h 12 min, 21.30 g total filament, eight filament changes. These are estimates, not measured print results.
+- Slicer screenshot is shown at layer 41 / Z 3.36 mm. This is the displayed preview position, not a confirmed total model height.
+
+Keep the two previews distinguishable from the eventual physical-print photograph. No color-accuracy conclusion has been drawn from these screenshots.
diff --git a/content/community/candidates/batmanga-2026-09-06/batmanga-kromacut-preview.png b/content/community/candidates/batmanga-2026-09-06/batmanga-kromacut-preview.png
new file mode 100644
index 00000000..3ca53ed2
Binary files /dev/null and b/content/community/candidates/batmanga-2026-09-06/batmanga-kromacut-preview.png differ
diff --git a/content/community/candidates/batmanga-2026-09-06/batmanga-slicer-preview.png b/content/community/candidates/batmanga-2026-09-06/batmanga-slicer-preview.png
new file mode 100644
index 00000000..9f3641a2
Binary files /dev/null and b/content/community/candidates/batmanga-2026-09-06/batmanga-slicer-preview.png differ
diff --git a/content/community/candidates/naruto-2026-09-06/README.md b/content/community/candidates/naruto-2026-09-06/README.md
new file mode 100644
index 00000000..562f5ac8
--- /dev/null
+++ b/content/community/candidates/naruto-2026-09-06/README.md
@@ -0,0 +1,18 @@
+# Naruto showcase candidate
+
+Saved at the user's request on 2026-09-06 before a planned overnight print. The user was unsure whether the calibration supported this image well or whether the prediction would be accurate. On 2026-09-07 the user supplied `D:/Downloads/20260907_080653.jpg` and approved adding all three views to the showcase. Exact copies are included at `content/community/naruto-kromacut-preview.png`, `naruto-slicer-preview.png`, and `naruto-finished.jpg`. All images were copied without editing; the archived previews remain unchanged.
+
+- `naruto-kromacut-preview.png`: original Kromacut screenshot, copied without resizing or editing.
+- `naruto-slicer-preview.png`: original Creality Print screenshot, copied without resizing or editing.
+
+Visible context:
+
+- Naruto looking upward against a blue sky in the Kromacut appearance preview. The user supplied this [Pinterest pin](https://in.pinterest.com/pin/169870217190172931/) as the artwork source; the original artist has not been verified.
+- Kromacut model display: approximately 110.4 × 190.2 × 4.1 mm; layer preview endpoint 4.08 mm.
+- Pixel size 0.15 mm, layer height 0.08 mm, first-layer height 0.40 mm, Smooth Meshing enabled.
+- Selected profile: 8 Colors Calibrated Frontlit. Profile ratings shown in the screenshot do not establish prediction accuracy for this particular stack.
+- Creality Hi with a 0.4 mm nozzle. Slicer layer height 0.08 mm and first-layer height 0.16 mm. The first-layer values differ between screenshots; this is recorded without diagnosing its effect.
+- Slicer estimates: 5 h 55 min, 72.10 g total filament, nine filament changes; seven listed filaments. These are estimates, not completed-print measurements.
+- Slicer preview is displayed at layer 50 / Z 4.08 mm.
+
+The showcase distinguishes the Kromacut appearance prediction, slicer's filament-color preview, and finished print without claiming measured color accuracy. Print and screenshots by vycdev. The footer links the user's Pinterest source without attributing the underlying artwork to the print maker or guessing the original artist.
diff --git a/content/community/candidates/naruto-2026-09-06/naruto-kromacut-preview.png b/content/community/candidates/naruto-2026-09-06/naruto-kromacut-preview.png
new file mode 100644
index 00000000..8060c37f
Binary files /dev/null and b/content/community/candidates/naruto-2026-09-06/naruto-kromacut-preview.png differ
diff --git a/content/community/candidates/naruto-2026-09-06/naruto-slicer-preview.png b/content/community/candidates/naruto-2026-09-06/naruto-slicer-preview.png
new file mode 100644
index 00000000..f45c3052
Binary files /dev/null and b/content/community/candidates/naruto-2026-09-06/naruto-slicer-preview.png differ
diff --git a/content/community/candidates/titan-2026-09-06/README.md b/content/community/candidates/titan-2026-09-06/README.md
new file mode 100644
index 00000000..e9bd83ca
--- /dev/null
+++ b/content/community/candidates/titan-2026-09-06/README.md
@@ -0,0 +1,20 @@
+# Titan showcase candidate
+
+Saved at the user's request on 2026-09-06. The user subsequently approved adding a Titan showcase with these screenshots and selected `20260906_172702.jpg` as the replacement finished-print photo. Exact copies are included as `content/community/titan-kromacut-preview.png`, `titan-slicer-preview.png`, and `titan-finished.jpg`; these archived screenshots remain unchanged. The previous photo `20260906_170622.jpg` remains at its original camera-file location.
+
+- `titan-kromacut-preview.png`: original Kromacut screenshot, copied without resizing or editing.
+- `titan-slicer-preview.png`: original Creality Print screenshot, copied without resizing or editing.
+
+Context visible in the screenshots:
+
+- Titan artwork; Kromacut model dimensions displayed as approximately 102.4 × 152.7 × 2.6 mm.
+- Regular layer height: 0.08 mm in both screenshots.
+- First-layer settings differ: Kromacut shows 0.40 mm; slicer shows 0.16 mm. Retained as observed, without interpreting the effect on the exported geometry.
+- Slicer estimates: 2 h 58 min, 28.90 g total filament, six filament changes. These are estimates, not completed-print measurements.
+
+Original artwork: [NASA/JPL — Titan, Visions of the Future](https://www.jpl.nasa.gov/images/titan-jpl-travel-poster/). Print and screenshots by vycdev. The landing page labels each view separately and makes no measured color-accuracy claim. The selected finished photograph is retained without image edits; the earlier washed-out phone photo is not used.
+
+Verified original-file SHA-256 hashes:
+
+- Kromacut: `735f5066a77e62a72687997c5790d6f8480430e30719a5385da444e5c287c48d`
+- Slicer: `23054e0ebddd9b0a37b84b87bf92a47db09e5ef479f5f8e52ce0d51635be52cd`
diff --git a/content/community/candidates/titan-2026-09-06/titan-kromacut-preview.png b/content/community/candidates/titan-2026-09-06/titan-kromacut-preview.png
new file mode 100644
index 00000000..d2b4f8a5
Binary files /dev/null and b/content/community/candidates/titan-2026-09-06/titan-kromacut-preview.png differ
diff --git a/content/community/candidates/titan-2026-09-06/titan-slicer-preview.png b/content/community/candidates/titan-2026-09-06/titan-slicer-preview.png
new file mode 100644
index 00000000..05f45e7a
Binary files /dev/null and b/content/community/candidates/titan-2026-09-06/titan-slicer-preview.png differ
diff --git a/content/community/hobbits-and-dragons-1.jpg b/content/community/hobbits-and-dragons-1.jpg
new file mode 100644
index 00000000..0b998412
Binary files /dev/null and b/content/community/hobbits-and-dragons-1.jpg differ
diff --git a/content/community/hobbits-and-dragons-2.jpg b/content/community/hobbits-and-dragons-2.jpg
new file mode 100644
index 00000000..b87d9b16
Binary files /dev/null and b/content/community/hobbits-and-dragons-2.jpg differ
diff --git a/content/community/hope-finished.jpg b/content/community/hope-finished.jpg
new file mode 100644
index 00000000..bfcfae35
Binary files /dev/null and b/content/community/hope-finished.jpg differ
diff --git a/content/community/hope-kromacut-preview.jpg b/content/community/hope-kromacut-preview.jpg
new file mode 100644
index 00000000..d2364728
Binary files /dev/null and b/content/community/hope-kromacut-preview.jpg differ
diff --git a/content/community/hope-slicer-preview.jpg b/content/community/hope-slicer-preview.jpg
new file mode 100644
index 00000000..dd4695aa
Binary files /dev/null and b/content/community/hope-slicer-preview.jpg differ
diff --git a/content/community/king-of-hearts-slicer.jpg b/content/community/king-of-hearts-slicer.jpg
new file mode 100644
index 00000000..5d615b34
Binary files /dev/null and b/content/community/king-of-hearts-slicer.jpg differ
diff --git a/content/community/king-of-hearts.jpg b/content/community/king-of-hearts.jpg
new file mode 100644
index 00000000..ef8aa1ed
Binary files /dev/null and b/content/community/king-of-hearts.jpg differ
diff --git a/content/community/naruto-finished.jpg b/content/community/naruto-finished.jpg
new file mode 100644
index 00000000..05daf552
Binary files /dev/null and b/content/community/naruto-finished.jpg differ
diff --git a/content/community/naruto-kromacut-preview.png b/content/community/naruto-kromacut-preview.png
new file mode 100644
index 00000000..8060c37f
Binary files /dev/null and b/content/community/naruto-kromacut-preview.png differ
diff --git a/content/community/naruto-slicer-preview.png b/content/community/naruto-slicer-preview.png
new file mode 100644
index 00000000..f45c3052
Binary files /dev/null and b/content/community/naruto-slicer-preview.png differ
diff --git a/content/community/titan-finished.jpg b/content/community/titan-finished.jpg
new file mode 100644
index 00000000..14355646
Binary files /dev/null and b/content/community/titan-finished.jpg differ
diff --git a/content/community/titan-kromacut-preview.png b/content/community/titan-kromacut-preview.png
new file mode 100644
index 00000000..d2b4f8a5
Binary files /dev/null and b/content/community/titan-kromacut-preview.png differ
diff --git a/content/community/titan-slicer-preview.png b/content/community/titan-slicer-preview.png
new file mode 100644
index 00000000..05f45e7a
Binary files /dev/null and b/content/community/titan-slicer-preview.png differ
diff --git a/index.html b/index.html
index 10f08e30..fe4e9bb4 100644
--- a/index.html
+++ b/index.html
@@ -59,6 +59,34 @@
Kromacut - Free Image-to-3D Color Layer Print Generator
+
diff --git a/package-lock.json b/package-lock.json
index e26282c8..3e278e0c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "kromacut",
- "version": "3.1.0",
+ "version": "4.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kromacut",
- "version": "3.1.0",
+ "version": "4.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -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",
@@ -61,13 +62,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 +77,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 +87,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 +118,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 +135,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 +152,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 +162,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 +204,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 +214,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 +224,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 +234,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 +296,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 +330,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 +418,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 +435,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 +452,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 +469,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 +486,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 +503,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 +520,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 +537,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 +554,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 +571,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 +588,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 +605,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 +622,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 +639,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 +656,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 +673,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 +690,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 +707,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 +724,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 +741,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 +758,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 +775,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 +792,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 +809,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 +826,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 +843,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"
],
@@ -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",
@@ -2123,9 +2324,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 +2338,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 +2352,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 +2366,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 +2380,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 +2394,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 +2408,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 +2422,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 +2436,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 +2450,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 +2464,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 +2478,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 +2492,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 +2506,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 +2520,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 +2534,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 +2548,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 +2562,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 +2576,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 +2590,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 +2604,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 +2618,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 +2632,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 +2646,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 +2660,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 +3323,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 +3594,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 +3604,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 +3739,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 +3906,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 +4237,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 +4250,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 +4634,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 +4945,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 +5440,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 +5460,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 +5698,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 +5758,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 +5778,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -5817,13 +6028,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 +6044,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 +6459,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/package.json b/package.json
index 9939a143..90368d2b 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,23 @@
{
"name": "kromacut",
"private": true,
- "version": "3.1.0",
+ "version": "4.0.0",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"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",
+ "benchmark:autopaint": "node --no-warnings --experimental-strip-types tests/benchmark/autoPaintBench.ts",
+ "benchmark:appearance": "node --no-warnings --experimental-strip-types scripts/validate-appearance.ts",
+ "diagnostics:print": "node --no-warnings --experimental-strip-types scripts/prepare-diagnostic-print.ts",
+ "benchmark:calibrated": "node --expose-gc --no-warnings --experimental-strip-types tests/benchmark/calibratedOptimizer.ts",
+ "benchmark:calibrated-exact": "node --expose-gc --no-warnings --experimental-strip-types tests/benchmark/calibratedOptimizer.ts",
+ "benchmark:calibrated-series": "node scripts/benchmark-calibrated-series.mjs",
+ "benchmark:hotpaths": "node --no-warnings --experimental-strip-types tests/benchmark/performanceHotPaths.ts",
+ "profile:calibrated": "node scripts/profile-calibrated.mjs",
+ "profile:compare": "node scripts/compare-calibrated-profiles.mjs",
"test:e2e": "playwright test --grep @smoke",
"test:e2e:matrix": "playwright test --grep @matrix",
"test:e2e:stress": "playwright test --grep @stress",
@@ -27,6 +37,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/public/site.webmanifest b/public/site.webmanifest
index e86f10b0..957d45a0 100644
--- a/public/site.webmanifest
+++ b/public/site.webmanifest
@@ -2,7 +2,8 @@
"name": "Kromacut",
"short_name": "Kromacut",
"description": "Turn 2D images into color-layered 3D prints for free and export STL or 3MF models.",
- "start_url": "/",
+ "start_url": "/app",
+ "id": "/",
"scope": "/",
"icons": [
{
diff --git a/public/version.json b/public/version.json
index 38026846..b46fe019 100644
--- a/public/version.json
+++ b/public/version.json
@@ -1,5 +1,5 @@
{
- "version": "3.1.0",
+ "version": "4.0.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": "Kromacut 4.0.0 adds Stack Matrix calibration, Palette Proofs, updated Auto-paint color prediction, 2D touch-up tools, and a new web experience. Back up filament profiles before upgrading: legacy photo-opacity calibrations require recalibration with the new wedge workflow. Regenerate Auto-paint results before printing and read the upgrade notes in the release changelog."
}
diff --git a/scripts/benchmark-calibrated-series.mjs b/scripts/benchmark-calibrated-series.mjs
new file mode 100644
index 00000000..1c7e6e33
--- /dev/null
+++ b/scripts/benchmark-calibrated-series.mjs
@@ -0,0 +1,211 @@
+import { spawn, spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { arch, cpus, platform, release } from 'node:os';
+import { resolve } from 'node:path';
+
+const caseName = process.argv[2] ?? 'cats';
+const variantName = process.argv[3] ?? 'baseline';
+const requestedCount = Number(process.argv[4] ?? 3);
+
+if (!/^[a-z0-9-]+$/.test(caseName)) throw new Error(`Invalid case name: ${caseName}`);
+if (!/^[a-z0-9-]+$/.test(variantName)) throw new Error(`Invalid variant name: ${variantName}`);
+if (!Number.isInteger(requestedCount) || requestedCount < 1 || requestedCount > 20) {
+ throw new Error(`Run count must be an integer from 1 to 20, received: ${process.argv[4]}`);
+}
+
+const timestamp = new Date()
+ .toISOString()
+ .replaceAll(':', '-')
+ .replace(/\.\d{3}Z$/, 'Z');
+const outputDirectory = resolve('.profiles', 'series', `${timestamp}-${caseName}-${variantName}`);
+mkdirSync(outputDirectory, { recursive: true });
+
+const runs = [];
+for (let index = 0; index < requestedCount; index++) {
+ process.stderr.write(
+ `Calibrated series ${caseName}/${variantName}: run ${index + 1}/${requestedCount}\n`
+ );
+ const payload = await runBenchmark(caseName, variantName);
+ runs.push(payload);
+ writeFileSync(
+ resolve(outputDirectory, `run-${String(index + 1).padStart(2, '0')}.json`),
+ `${JSON.stringify(payload, null, 2)}\n`
+ );
+}
+
+assertDeterministicResults(runs);
+
+const summary = {
+ schemaVersion: 1,
+ createdAt: new Date().toISOString(),
+ caseName,
+ variantName,
+ runCount: runs.length,
+ environment: benchmarkEnvironment(),
+ configuration: runs[0].configuration,
+ deterministicResult: runs[0].result,
+ timing: summarizeMeasurements(runs.map((run) => run.timing)),
+ memory: summarizeMeasurements(runs.map((run) => run.memory)),
+ runs: runs.map((run, index) => ({
+ run: index + 1,
+ timing: run.timing,
+ memory: run.memory,
+ })),
+};
+
+writeFileSync(resolve(outputDirectory, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`);
+writeFileSync(resolve(outputDirectory, 'summary.md'), renderMarkdown(summary));
+
+console.log(JSON.stringify({ outputDirectory, ...summary }, null, 2));
+
+function runBenchmark(selectedCase, selectedVariant) {
+ const args = [
+ '--expose-gc',
+ '--no-warnings',
+ '--experimental-strip-types',
+ 'tests/benchmark/calibratedOptimizer.ts',
+ selectedCase,
+ selectedVariant,
+ ];
+ return new Promise((resolveRun, rejectRun) => {
+ const child = spawn(process.execPath, args, {
+ cwd: process.cwd(),
+ env: process.env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.setEncoding('utf8');
+ child.stderr.setEncoding('utf8');
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+ child.on('error', rejectRun);
+ child.on('close', (code) => {
+ if (code !== 0) {
+ rejectRun(
+ new Error(`Calibrated benchmark exited with code ${code}.\n${stderr || stdout}`)
+ );
+ return;
+ }
+ try {
+ resolveRun(JSON.parse(stdout));
+ } catch (error) {
+ rejectRun(
+ new Error(
+ `Could not parse calibrated benchmark output: ${error.message}\n${stdout}\n${stderr}`
+ )
+ );
+ }
+ });
+ });
+}
+
+function benchmarkEnvironment() {
+ const cpuList = cpus();
+ const revision = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' });
+ const sourceDiff = spawnSync('git', ['diff', '--no-ext-diff', 'HEAD', '--', 'src'], {
+ encoding: 'utf8',
+ maxBuffer: 32 * 1024 * 1024,
+ });
+ return {
+ node: process.version,
+ platform: platform(),
+ release: release(),
+ architecture: arch(),
+ cpuModel: cpuList[0]?.model ?? null,
+ logicalCpuCount: cpuList.length,
+ gitRevision: revision.status === 0 ? revision.stdout.trim() : null,
+ sourceDiffSha256:
+ sourceDiff.status === 0
+ ? createHash('sha256').update(sourceDiff.stdout).digest('hex')
+ : null,
+ };
+}
+
+function assertDeterministicResults(results) {
+ const expected = stableResult(results[0].result);
+ for (let index = 1; index < results.length; index++) {
+ const actual = stableResult(results[index].result);
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ throw new Error(
+ `Run ${index + 1} changed the calibrated result.\nExpected ${JSON.stringify(expected)}\nReceived ${JSON.stringify(actual)}`
+ );
+ }
+ }
+}
+
+function stableResult(result) {
+ return {
+ score: result.score,
+ iterations: result.iterations,
+ converged: result.converged,
+ extraRepeatCount: result.extraRepeatCount,
+ orderColors: result.orderColors,
+ transitionZones: result.transitionZones,
+ physicalLayers: result.physicalLayers,
+ totalHeight: result.totalHeight,
+ finalStackFingerprint: result.finalStackFingerprint,
+ separation: result.separation,
+ };
+}
+
+function summarizeMeasurements(measurements) {
+ const keys = Object.keys(measurements[0]);
+ return Object.fromEntries(
+ keys.map((key) => {
+ const values = measurements.map((measurement) => measurement[key]);
+ return [
+ key,
+ {
+ median: median(values),
+ minimum: Math.min(...values),
+ maximum: Math.max(...values),
+ values,
+ },
+ ];
+ })
+ );
+}
+
+function median(values) {
+ const sorted = [...values].sort((left, right) => left - right);
+ const middle = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
+}
+
+function renderMarkdown(result) {
+ const milliseconds = (value) => `${value.toFixed(1)} ms`;
+ const mebibytes = (value) => `${(value / 1024 / 1024).toFixed(1)} MiB`;
+ return [
+ `# Calibrated benchmark series: ${result.caseName}/${result.variantName}`,
+ '',
+ `Runs: ${result.runCount}`,
+ '',
+ '| Measurement | Median | Minimum | Maximum |',
+ '| --- | ---: | ---: | ---: |',
+ row('Appearance fit', result.timing.appearanceFitMs, milliseconds),
+ row('Optimization', result.timing.optimizationMs, milliseconds),
+ row('Total', result.timing.totalMs, milliseconds),
+ ...(result.memory.retainedHeapAfterGcBytes
+ ? [row('Retained heap after GC', result.memory.retainedHeapAfterGcBytes, mebibytes)]
+ : []),
+ ...(result.memory.residentSetAfterGcBytes
+ ? [row('Resident set after GC', result.memory.residentSetAfterGcBytes, mebibytes)]
+ : []),
+ row('Maximum RSS', result.memory.maximumResidentSetBytes, mebibytes),
+ '',
+ `Final-stack fingerprint: \`${result.deterministicResult.finalStackFingerprint}\``,
+ `Optimizer score: ${result.deterministicResult.score}`,
+ `Iterations: ${result.deterministicResult.iterations}`,
+ '',
+ ].join('\n');
+}
+
+function row(label, values, formatter) {
+ return `| ${label} | ${formatter(values.median)} | ${formatter(values.minimum)} | ${formatter(values.maximum)} |`;
+}
diff --git a/scripts/compare-calibrated-profiles.mjs b/scripts/compare-calibrated-profiles.mjs
new file mode 100644
index 00000000..b62b8fc9
--- /dev/null
+++ b/scripts/compare-calibrated-profiles.mjs
@@ -0,0 +1,465 @@
+import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const profileRoot = resolve('.profiles');
+mkdirSync(profileRoot, { recursive: true });
+
+const skipped = [];
+const discoveredRuns = readdirSync(profileRoot, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .sort((left, right) => left.name.localeCompare(right.name, 'en'))
+ .flatMap((entry) => {
+ const relativeSummaryPath = `${entry.name}/summary.json`;
+ try {
+ const summary = JSON.parse(
+ readFileSync(resolve(profileRoot, entry.name, 'summary.json'), 'utf8')
+ );
+ return [normalizeRun(summary, entry.name, relativeSummaryPath)];
+ } catch (error) {
+ if (error?.code !== 'ENOENT') {
+ skipped.push({
+ summary: relativeSummaryPath,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+ return [];
+ }
+ });
+const enrichedRuns = discoveredRuns.filter((run) => run.workload && run.benchmark && run.result);
+const runs = enrichedRuns.length > 0 ? enrichedRuns : discoveredRuns;
+
+const comparison = {
+ schemaVersion: 1,
+ profilesCompared: runs.length,
+ runs,
+ hotspotAggregates: aggregateHotspots(runs).slice(0, 50),
+ ...(skipped.length > 0 ? { skipped } : {}),
+};
+
+writeFileSync(resolve(profileRoot, 'comparison.json'), `${JSON.stringify(comparison, null, 2)}\n`);
+writeFileSync(resolve(profileRoot, 'comparison.md'), renderMarkdown(comparison));
+
+console.log(
+ `Compared ${runs.length} calibrated profile${runs.length === 1 ? '' : 's'} in ${profileRoot}`
+);
+if (skipped.length > 0) console.warn(`Skipped ${skipped.length} unreadable summary file(s).`);
+
+function normalizeRun(summary, directory, summaryPath) {
+ const benchmark = asObject(summary.benchmark) ?? summary;
+ const workload =
+ asObject(summary.workload) ??
+ asObject(benchmark.workload) ??
+ asObject(summary.benchmarkWorkload);
+ const settings =
+ asObject(workload?.settings) ??
+ asObject(workload?.config) ??
+ asObject(benchmark.settings) ??
+ asObject(benchmark.config) ??
+ asObject(summary.settings) ??
+ asObject(summary.config);
+ const timing = asObject(benchmark.timing) ?? asObject(summary.timing);
+ const memory = asObject(benchmark.memory) ?? asObject(summary.memory);
+ const result =
+ asObject(benchmark.result) ??
+ asObject(summary.result) ??
+ asObject(summary.benchmarkResult) ??
+ asObject(benchmark.output);
+ const appearanceFitMs = firstFiniteNumber(
+ timing?.appearanceFitMs,
+ timing?.appearanceWallMs,
+ timing?.appearanceMs,
+ benchmark.appearanceFitMs,
+ benchmark.appearanceWallMs
+ );
+ const optimizationMs = firstFiniteNumber(
+ timing?.optimizationMs,
+ timing?.optimizationWallMs,
+ timing?.optimizerMs,
+ benchmark.optimizationMs,
+ benchmark.optimizationWallMs
+ );
+ const explicitTotalMs = firstFiniteNumber(
+ timing?.totalWallMs,
+ timing?.totalMs,
+ benchmark.totalWallMs,
+ benchmark.totalMs
+ );
+ const totalWallMs =
+ explicitTotalMs ??
+ (appearanceFitMs !== null && optimizationMs !== null
+ ? appearanceFitMs + optimizationMs
+ : null);
+ const peakRssBytes = firstFiniteNumber(
+ memory?.maximumResidentSetBytes,
+ memory?.peakRssBytes,
+ memory?.maxRssBytes,
+ benchmark.maximumResidentSetBytes,
+ benchmark.peakRssBytes
+ );
+ const order = firstArray(
+ result?.orderColors,
+ result?.filamentOrderColors,
+ result?.filamentOrder,
+ result?.order
+ );
+ const separation =
+ asObject(result?.separation) ??
+ asObject(result?.colorSeparation) ??
+ asObject(benchmark.separation);
+ const sampledMicroseconds = firstFiniteNumber(
+ summary.sampledMicroseconds,
+ summary.cpuProfile?.sampledMicroseconds,
+ summary.cpu?.sampledMicroseconds
+ );
+ const sampleCount = firstFiniteNumber(
+ summary.sampleCount,
+ summary.cpuProfile?.sampleCount,
+ summary.cpu?.sampleCount
+ );
+ const hotspots = normalizeHotspots(
+ summary.applicationHotSymbols ??
+ summary.applicationHotFunctions ??
+ summary.cpuProfile?.applicationHotFunctions ??
+ summary.cpu?.applicationHotFunctions ??
+ summary.hotspots,
+ sampledMicroseconds
+ );
+
+ return {
+ id: directory,
+ summary: summaryPath,
+ fixture: firstString(
+ workload?.fixture,
+ benchmark.fixture,
+ summary.fixture,
+ workload?.caseName,
+ summary.caseName
+ ),
+ capturedAt: firstString(summary.capturedAt, benchmark.capturedAt),
+ workload: workload ? stableClone(workload) : null,
+ settings: settings ? stableClone(settings) : null,
+ benchmark: [appearanceFitMs, optimizationMs, totalWallMs, peakRssBytes].some(
+ (value) => value !== null
+ )
+ ? { appearanceFitMs, optimizationMs, totalWallMs, peakRssBytes }
+ : null,
+ result: result
+ ? {
+ score: firstFiniteNumber(result.score, result.optimizerScore),
+ iterations: firstFiniteNumber(result.iterations, result.optimizerIterations),
+ order: order ? order.map((value) => String(value)) : null,
+ finalStackFingerprint: firstString(
+ result.finalStackFingerprint,
+ result.finalFingerprint,
+ result.fingerprint
+ ),
+ separation: separation ? stableClone(separation) : null,
+ }
+ : null,
+ cpuProfile:
+ sampledMicroseconds !== null || sampleCount !== null || hotspots.length > 0
+ ? { sampledMicroseconds, sampleCount, hotspots }
+ : null,
+ };
+}
+
+function normalizeHotspots(value, sampledMicroseconds) {
+ if (!Array.isArray(value)) return [];
+ return value
+ .flatMap((entry) => {
+ if (!asObject(entry)) return [];
+ const selfMicroseconds = firstFiniteNumber(
+ entry.selfMicroseconds,
+ entry.microseconds,
+ entry.selfTimeMicroseconds
+ );
+ const percent = firstFiniteNumber(entry.percent, entry.selfPercent);
+ const functionName = firstString(entry.functionName, entry.name) ?? '(anonymous)';
+ const location = normalizeLocation(
+ firstString(entry.source, entry.location, entry.url) ?? '(runtime)'
+ );
+ if (selfMicroseconds === null && percent === null) return [];
+ return [
+ {
+ functionName,
+ location,
+ selfMicroseconds,
+ percent:
+ percent ??
+ (selfMicroseconds !== null && sampledMicroseconds
+ ? (selfMicroseconds / sampledMicroseconds) * 100
+ : null),
+ samples: firstFiniteNumber(entry.samples, entry.sampleCount),
+ },
+ ];
+ })
+ .sort(compareHotspots);
+}
+
+function aggregateHotspots(runs) {
+ const aggregates = new Map();
+ for (const run of runs) {
+ const perRun = new Map();
+ for (const hotspot of run.cpuProfile?.hotspots ?? []) {
+ const stableLocation = stableHotspotLocation(hotspot.functionName, hotspot.location);
+ const key = `${hotspot.functionName}\u0000${stableLocation}`;
+ const entry = perRun.get(key) ?? {
+ functionName: hotspot.functionName,
+ location: stableLocation,
+ selfMicroseconds: 0,
+ percent: 0,
+ samples: 0,
+ };
+ entry.selfMicroseconds += hotspot.selfMicroseconds ?? 0;
+ entry.percent += hotspot.percent ?? 0;
+ entry.samples += hotspot.samples ?? 0;
+ perRun.set(key, entry);
+ }
+ for (const [key, entry] of perRun) {
+ const aggregate = aggregates.get(key) ?? {
+ functionName: entry.functionName,
+ location: entry.location,
+ runCount: 0,
+ totalSelfMicroseconds: 0,
+ totalSamples: 0,
+ percentSum: 0,
+ maximumPercent: 0,
+ sampledMicroseconds: 0,
+ runs: [],
+ };
+ aggregate.runCount += 1;
+ aggregate.totalSelfMicroseconds += entry.selfMicroseconds;
+ aggregate.totalSamples += entry.samples;
+ aggregate.percentSum += entry.percent;
+ aggregate.maximumPercent = Math.max(aggregate.maximumPercent, entry.percent);
+ aggregate.sampledMicroseconds += run.cpuProfile?.sampledMicroseconds ?? 0;
+ aggregate.runs.push(run.id);
+ aggregates.set(key, aggregate);
+ }
+ }
+
+ return [...aggregates.values()]
+ .map(({ percentSum, sampledMicroseconds, ...entry }) => ({
+ ...entry,
+ meanPercent: round(percentSum / entry.runCount),
+ maximumPercent: round(entry.maximumPercent),
+ weightedPercent:
+ sampledMicroseconds > 0
+ ? round((entry.totalSelfMicroseconds / sampledMicroseconds) * 100)
+ : null,
+ }))
+ .sort(
+ (left, right) =>
+ right.runCount - left.runCount ||
+ right.meanPercent - left.meanPercent ||
+ right.totalSelfMicroseconds - left.totalSelfMicroseconds ||
+ left.functionName.localeCompare(right.functionName, 'en') ||
+ left.location.localeCompare(right.location, 'en')
+ );
+}
+
+function renderMarkdown(comparison) {
+ const timingRows = comparison.runs.map((run) => {
+ const benchmark = run.benchmark;
+ return `| ${escapeCell(run.id)} | ${escapeCell(run.fixture ?? 'n/a')} | ${escapeCell(formatSettings(run.settings))} | ${formatMilliseconds(benchmark?.appearanceFitMs)} | ${formatMilliseconds(benchmark?.optimizationMs)} | ${formatMilliseconds(benchmark?.totalWallMs)} | ${formatBytes(benchmark?.peakRssBytes)} |`;
+ });
+ const resultRows = comparison.runs.map((run) => {
+ const result = run.result;
+ return `| ${escapeCell(run.id)} | ${formatNumber(result?.score)} | ${formatInteger(result?.iterations)} | ${escapeCell(result?.order?.join(' -> ') ?? 'n/a')} | ${escapeCell(result?.finalStackFingerprint ?? 'n/a')} | ${escapeCell(formatSeparation(result?.separation))} |`;
+ });
+ const hotspotRows = comparison.hotspotAggregates
+ .slice(0, 20)
+ .map(
+ (entry) =>
+ `| ${entry.runCount}/${comparison.runs.length} | ${formatPercent(entry.meanPercent)} | ${formatPercent(entry.maximumPercent)} | ${formatMicroseconds(entry.totalSelfMicroseconds)} | ${escapeCell(entry.functionName)} | ${escapeCell(entry.location)} |`
+ );
+ return [
+ '# Calibrated optimizer profile comparison',
+ '',
+ `Profiles compared: ${comparison.profilesCompared}`,
+ '',
+ '## Workloads and benchmark timing',
+ '',
+ '| Run | Fixture | Settings | Appearance fit | Optimization | Total wall | Peak RSS |',
+ '| --- | --- | --- | ---: | ---: | ---: | ---: |',
+ ...timingRows,
+ '',
+ '## Results',
+ '',
+ '| Run | Score | Iterations | Filament order | Final fingerprint | Separation |',
+ '| --- | ---: | ---: | --- | --- | --- |',
+ ...resultRows,
+ '',
+ '## Stable CPU hotspots',
+ '',
+ '| Runs | Mean self | Max self | Total sampled self | Function | Location |',
+ '| ---: | ---: | ---: | ---: | --- | --- |',
+ ...hotspotRows,
+ '',
+ ...(comparison.skipped?.length
+ ? [
+ '## Skipped summaries',
+ '',
+ ...comparison.skipped.map((entry) => `- ${entry.summary}: ${entry.reason}`),
+ '',
+ ]
+ : []),
+ ].join('\n');
+}
+
+function stableHotspotLocation(functionName, location) {
+ if (functionName === '(anonymous)') return location;
+ return location.replace(/:\d+(?::\d+)?$/, '');
+}
+
+function normalizeLocation(location) {
+ const normalized = String(location).replaceAll('\\', '/');
+ const match = normalized.match(/(?:^|\/)(src|tests\/benchmark|scripts)\/(.+)$/i);
+ return match ? `${match[1].toLowerCase()}/${match[2]}` : normalized;
+}
+
+function formatSettings(settings) {
+ if (!settings) return 'n/a';
+ const values = flattenObject(settings);
+ if (values.length === 0) return 'n/a';
+ const visible = values.slice(0, 12).map(([key, value]) => `${key}=${formatValue(value)}`);
+ if (values.length > visible.length) visible.push(`+${values.length - visible.length} more`);
+ return visible.join('; ');
+}
+
+function formatSeparation(separation) {
+ if (!separation) return 'n/a';
+ const requested = firstFiniteNumber(separation.requestedColorCount, separation.requested);
+ const assigned = firstFiniteNumber(
+ separation.assignedDistinctColorCount,
+ separation.assignedDistinct,
+ separation.assigned
+ );
+ const unacceptable = firstFiniteNumber(
+ separation.unacceptableColorCount,
+ separation.unacceptable,
+ separation.fallbackColorCount
+ );
+ const maximum = firstFiniteNumber(separation.maximumDeltaE, separation.maxDeltaE);
+ const allowed = firstFiniteNumber(
+ separation.maximumAllowedDeltaE,
+ separation.allowedDeltaE,
+ separation.threshold
+ );
+ const pieces = [];
+ if (assigned !== null || requested !== null) {
+ pieces.push(`${assigned ?? '?'} / ${requested ?? '?'} assigned`);
+ }
+ if (unacceptable !== null) pieces.push(`${unacceptable} unacceptable`);
+ if (maximum !== null) {
+ pieces.push(
+ `max Delta E ${formatNumber(maximum)}${allowed !== null ? ` / ${formatNumber(allowed)}` : ''}`
+ );
+ }
+ if (typeof separation.satisfied === 'boolean') {
+ pieces.push(separation.satisfied ? 'satisfied' : 'unsatisfied');
+ }
+ return pieces.length > 0 ? pieces.join('; ') : formatSettings(separation);
+}
+
+function flattenObject(value, prefix = '') {
+ if (!asObject(value)) return prefix ? [[prefix, value]] : [];
+ return Object.keys(value)
+ .sort((left, right) => left.localeCompare(right, 'en'))
+ .flatMap((key) => {
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
+ return asObject(value[key])
+ ? flattenObject(value[key], nextPrefix)
+ : [[nextPrefix, value[key]]];
+ });
+}
+
+function stableClone(value) {
+ if (Array.isArray(value)) return value.map(stableClone);
+ if (!asObject(value)) return value;
+ return Object.fromEntries(
+ Object.keys(value)
+ .sort((left, right) => left.localeCompare(right, 'en'))
+ .map((key) => [key, stableClone(value[key])])
+ );
+}
+
+function asObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
+}
+
+function firstFiniteNumber(...values) {
+ for (const value of values) {
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ }
+ return null;
+}
+
+function firstString(...values) {
+ for (const value of values) {
+ if (typeof value === 'string' && value.length > 0) return value;
+ }
+ return null;
+}
+
+function firstArray(...values) {
+ return values.find(Array.isArray) ?? null;
+}
+
+function compareHotspots(left, right) {
+ return (
+ (right.selfMicroseconds ?? 0) - (left.selfMicroseconds ?? 0) ||
+ (right.percent ?? 0) - (left.percent ?? 0) ||
+ left.functionName.localeCompare(right.functionName, 'en') ||
+ left.location.localeCompare(right.location, 'en')
+ );
+}
+
+function formatMilliseconds(value) {
+ if (value === null || value === undefined) return 'n/a';
+ if (value >= 60_000) return `${(value / 60_000).toFixed(2)} min`;
+ if (value >= 1_000) return `${(value / 1_000).toFixed(2)} s`;
+ return `${value.toFixed(1)} ms`;
+}
+
+function formatMicroseconds(value) {
+ return formatMilliseconds(value / 1_000);
+}
+
+function formatBytes(value) {
+ if (value === null || value === undefined) return 'n/a';
+ return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
+}
+
+function formatPercent(value) {
+ return value === null || value === undefined ? 'n/a' : `${value.toFixed(2)}%`;
+}
+
+function formatInteger(value) {
+ return value === null || value === undefined
+ ? 'n/a'
+ : Math.round(value).toLocaleString('en-US');
+}
+
+function formatNumber(value) {
+ if (value === null || value === undefined) return 'n/a';
+ if (Number.isInteger(value)) return String(value);
+ const magnitude = Math.abs(value);
+ if (magnitude >= 1_000_000) return value.toPrecision(8);
+ return String(round(value));
+}
+
+function formatValue(value) {
+ if (Array.isArray(value)) return `[${value.map(formatValue).join(',')}]`;
+ if (typeof value === 'string') return value;
+ if (value === null) return 'null';
+ return JSON.stringify(value);
+}
+
+function round(value) {
+ return Number(value.toFixed(6));
+}
+
+function escapeCell(value) {
+ return String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
+}
diff --git a/scripts/generate-auto-paint-goldens.ts b/scripts/generate-auto-paint-goldens.ts
new file mode 100644
index 00000000..53017d9a
--- /dev/null
+++ b/scripts/generate-auto-paint-goldens.ts
@@ -0,0 +1,46 @@
+import { writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { autoPaintGoldenScenarios } from '../tests/autoPaintGoldenFixtures.ts';
+import { loadViteModule } from '../tests/helpers/viteModule.ts';
+
+type AutoPaintModule = typeof import('../src/lib/autoPaint.ts');
+
+const LAYER_HEIGHT = 0.08;
+const FIRST_LAYER_HEIGHT = 0.16;
+
+const { generateAutoLayers } = await loadViteModule('/src/lib/autoPaint.ts');
+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: 'balanced', seed: scenario.seed }
+ );
+
+ 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/scripts/generate-docs-seo-pages.mjs b/scripts/generate-docs-seo-pages.mjs
index 324095e9..850a06d0 100644
--- a/scripts/generate-docs-seo-pages.mjs
+++ b/scripts/generate-docs-seo-pages.mjs
@@ -110,6 +110,16 @@ function resolveDocImage(src) {
if (clean.includes('kromacut-logo.png')) {
return findBuiltAsset('logo-') ?? clean;
}
+ const diagramPrefixes = {
+ '06_frontlit_hiding_distance.svg': '06_frontlit_hiding_distance-',
+ '07_calibration_wedge.svg': '07_calibration_wedge-',
+ '08_opacity_solve.svg': '08_opacity_solve-',
+ '09_palette_proof.svg': '09_palette_proof-',
+ };
+ const diagramName = Object.keys(diagramPrefixes).find((name) => clean.includes(name));
+ if (diagramName) {
+ return findBuiltAsset(diagramPrefixes[diagramName]) ?? clean;
+ }
return clean;
}
@@ -136,12 +146,20 @@ function resolveDocHref(href, currentDocSlug, docsBySlug) {
function renderInline(markdown, currentDocSlug, docsBySlug) {
let html = escapeHtml(markdown);
+ const protectedHtml = [];
+ const protect = (value) => {
+ const token = `\u0000${protectedHtml.length}\u0000`;
+ protectedHtml.push(value);
+ return token;
+ };
+
+ html = html.replace(/`([^`]+)`/g, (_match, code) => protect(`${code}`));
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, rawSrc) => {
const [srcPart, titlePart] = rawSrc.trim().split(/\s+["']/);
const title = titlePart ? titlePart.replace(/["']$/, '') : '';
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
- return ` `;
+ return protect(` `);
});
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, href) => {
@@ -149,16 +167,15 @@ function renderInline(markdown, currentDocSlug, docsBySlug) {
const externalAttrs = /^(https?:|mailto:)/i.test(resolved)
? ' target="_blank" rel="noopener noreferrer"'
: '';
- return `${renderInline(label, currentDocSlug, docsBySlug)} `;
+ return protect(`${renderInline(label, currentDocSlug, docsBySlug)} `);
});
- html = html.replace(/`([^`]+)`/g, '$1');
html = html.replace(/\*\*([^*]+)\*\*/g, '$1 ');
html = html.replace(/__([^_]+)__/g, '$1 ');
html = html.replace(/\*([^*]+)\*/g, '$1 ');
html = html.replace(/_([^_]+)_/g, '$1 ');
- return html;
+ return html.replace(/\u0000(\d+)\u0000/g, (_match, index) => protectedHtml[Number(index)]);
}
function isBlockStart(line) {
@@ -325,6 +342,28 @@ function updateDocHead(template, doc) {
return html;
}
+function updateAppHead(template) {
+ const title = 'Kromacut App - Image to 3D Print Tool';
+ const description =
+ 'Create color-layered 3D prints from images with Kromacut. The browser tool runs locally and exports STL or 3MF models.';
+ const url = `${siteUrl}/app`;
+ let html = template.replace(/.*?<\/title>/, `${escapeHtml(title)} `);
+
+ html = updateMeta(html, 'name', 'description', description);
+ html = updateMeta(html, 'name', 'robots', 'noindex,nofollow');
+ html = html.replace(
+ / /,
+ ` `
+ );
+ html = updateMeta(html, 'property', 'og:title', title);
+ html = updateMeta(html, 'property', 'og:description', description);
+ html = updateMeta(html, 'property', 'og:url', url);
+ html = updateMeta(html, 'name', 'twitter:title', title);
+ html = updateMeta(html, 'name', 'twitter:description', description);
+
+ return html;
+}
+
function renderStaticRoot(doc, docs, docsBySlug) {
const nav = docs
.map((entry) => `${escapeHtml(entry.title)} `)
@@ -334,7 +373,8 @@ function renderStaticRoot(doc, docs, docsBySlug) {
return `
- Kromacut app
+ Kromacut home
+ Open Kromacut
@@ -362,6 +402,12 @@ function writeDocPage(template, doc, docs, docsBySlug, slug = doc.slug) {
}
}
+function writeAppPage(template) {
+ const outputDir = path.join(distDir, 'app');
+ mkdirSync(outputDir, { recursive: true });
+ writeFileSync(path.join(outputDir, 'index.html'), updateAppHead(template));
+}
+
function writeSitemap(docs) {
const urls = ['/', ...docs.map((doc) => `/docs/${doc.slug}`)];
const body = urls
@@ -380,6 +426,62 @@ function writeRobots() {
);
}
+function verifyGeneratedOutput(docs) {
+ const requiredFiles = [
+ 'index.html',
+ 'app/index.html',
+ 'docs/index.html',
+ 'robots.txt',
+ 'sitemap.xml',
+ 'site.webmanifest',
+ 'version.json',
+ ];
+ const assertGenerated = (condition, message) => {
+ if (!condition) throw new Error(`Generated output verification failed: ${message}`);
+ };
+
+ requiredFiles.forEach((file) => {
+ assertGenerated(existsSync(path.join(distDir, file)), `missing dist/${file}`);
+ });
+
+ const rootHtml = readFileSync(path.join(distDir, 'index.html'), 'utf8');
+ const appHtml = readFileSync(path.join(distDir, 'app', 'index.html'), 'utf8');
+ const sitemap = readFileSync(path.join(distDir, 'sitemap.xml'), 'utf8');
+ const robots = readFileSync(path.join(distDir, 'robots.txt'), 'utf8');
+ const manifest = JSON.parse(readFileSync(path.join(distDir, 'site.webmanifest'), 'utf8'));
+ const version = JSON.parse(readFileSync(path.join(distDir, 'version.json'), 'utf8'));
+
+ assertGenerated(rootHtml.includes(' https://kromacut.com/'), 'root is missing from sitemap');
+ assertGenerated(!sitemap.includes('https://kromacut.com/app'), '/app must not appear in sitemap');
+ assertGenerated(robots.includes('Sitemap: https://kromacut.com/sitemap.xml'), 'robots.txt sitemap reference is missing');
+ assertGenerated(manifest.start_url === '/app', 'manifest start_url must be /app');
+ assertGenerated(manifest.id === '/', 'manifest id must remain stable at /');
+ assertGenerated(typeof version.version === 'string' && version.version.length > 0, 'version.json is invalid');
+
+ docs.forEach((doc) => {
+ const relativePage = path.join('docs', doc.slug, 'index.html');
+ const pagePath = path.join(distDir, relativePage);
+ assertGenerated(existsSync(pagePath), `missing dist/${relativePage}`);
+ const html = readFileSync(pagePath, 'utf8');
+ assertGenerated(
+ html.includes(` ]*src="([^"]+)"/g)) {
+ const src = match[1];
+ if (/^(https?:|data:)/i.test(src)) continue;
+ assertGenerated(!src.includes('<') && !src.includes('>'), `malformed image URL in ${doc.slug}: ${src}`);
+ assertGenerated(src.startsWith('/'), `image URL is not root-relative in ${doc.slug}: ${src}`);
+ assertGenerated(existsSync(path.join(distDir, src.slice(1))), `missing image used by ${doc.slug}: ${src}`);
+ }
+ });
+}
+
if (!existsSync(distIndexPath)) {
throw new Error('dist/index.html was not found. Run this script after vite build.');
}
@@ -391,5 +493,7 @@ const overviewDoc = docsBySlug.get('overview') ?? docs[0];
docs.forEach((doc) => writeDocPage(template, doc, docs, docsBySlug));
if (overviewDoc) writeDocPage(template, overviewDoc, docs, docsBySlug, '');
+writeAppPage(template);
writeSitemap(docs);
writeRobots();
+verifyGeneratedOutput(docs);
diff --git a/scripts/prepare-diagnostic-print.ts b/scripts/prepare-diagnostic-print.ts
new file mode 100644
index 00000000..f5b95b3f
--- /dev/null
+++ b/scripts/prepare-diagnostic-print.ts
@@ -0,0 +1,416 @@
+import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
+import { resolve, relative } from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import JSZip from 'jszip';
+import { withViteTestServer } from '../tests/helpers/viteModule.ts';
+import { verifyDiagnostic3mf } from '../tests/helpers/diagnostic3mf.ts';
+import type { AutoPaintDiagnosticRunInputV1 } from '../src/lib/autoPaintDiagnostics.ts';
+import type { DiagnosticStripDesign, DiagnosticStrip } from '../src/lib/diagnosticPrint.ts';
+import type { FinalPrintableStackSnapshot } from '../src/types/appearance.ts';
+
+const help = `Prepare an OFFLINE, frozen diagnostic print bundle (no calibration writes).
+npm run diagnostics:print -- --input --design --out
+The design explicitly selects physical layers, not optimizer results. Layer heights must match the trace.
+Refuses existing output directories. See tests/benchmark/diagnostic-print.md.`;
+const hash = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
+const escape = (s: string) =>
+ s.replace(
+ /[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!
+ );
+
+function instructions(strips: DiagnosticStrip[]) {
+ const lines = [
+ '# Frozen diagnostic print bundle',
+ '',
+ 'Start with A, B and C. Print ONE strip per job: combining different stack families on one plate can cause many extra filament changes. D–H are simpler-backing controls; I–J repeat known photographed Matrix recipes.',
+ '',
+ '## Before printing',
+ '',
+ '- Open patch-map.html for the numbered map. The notched corner is top-left; pads are numbered left to right. IDs are on the map, NOT embossed (embossing would change the sample stack). Label the back after printing, one strip at a time.',
+ `- Use ${strips[0].context.layerHeight} mm regular layers and ${strips[0].context.firstLayerHeight} mm first layer; 100% scale, face-up, no supports/raft, no adaptive layers, 100% infill and one wall. Keep pad top surfaces free of ironing or other new surface treatment. A removable brim outside the specimen is fine; do not add material under it.`,
+ '- Import as one multipart object, not separate independently grounded objects. Retain all layer parts and their relative Z positions. Check the slicer layer preview against every swap table below.',
+ '- IMPORTANT: the embedded slicer profile is only a generic placeholder, NOT your printer/material preset. Select your actual printer and spools, then recheck layer heights, infill, walls and assignments. A 0.40 mm first layer is deliberately frozen from today’s print; use it only with the same already-working nozzle/process, not an untested setup.',
+ '- Record the actual printer/nozzle, spool IDs, temperatures, flow, line widths, speeds, infill directions, cooling, surface treatment and purge settings before printing. These are NOT known from the desktop trace. Save the slicer project and sliced output alongside your observations; do not silently substitute these settings.',
+ '- In particular, inspect the black → orange purge in A/C. Residual black could contribute to brown; this bundle does not assume the optical model is the only cause. Keep purge settings documented so this can be separated from backing effects.',
+ '- Measure/photograph only the CENTRE of each 8 × 8 mm pad. Gaps and margins expose the thin first layer, not the full backing. Pad 1 is a full-backing reference in A–H; the last pad repeats an earlier thickness.',
+ '',
+ '## What is frozen',
+ '',
+ 'predictions.json contains every complete layer recipe, unrounded physical/final prediction, evidence contributors, model fingerprint and warnings. full-profile-model.json was fitted with the FULL frozen profile, not benchmark holdouts. frozen-profile.kfil and source-trace.jsonl preserve the input. source-code.zip, source-manifest.json and working-tree.patch preserve the actual working source, including uncommitted edits; the Git commit alone is not the source snapshot.',
+ '',
+ 'manifest.json hashes all payload files. Run `node verify-bundle.mjs` from this folder before printing to check integrity. Record observations separately (copy observations-template.json outside this bundle, or simply send labeled photos and notes). Predictions are estimates, not promised physical colors. “Opaque” control foundations are sized to the model’s 95% assumption, not measured proof of opacity. Known Matrix controls are training recipes, not independent test predictions; their original first-layer schedule may differ.',
+ '',
+ '## Record results without changing calibration',
+ '',
+ 'After cooling, photograph A/B/C and comparison controls together in even, fixed lighting, with exposure/white balance locked if possible. Avoid glare and keep an untouched original photo. Include a known neutral reference if you have one; phone RGB alone is not absolute colorimetry. Compare duplicate pads for repeatability. Record each pad ID and whether it looks different from its repeat. Do not import measurements or retune HD until the frozen predictions and actual recipes have been compared.',
+ '',
+ 'The file checks validate geometry, physical materials, layer heights and pad stacks. They do not validate your slicer’s toolpaths or prove the optical model is accurate.',
+ '',
+ ];
+ for (const s of strips) {
+ lines.push(
+ `## ${s.design.id}: ${s.design.title}`,
+ '',
+ s.design.purpose,
+ '',
+ `File: strips/${s.design.id}.3mf — ${s.dimensions.width} × ${s.dimensions.depth} × ${s.dimensions.height} mm.`,
+ '',
+ `Backing, bottom → top: ${s.backing.map((r) => `${r.filamentColor} ${r.thickness.toFixed(2)} mm`).join(' → ')}. Top filament: ${s.design.foreground}.`,
+ '',
+ ...s.warnings.map((w) => `WARNING: ${w}`),
+ '',
+ '| Pad | Top layers | Top mm | Total Z mm | Repeat of |',
+ '| --- | ---: | ---: | ---: | --- |',
+ ...s.patches.map(
+ (p) =>
+ `| ${p.id} | ${p.topLayers} | ${p.topThickness.toFixed(2)} | ${p.totalHeight.toFixed(2)} | ${p.repeatOf ?? '—'} |`
+ ),
+ '',
+ '| First layer using filament (1-based) | Start Z mm | Physical color |',
+ '| ---: | ---: | --- |',
+ ...s.layers.flatMap((l, i) =>
+ i === 0 || s.layers[i - 1].filamentId !== l.filamentId
+ ? [`| ${i + 1} | ${l.startHeight.toFixed(2)} | ${l.filamentColor} |`]
+ : []
+ ),
+ ''
+ );
+ }
+ return lines.join('\n');
+}
+
+function patchMap(strips: DiagnosticStrip[]) {
+ return `Kromacut diagnostic patch map
+
+Frozen diagnostic print map Start with A · Orange , B · Cyan , C · White . One strip per print job. Read print instructions before slicing.
Notch at top-left. Pad IDs run left → right. Measure the pad centres, not the exposed gaps. Diagrams are NOT to scale. Predictions are hidden initially so you can record observations first.
+${strips
+ .map(
+ (
+ s
+ ) => `${escape(s.design.id)} · ${escape(s.design.title)} ${escape(s.design.purpose)}
${s.dimensions.width} × ${s.dimensions.depth} × ${s.dimensions.height} mm · 3MF · ${s.layers.filter((l, i) => i > 0 && l.filamentId !== s.layers[i - 1].filamentId).length} filament changes
+NOTCH ${s.patches.map((p) => `${p.number} ${p.topThickness.toFixed(2)} mm `).join('')}
+Backing: ${s.backing.map((r) => `${escape(r.filamentColor)} ${r.thickness.toFixed(2)} mm`).join(' → ')}. Top: ${escape(s.design.foreground)}. ${s.patches
+ .filter((p) => p.repeatOf)
+ .map((p) => `${p.id} repeats ${p.repeatOf}.`)
+ .join(' ')}
+${s.warnings.map((w) => `${escape(w)}
`).join('')}
+Reveal frozen predictions (estimates, not physical guarantees) Pad Top layers Predicted sRGB Evidence method ${s.patches.map((p) => `${p.id} ${p.topLayers} ${p.predictedHex}${escape(p.prediction.predictionConfidence.method)} `).join('')}
`
+ )
+ .join('')}
+`;
+}
+
+async function main() {
+ if (process.argv.includes('--help')) {
+ console.log(help);
+ return;
+ }
+ const args = process.argv.slice(2),
+ options = new Map();
+ for (let i = 0; i < args.length; i += 2) {
+ if (
+ !['--input', '--design', '--out'].includes(args[i]) ||
+ !args[i + 1] ||
+ args[i + 1].startsWith('--') ||
+ options.has(args[i])
+ )
+ throw new Error(help);
+ options.set(args[i], args[i + 1]);
+ }
+ if (options.size !== 3) throw new Error(help);
+ const out = resolve(options.get('--out')!),
+ input = resolve(options.get('--input')!),
+ designPath = resolve(options.get('--design')!);
+ if (existsSync(out))
+ throw new Error('Output directory already exists; choose a new directory.');
+ const raw = readFileSync(input),
+ designRaw = readFileSync(designPath);
+ const records = raw
+ .toString('utf8')
+ .replace(/^\uFEFF/, '')
+ .trim()
+ .split(/\r?\n/)
+ .map((line) => JSON.parse(line));
+ const starts = records.filter((r) => r.kind === 'run-start');
+ if (starts.length !== 1) throw new Error('Select a trace containing exactly one run-start');
+ const trace = starts[0].payload as AutoPaintDiagnosticRunInputV1;
+ if (
+ trace.schemaVersion !== 1 ||
+ !trace.appearanceProfile ||
+ !trace.settings ||
+ !trace.filaments?.length
+ )
+ throw new Error('Incomplete diagnostic input');
+ const design = JSON.parse(designRaw.toString('utf8')) as {
+ schemaVersion: number;
+ layerHeight: number;
+ firstLayerHeight: number;
+ strips: DiagnosticStripDesign[];
+ traceMatches?: { stripId: string; patchNumber: number; targetColor: string }[];
+ knownReferences?: { id: string; title: string; foreground: string; substrate: string }[];
+ };
+ if (
+ design.schemaVersion !== 1 ||
+ design.layerHeight !== trace.settings.layerHeight ||
+ design.firstLayerHeight !== trace.settings.firstLayerHeight ||
+ !design.strips?.length
+ )
+ throw new Error('Design and trace must specify identical physical layer heights');
+ const files: Record = {};
+ const write = (name: string, value: string | Uint8Array) => {
+ writeFileSync(resolve(out, name), value, { flag: 'wx' });
+ files[name] = { sha256: hash(value), bytes: Buffer.byteLength(value) };
+ };
+ const json = (name: string, value: unknown) =>
+ write(name, JSON.stringify(value, null, 2) + '\n');
+ await withViteTestServer(async (server) => {
+ const [appearance, fit, diagnostics, validation, profiles] = await Promise.all([
+ server.ssrLoadModule('/src/lib/appearanceProfile.ts') as Promise<
+ typeof import('../src/lib/appearanceProfile.ts')
+ >,
+ server.ssrLoadModule('/src/lib/appearanceModel.ts') as Promise<
+ typeof import('../src/lib/appearanceModel.ts')
+ >,
+ server.ssrLoadModule('/src/lib/diagnosticPrint.ts') as Promise<
+ typeof import('../src/lib/diagnosticPrint.ts')
+ >,
+ server.ssrLoadModule('/src/lib/appearanceValidation.ts') as Promise<
+ typeof import('../src/lib/appearanceValidation.ts')
+ >,
+ server.ssrLoadModule('/src/lib/profileManager.ts') as Promise<
+ typeof import('../src/lib/profileManager.ts')
+ >,
+ ]);
+ const profile = {
+ id: 'frozen-diagnostic-profile',
+ name: 'Frozen full diagnostic profile',
+ version: 3,
+ createdAt: 0,
+ updatedAt: 0,
+ filaments: trace.filaments,
+ appearance: trace.appearanceProfile,
+ };
+ const parsed = profiles.parseProfileFile(JSON.stringify(profile));
+ if (
+ parsed?.length !== 1 ||
+ appearance.fingerprintAppearanceFilaments(parsed[0].filaments) !==
+ appearance.fingerprintAppearanceFilaments(trace.filaments)
+ )
+ throw new Error('Invalid profile; refusing to silently repair diagnostic input');
+ const context = {
+ filaments: trace.filaments,
+ filamentProfileFingerprint: appearance.fingerprintAppearanceFilaments(trace.filaments),
+ layerHeight: design.layerHeight,
+ firstLayerHeight: design.firstLayerHeight,
+ transitionOpacity: trace.settings.transitionOpacity,
+ };
+ console.log('Fitting the full frozen profile (no benchmark holdouts)...');
+ const model = fit.fitAppearanceRankModel(trace.appearanceProfile!, context);
+ const observations = validation.planAppearanceValidation(
+ trace.appearanceProfile!,
+ context,
+ {
+ scenarios: [],
+ }
+ );
+ const known = (design.knownReferences ?? []).map((ref) => {
+ const candidates = observations.observations.filter(
+ (o) =>
+ o.layers.length >= 2 &&
+ o.layers.at(-1)!.filamentColor.toLowerCase() === ref.foreground.toLowerCase() &&
+ o.layers.at(-2)!.filamentColor.toLowerCase() === ref.substrate.toLowerCase()
+ );
+ candidates.sort(
+ (a, b) =>
+ a.layers.length - b.layers.length ||
+ a.layers.reduce((s, l) => s + l.thickness, 0) -
+ b.layers.reduce((s, l) => s + l.thickness, 0) ||
+ a.sampleIndex - b.sampleIndex
+ );
+ const chosen = candidates.find((o) =>
+ o.layers.every((l, i) => {
+ const n =
+ i === 0
+ ? 1 + (l.thickness - design.firstLayerHeight) / design.layerHeight
+ : l.thickness / design.layerHeight;
+ return n >= 1 && Math.abs(n - Math.round(n)) < 1e-6;
+ })
+ );
+ if (!chosen) throw new Error(`No measured, layer-compatible reference for ${ref.id}`);
+ const matrix = observations.matrices.find((m) => m.id === chosen.matrixId)!;
+ const sample = matrix.samples.find((s) => s.index === chosen.sampleIndex)!;
+ const top = Math.round(chosen.layers.at(-1)!.thickness / design.layerHeight);
+ return {
+ id: ref.id,
+ title: ref.title,
+ purpose:
+ 'Two identical copies of a known photographed Matrix recipe. Compare repeatability and transfer, not independent prediction accuracy.',
+ backing: chosen.layers.slice(0, -1).map((l, i) => ({
+ color: l.filamentColor,
+ layers: Math.round(
+ i === 0
+ ? 1 + (l.thickness - design.firstLayerHeight) / design.layerHeight
+ : l.thickness / design.layerHeight
+ ),
+ })),
+ foreground: ref.foreground,
+ topLayers: [top, top],
+ reference: {
+ matrixId: chosen.matrixId,
+ sampleIndex: chosen.sampleIndex,
+ measuredRgb: sample.measuredColor!.rgb,
+ originalFirstLayerHeight: matrix.process.firstLayerHeight,
+ },
+ } satisfies DiagnosticStripDesign;
+ });
+ const strips = [...design.strips, ...known].map((s) =>
+ diagnostics.planDiagnosticStrip(s, context, model)
+ );
+ if (new Set(strips.map((s) => s.design.id)).size !== strips.length)
+ throw new Error('Duplicate strip IDs');
+ const results = records.filter((r) => r.kind === 'result');
+ if (
+ design.traceMatches?.length &&
+ (results.length !== 1 || !results[0].payload.result?.finalStack)
+ )
+ throw new Error('Trace-matched designs require one completed final-stack snapshot');
+ const traceMatches = (design.traceMatches ?? []).map((ref) => {
+ const strip = strips.find((s) => s.design.id === ref.stripId);
+ if (!strip) throw new Error(`Unknown trace-match strip ${ref.stripId}`);
+ return diagnostics.verifyDiagnosticTraceMatch(
+ strip,
+ ref.patchNumber,
+ ref.targetColor,
+ results[0].payload.result.finalStack as FinalPrintableStackSnapshot
+ );
+ });
+ const execution = {
+ createdAt: new Date().toISOString(),
+ codeRevision: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
+ workingTreeStatus: execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }),
+ node: process.version,
+ inputName: input,
+ inputSha256: hash(raw),
+ designSha256: hash(designRaw),
+ fullProfile: true,
+ optimizerRun: false,
+ modelFingerprint: model.fingerprint,
+ command: process.argv.slice(1),
+ };
+ // Snapshot source BEFORE export. Include new/uncommitted files, not only git HEAD.
+ const sourcePaths = execFileSync(
+ 'git',
+ [
+ 'ls-files',
+ '--cached',
+ '--others',
+ '--exclude-standard',
+ 'src',
+ 'scripts',
+ 'tests/helpers',
+ 'tests/benchmark',
+ 'package.json',
+ 'package-lock.json',
+ 'tsconfig.json',
+ 'tsconfig.app.json',
+ 'tsconfig.node.json',
+ ],
+ { encoding: 'utf8' }
+ )
+ .trim()
+ .split(/\r?\n/)
+ .filter((p) => /\.(?:tsx?|m?js|json|css)$/.test(p));
+ const sourceZip = new JSZip(),
+ sourceHashes: Record = {};
+ for (const name of [...new Set(sourcePaths)].sort()) {
+ const bytes = readFileSync(resolve(name));
+ sourceZip.file(name, bytes);
+ sourceHashes[name] = hash(bytes);
+ }
+ mkdirSync(out);
+ mkdirSync(resolve(out, 'strips'));
+ write('source-trace.jsonl', raw);
+ write('design.json', designRaw);
+ json('frozen-profile.kfil', profile);
+ json('full-profile-model.json', model);
+ json('predictions.json', { schemaVersion: 1, execution, context, strips });
+ json('source-manifest.json', sourceHashes);
+ write(
+ 'working-tree.patch',
+ execFileSync('git', ['diff', 'HEAD', '--binary'], { maxBuffer: 32 * 1024 * 1024 })
+ );
+ write(
+ 'source-code.zip',
+ await sourceZip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' })
+ );
+ write('README.md', instructions(strips));
+ write('patch-map.html', patchMap(strips));
+ write('verify-bundle.mjs', readFileSync(resolve('scripts/verify-diagnostic-bundle.mjs')));
+ json('observations-template.json', {
+ note: 'COPY outside the frozen bundle before editing. Record observations BEFORE changing calibration.',
+ printer: null,
+ nozzle: null,
+ spools: null,
+ temperatures: null,
+ flow: null,
+ lineWidths: null,
+ speeds: null,
+ infillDirections: null,
+ cooling: null,
+ surfaceTreatment: null,
+ purgeSettings: null,
+ slicerProject: null,
+ slicedOutput: null,
+ lighting: null,
+ cameraSettings: null,
+ photos: [],
+ patches: strips.flatMap((s) =>
+ s.patches.map((p) => ({
+ id: p.id,
+ observation: null,
+ measuredRgb: null,
+ repeatAgreement: null,
+ }))
+ ),
+ });
+ const checks = [];
+ for (const strip of strips) {
+ const bytes = new Uint8Array(
+ await (await diagnostics.exportDiagnosticStrip(strip)).arrayBuffer()
+ );
+ const check = await verifyDiagnostic3mf(bytes, strip);
+ write(`strips/${strip.design.id}.3mf`, bytes);
+ checks.push({ strip: strip.design.id, ...check });
+ console.log(
+ `${strip.design.id}: ${strip.patches.length} pads, ${strip.layers.length} layers; serialized stacks/materials/topology verified`
+ );
+ }
+ // Detect accidental source edits during the run before declaring the bundle complete.
+ for (const [name, expected] of Object.entries(sourceHashes))
+ if (hash(readFileSync(resolve(name))) !== expected)
+ throw new Error(`Source changed during export: ${name}; bundle is incomplete`);
+ json('verification.json', {
+ checks,
+ traceMatches,
+ scope: 'Serialized 3MF topology, exact physical layers, pad centre stacks and material assignments. Does not validate slicer toolpaths or physical color accuracy.',
+ });
+ writeFileSync(
+ resolve(out, 'manifest.json'),
+ JSON.stringify(
+ { schemaVersion: 1, status: 'frozen-pre-print', execution, files },
+ null,
+ 2
+ ) + '\n',
+ { flag: 'wx' }
+ );
+ console.log(
+ `Ready: ${relative(process.cwd(), out)} (${strips.length} strips; ${strips.reduce((n, s) => n + s.patches.length, 0)} pads).`
+ );
+ });
+}
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/scripts/profile-calibrated.mjs b/scripts/profile-calibrated.mjs
new file mode 100644
index 00000000..35cead8c
--- /dev/null
+++ b/scripts/profile-calibrated.mjs
@@ -0,0 +1,472 @@
+import { spawn, spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { arch, cpus, platform, release, totalmem } from 'node:os';
+import { dirname, resolve } from 'node:path';
+
+const caseName = process.argv[2] ?? 'cats';
+if (!/^[a-z0-9-]+$/.test(caseName)) {
+ throw new Error(`Invalid calibrated optimizer case name: ${caseName}`);
+}
+const variantName = process.argv[3] ?? 'baseline';
+if (!/^[a-z0-9-]+$/.test(variantName)) {
+ throw new Error(`Invalid calibrated optimizer variant name: ${variantName}`);
+}
+
+const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+const runName = variantName === 'baseline' ? caseName : `${caseName}-${variantName}`;
+const outputDirectory = resolve('.profiles', `${timestamp}-${runName}`);
+const profilePath = resolve(outputDirectory, 'optimizer.cpuprofile');
+const samplingIntervalMicroseconds = 1000;
+const calibratedFixtureRoot = [
+ 'tests',
+ 'assets',
+ 'performance',
+ '8-colors-frontlit-2026-08-26',
+];
+const profiledSourcePaths = [
+ 'src',
+ 'tests/benchmark/calibratedOptimizer.ts',
+ 'tests/helpers/viteModule.ts',
+ 'tests/imageFixtures.ts',
+ 'tests/assets/performance/8-colors-frontlit-2026-08-26/optimizer-variant-goldens.json',
+ 'scripts/profile-calibrated.mjs',
+];
+const sourceStatePaths = [...profiledSourcePaths, 'package.json', 'package-lock.json'];
+const capturedWorkload = readWorkload(caseName);
+const capturedEnvironment = collectEnvironment(samplingIntervalMicroseconds);
+mkdirSync(outputDirectory, { recursive: true });
+
+const childArguments = [
+ '--cpu-prof',
+ `--cpu-prof-dir=${outputDirectory}`,
+ '--cpu-prof-name=optimizer.cpuprofile',
+ `--cpu-prof-interval=${samplingIntervalMicroseconds}`,
+ '--enable-source-maps',
+ '--expose-gc',
+ '--no-warnings',
+ '--experimental-strip-types',
+ resolve('tests', 'benchmark', 'calibratedOptimizer.ts'),
+ caseName,
+ variantName,
+];
+
+console.error(`Profiling calibrated case "${caseName}"...`);
+console.error(`Profile output: ${outputDirectory}`);
+
+let benchmarkOutput = '';
+const exitCode = await new Promise((resolveExit, reject) => {
+ const child = spawn(process.execPath, childArguments, {
+ cwd: process.cwd(),
+ env: { ...process.env, NO_COLOR: '1' },
+ stdio: ['inherit', 'pipe', 'inherit'],
+ });
+ child.stdout.setEncoding('utf8');
+ child.stdout.on('data', (chunk) => {
+ benchmarkOutput += chunk;
+ process.stdout.write(chunk);
+ });
+ child.once('error', reject);
+ child.once('exit', (code, signal) => {
+ if (signal) reject(new Error(`Profiler process stopped by ${signal}`));
+ else resolveExit(code ?? 1);
+ });
+});
+
+if (exitCode !== 0) {
+ process.exitCode = exitCode;
+} else {
+ const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
+ const benchmark = parseBenchmarkOutput(benchmarkOutput);
+ const finalWorkload = readWorkload(caseName);
+ const finalEnvironment = collectEnvironment(samplingIntervalMicroseconds);
+ const summary = summarizeProfile(profile, runName, {
+ benchmark,
+ workload: capturedWorkload,
+ environment: capturedEnvironment,
+ provenance: compareCaptureProvenance(
+ capturedWorkload,
+ finalWorkload,
+ capturedEnvironment,
+ finalEnvironment
+ ),
+ });
+ const benchmarkJsonPath = resolve(outputDirectory, 'benchmark.json');
+ const summaryJsonPath = resolve(outputDirectory, 'summary.json');
+ const summaryMarkdownPath = resolve(outputDirectory, 'summary.md');
+ writeFileSync(benchmarkJsonPath, `${JSON.stringify(benchmark, null, 2)}\n`);
+ writeFileSync(summaryJsonPath, `${JSON.stringify(summary, null, 2)}\n`);
+ writeFileSync(summaryMarkdownPath, renderMarkdown(summary));
+
+ console.error('');
+ console.error('Top application functions by sampled self time:');
+ for (const entry of summary.applicationHotFunctions.slice(0, 20)) {
+ console.error(
+ `${entry.percent.toFixed(1).padStart(5)}% ${formatDuration(entry.selfMicroseconds).padStart(9)} ${entry.functionName} ${entry.location}`
+ );
+ }
+ console.error('');
+ console.error(`Readable summary: ${summaryMarkdownPath}`);
+ console.error(`Raw CPU profile: ${profilePath}`);
+}
+
+function summarizeProfile(profile, fallbackRunName, metadata) {
+ const nodesById = new Map(profile.nodes.map((node) => [node.id, node]));
+ const parentByNodeId = new Map();
+ for (const node of profile.nodes) {
+ for (const childId of node.children ?? []) parentByNodeId.set(childId, node.id);
+ }
+ const totalsByFrame = new Map();
+ const samples = profile.samples ?? [];
+ const timeDeltas = profile.timeDeltas ?? [];
+ let sampledMicroseconds = 0;
+
+ for (let index = 0; index < samples.length; index++) {
+ const delta = Number(timeDeltas[index] ?? 0);
+ if (!Number.isFinite(delta) || delta < 0) continue;
+ sampledMicroseconds += delta;
+ const node = nodesById.get(samples[index]);
+ if (!node) continue;
+ const frame = node.callFrame;
+ const functionName = frame.functionName || '(anonymous)';
+ const url = normalizeLocationUrl(frame.url || '');
+ const source = relativeSource(url);
+ const line = Math.max(0, Number(frame.lineNumber ?? -1)) + 1;
+ const column = Math.max(0, Number(frame.columnNumber ?? -1)) + 1;
+ const location = url ? `${url}:${line}:${column}` : '(runtime)';
+ const key = `${functionName}\u0000${location}`;
+ const current = totalsByFrame.get(key) ?? {
+ functionName,
+ source,
+ location,
+ selfMicroseconds: 0,
+ samples: 0,
+ callersByFrame: new Map(),
+ };
+ current.selfMicroseconds += delta;
+ current.samples += 1;
+ const parent = nodesById.get(parentByNodeId.get(node.id));
+ const callerName = parent ? parent.callFrame.functionName || '(anonymous)' : '(root)';
+ const callerUrl = normalizeLocationUrl(parent?.callFrame?.url || '');
+ const callerLine = Math.max(0, Number(parent?.callFrame?.lineNumber ?? -1)) + 1;
+ const callerLocation = callerUrl ? `${callerUrl}:${callerLine}` : '(runtime)';
+ const callerKey = `${callerName}\u0000${callerLocation}`;
+ const caller = current.callersByFrame.get(callerKey) ?? {
+ functionName: callerName,
+ location: callerLocation,
+ selfMicroseconds: 0,
+ };
+ caller.selfMicroseconds += delta;
+ current.callersByFrame.set(callerKey, caller);
+ totalsByFrame.set(key, current);
+ }
+
+ const ranked = Array.from(totalsByFrame.values(), (entry) => {
+ const { callersByFrame, ...frame } = entry;
+ return {
+ ...frame,
+ percent:
+ sampledMicroseconds > 0 ? (entry.selfMicroseconds / sampledMicroseconds) * 100 : 0,
+ callers: [...callersByFrame.values()].sort(
+ (left, right) => right.selfMicroseconds - left.selfMicroseconds
+ ),
+ };
+ }).sort(
+ (left, right) =>
+ right.selfMicroseconds - left.selfMicroseconds ||
+ left.functionName.localeCompare(right.functionName)
+ );
+ const applicationHotFunctions = ranked.filter((entry) => isApplicationLocation(entry.location));
+ const applicationHotSymbols = aggregateBySymbol(applicationHotFunctions, sampledMicroseconds);
+
+ const effectiveSettings = metadata.benchmark.configuration ?? metadata.workload.settings;
+ const effectiveFixture =
+ metadata.benchmark.variant && metadata.benchmark.variant !== 'baseline'
+ ? `${metadata.benchmark.fixture}/${metadata.benchmark.variant}`
+ : (metadata.benchmark.fixture ?? fallbackRunName);
+ return {
+ fixture: effectiveFixture,
+ capturedAt: new Date().toISOString(),
+ workload: {
+ name:
+ metadata.benchmark.variant && metadata.benchmark.variant !== 'baseline'
+ ? `${metadata.workload.name} / ${metadata.benchmark.variant}`
+ : metadata.workload.name,
+ profileSha256: metadata.workload.currentSha256.profile,
+ sourceImageSha256: metadata.workload.currentSha256.sourceImage,
+ caseSha256: metadata.workload.currentSha256.case,
+ settingsSha256: hashJson(effectiveSettings),
+ settings: effectiveSettings,
+ },
+ benchmark: metadata.benchmark,
+ environment: metadata.environment,
+ provenance: metadata.provenance,
+ sampledMicroseconds,
+ sampledDuration: formatDuration(sampledMicroseconds),
+ sampleCount: samples.length,
+ applicationHotFunctions: applicationHotFunctions.slice(0, 100),
+ applicationHotSymbols: applicationHotSymbols.slice(0, 100),
+ overallHotFunctions: ranked.slice(0, 100),
+ };
+}
+
+function parseBenchmarkOutput(output) {
+ const trimmed = output.trim();
+ if (!trimmed) throw new Error('Calibrated benchmark produced no JSON output');
+ try {
+ return JSON.parse(trimmed);
+ } catch (error) {
+ throw new Error(`Could not parse calibrated benchmark JSON: ${error.message}`);
+ }
+}
+
+function readWorkload(fixtureName) {
+ const casePath = resolve(...calibratedFixtureRoot, fixtureName, 'case.json');
+ const workload = JSON.parse(readFileSync(casePath, 'utf8'));
+ const caseDirectory = dirname(casePath);
+ return {
+ ...workload,
+ // case.json records the hashes captured when the fixture was added, but a
+ // profile must identify the files it actually consumed. Hash the current
+ // files so a locally modified fixture cannot inherit stale provenance.
+ currentSha256: {
+ case: hashFile(casePath),
+ profile: hashFile(resolve(caseDirectory, workload.profile)),
+ sourceImage: hashFile(resolve(caseDirectory, workload.sourceImage)),
+ },
+ };
+}
+
+function compareCaptureProvenance(startWorkload, endWorkload, startEnvironment, endEnvironment) {
+ const sourceCaptureAvailable = Boolean(
+ startEnvironment.gitRevision &&
+ endEnvironment.gitRevision &&
+ startEnvironment.sourceStateSha256 &&
+ endEnvironment.sourceStateSha256
+ );
+ const sourceChanged =
+ !sourceCaptureAvailable ||
+ startEnvironment.gitRevision !== endEnvironment.gitRevision ||
+ startEnvironment.sourceStateSha256 !== endEnvironment.sourceStateSha256;
+ const workloadChanged =
+ startWorkload.currentSha256.case !== endWorkload.currentSha256.case ||
+ startWorkload.currentSha256.profile !== endWorkload.currentSha256.profile ||
+ startWorkload.currentSha256.sourceImage !== endWorkload.currentSha256.sourceImage;
+ return {
+ stable: !sourceChanged && !workloadChanged,
+ sourceCaptureAvailable,
+ sourceChangedDuringCapture: sourceChanged,
+ workloadChangedDuringCapture: workloadChanged,
+ finalGitRevision: endEnvironment.gitRevision,
+ finalSourceStateSha256: endEnvironment.sourceStateSha256,
+ finalCaseSha256: endWorkload.currentSha256.case,
+ finalProfileSha256: endWorkload.currentSha256.profile,
+ finalSourceImageSha256: endWorkload.currentSha256.sourceImage,
+ };
+}
+
+function collectEnvironment(samplingInterval) {
+ const cpuList = cpus();
+ const gitRevision = spawnSync('git', ['rev-parse', 'HEAD'], {
+ cwd: process.cwd(),
+ encoding: 'utf8',
+ });
+ const gitStatus = spawnSync('git', ['status', '--porcelain'], {
+ cwd: process.cwd(),
+ encoding: 'utf8',
+ });
+ const sourceDiff = spawnSync(
+ 'git',
+ [
+ 'diff',
+ '--no-ext-diff',
+ 'HEAD',
+ '--',
+ ...profiledSourcePaths,
+ ],
+ {
+ cwd: process.cwd(),
+ encoding: 'utf8',
+ maxBuffer: 32 * 1024 * 1024,
+ }
+ );
+ return {
+ node: process.version,
+ platform: platform(),
+ release: release(),
+ architecture: arch(),
+ cpuModel: cpuList[0]?.model ?? null,
+ logicalCpuCount: cpuList.length,
+ totalMemoryBytes: totalmem(),
+ gitRevision: gitRevision.status === 0 ? gitRevision.stdout.trim() : null,
+ gitDirty: gitStatus.status === 0 ? gitStatus.stdout.trim().length > 0 : null,
+ sourceDiffSha256:
+ sourceDiff.status === 0
+ ? createHash('sha256').update(sourceDiff.stdout).digest('hex')
+ : null,
+ sourceStateSha256: hashCurrentSourceState(),
+ samplingIntervalMicroseconds: samplingInterval,
+ };
+}
+
+function aggregateBySymbol(entries, sampledMicroseconds) {
+ const bySymbol = new Map();
+ for (const entry of entries) {
+ // Named functions are stable across small edits, while anonymous
+ // callbacks need their line number to avoid merging unrelated hot
+ // loops from the same source file into one misleading symbol.
+ const source =
+ entry.functionName === '(anonymous)'
+ ? entry.location.replace(/:\d+$/, '')
+ : entry.source;
+ const key = `${entry.functionName}\u0000${source}`;
+ const current = bySymbol.get(key) ?? {
+ functionName: entry.functionName,
+ source,
+ selfMicroseconds: 0,
+ samples: 0,
+ };
+ current.selfMicroseconds += entry.selfMicroseconds;
+ current.samples += entry.samples;
+ bySymbol.set(key, current);
+ }
+ return [...bySymbol.values()]
+ .map((entry) => ({
+ ...entry,
+ percent:
+ sampledMicroseconds > 0 ? (entry.selfMicroseconds / sampledMicroseconds) * 100 : 0,
+ }))
+ .sort(
+ (left, right) =>
+ right.selfMicroseconds - left.selfMicroseconds ||
+ left.functionName.localeCompare(right.functionName)
+ );
+}
+
+function relativeSource(source) {
+ if (!source) return '(runtime)';
+ const normalized = source.replaceAll('\\', '/');
+ const workspace = process.cwd().replaceAll('\\', '/');
+ return normalized.toLowerCase().startsWith(`${workspace.toLowerCase()}/`)
+ ? normalized.slice(workspace.length + 1)
+ : normalized;
+}
+
+function hashJson(value) {
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
+}
+
+function hashFile(path) {
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
+}
+
+function hashCurrentSourceState() {
+ const sourceFiles = spawnSync(
+ 'git',
+ [
+ 'ls-files',
+ '--cached',
+ '--others',
+ '--exclude-standard',
+ '-z',
+ '--',
+ ...sourceStatePaths,
+ ],
+ {
+ cwd: process.cwd(),
+ encoding: 'utf8',
+ maxBuffer: 32 * 1024 * 1024,
+ }
+ );
+ if (sourceFiles.status !== 0) return null;
+
+ const hash = createHash('sha256');
+ const paths = sourceFiles.stdout.split('\0').filter(Boolean).sort();
+ for (const relativePath of paths) {
+ hash.update(relativePath);
+ hash.update('\0');
+ try {
+ hash.update(readFileSync(resolve(process.cwd(), relativePath)));
+ } catch (error) {
+ if (error?.code !== 'ENOENT') return null;
+ // A tracked deletion is part of the working-tree identity too.
+ hash.update('');
+ }
+ hash.update('\0');
+ }
+ return hash.digest('hex');
+}
+
+function isApplicationLocation(location) {
+ const normalized = location.replaceAll('\\', '/').toLowerCase();
+ return (
+ normalized.includes('/src/') ||
+ normalized.includes('/tests/benchmark/') ||
+ normalized.includes('/scripts/')
+ );
+}
+
+function normalizeLocationUrl(url) {
+ try {
+ if (url.startsWith('file:'))
+ return decodeURIComponent(new URL(url).pathname).replace(/^\/(.:\/)/, '$1');
+ } catch {
+ // Keep the original profiler URL if it is not a valid file URL.
+ }
+ return url;
+}
+
+function formatDuration(microseconds) {
+ if (microseconds >= 60_000_000) return `${(microseconds / 60_000_000).toFixed(2)} min`;
+ if (microseconds >= 1_000_000) return `${(microseconds / 1_000_000).toFixed(2)} s`;
+ if (microseconds >= 1_000) return `${(microseconds / 1_000).toFixed(1)} ms`;
+ return `${microseconds.toFixed(0)} us`;
+}
+
+function renderMarkdown(summary) {
+ const settings = summary.workload.settings;
+ const benchmark = summary.benchmark;
+ const separationLabel = benchmark.result.separation
+ ? `${benchmark.result.separation.requestedColorCount} targets`
+ : 'separation off';
+ const rows = summary.applicationHotFunctions.slice(0, 50).map((entry) => {
+ const caller = entry.callers[0];
+ const callerLabel = caller ? `${caller.functionName} -- ${caller.location}` : '(unknown)';
+ return `| ${entry.percent.toFixed(2)}% | ${formatDuration(entry.selfMicroseconds)} | ${entry.samples} | ${escapeCell(entry.functionName)} | ${escapeCell(entry.location)} | ${escapeCell(callerLabel)} |`;
+ });
+ return [
+ `# Calibrated optimizer CPU profile: ${summary.fixture}`,
+ '',
+ `- Captured: ${summary.capturedAt}`,
+ `- Workload: ${summary.workload.name}`,
+ `- Configuration: ${settings.optimizerAlgorithm}, ${separationLabel}, Delta E ${settings.maximumColorErrorDeltaE}, ${settings.maximumExtraFilamentAppearances} extra appearances`,
+ `- Benchmark wall time: ${formatDuration(benchmark.timing.totalMs * 1000)} (${formatDuration(benchmark.timing.appearanceFitMs * 1000)} appearance fit, ${formatDuration(benchmark.timing.optimizationMs * 1000)} optimization)`,
+ `- Peak RSS: ${formatBytes(benchmark.memory.maximumResidentSetBytes)}`,
+ `- Output fingerprint: ${benchmark.result.finalStackFingerprint}`,
+ `- Git revision: ${summary.environment.gitRevision ?? 'unknown'}`,
+ `- Source diff SHA-256: ${summary.environment.sourceDiffSha256 ?? 'unknown'}`,
+ `- Source state SHA-256: ${summary.environment.sourceStateSha256 ?? 'unknown'}`,
+ `- Profile fixture SHA-256: ${summary.workload.profileSha256}`,
+ `- Source image SHA-256: ${summary.workload.sourceImageSha256}`,
+ `- Case SHA-256: ${summary.workload.caseSha256}`,
+ `- Provenance stable during capture: ${summary.provenance.stable ? 'yes' : 'NO'}`,
+ `- Sampled duration: ${summary.sampledDuration}`,
+ `- Samples: ${summary.sampleCount.toLocaleString('en-US')}`,
+ '',
+ '## Application hot functions',
+ '',
+ '| Self | Time | Samples | Function | Location | Primary caller |',
+ '| ---: | ---: | ---: | --- | --- | --- |',
+ ...rows,
+ '',
+ ].join('\n');
+}
+
+function formatBytes(bytes) {
+ if (!Number.isFinite(bytes)) return 'unknown';
+ return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
+}
+
+function escapeCell(value) {
+ return String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
+}
diff --git a/scripts/replay-diagnostic-predictions.ts b/scripts/replay-diagnostic-predictions.ts
new file mode 100644
index 00000000..de2664dd
--- /dev/null
+++ b/scripts/replay-diagnostic-predictions.ts
@@ -0,0 +1,515 @@
+/** Post-print, read-only replay of preserved recipes. Never imported by the app. */
+import { readFileSync, mkdirSync, writeFileSync, existsSync, realpathSync } from 'node:fs';
+import { dirname, isAbsolute, relative, resolve } from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { isDeepStrictEqual } from 'node:util';
+import { withViteTestServer } from '../tests/helpers/viteModule.ts';
+import type { AppearanceFitContext } from '../src/lib/appearanceModel.ts';
+import type { DiagnosticStrip } from '../src/lib/diagnosticPrint.ts';
+import type { AutoPaintProfile } from '../src/lib/profileManager.ts';
+import type { FinalPrintableStackSnapshot } from '../src/types/appearance.ts';
+
+const help = `Replay a frozen diagnostic bundle with CURRENT prediction code, without new prints or calibration writes.
+node --no-warnings --experimental-strip-types scripts/replay-diagnostic-predictions.ts --bundle --out [--observations ]
+Requires predictions.json, frozen-profile.kfil, design.json, source-trace.jsonl and a valid frozen manifest.
+The output parent must already exist inside this workspace. Writes only report.json and report.md.
+Original pre-print predictions stay untouched. Observation notes are qualitative annotations, never fitting data.`;
+const hash = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
+const json = (bytes: Uint8Array) =>
+ JSON.parse(
+ Buffer.from(bytes)
+ .toString('utf8')
+ .replace(/^\uFEFF/, '')
+ );
+const within = (root: string, path: string) => {
+ const name = relative(root, path);
+ return (
+ !!name &&
+ name !== '..' &&
+ !name.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) &&
+ !isAbsolute(name)
+ );
+};
+const md = (value: unknown) =>
+ String(value)
+ .replace(/\|/g, '\\|')
+ .replace(/[\r\n]+/g, ' ');
+
+interface PayloadHash {
+ sha256: string;
+ bytes: number;
+}
+interface FrozenPredictions {
+ schemaVersion: number;
+ execution: { modelFingerprint: string; [key: string]: unknown };
+ context: AppearanceFitContext;
+ strips: DiagnosticStrip[];
+}
+interface FrozenManifest {
+ schemaVersion: number;
+ status: string;
+ files: Record;
+}
+
+function verifyBundle(bundle: string, manifest: FrozenManifest) {
+ if (
+ manifest.schemaVersion !== 1 ||
+ manifest.status !== 'frozen-pre-print' ||
+ !Object.keys(manifest.files ?? {}).length
+ )
+ throw new Error('Missing or unsupported frozen-pre-print manifest');
+ const hashes: Record = {};
+ for (const [name, expected] of Object.entries(manifest.files)) {
+ const path = resolve(bundle, name);
+ if (!within(bundle, path) || !within(bundle, realpathSync(path)))
+ throw new Error(`Unsafe frozen payload path: ${name}`);
+ const bytes = readFileSync(path);
+ const actual = { sha256: hash(bytes), bytes: bytes.length };
+ if (actual.sha256 !== expected.sha256 || actual.bytes !== expected.bytes)
+ throw new Error(`Frozen payload changed: ${name}`);
+ hashes[name] = actual;
+ }
+ for (const name of [
+ 'predictions.json',
+ 'frozen-profile.kfil',
+ 'design.json',
+ 'source-trace.jsonl',
+ ])
+ if (!hashes[name]) throw new Error(`Required payload is not in the manifest: ${name}`);
+ return hashes;
+}
+
+/** Resolve the old model's opaque-sizing instruction to the material that was actually exported. */
+function pinFrozenDesign(strip: DiagnosticStrip) {
+ const design = structuredClone(strip.design);
+ const pinnedBackingRuns: { runIndex: number; original: 'opaque'; frozenLayers: number }[] = [];
+ for (const [index, run] of design.backing.entries()) {
+ if (run.layers !== 'opaque') continue;
+ if (index !== 0) throw new Error(`Invalid opaque instruction in ${strip.design.id}`);
+ // Subtract explicit later runs rather than scanning the first color:
+ // adjacent runs may intentionally use the same filament.
+ const laterRuns = design.backing.slice(1);
+ if (laterRuns.some((r) => !Number.isInteger(r.layers) || Number(r.layers) < 1))
+ throw new Error(`Invalid backing layer counts in ${strip.design.id}`);
+ const count =
+ strip.backingLayerCount - laterRuns.reduce((sum, r) => sum + Number(r.layers), 0);
+ if (
+ !Number.isInteger(count) ||
+ count < 1 ||
+ strip.layers
+ .slice(0, count)
+ .some((l) => l.filamentColor.toLowerCase() !== run.color.toLowerCase())
+ )
+ throw new Error(`Cannot recover frozen foundation for ${strip.design.id}`);
+ run.layers = count;
+ pinnedBackingRuns.push({ runIndex: index, original: 'opaque', frozenLayers: count });
+ }
+ return { design, pinnedBackingRuns };
+}
+
+function assertSamePhysicalStrip(frozen: DiagnosticStrip, fresh: DiagnosticStrip) {
+ const fields = [
+ 'dimensions',
+ 'layers',
+ 'backing',
+ 'backingLayerCount',
+ 'patchSize',
+ 'gap',
+ 'orientation',
+ ] as const;
+ for (const field of fields)
+ if (!isDeepStrictEqual(frozen[field], fresh[field]))
+ throw new Error(
+ `Physical ${field} changed in ${frozen.design.id}; refusing a different-recipe replay`
+ );
+ if (frozen.patches.length !== fresh.patches.length)
+ throw new Error(`Patch count changed in ${frozen.design.id}`);
+ const patchFields = [
+ 'id',
+ 'number',
+ 'topLayers',
+ 'topThickness',
+ 'layerCount',
+ 'totalHeight',
+ 'recipe',
+ 'bounds',
+ 'repeatOf',
+ ] as const;
+ for (const [index, patch] of frozen.patches.entries())
+ for (const field of patchFields)
+ if (!isDeepStrictEqual(patch[field], fresh.patches[index][field]))
+ throw new Error(`Physical ${field} changed for ${patch.id}`);
+ return {
+ dimensionsIdentical: true,
+ completePhysicalLayerSequenceIdentical: true,
+ allPatchRecipesBoundsAndRepeatsIdentical: true,
+ };
+}
+
+function predictionSummary(patch: DiagnosticStrip['patches'][number]) {
+ const prediction = patch.prediction;
+ const empirical = prediction.empiricalMatch;
+ return {
+ predictedHex: patch.predictedHex,
+ predictedRgb: patch.predictedRgb,
+ physicalRgb: patch.physicalRgb,
+ method: prediction.predictionConfidence.method,
+ sourceIds: [
+ ...new Set([
+ ...(empirical?.lutIds ?? (empirical?.lutId ? [empirical.lutId] : [])),
+ ...(prediction.exactAnchor ? [prediction.exactAnchor.proofId] : []),
+ ...(prediction.localMatch?.contributions ?? []).flatMap((c) =>
+ c.sourceId ? [c.sourceId] : []
+ ),
+ ]),
+ ],
+ evidenceIds: [
+ ...new Set([
+ ...(empirical?.sampleIds ?? []),
+ ...(prediction.localMatch?.evidenceIds ?? []),
+ ...(prediction.exactAnchor ? [prediction.exactAnchor.id] : []),
+ ]),
+ ],
+ // Retain unrounded Lab, confidence semantics and all requested contribution weights.
+ prediction,
+ };
+}
+
+async function main() {
+ if (process.argv.includes('--help')) {
+ console.log(help);
+ return;
+ }
+ const args = process.argv.slice(2),
+ options = new Map();
+ for (let i = 0; i < args.length; i += 2) {
+ if (
+ !['--bundle', '--out', '--observations'].includes(args[i]) ||
+ !args[i + 1] ||
+ args[i + 1].startsWith('--') ||
+ options.has(args[i])
+ )
+ throw new Error(help);
+ options.set(args[i], args[i + 1]);
+ }
+ if (!options.has('--bundle') || !options.has('--out')) throw new Error(help);
+ const workspace = realpathSync(process.cwd());
+ const bundle = realpathSync(resolve(options.get('--bundle')!));
+ const out = resolve(options.get('--out')!);
+ if (existsSync(out))
+ throw new Error('Output directory already exists; choose a new directory.');
+ // Require an existing canonical parent: do not follow an output ancestor outside the workspace.
+ const parent = realpathSync(dirname(out));
+ if (!within(workspace, out) || (parent !== workspace && !within(workspace, parent)))
+ throw new Error('Output must be a NEW directory under this workspace');
+ if (within(bundle, out) || out === bundle || within(bundle, parent) || parent === bundle)
+ throw new Error('Reports cannot be written inside the frozen bundle');
+ const manifestBytes = readFileSync(resolve(bundle, 'manifest.json'));
+ const manifest = json(manifestBytes) as FrozenManifest;
+ const payloadHashes = verifyBundle(bundle, manifest);
+ const frozen = json(readFileSync(resolve(bundle, 'predictions.json'))) as FrozenPredictions;
+ const profile = json(readFileSync(resolve(bundle, 'frozen-profile.kfil'))) as AutoPaintProfile;
+ const designFile = json(readFileSync(resolve(bundle, 'design.json'))) as {
+ traceMatches?: { stripId: string; patchNumber: number; targetColor: string }[];
+ };
+ if (
+ frozen.schemaVersion !== 1 ||
+ !frozen.strips?.length ||
+ profile.version !== 3 ||
+ !profile.appearance ||
+ !profile.filaments?.length
+ )
+ throw new Error('Unsupported or incomplete frozen predictions/profile');
+ if (!isDeepStrictEqual(frozen.context.filaments, profile.filaments))
+ throw new Error('Frozen profile filaments differ from the saved prediction context');
+ if (new Set(frozen.strips.map((s) => s.design.id)).size !== frozen.strips.length)
+ throw new Error('Duplicate frozen strip IDs');
+ const contextFields = [
+ 'filamentProfileFingerprint',
+ 'layerHeight',
+ 'firstLayerHeight',
+ 'transitionOpacity',
+ ] as const;
+ for (const strip of frozen.strips) {
+ if (
+ strip.schemaVersion !== 1 ||
+ strip.modelFingerprint !== frozen.execution.modelFingerprint
+ )
+ throw new Error(`Inconsistent frozen schema/model for ${strip.design.id}`);
+ for (const field of contextFields)
+ if (strip.context[field] !== frozen.context[field])
+ throw new Error(`Inconsistent frozen ${field} for ${strip.design.id}`);
+ }
+ const records = readFileSync(resolve(bundle, 'source-trace.jsonl'), 'utf8')
+ .replace(/^\uFEFF/, '')
+ .trim()
+ .split(/\r?\n/)
+ .map((line) => JSON.parse(line));
+ const starts = records.filter((r) => r.kind === 'run-start');
+ const results = records.filter((r) => r.kind === 'result');
+ if (
+ starts.length !== 1 ||
+ !isDeepStrictEqual(starts[0].payload.filaments, profile.filaments) ||
+ !isDeepStrictEqual(starts[0].payload.appearanceProfile, profile.appearance)
+ )
+ throw new Error('Source trace does not reproduce the complete frozen profile');
+ for (const field of ['layerHeight', 'firstLayerHeight', 'transitionOpacity'] as const)
+ if (starts[0].payload.settings?.[field] !== frozen.context[field])
+ throw new Error(`Source trace does not reproduce frozen ${field}`);
+ const snapshot =
+ results.length === 1
+ ? (results[0].payload.result?.finalStack as FinalPrintableStackSnapshot | undefined)
+ : undefined;
+ if (designFile.traceMatches?.length && !snapshot)
+ throw new Error(
+ 'Portrait design matches require exactly one completed final-stack snapshot'
+ );
+ const observationPath = options.has('--observations')
+ ? resolve(options.get('--observations')!)
+ : undefined;
+ const observationBytes = observationPath ? readFileSync(observationPath) : undefined;
+ const observations = observationBytes
+ ? {
+ path: observationPath,
+ sha256: hash(observationBytes),
+ text: observationBytes.toString('utf8'),
+ usedForFitting: false,
+ }
+ : null;
+
+ // Hash current source as well as Git HEAD: uncommitted work is part of the replay implementation.
+ const sourcePaths = execFileSync(
+ 'git',
+ [
+ 'ls-files',
+ '--cached',
+ '--others',
+ '--exclude-standard',
+ 'src',
+ 'scripts',
+ 'tests/helpers',
+ 'package.json',
+ 'package-lock.json',
+ ],
+ { encoding: 'utf8', cwd: workspace }
+ )
+ .trim()
+ .split(/\r?\n/)
+ .filter((p) => /\.(?:tsx?|m?js|json)$/.test(p));
+ const sourceHashes = Object.fromEntries(
+ [...new Set(sourcePaths)]
+ .sort()
+ .map((name) => [name, hash(readFileSync(resolve(workspace, name)))])
+ );
+ const codeRevision = execFileSync('git', ['rev-parse', 'HEAD'], {
+ encoding: 'utf8',
+ cwd: workspace,
+ }).trim();
+ const workingTreeStatus = execFileSync('git', ['status', '--porcelain'], {
+ encoding: 'utf8',
+ cwd: workspace,
+ });
+ await withViteTestServer(async (server) => {
+ const [fit, diagnostics, appearance] = await Promise.all([
+ server.ssrLoadModule('/src/lib/appearanceModel.ts') as Promise<
+ typeof import('../src/lib/appearanceModel.ts')
+ >,
+ server.ssrLoadModule('/src/lib/diagnosticPrint.ts') as Promise<
+ typeof import('../src/lib/diagnosticPrint.ts')
+ >,
+ server.ssrLoadModule('/src/lib/appearanceProfile.ts') as Promise<
+ typeof import('../src/lib/appearanceProfile.ts')
+ >,
+ ]);
+ const context: AppearanceFitContext = { ...frozen.context, filaments: profile.filaments };
+ if (
+ appearance.fingerprintAppearanceFilaments(profile.filaments) !==
+ context.filamentProfileFingerprint
+ )
+ throw new Error('Frozen filament fingerprint mismatch');
+ console.log(
+ 'Refitting the FULL frozen profile; no optimizer, holdout or observation fitting.'
+ );
+ const model = fit.fitAppearanceRankModel(profile.appearance!, context);
+ const strips = frozen.strips.map((oldStrip) => {
+ const pinned = pinFrozenDesign(oldStrip);
+ const fresh = diagnostics.planDiagnosticStrip(pinned.design, context, model);
+ const physicalChecks = assertSamePhysicalStrip(oldStrip, fresh);
+ return {
+ stripId: oldStrip.design.id,
+ originalDesign: oldStrip.design,
+ replayDesign: pinned.design,
+ pinnedBackingRuns: pinned.pinnedBackingRuns,
+ dimensions: fresh.dimensions,
+ physicalLayers: fresh.layers,
+ physicalChecks,
+ frozenWarnings: oldStrip.warnings,
+ replayWarnings: fresh.warnings,
+ patches: oldStrip.patches.map((oldPatch, index) => ({
+ id: oldPatch.id,
+ number: oldPatch.number,
+ topThickness: oldPatch.topThickness,
+ totalHeight: oldPatch.totalHeight,
+ recipe: oldPatch.recipe,
+ repeatOf: oldPatch.repeatOf,
+ frozenPrePrint: predictionSummary(oldPatch),
+ currentCodeReplay: predictionSummary(fresh.patches[index]),
+ roundedPredictionChanged:
+ oldPatch.predictedHex.toLowerCase() !==
+ fresh.patches[index].predictedHex.toLowerCase(),
+ evidenceMethodChanged:
+ oldPatch.prediction.predictionConfidence.method !==
+ fresh.patches[index].prediction.predictionConfidence.method,
+ })),
+ fresh,
+ };
+ });
+ const portraitMatches = (designFile.traceMatches ?? []).map((match) => {
+ const index = strips.findIndex((s) => s.stripId === match.stripId);
+ if (index < 0) throw new Error(`Unknown portrait strip ${match.stripId}`);
+ const original = diagnostics.verifyDiagnosticTraceMatch(
+ frozen.strips[index],
+ match.patchNumber,
+ match.targetColor,
+ snapshot!
+ );
+ const replay = diagnostics.verifyDiagnosticTraceMatch(
+ strips[index].fresh,
+ match.patchNumber,
+ match.targetColor,
+ snapshot!
+ );
+ const patch = strips[index].patches.find((p) => p.number === match.patchNumber)!;
+ return {
+ ...match,
+ originalTraceAndFrozenMatch: original,
+ currentCodeSameRecipeMatch: replay,
+ frozenPrePrint: patch.frozenPrePrint,
+ currentCodeReplay: patch.currentCodeReplay,
+ };
+ });
+ const patches = strips.flatMap((s) => s.patches);
+ // A concurrent source edit or any original-payload change invalidates this replay.
+ for (const [name, expected] of Object.entries(sourceHashes))
+ if (hash(readFileSync(resolve(workspace, name))) !== expected)
+ throw new Error(`Source changed during replay: ${name}; rerun after edits settle`);
+ if (
+ hash(readFileSync(resolve(bundle, 'manifest.json'))) !== hash(manifestBytes) ||
+ !isDeepStrictEqual(payloadHashes, verifyBundle(bundle, manifest))
+ )
+ throw new Error('Frozen bundle changed during replay');
+ if (
+ observationPath &&
+ observationBytes &&
+ hash(readFileSync(observationPath)) !== hash(observationBytes)
+ )
+ throw new Error('Observation notes changed during replay');
+ const report = {
+ schemaVersion: 1,
+ status: 'post-print-current-code-reinterpretation',
+ scope: {
+ originalPrePrintPredictionsPreserved: true,
+ fullFrozenProfileRefitted: true,
+ physicalRecipesPreserved: true,
+ opaqueSizing:
+ 'Pinned to frozen physical layer counts; no new model-dependent sizing.',
+ optimizerRun: false,
+ newPrintClaim: false,
+ calibrationWritten: false,
+ photoColorimetryAvailable: false,
+ limitation:
+ 'This report reinterprets already-frozen recipes with current code. It is not a new pre-print prediction bundle, an independent measurement validation, or a guarantee of physical color accuracy. Qualitative observations do not supply RGB/Delta E ground truth.',
+ },
+ execution: {
+ createdAt: new Date().toISOString(),
+ command: process.argv.slice(1),
+ node: process.version,
+ workspace,
+ codeRevision,
+ workingTreeStatus,
+ sourceHashes,
+ sourceFingerprintSha256: hash(JSON.stringify(sourceHashes)),
+ currentModelFingerprint: model.fingerprint,
+ },
+ originalBundle: {
+ path: bundle,
+ manifestSha256: hash(manifestBytes),
+ payloadHashes,
+ verifiedBeforeAndAfter: true,
+ frozenExecution: frozen.execution,
+ frozenModelFingerprint: frozen.execution.modelFingerprint,
+ filamentProfileFingerprint: context.filamentProfileFingerprint,
+ recordedPortraitFingerprint: snapshot?.fingerprint ?? null,
+ },
+ context: { ...context, filaments: undefined },
+ observations,
+ summary: {
+ strips: strips.length,
+ patches: patches.length,
+ roundedPredictionsChanged: patches.filter((p) => p.roundedPredictionChanged).length,
+ evidenceMethodsChanged: patches.filter((p) => p.evidenceMethodChanged).length,
+ portraitMatches: portraitMatches.length,
+ },
+ strips: strips.map(({ fresh: _fresh, ...strip }) => {
+ void _fresh;
+ return strip;
+ }),
+ portraitMatches,
+ };
+ const lines = [
+ '# Current-code replay of preserved diagnostic recipes',
+ '',
+ '**Post-print reinterpretation, not replacement pre-print predictions.** The original bundle and calibration remain unchanged. No optimizer ran and no additional print is claimed.',
+ '',
+ `Original model: \`${md(frozen.execution.modelFingerprint)}\`. Current model: \`${md(model.fingerprint)}\`.`,
+ `Verified all ${Object.keys(payloadHashes).length} original payload hashes before and after replay. Manifest SHA-256: \`${hash(manifestBytes)}\`. Full payload/source hashes and contribution details are in report.json.`,
+ '',
+ `All ${strips.length} strips / ${patches.length} patches retain their exact physical layers, recipes, bounds, repeats and dimensions. Originally automatic opaque backing sizes were pinned to the exported layer counts.`,
+ `${report.summary.roundedPredictionsChanged} rounded colors and ${report.summary.evidenceMethodsChanged} evidence methods changed. These are software prediction changes, not measured print errors.`,
+ '',
+ '## Original portrait target recipes',
+ '',
+ '| Target | Matching patch | Recorded portrait | Frozen strip | Current replay | Current method |',
+ '| --- | --- | --- | --- | --- | --- |',
+ ...portraitMatches.map(
+ (p) =>
+ `| ${md(p.targetColor)} | ${md(p.currentCodeSameRecipeMatch.patchId)} | ${p.originalTraceAndFrozenMatch.recordedPredictedHex} | ${p.frozenPrePrint.predictedHex} | ${p.currentCodeReplay.predictedHex} | ${md(p.currentCodeReplay.method)} |`
+ ),
+ '',
+ '## All frozen patch recipes',
+ '',
+ '| Patch | Top mm | Frozen prediction / method | Current prediction / method | Current evidence sources |',
+ '| --- | ---: | --- | --- | --- |',
+ ...patches.map(
+ (p) =>
+ `| ${md(p.id)} | ${p.topThickness.toFixed(2)} | ${p.frozenPrePrint.predictedHex} / ${md(p.frozenPrePrint.method)} | ${p.currentCodeReplay.predictedHex} / ${md(p.currentCodeReplay.method)} | ${md(p.currentCodeReplay.sourceIds.join(', ') || 'none')} |`
+ ),
+ '',
+ '## Observation notes and limits',
+ '',
+ 'No absolute colorimetry was extracted from new photographs. Any supplied notes below are qualitative annotations, excluded from fitting; they are not new RGB measurements or calibration imports.',
+ '',
+ ...(observations
+ ? observations.text.split(/\r?\n/).map((line) => `> ${line}`)
+ : ['No observation notes file supplied.']),
+ '',
+ report.scope.limitation,
+ '',
+ ];
+ // Non-recursive creation is exclusive; a concurrent collision must not overwrite a report.
+ mkdirSync(out);
+ writeFileSync(resolve(out, 'report.json'), JSON.stringify(report, null, 2) + '\n', {
+ flag: 'wx',
+ });
+ writeFileSync(resolve(out, 'report.md'), lines.join('\n'), { flag: 'wx' });
+ console.log(
+ `Replay complete: ${relative(workspace, out)}; ${patches.length} unchanged physical recipes, ${report.summary.roundedPredictionsChanged} revised colors.`
+ );
+ });
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/scripts/validate-appearance.ts b/scripts/validate-appearance.ts
new file mode 100644
index 00000000..04b7ce89
--- /dev/null
+++ b/scripts/validate-appearance.ts
@@ -0,0 +1,281 @@
+import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { withViteTestServer } from '../tests/helpers/viteModule.ts';
+import type { AutoPaintDiagnosticRunInputV1 } from '../src/lib/autoPaintDiagnostics.ts';
+import type { AppearanceFitContext } from '../src/lib/appearanceModel.ts';
+
+const help = `Offline, diagnostics-only Matrix prediction validation.
+Usage: npm run benchmark:appearance -- --input --out
+Options:
+ --profile Required if the file contains multiple profiles
+ --run Required if a trace contains multiple run-start records
+ --folds <2..10> Default 4
+ --delta-e Reporting threshold only; default 10
+ --layer-height Required for profiles with multiple Matrix layer heights
+ --first-layer-height Default from trace or first compatible Matrix
+ --opacity <0..1> Default from trace or 0.9; no optimizer runs
+ --sessions Optional object mapping EVERY included Matrix id to its shared print/photo session
+No application settings or calibrations are written. Existing output directories are refused.`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) {
+ console.log(help);
+ return;
+ }
+ const values = new Map();
+ const allowed = new Set([
+ 'input',
+ 'out',
+ 'profile',
+ 'run',
+ 'folds',
+ 'delta-e',
+ 'layer-height',
+ 'first-layer-height',
+ 'opacity',
+ 'sessions',
+ ]);
+ for (let i = 0; i < args.length; i += 2) {
+ const key = args[i].replace(/^--/, '');
+ if (
+ !args[i].startsWith('--') ||
+ !allowed.has(key) ||
+ !args[i + 1] ||
+ args[i + 1].startsWith('--') ||
+ values.has(key)
+ ) {
+ throw new Error(`Invalid or duplicate option: ${args[i]}\n${help}`);
+ }
+ values.set(key, args[i + 1]);
+ }
+ if (!values.has('input') || !values.has('out')) throw new Error(help);
+ const inputPath = resolve(values.get('input')!);
+ const outputPath = resolve(values.get('out')!);
+ if (existsSync(outputPath))
+ throw new Error('Output directory already exists; choose a new directory.');
+ const numberOption = (key: string, fallback: number): number => {
+ const value = values.has(key) ? Number(values.get(key)) : fallback;
+ if (!Number.isFinite(value)) throw new Error(`Invalid --${key}`);
+ return value;
+ };
+ let sessions: Record | undefined;
+ if (values.has('sessions')) {
+ const parsed: unknown = JSON.parse(readFileSync(resolve(values.get('sessions')!), 'utf8'));
+ if (
+ !parsed ||
+ typeof parsed !== 'object' ||
+ Array.isArray(parsed) ||
+ Object.values(parsed).some((v) => typeof v !== 'string' || !v.trim())
+ ) {
+ throw new Error(
+ 'Sessions must be a JSON object mapping Matrix IDs to nonempty session strings'
+ );
+ }
+ sessions = parsed as Record;
+ }
+ const raw = readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, '');
+ await withViteTestServer(async (server) => {
+ const validation = (await server.ssrLoadModule(
+ '/src/lib/appearanceValidation.ts'
+ )) as typeof import('../src/lib/appearanceValidation.ts');
+ const profiles = (await server.ssrLoadModule(
+ '/src/lib/profileManager.ts'
+ )) as typeof import('../src/lib/profileManager.ts');
+ const appearance = (await server.ssrLoadModule(
+ '/src/lib/appearanceProfile.ts'
+ )) as typeof import('../src/lib/appearanceProfile.ts');
+ let trace: AutoPaintDiagnosticRunInputV1 | undefined;
+ let profileJson = raw;
+ if (inputPath.toLowerCase().endsWith('.jsonl')) {
+ const records = raw
+ .trim()
+ .split(/\r?\n/)
+ .map((line) => JSON.parse(line))
+ .filter((r) => r.kind === 'run-start');
+ if (!records.length || (records.length > 1 && !values.has('run')))
+ throw new Error(
+ 'Select --run when a trace has multiple runs; at least one run-start is required'
+ );
+ const run = numberOption('run', 0);
+ if (!Number.isInteger(run) || run < 0 || run >= records.length)
+ throw new Error('Invalid --run index');
+ trace = records[run].payload;
+ if (trace?.schemaVersion !== 1 || !Array.isArray(trace.filaments) || !trace.settings)
+ throw new Error('Unsupported diagnostic input schema');
+ profileJson = JSON.stringify({
+ id: 'diagnostic-snapshot',
+ name: 'Diagnostic snapshot',
+ version: 3,
+ filaments: trace.filaments,
+ appearance: trace.appearanceProfile,
+ createdAt: 0,
+ updatedAt: 0,
+ });
+ } else if (values.has('run')) throw new Error('--run is only valid for JSONL traces');
+ const candidates = profiles.parseProfileFile(profileJson);
+ if (!candidates?.length) throw new Error('No valid profiles found');
+ if (candidates.length > 1 && !values.has('profile'))
+ throw new Error(`Select --profile: ${candidates.map((p) => p.id).join(', ')}`);
+ const profile = values.has('profile')
+ ? candidates.find((p) => p.id === values.get('profile'))
+ : candidates[0];
+ if (!profile?.appearance?.stackMatrices?.length)
+ throw new Error('Selected profile has no Matrix measurements');
+ const matrices = profile.appearance.stackMatrices.filter((m) => m.status === 'complete');
+ const heights = [...new Set(matrices.map((m) => m.process.layerHeight))];
+ if (!trace && heights.length > 1 && !values.has('layer-height'))
+ throw new Error('Multiple Matrix layer heights: select --layer-height');
+ const layerHeight = numberOption('layer-height', trace?.settings.layerHeight ?? heights[0]);
+ const firstLayerHeight = numberOption(
+ 'first-layer-height',
+ trace?.settings.firstLayerHeight ??
+ matrices.find((m) => m.process.layerHeight === layerHeight)?.process
+ .firstLayerHeight ??
+ layerHeight
+ );
+ const transitionOpacity = numberOption('opacity', trace?.settings.transitionOpacity ?? 0.9);
+ if (
+ layerHeight <= 0 ||
+ firstLayerHeight <= 0 ||
+ transitionOpacity <= 0 ||
+ transitionOpacity >= 1
+ )
+ throw new Error('Invalid layer height or opacity');
+ const context: AppearanceFitContext = {
+ filaments: profile.filaments,
+ filamentProfileFingerprint: appearance.fingerprintAppearanceFilaments(
+ profile.filaments
+ ),
+ layerHeight,
+ firstLayerHeight,
+ transitionOpacity,
+ };
+ const options = {
+ folds: numberOption('folds', 4),
+ maximumDeltaE: numberOption('delta-e', 10),
+ sessions,
+ onProgress: (message: string) => console.log(message),
+ };
+ const plan = validation.planAppearanceValidation(profile.appearance, context, options);
+ if (!plan.observations.length)
+ throw new Error(
+ 'No compatible, uncorrected measured Matrix samples at this layer height'
+ );
+ let commit = 'unknown';
+ let dirty = true;
+ try {
+ commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
+ dirty = Boolean(
+ execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).trim()
+ );
+ } catch {
+ /* Git is optional for an exported source tree. */
+ }
+ const sourceHashes = Object.fromEntries(
+ [
+ 'scripts/validate-appearance.ts',
+ 'src/lib/appearanceValidation.ts',
+ 'src/lib/appearanceModel.ts',
+ 'src/lib/effectiveOptics.ts',
+ 'src/lib/appearanceProfile.ts',
+ 'src/lib/calibration.ts',
+ 'src/lib/colorDifference.ts',
+ 'src/lib/colorSpace.ts',
+ 'src/lib/profileManager.ts',
+ ].map((path) => [
+ path,
+ createHash('sha256')
+ .update(readFileSync(resolve(path)))
+ .digest('hex'),
+ ])
+ );
+ const report = {
+ ...validation.runAppearanceValidation(profile.appearance, context, options),
+ execution: {
+ commit,
+ dirty,
+ source: inputPath,
+ profileId: profile.id,
+ node: process.version,
+ sourceHashes,
+ },
+ };
+ const lines = [
+ '# Matrix end-to-end validation',
+ '',
+ `Code: ${commit}${dirty ? ' (working-tree changes present)' : ''}. Input fingerprint: ${report.inputFingerprint}.`,
+ '',
+ report.scope,
+ '',
+ `${report.measuredSampleCount} measured samples, ${report.compatibleMatrixCount} matrices, ${report.boardGroupCount} board/photo groups. Reporting threshold: ΔE00 ${report.maximumDeltaE}.`,
+ '',
+ '| Holdout | Prediction | Mean ΔE00 | P90 | Worst | Within limit |',
+ '| --- | --- | ---: | ---: | ---: | ---: |',
+ ];
+ for (const scenario of report.scenarios) {
+ for (const stage of ['baseline', 'physical', 'full'] as const) {
+ const result = scenario[stage];
+ lines.push(
+ result
+ ? `| ${scenario.scenario} | ${stage} | ${result.mean.toFixed(2)} | ${result.p90.toFixed(2)} | ${result.worst.toFixed(2)} | ${result.withinLimit}/${result.count} |`
+ : `| ${scenario.scenario} | ${stage} | not evaluated | — | — | — |`
+ );
+ }
+ }
+ lines.push(
+ '',
+ 'Baseline = fixed filament/wedge prior; physical = training-only fitted optics with deployed support handling; full = physical plus training-only Matrix lookup and anchors.',
+ '',
+ '## Limits and exclusions',
+ '',
+ ...report.limitations.map((message) => `- ${message}`),
+ ...report.skipped.map((s) => `- ${s.scenario}: ${s.reason}`),
+ ...report.scenarios.map(
+ (s) =>
+ `- ${s.scenario}: ${s.evaluatedSampleCount} evaluated; ${s.unevaluatedSampleCount} not evaluated. Foundation-only samples have no pair for interaction holdouts.`
+ ),
+ ...report.excludedMatrices.map((m) => `- Excluded Matrix ${m.id}: ${m.reason}.`),
+ '',
+ '## Largest full-pipeline errors',
+ ''
+ );
+ const label = (id: string) => profile.filaments.find((f) => f.id === id)?.color ?? id;
+ for (const scenario of report.scenarios) {
+ if (!scenario.worstCases.length) continue;
+ lines.push(
+ `### ${scenario.scenario}`,
+ '',
+ '| Sample | Recipe (bottom → top, mm) | Prior ΔE | Full ΔE | Method |',
+ '| --- | --- | ---: | ---: | --- |'
+ );
+ for (const row of scenario.worstCases.slice(0, 8)) {
+ lines.push(
+ `| ${row.id.replace(/\|/g, '\\|')} | ${row.recipe.map((r) => `${label(r.filamentId)} ${r.thickness}`).join(' → ')} | ${row.errors.baseline.toFixed(2)} | ${row.errors.full.toFixed(2)} | ${row.method} |`
+ );
+ }
+ lines.push('');
+ }
+ lines.push(
+ '## Before new prints',
+ '',
+ 'Inspect leakage checks and regression pairs first. Do not tune the model against these holdouts and then reuse their scores as independent validation.',
+ '',
+ 'Next, freeze the profile/model and record predictions for a small set of diagnostic recipes, including a known reference and disputed combinations. Print at the recorded layer heights/backing, photograph together under consistent light, and keep the first comparison out of calibration until scored. An optimizer-chosen full-image print is a later acceptance check, not a replacement for those measurements.',
+ ''
+ );
+ mkdirSync(outputPath, { recursive: true });
+ writeFileSync(resolve(outputPath, 'report.json'), JSON.stringify(report, null, 2) + '\n', {
+ flag: 'wx',
+ });
+ writeFileSync(resolve(outputPath, 'report.md'), lines.join('\n'), { flag: 'wx' });
+ console.log(`Reports written to ${outputPath}`);
+ });
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exitCode = 1;
+});
diff --git a/scripts/verify-diagnostic-bundle.mjs b/scripts/verify-diagnostic-bundle.mjs
new file mode 100644
index 00000000..b0f87e56
--- /dev/null
+++ b/scripts/verify-diagnostic-bundle.mjs
@@ -0,0 +1,33 @@
+import { readFileSync } from 'node:fs';
+import { dirname, resolve, relative, isAbsolute } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createHash } from 'node:crypto';
+
+try {
+ const directory = resolve(process.argv[2] ?? dirname(fileURLToPath(import.meta.url)));
+ const manifest = JSON.parse(readFileSync(resolve(directory, 'manifest.json'), 'utf8'));
+ if (
+ manifest.schemaVersion !== 1 ||
+ manifest.status !== 'frozen-pre-print' ||
+ !Object.keys(manifest.files ?? {}).length
+ )
+ throw new Error('Missing or incomplete frozen-bundle manifest');
+ for (const [name, expected] of Object.entries(manifest.files)) {
+ const path = resolve(directory, name),
+ within = relative(directory, path);
+ if (!within || within.startsWith('..') || isAbsolute(within))
+ throw new Error('Unsafe manifest path');
+ const bytes = readFileSync(path);
+ if (
+ bytes.length !== expected.bytes ||
+ createHash('sha256').update(bytes).digest('hex') !== expected.sha256
+ )
+ throw new Error(`Changed file: ${name}`);
+ }
+ console.log(
+ `OK: ${Object.keys(manifest.files).length} frozen payload files match their SHA-256 hashes. This verifies integrity, not physical color accuracy.`
+ );
+} catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 99efa6e9..6577391d 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]]
@@ -1907,7 +1907,7 @@ dependencies = [
[[package]]
name = "kromacut"
-version = "3.1.0"
+version = "4.0.0"
dependencies = [
"log",
"reqwest 0.12.28",
@@ -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]]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 3be2eaaf..73c4ae71 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "kromacut"
-version = "3.1.0"
+version = "4.0.0"
description = "Kromacut - Multi-color lithophane 3D print generator"
authors = ["vycdev"]
license = "AGPL-3.0-only"
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 326728d9..10ea26d8 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,5 +1,12 @@
use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::fs::{create_dir_all, File, OpenOptions};
+use std::io::{BufWriter, Write};
+use std::path::{Path, PathBuf};
use std::process::Command;
+use std::sync::Mutex;
+use std::time::{SystemTime, UNIX_EPOCH};
+use tauri::{AppHandle, Manager, State};
const RELEASES_URL: &str = "https://github.com/vycdev/Kromacut/releases";
@@ -10,6 +17,154 @@ struct VersionInfo {
release_notes: Option,
}
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct DiagnosticSession {
+ id: String,
+ path: String,
+}
+
+#[derive(Default)]
+struct DiagnosticWriterState {
+ writers: Mutex>>,
+}
+
+fn auto_paint_diagnostics_directory(app: &AppHandle) -> Result {
+ let directory = app
+ .path()
+ .app_log_dir()
+ .map_err(|error| format!("Failed to resolve the app log directory: {error}"))?
+ .join("auto-paint-diagnostics");
+ create_dir_all(&directory)
+ .map_err(|error| format!("Failed to create the diagnostics directory: {error}"))?;
+ Ok(directory)
+}
+
+fn unix_timestamp_millis() -> Result {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_millis())
+ .map_err(|error| format!("System clock is before the Unix epoch: {error}"))
+}
+
+fn create_diagnostic_file(
+ directory: &Path,
+ timestamp: u128,
+) -> Result<(String, PathBuf, File), String> {
+ for collision in 0..1_000_u16 {
+ let id = if collision == 0 {
+ format!("auto-paint-{timestamp}")
+ } else {
+ format!("auto-paint-{timestamp}-{collision}")
+ };
+ let path = directory.join(format!("{id}.jsonl"));
+ match OpenOptions::new().write(true).create_new(true).open(&path) {
+ Ok(file) => return Ok((id, path, file)),
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(error) => {
+ return Err(format!("Failed to create the diagnostic file: {error}"));
+ }
+ }
+ }
+
+ Err("Could not allocate a unique diagnostic file name".to_string())
+}
+
+fn append_diagnostic_entries(writer: &mut W, entries: &[String]) -> Result<(), String> {
+ if entries
+ .iter()
+ .any(|entry| entry.contains('\n') || entry.contains('\r'))
+ {
+ return Err("Diagnostic entries must contain exactly one JSON line".to_string());
+ }
+
+ for entry in entries {
+ writer
+ .write_all(entry.as_bytes())
+ .and_then(|_| writer.write_all(b"\n"))
+ .map_err(|error| format!("Failed to append to the diagnostic file: {error}"))?;
+ }
+ writer
+ .flush()
+ .map_err(|error| format!("Failed to flush the diagnostic file: {error}"))
+}
+
+#[tauri::command]
+fn begin_auto_paint_diagnostic(
+ app: AppHandle,
+ state: State<'_, DiagnosticWriterState>,
+) -> Result {
+ let directory = auto_paint_diagnostics_directory(&app)?;
+ let timestamp = unix_timestamp_millis()?;
+ let (id, path, file) = create_diagnostic_file(&directory, timestamp)?;
+ state
+ .writers
+ .lock()
+ .map_err(|_| "Diagnostic writer lock was poisoned".to_string())?
+ .insert(id.clone(), BufWriter::new(file));
+
+ Ok(DiagnosticSession {
+ id,
+ path: path.to_string_lossy().into_owned(),
+ })
+}
+
+#[tauri::command]
+fn append_auto_paint_diagnostic(
+ session_id: String,
+ entries: Vec,
+ state: State<'_, DiagnosticWriterState>,
+) -> Result<(), String> {
+ let mut writers = state
+ .writers
+ .lock()
+ .map_err(|_| "Diagnostic writer lock was poisoned".to_string())?;
+ let writer = writers
+ .get_mut(&session_id)
+ .ok_or_else(|| format!("Unknown or closed diagnostic session: {session_id}"))?;
+
+ append_diagnostic_entries(writer, &entries)
+}
+
+#[tauri::command]
+fn finish_auto_paint_diagnostic(
+ session_id: String,
+ state: State<'_, DiagnosticWriterState>,
+) -> Result<(), String> {
+ let mut writers = state
+ .writers
+ .lock()
+ .map_err(|_| "Diagnostic writer lock was poisoned".to_string())?;
+ let mut writer = writers
+ .remove(&session_id)
+ .ok_or_else(|| format!("Unknown or closed diagnostic session: {session_id}"))?;
+ writer
+ .flush()
+ .map_err(|error| format!("Failed to close the diagnostic file: {error}"))
+}
+
+fn open_directory(path: &Path) -> Result<(), String> {
+ #[cfg(target_os = "windows")]
+ let result = Command::new("explorer").arg(path).spawn();
+
+ #[cfg(target_os = "macos")]
+ let result = Command::new("open").arg(path).spawn();
+
+ #[cfg(all(unix, not(target_os = "macos")))]
+ let result = Command::new("xdg-open").arg(path).spawn();
+
+ result
+ .map(|_| ())
+ .map_err(|error| format!("Failed to open the diagnostics directory: {error}"))
+}
+
+#[tauri::command]
+fn open_auto_paint_diagnostics_directory(app: AppHandle) -> Result {
+ let directory = auto_paint_diagnostics_directory(&app)?;
+ open_directory(&directory)?;
+ Ok(directory.to_string_lossy().into_owned())
+}
+
fn normalized_version(version: &str) -> &str {
version.trim().trim_start_matches(['v', 'V'])
}
@@ -72,12 +227,17 @@ fn open_external_url(url: &str) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
+ .manage(DiagnosticWriterState::default())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
check_for_updates,
get_app_version,
- open_releases_page
+ open_releases_page,
+ begin_auto_paint_diagnostic,
+ append_auto_paint_diagnostic,
+ finish_auto_paint_diagnostic,
+ open_auto_paint_diagnostics_directory
])
.setup(|app| {
if cfg!(debug_assertions) {
@@ -95,7 +255,17 @@ pub fn run() {
#[cfg(test)]
mod tests {
- use super::is_different_version;
+ use super::{append_diagnostic_entries, create_diagnostic_file, is_different_version};
+ use std::fs::{create_dir_all, read_to_string, remove_dir_all, File};
+ use std::io::Cursor;
+
+ fn diagnostic_test_directory(name: &str) -> std::path::PathBuf {
+ std::env::temp_dir().join(format!(
+ "kromacut-{name}-{}-{}",
+ std::process::id(),
+ super::unix_timestamp_millis().unwrap()
+ ))
+ }
#[test]
fn detects_any_different_release_version() {
@@ -109,4 +279,47 @@ mod tests {
assert!(!is_different_version("2.6.0", "2.6.0"));
assert!(!is_different_version(" v2.6.0 ", "2.6.0"));
}
+
+ #[test]
+ fn diagnostic_file_creation_avoids_collisions_and_persists_flushed_jsonl() {
+ let directory = diagnostic_test_directory("diagnostic-file");
+ create_dir_all(&directory).unwrap();
+ let timestamp = 123_u128;
+ File::create(directory.join("auto-paint-123.jsonl")).unwrap();
+
+ let (id, path, file) = create_diagnostic_file(&directory, timestamp).unwrap();
+ assert_eq!(id, "auto-paint-123-1");
+ let mut writer = std::io::BufWriter::new(file);
+ append_diagnostic_entries(
+ &mut writer,
+ &[
+ "{\"kind\":\"start\"}".to_string(),
+ "{\"kind\":\"end\"}".to_string(),
+ ],
+ )
+ .unwrap();
+ assert_eq!(
+ read_to_string(&path).unwrap(),
+ "{\"kind\":\"start\"}\n{\"kind\":\"end\"}\n"
+ );
+
+ drop(writer);
+ remove_dir_all(directory).unwrap();
+ }
+
+ #[test]
+ fn diagnostic_append_rejects_multiline_batches_without_partial_writes() {
+ let mut output = Cursor::new(Vec::::new());
+ let error = append_diagnostic_entries(
+ &mut output,
+ &[
+ "{\"kind\":\"valid\"}".to_string(),
+ "invalid\nline".to_string(),
+ ],
+ )
+ .unwrap_err();
+
+ assert!(error.contains("exactly one JSON line"));
+ assert!(output.into_inner().is_empty());
+ }
}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 34058649..0c03004a 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": "4.0.0",
"identifier": "com.kromacut.lithophane",
"build": {
"frontendDist": "../dist",
diff --git a/src/App.tsx b/src/App.tsx
index aab12b84..4d576271 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,9 +1,17 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState, useTransition } 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 PreviewColorMode,
+ type PreviewRenderMode,
+ type ThreeDControlsStateShape,
+ type TouchUpTool,
+} 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';
@@ -27,6 +35,12 @@ import { useAppHandlers, type ExportProgressStep } from './hooks/useAppHandlers'
import { useProcessingState } from './hooks/useProcessingState';
import { useBuildWarning } from './hooks/useBuildWarning';
import { clampProgress } from './lib/progress';
+import {
+ scheduleAfterTwoAnimationFrames,
+ type CancelDeferredWork,
+} from './lib/threeDWorkLifecycle';
+import { normalizeOptimizerTier } from './lib/optimizer';
+import { normalizeSeparationMaxDeltaE } from './lib/autoPaint';
import ResizableSplitter from './components/ResizableSplitter';
import { ControlsPanel } from './components/ControlsPanel';
import { usePaletteManager } from './hooks/usePaletteManager';
@@ -35,8 +49,24 @@ import ProgressOverlay from './components/ProgressOverlay';
import DocsPage from './components/docs/DocsPage';
import { defaultDocSlug } from './docs';
import { loadCameraMode, saveCameraMode } from './lib/cameraPrefs';
+import {
+ loadPreviewColorMode,
+ loadPreviewRenderMode,
+ savePreviewColorMode,
+ savePreviewRenderMode,
+} from './lib/previewPrefs';
import { buildDocsPath, parseDocsLocation } from './lib/docs/navigation';
-import { applyHomeSeo } from './lib/seo';
+import { applyAppSeo } from './lib/seo';
+import { appPath, markLaunched } from './lib/routes';
+import { isTauri } from '@tauri-apps/api/core';
+import {
+ migrateLegacyFilamentTd,
+ prewarmAutoPaintProfiles,
+ repairDuplicateFilamentIds,
+ sanitizeProfileFilament,
+} from './lib/profileManager';
+import PrintUnlockEffect from './components/PrintUnlockEffect';
+import { getMultiPlateEnabled, subscribeToMultiPlateEnabled } from './lib/experimentalFeatures';
import {
AlertDialog,
AlertDialogContent,
@@ -73,16 +103,43 @@ type AutoPaintPersisted = Pick<
ThreeDControlsStateShape,
| 'filaments'
| 'paintMode'
+ | 'autoPaintMaxHeight'
+ | 'calibrationLayerHeight'
| 'optimizerAlgorithm'
| 'optimizerSeed'
| 'regionWeightingMode'
| 'enhancedColorMatch'
+ | 'preserveSeparation'
+ | 'separationMaxDeltaE'
+ | 'failOnSeparationError'
| 'allowRepeatedSwaps'
+ | 'maxRepeatedSwaps'
+ | 'transitionOpacity'
| 'heightDithering'
| 'ditherLineWidth'
+ | 'omitAtRiskPixels'
| 'flatPaint'
+ | 'flatPaintFaceUp'
>;
+// 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]);
+}
+
+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);
@@ -90,6 +147,20 @@ 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 sanitized = parsed.filaments
+ .map((filament: unknown) => sanitizeProfileFilament(filament))
+ .filter(
+ (
+ filament: ReturnType
+ ): filament is NonNullable> =>
+ filament !== null
+ );
+ const persistedSchema = typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : 1;
+ const migratedFilaments =
+ persistedSchema >= AUTOPAINT_SCHEMA_VERSION
+ ? sanitized
+ : sanitized.map(migrateLegacyFilamentTd);
+ const filaments = repairDuplicateFilamentIds(migratedFilaments);
// Migrate legacy `autoPaintEnabled` boolean → `paintMode`
const paintMode: 'manual' | 'autopaint' =
parsed.paintMode === 'autopaint' || parsed.paintMode === 'manual'
@@ -98,16 +169,37 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => {
? 'autopaint'
: 'manual';
return {
- filaments: parsed.filaments,
+ filaments,
paintMode,
- optimizerAlgorithm: parsed.optimizerAlgorithm,
+ autoPaintMaxHeight:
+ typeof parsed.autoPaintMaxHeight === 'number' &&
+ Number.isFinite(parsed.autoPaintMaxHeight) &&
+ parsed.autoPaintMaxHeight > 0
+ ? parsed.autoPaintMaxHeight
+ : undefined,
+ calibrationLayerHeight:
+ typeof parsed.calibrationLayerHeight === 'number' &&
+ Number.isFinite(parsed.calibrationLayerHeight) &&
+ parsed.calibrationLayerHeight > 0
+ ? parsed.calibrationLayerHeight
+ : undefined,
+ optimizerAlgorithm: normalizeOptimizerTier(parsed.optimizerAlgorithm),
optimizerSeed: parsed.optimizerSeed,
regionWeightingMode: parsed.regionWeightingMode,
enhancedColorMatch: parsed.enhancedColorMatch ?? false,
- allowRepeatedSwaps: parsed.allowRepeatedSwaps ?? false,
+ preserveSeparation: parsed.preserveSeparation ?? false,
+ separationMaxDeltaE: normalizeSeparationMaxDeltaE(parsed.separationMaxDeltaE),
+ failOnSeparationError: parsed.failOnSeparationError !== false,
+ maxRepeatedSwaps: normalizeRepeatLimit(
+ parsed.maxRepeatedSwaps,
+ parsed.allowRepeatedSwaps
+ ),
+ transitionOpacity: normalizeTransitionOpacity(parsed.transitionOpacity),
heightDithering: parsed.heightDithering ?? false,
ditherLineWidth: parsed.ditherLineWidth,
+ omitAtRiskPixels: parsed.omitAtRiskPixels === true,
flatPaint: parsed.flatPaint ?? false,
+ flatPaintFaceUp: parsed.flatPaintFaceUp === true,
};
} catch {
return null;
@@ -116,13 +208,38 @@ 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
}
};
function App(): React.ReactElement | null {
+ const toolPath = appPath(isTauri());
+ // Multi-plate mode (issue #35) is still a stub. Per the plan, the flow diverges
+ // at *image upload*, not at app start — so until an image is uploaded the app
+ // must look and behave exactly like today. The experimental "Multi-plate mode"
+ // toggle in Settings arms the flag; the future upload decider will read it (via
+ // `getMultiPlateEnabled()`) inside the upload path to fork into multi-plate
+ // handling. The toggle persists (localStorage) like the other settings. Flipping
+ // it on plays a one-shot 3D-print flourish as feedback, then hands straight back
+ // to the untouched single-image UI; a persisted-on flag on a fresh load does not
+ // replay the flourish, so App only reacts to off→on transitions.
+ const multiPlateEnabledRef = useRef(getMultiPlateEnabled());
+ const [printing, setPrinting] = useState(false);
+
+ useEffect(() => {
+ return subscribeToMultiPlateEnabled((enabled) => {
+ if (enabled && !multiPlateEnabledRef.current) setPrinting(true); // flourish only on off→on
+ multiPlateEnabledRef.current = enabled;
+ });
+ }, []);
+
+ const handleEffectDone = useCallback(() => setPrinting(false), []);
+
// dropzone state managed by hook below
// `weight` is the algorithm parameter; `finalColors` is the postprocess target
const [weight, setWeight] = useState(128);
@@ -139,10 +256,21 @@ function App(): React.ReactElement | null {
importInputRef: paletteImportInputRef,
handleCreatePalette,
handleUpdatePalette,
+ handleClonePalette,
handleDeletePalette,
handleExportPalette,
handleImportFile: handleImportPaletteFile,
} = usePaletteManager();
+
+ // Keep the postprocess color count in sync with the selected palette's
+ // enabled size. Edits, per-color toggles, clones, and imports all change
+ // the size without a re-selection, so the dropdown callback alone is not
+ // enough. Manual overrides persist until the palette or selection changes.
+ useEffect(() => {
+ if (selectedPalette === 'auto') return;
+ const pal = allPalettes.find((p) => p.id === selectedPalette);
+ if (pal && pal.size > 0) setFinalColors(pal.size);
+ }, [allPalettes, selectedPalette]);
const { imageSrc, setImage, clearCurrent, undo, redo, canUndo, canRedo } = useImageHistory(
logo,
undefined
@@ -156,6 +284,11 @@ function App(): React.ReactElement | null {
const [showCheckerboard, setShowCheckerboard] = useState(false);
const [isCropMode, setIsCropMode] = useState(false);
const [hasValidCropSelection, setHasValidCropSelection] = useState(false);
+ // 2D touch-up tools are session-only; image changes still use shared history.
+ const [touchUpTool, setTouchUpTool] = useState(null);
+ const [touchUpColor, setTouchUpColor] = useState('#000000');
+ const [touchUpBrushSize, setTouchUpBrushSize] = useState(4);
+ const [touchUpTextSize, setTouchUpTextSize] = useState(24);
const {
isQuantizing,
setIsQuantizing,
@@ -201,8 +334,64 @@ function App(): React.ReactElement | null {
const [adjustmentsEpoch, setAdjustmentsEpoch] = useState(0);
// UI mode toggles (2D / 3D) - UI only for now
const [mode, setMode] = useState<'2d' | '3d'>('2d');
+ const [hasMountedThreeDControls, setHasMountedThreeDControls] = useState(false);
+ const [, startThreeDControlsTransition] = useTransition();
+ const cancelThreeDMountRef = useRef(null);
+ const handleModeChange = useCallback(
+ (nextMode: '2d' | '3d') => {
+ setMode(nextMode);
+ if (nextMode !== '3d') {
+ cancelThreeDMountRef.current?.();
+ cancelThreeDMountRef.current = null;
+ return;
+ }
+
+ if (!hasMountedThreeDControls && !cancelThreeDMountRef.current) {
+ cancelThreeDMountRef.current = scheduleAfterTwoAnimationFrames(() => {
+ cancelThreeDMountRef.current = null;
+ startThreeDControlsTransition(() => setHasMountedThreeDControls(true));
+ });
+ }
+ },
+ [hasMountedThreeDControls]
+ );
+ useEffect(
+ () => () => {
+ cancelThreeDMountRef.current?.();
+ cancelThreeDMountRef.current = null;
+ },
+ []
+ );
+ useEffect(() => {
+ let cancelled = false;
+ const prewarm = () => {
+ if (!cancelled) prewarmAutoPaintProfiles();
+ };
+
+ const idleWindow = window as unknown as {
+ requestIdleCallback?: Window['requestIdleCallback'];
+ cancelIdleCallback?: Window['cancelIdleCallback'];
+ };
+ if (idleWindow.requestIdleCallback && idleWindow.cancelIdleCallback) {
+ const idleId = idleWindow.requestIdleCallback(prewarm);
+ return () => {
+ cancelled = true;
+ idleWindow.cancelIdleCallback?.(idleId);
+ };
+ }
+
+ const timerId = globalThis.setTimeout(prewarm, 250);
+ return () => {
+ cancelled = true;
+ globalThis.clearTimeout(timerId);
+ };
+ }, []);
const [docsOpen, setDocsOpen] = useState(() => parseDocsLocation(window.location) !== null);
const [isOrtho, setIsOrtho] = useState(loadCameraMode);
+ const [previewRenderMode, setPreviewRenderMode] =
+ useState(loadPreviewRenderMode);
+ const [previewColorMode, setPreviewColorMode] =
+ useState(loadPreviewColorMode);
const [exportingSTL, setExportingSTL] = useState(false);
const [exportProgress, setExportProgress] = useState(0); // 0..1
const [exportStep, setExportStep] = useState({
@@ -211,6 +400,12 @@ function App(): React.ReactElement | null {
stepIndex: 1,
stepCount: 1,
});
+ // Hydrate before the shared 3D state is created. Hydrating in an effect lets
+ // the persistence effect from the same initial commit overwrite saved values
+ // with defaults before React applies the hydrated state (especially on HMR).
+ const [autopaintHydrated] = useState(loadAutoPaintPersisted);
+ const autoPaintWorkingStateInitializedRef = useRef(autopaintHydrated !== null);
+
// 3D printing shared state
const {
threeDState,
@@ -219,37 +414,15 @@ function App(): React.ReactElement | null {
builtThreeDState,
builtFlatPaint,
buildWarning,
+ isBuildStarting,
handleThreeDStateChange,
confirmBuild,
cancelBuild,
- } = useBuildWarning({ imageSrc });
+ markBuildStarted,
+ } = useBuildWarning({ imageSrc, initialState: autopaintHydrated });
const builtModelState = builtThreeDState ?? threeDState;
const builtModelAutoPaint = builtModelState.paintMode === 'autopaint';
-
- // Hydrate threeDState once with persisted autopaint data
- const [autopaintHydrated] = useState(() => {
- const persisted = loadAutoPaintPersisted();
- return persisted;
- });
- useEffect(() => {
- if (autopaintHydrated) {
- setThreeDState((prev) => ({
- ...prev,
- filaments: autopaintHydrated.filaments ?? prev.filaments,
- paintMode: autopaintHydrated.paintMode ?? prev.paintMode,
- optimizerAlgorithm: autopaintHydrated.optimizerAlgorithm ?? prev.optimizerAlgorithm,
- optimizerSeed: autopaintHydrated.optimizerSeed ?? prev.optimizerSeed,
- regionWeightingMode:
- autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode,
- enhancedColorMatch: autopaintHydrated.enhancedColorMatch ?? prev.enhancedColorMatch,
- allowRepeatedSwaps: autopaintHydrated.allowRepeatedSwaps ?? prev.allowRepeatedSwaps,
- heightDithering: autopaintHydrated.heightDithering ?? prev.heightDithering,
- ditherLineWidth: autopaintHydrated.ditherLineWidth ?? prev.ditherLineWidth,
- flatPaint: autopaintHydrated.flatPaint ?? prev.flatPaint,
- }));
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ const builtModelValid = !builtModelAutoPaint || builtModelState.autoPaintResult !== undefined;
const prevModeRef = useRef(mode);
@@ -257,26 +430,42 @@ function App(): React.ReactElement | null {
saveAutoPaintPersisted({
filaments: threeDState.filaments,
paintMode: threeDState.paintMode ?? 'manual',
+ autoPaintMaxHeight: threeDState.autoPaintMaxHeight,
+ calibrationLayerHeight: threeDState.calibrationLayerHeight,
optimizerAlgorithm: threeDState.optimizerAlgorithm,
optimizerSeed: threeDState.optimizerSeed,
regionWeightingMode: threeDState.regionWeightingMode,
enhancedColorMatch: threeDState.enhancedColorMatch,
- allowRepeatedSwaps: threeDState.allowRepeatedSwaps,
+ preserveSeparation: threeDState.preserveSeparation,
+ separationMaxDeltaE: threeDState.separationMaxDeltaE,
+ failOnSeparationError: threeDState.failOnSeparationError,
+ maxRepeatedSwaps: threeDState.maxRepeatedSwaps,
+ transitionOpacity: threeDState.transitionOpacity,
heightDithering: threeDState.heightDithering,
ditherLineWidth: threeDState.ditherLineWidth,
+ omitAtRiskPixels: threeDState.omitAtRiskPixels,
flatPaint: threeDState.flatPaint,
+ flatPaintFaceUp: threeDState.flatPaintFaceUp,
});
}, [
threeDState.filaments,
threeDState.paintMode,
+ threeDState.autoPaintMaxHeight,
+ threeDState.calibrationLayerHeight,
threeDState.optimizerAlgorithm,
threeDState.optimizerSeed,
threeDState.regionWeightingMode,
threeDState.enhancedColorMatch,
- threeDState.allowRepeatedSwaps,
+ threeDState.preserveSeparation,
+ threeDState.separationMaxDeltaE,
+ threeDState.failOnSeparationError,
+ threeDState.maxRepeatedSwaps,
+ threeDState.transitionOpacity,
threeDState.heightDithering,
threeDState.ditherLineWidth,
+ threeDState.omitAtRiskPixels,
threeDState.flatPaint,
+ threeDState.flatPaintFaceUp,
]);
// No auto-build on tab switch — the user must click "Build 3D Model" / "Apply Changes".
@@ -305,23 +494,24 @@ function App(): React.ReactElement | null {
useEffect(() => {
if (!docsOpen) {
- applyHomeSeo();
+ markLaunched();
+ }
+ }, [docsOpen]);
+
+ useEffect(() => {
+ if (!docsOpen) {
+ applyAppSeo();
}
}, [docsOpen]);
const backToApp = () => {
setDocsOpen(false);
if (parseDocsLocation(window.location)) {
- window.history.pushState(null, '', '/');
+ window.history.pushState(null, '', toolPath);
}
};
- const toggleDocs = () => {
- if (docsOpen) {
- backToApp();
- return;
- }
-
+ const openDocs = () => {
setDocsOpen(true);
if (!parseDocsLocation(window.location)) {
window.history.pushState(null, '', buildDocsPath(defaultDocSlug));
@@ -334,6 +524,7 @@ function App(): React.ReactElement | null {
alert('Please upload an image file');
return;
}
+ setTouchUpTool(null);
const url = URL.createObjectURL(file);
invalidate();
setImage(url, true);
@@ -342,6 +533,7 @@ function App(): React.ReactElement | null {
// removed unused handlers (inline handlers used instead)
const clear = () => {
+ setTouchUpTool(null);
clearCurrent();
if (inputRef.current) inputRef.current.value = '';
};
@@ -407,6 +599,19 @@ function App(): React.ReactElement | null {
// Layout splitter state
const layoutRef = useRef(null);
+ const touchUpPaletteColors = useMemo(
+ () =>
+ Array.from(
+ new Set(swatches.filter((swatch) => swatch.a !== 0).map((swatch) => swatch.hex))
+ ),
+ [swatches]
+ );
+
+ const handleTouchUpToolChange = (nextTool: TouchUpTool | null) => {
+ if (!imageSrc) return;
+ setTouchUpTool(nextTool);
+ };
+
const dropzone = useDropzone({ enabled: mode === '2d', onFile: handleFiles });
const { onExportImage, onExportStl, onExport3MF, onSwatchApply, onSwatchDelete } =
useAppHandlers({
@@ -423,10 +628,9 @@ function App(): React.ReactElement | null {
exportObjectTo3MFBlob(obj, {
layerHeight: builtModelState.layerHeight,
firstLayerHeight: builtModelState.slicerFirstLayerHeight,
- layerFilamentColors:
- builtModelAutoPaint
- ? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex)
- : undefined,
+ layerFilamentColors: builtModelAutoPaint
+ ? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex)
+ : undefined,
onProgress,
onZipProgress,
}),
@@ -437,35 +641,23 @@ function App(): React.ReactElement | null {
const processingActive = mode === '2d' && (isQuantizing || isDedithering);
return (
-
-
{
- invalidate();
- setImage(tdTestImg, true);
- setMode('2d');
- if (parseDocsLocation(window.location)) {
- window.history.pushState(null, '', '/');
- }
- setDocsOpen(false);
- }}
- />
- {docsOpen && (
-
-
-
- )}
-
+ <>
+
+
+ {docsOpen && (
+
+
+
+ )}
+
-
-
+
+
- {mode === '2d' ? (
+ {mode === '2d' && (
<>
{
+ setTouchUpTool(null);
setAdjustments(vals);
// schedule a redraw
requestAnimationFrame(() =>
canvasPreviewRef.current?.redraw()
);
}}
- onBake={async () => {
+ onBake={async (vals) => {
if (!canvasPreviewRef.current) return;
try {
const blob =
- await canvasPreviewRef.current.exportAdjustedImageBlob?.();
+ await canvasPreviewRef.current.exportAdjustedImageBlob?.(
+ vals
+ );
if (!blob) return;
const url = URL.createObjectURL(blob);
invalidate();
@@ -597,6 +792,7 @@ function App(): React.ReactElement | null {
importInputRef={paletteImportInputRef}
onCreatePalette={handleCreatePalette}
onUpdatePalette={handleUpdatePalette}
+ onClonePalette={handleClonePalette}
onDeletePalette={handleDeletePalette}
onExportPalette={handleExportPalette}
onImportPaletteFile={handleImportPaletteFile}
@@ -610,18 +806,32 @@ function App(): React.ReactElement | null {
/>
>
- ) : (
+ )}
+ {mode === '3d' && !hasMountedThreeDControls && (
+
+ Preparing 3D controls…
+
+ )}
+ {hasMountedThreeDControls && (
+
- setThreeDState((prev) => ({ ...prev, ...partial }))
- }
+ onSettingsChange={(partial) => {
+ autoPaintWorkingStateInitializedRef.current = true;
+ setThreeDState((prev) => ({ ...prev, ...partial }));
+ }}
persisted={threeDState}
+ hasAutoPaintWorkingState={
+ autoPaintWorkingStateInitializedRef.current
+ }
/>
+
)}
@@ -632,7 +842,15 @@ function App(): React.ReactElement | null {
onDragOver={dropzone.onDragOver}
onDragLeave={dropzone.onDragLeave}
>
- {mode === '2d' ? (
+ {mode === '3d' && !hasMountedThreeDControls && (
+
+ )}
+ {mode === '2d' && (
<>
{
+ const url = URL.createObjectURL(blob);
+ invalidate();
+ setImage(url, true);
+ return url;
+ }}
+ onTouchUpPickColor={(hex) => {
+ setTouchUpColor(hex);
+ setTouchUpTool('brush');
+ }}
+ onTouchUpExit={() => setTouchUpTool(null)}
/>
{processingActive && (
)}
>
- ) : (
+ )}
+ {hasMountedThreeDControls && (
+
<>
+ {isBuildStarting && (
+
+ )}
{exportingSTL && (
)}
>
+
)}
imageSrc && setIsCropMode(true)}
+ onEnterCrop={() => {
+ if (!imageSrc) return;
+ setTouchUpTool(null);
+ setIsCropMode(true);
+ }}
onSaveCrop={async () => {
if (!canvasPreviewRef.current) return;
const blob =
@@ -733,7 +998,28 @@ function App(): React.ReactElement | null {
onExportStl={onExportStl}
onExport3MF={onExport3MF}
flatPaintModel={builtFlatPaint}
+ modelExportDisabled={!builtModelValid}
isOrtho={isOrtho}
+ previewRenderMode={previewRenderMode}
+ onPreviewRenderModeChange={(next) => {
+ setPreviewRenderMode(next);
+ savePreviewRenderMode(next);
+ }}
+ autoPaintEnabled={builtModelAutoPaint}
+ previewColorMode={previewColorMode}
+ onPreviewColorModeChange={(next) => {
+ setPreviewColorMode(next);
+ savePreviewColorMode(next);
+ }}
+ touchUpTool={touchUpTool}
+ onTouchUpToolChange={handleTouchUpToolChange}
+ touchUpColor={touchUpColor}
+ onTouchUpColorChange={setTouchUpColor}
+ touchUpBrushSize={touchUpBrushSize}
+ onTouchUpBrushSizeChange={setTouchUpBrushSize}
+ touchUpTextSize={touchUpTextSize}
+ onTouchUpTextSizeChange={setTouchUpTextSize}
+ paletteColors={touchUpPaletteColors}
onToggleCamera={() =>
setIsOrtho((v) => {
const next = !v;
@@ -745,38 +1031,42 @@ function App(): React.ReactElement | null {
-
+
- {/* Build warning dialog */}
- !open && cancelBuild()}
- >
-
-
- Performance Warning
-
-
-
Building the 3D model may be slow due to:
-
- {buildWarning?.warnings.map((w, i) => (
- {w}
- ))}
-
-
Do you want to continue?
-
-
-
-
- Cancel
- Build Anyway
-
-
-
-
- {/* Update checker for Tauri desktop app */}
-
-
+ {/* Build warning dialog */}
+ !open && cancelBuild()}
+ >
+
+
+ Performance Warning
+
+
+
Building the 3D model may be slow due to:
+
+ {buildWarning?.warnings.map((w, i) => (
+ {w}
+ ))}
+
+
Do you want to continue?
+
+
+
+
+ Cancel
+
+ Build Anyway
+
+
+
+
+
+ {/* Update checker for Tauri desktop app */}
+
+
+ {printing && }
+ >
);
}
diff --git a/src/assets/diagrams/06_frontlit_hiding_distance.svg b/src/assets/diagrams/06_frontlit_hiding_distance.svg
new file mode 100644
index 00000000..6e409e26
--- /dev/null
+++ b/src/assets/diagrams/06_frontlit_hiding_distance.svg
@@ -0,0 +1,62 @@
+
+
+
+Hiding Distance — Thin Layers Only Partly Hide the Base
+Frontlit viewing: light passes through the filament, reflects off what is beneath, and passes back out.
+Transmission T = 10^(−thickness / HD) — every added layer multiplies the show-through.
+
+what you see
+
+
+
+
+
+
+1 layer · T ≈ 50%
+
+
+
+
+
+
+
+2 layers · T ≈ 25%
+
+
+
+
+
+
+
+
+
+4 layers · T ≈ 6%
+
+
+
+
+
+
+
+
+
+
+
+
+
+8 layers · T < 1%
+
+black base
+
+
+
+opaque filament color
+(the wedge's reference rail)
+T = 10^(−thickness / HD)
+HD (hiding distance): the depth at
+which a filament visually hides
+what's beneath it.
+Conventional backlit TD ≈ 10 × HD.
+
+Kromacut · frontlit calibration
+
diff --git a/src/assets/diagrams/07_calibration_wedge.svg b/src/assets/diagrams/07_calibration_wedge.svg
new file mode 100644
index 00000000..f2919390
--- /dev/null
+++ b/src/assets/diagrams/07_calibration_wedge.svg
@@ -0,0 +1,64 @@
+
+
+Reading the Calibration Wedge
+Each tile prints patches of 1..N filament layers over a base, beside a rail of fully opaque filament.
+You report one number per tile: the first patch that looks identical to the rail.
+
+
+
+reference rail
+(fully opaque)
+
+
+
+foot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+first patch identical to the rail → report 8
+
+
+side view (single-filament STL workflow)
+
+
+
+
+
+
+
+
+
+
+
+
+
+base filament
+
+single swap:
+base → color
+
+Kromacut · frontlit calibration
+
diff --git a/src/assets/diagrams/08_opacity_solve.svg b/src/assets/diagrams/08_opacity_solve.svg
new file mode 100644
index 00000000..ca555d5e
--- /dev/null
+++ b/src/assets/diagrams/08_opacity_solve.svg
@@ -0,0 +1,48 @@
+
+
+From One Read to a Hiding Distance
+The difference between each patch and the opaque rail shrinks as layers stack.
+Your read pins where that difference crosses one just-noticeable difference (JND).
+
+
+
+
+
+
+
+
+
+0
+2
+4
+8
+12
+0
+2
+4
+6
+8
+10
+12
+ΔE00 difference from the opaque rail
+wedge patch — layers of filament over the base
+
+
+
+
+
+
+one JND (ΔE00 ≈ 2) — “looks identical”
+
+
+
+
+your read: patch 8 → d* = 8 × layer height
+
+
+HD = −d* / log₁₀(T*)
+T* = the show-through that sits exactly one JND
+from opaque — solved from the colors alone
+
+Kromacut · frontlit calibration
+
diff --git a/src/assets/diagrams/09_palette_proof.svg b/src/assets/diagrams/09_palette_proof.svg
new file mode 100644
index 00000000..d1cab9b1
--- /dev/null
+++ b/src/assets/diagrams/09_palette_proof.svg
@@ -0,0 +1,68 @@
+
+
+
+Palette Proof Layout
+Each target is one printed row; its A-E candidates run left to right in both app views.
+
+Patch map
+T = target shown in Kromacut, A-E = physical candidates
+
+
+A B C D E
+
+
+
+
+
+
+
+
+
+
+1 2 3 4
+5 6 7 8
+
+
+
+
+
+
+
+
+
+
+
+
+A1 F B1 C1 D1 E1
+A2 B2 C2 D2 E2
+A3 B3 C3 D3 E3
+A4 B4 C4 D4 E4
+A5 B5 C5 D5 E5
+A6 B6 C6 D6 E6
+A7 B7 C7 D7 E7
+A8 B8 C8 D8 E8
+
+
+44 mm wide x 68 mm high at the default layout
+notch
+
+Cross-section
+Candidates stay exact while touching neighbors share layer regions
+
+
+
+
+
+
+
+
+
+
+prefix 2 prefix 3 prefix 4 prefix 5 prefix 6
+
+continuous first layer; touching active cells union on each later layer
+One physical filament is assigned to each horizontal layer.
+No arbitrary color combinations are fabricated.
+
+Kromacut - camera-free appearance proof
+
diff --git a/src/assets/discord.svg b/src/assets/discord.svg
new file mode 100644
index 00000000..9d7796b8
--- /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 00000000..538ec5bf
--- /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 00000000..ea038831
--- /dev/null
+++ b/src/assets/reddit.svg
@@ -0,0 +1 @@
+Reddit
\ No newline at end of file
diff --git a/src/components/AdjustmentsPanel.tsx b/src/components/AdjustmentsPanel.tsx
index dd69e0d0..c2513dbc 100644
--- a/src/components/AdjustmentsPanel.tsx
+++ b/src/components/AdjustmentsPanel.tsx
@@ -1,18 +1,9 @@
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 = {
- key: string;
- label: string;
- min: number;
- max: number;
- step: number;
- default: number;
- unit?: string;
-};
+import type { SliderDef } from '@/components/sliderDefs';
interface Props {
defs: SliderDef[];
@@ -120,12 +111,14 @@ export const AdjustmentsPanel: React.FC = React.memo(
}, [onBake, onCommit]);
return (
-
-
-
-
Adjustments
-
Fine-tune image properties
-
+
: undefined
+ }
+ actions={
= React.memo(
>
-
-
+ }
+ >
{defs.map((s) => {
const displayVal = values[s.key];
@@ -188,7 +181,7 @@ export const AdjustmentsPanel: React.FC
= React.memo(
);
})}
-
+
= React.memo(
Apply
-
+
);
}
);
diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx
index 5f93e400..9aa32794 100644
--- a/src/components/AutoPaintTab.tsx
+++ b/src/components/AutoPaintTab.tsx
@@ -1,18 +1,17 @@
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 {
Plus,
Trash2,
- Sparkles,
Save,
Download,
Upload,
FilePlus,
Pencil,
- BadgeCheck,
Loader2,
+ FlaskConical,
} from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
@@ -25,14 +24,284 @@ import {
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { TabsContent } from '@/components/ui/tabs';
-import type { AutoPaintResult, TransitionZone } from '../lib/autoPaint';
+import {
+ MAX_SEPARATION_MAX_DELTA_E,
+ MIN_SEPARATION_MAX_DELTA_E,
+ normalizeSeparationMaxDeltaE,
+ type AutoPaintResult,
+ type TransitionZone,
+} from '../lib/autoPaint';
+import type {
+ PaletteProofRecord,
+ PaletteTargetResponse,
+ StackMatrixCalibrationV1,
+} from '../lib/appearanceProfile';
+import type { PaletteProofSpec } from '../lib/paletteProof';
import type { AutoPaintProfile } from '../lib/profileManager';
-import type { Filament, Swatch } from '../types';
-import type { CalibrationResult } from '../lib/calibration';
+import type {
+ AutoPaintRepeatLimit,
+ AutoPaintTransitionOpacity,
+ Filament,
+ FinalPrintableStackSnapshot,
+ Swatch,
+} from '../types';
import FilamentRow from './FilamentRow';
-import { FilamentCalibrationWizard } from './FilamentCalibrationWizard';
+import {
+ FilamentCalibrationDialog,
+ type CalibrationApplyUpdate,
+} from './FilamentCalibrationDialog';
+import { TEMPLATE_PROFILES, isTemplateProfileId } from '../data/supplierFilaments';
import { getConfidenceLabel, getConfidenceColor } from '../lib/calibration';
+import { getExactBaseOrderCount } from '../lib/optimizer';
import { useNextBestColorWorker } from '../hooks/useNextBestColorWorker';
+import type { PrintableFeatureSimulation } from '../lib/printableFeatures.ts';
+import PrintableFeaturePreview from './PrintableFeaturePreview';
+import { formatColorSeparationStatus } from '../lib/colorSeparationStatus';
+
+/** Percentage stat tile with a slim progress bar, colored by confidence band. */
+function ConfidenceStat({ label, value }: { label: string; value: number }) {
+ const pct = Math.round(value * 100);
+ return (
+
+ );
+}
+
+function ColorSeparationStatus({ result }: { result: AutoPaintResult }) {
+ const report = result.colorSeparation;
+ if (!report) return null;
+ const extraRepeatCount = result.optimizerMetadata?.extraRepeatCount ?? 0;
+
+ return (
+
+ {formatColorSeparationStatus(report, extraRepeatCount)}
+
+ );
+}
+
+function AppearanceModelStat({ result }: { result: AutoPaintResult }) {
+ const model = result.finalStack.appearanceModel;
+ const effectiveOptics = model.effectiveOptics;
+ const physicalFitApplied = effectiveOptics?.applied ?? false;
+ const exactAnchorCount = model.exactAnchors?.length ?? 0;
+ const matrixAnchorCount =
+ model.exactAnchors?.filter((anchor) => anchor.source === 'stack-matrix').length ?? 0;
+ const matrixLutSampleCount =
+ model.empiricalLuts?.reduce((sum, lut) => sum + lut.samples.length, 0) ?? 0;
+ const localEvidenceCount = model.localEvidence?.length ?? 0;
+ const proofAnchorCount = exactAnchorCount - matrixAnchorCount;
+ const comparedStackKeys = new Set(model.comparedStackKeys);
+ const comparedCoverage = result.finalStack.targetMappings
+ .filter((mapping) => comparedStackKeys.has(mapping.canonicalStackKey))
+ .reduce((sum, mapping) => sum + mapping.usageWeight, 0);
+ const localCoverage = result.finalStack.targetMappings
+ .filter(
+ (mapping) =>
+ (result.finalStack.palette[mapping.paletteIndex]?.localEvidenceIds?.length ?? 0) > 0
+ )
+ .reduce((sum, mapping) => sum + mapping.usageWeight, 0);
+ const mappedPredictionConfidence = result.finalStack.targetMappings
+ .map((mapping) => ({
+ confidence: mapping.predictionConfidence,
+ weight: mapping.usageWeight,
+ }))
+ .filter(
+ (
+ entry
+ ): entry is {
+ confidence: NonNullable;
+ weight: number;
+ } => Boolean(entry.confidence)
+ );
+ const predictionWeight = mappedPredictionConfidence.reduce(
+ (sum, entry) => sum + entry.weight,
+ 0
+ );
+ const averagePredictionConfidence =
+ predictionWeight > 0
+ ? mappedPredictionConfidence.reduce(
+ (sum, entry) => sum + entry.confidence.confidence * entry.weight,
+ 0
+ ) / predictionWeight
+ : null;
+ const minimumPredictionConfidence =
+ mappedPredictionConfidence.length > 0
+ ? Math.min(...mappedPredictionConfidence.map((entry) => entry.confidence.confidence))
+ : null;
+ const predictionMethods = new Map();
+ for (const entry of mappedPredictionConfidence) {
+ const method = entry.confidence.method;
+ predictionMethods.set(method, (predictionMethods.get(method) ?? 0) + 1);
+ }
+ const evidenceNeeds = [
+ model.trainingObservationCount < 8
+ ? `${8 - model.trainingObservationCount} more training choices`
+ : null,
+ model.trainingDistinctStackCount < 8
+ ? `${8 - model.trainingDistinctStackCount} more training stacks`
+ : null,
+ ].filter((need): need is string => need !== null);
+ const gateDetail =
+ model.gateReason === 'insufficient-evidence'
+ ? evidenceNeeds.join(' / ')
+ : model.gateReason === 'insufficient-heldout'
+ ? 'Complete another proof for validation'
+ : model.gateReason === 'no-training-improvement'
+ ? 'Base model already ranks these choices'
+ : model.gateReason === 'heldout-below-threshold'
+ ? 'Held-out agreement is below 70%'
+ : model.gateReason === 'heldout-no-improvement'
+ ? 'Held-out gain is below 10 points'
+ : null;
+
+ return (
+
+
+ Appearance model
+ 0 || localEvidenceCount > 0
+ ? 'text-green-500'
+ : 'text-muted-foreground'
+ }
+ >
+ {physicalFitApplied
+ ? `Matrix physical fit (${((effectiveOptics?.confidence ?? 0) * 100).toFixed(0)}%)`
+ : model.applied
+ ? localEvidenceCount > 0
+ ? `Global + local fit (${(model.confidence * 100).toFixed(0)}%)`
+ : `Fitted estimate (${(model.confidence * 100).toFixed(0)}%)`
+ : localEvidenceCount > 0
+ ? exactAnchorCount > 0 || matrixLutSampleCount > 0
+ ? 'Measured + local evidence active'
+ : 'Local Palette Proof evidence active'
+ : exactAnchorCount > 0 || matrixLutSampleCount > 0
+ ? matrixAnchorCount > 0 && proofAnchorCount === 0
+ ? 'Stack Matrix LUT active'
+ : 'Measured anchors active'
+ : model.observationCount > 0 || model.noneCount > 0
+ ? 'Evidence gathered, fit gated'
+ : 'Estimated only'}
+
+
+
+ {model.sourceProofIds.length} evidence sets {' / '}
+ {model.distinctStackCount} physically compared stacks {' / '}
+ {proofAnchorCount} dead-on anchors {' / '}
+ {localEvidenceCount} local neighborhoods {' / '}
+ {matrixLutSampleCount} matrix LUT recipes {' / '}
+ {model.noneCount} no matches
+
+ {effectiveOptics && effectiveOptics.sampleCount > 0 && (
+
+ {effectiveOptics.sampleCount} physical fit samples {' / '}
+ {effectiveOptics.substrateInteractions.length} substrate pairs
+ {physicalFitApplied && (
+ <>
+ {' / '}mean ΔE {effectiveOptics.baselineMeanDeltaE.toFixed(1)} →{' '}
+ {effectiveOptics.fittedMeanDeltaE.toFixed(1)}
+ {effectiveOptics.crossValidationSampleCount > 0 && (
+ <>
+ {' / '}held-out ΔE{' '}
+ {effectiveOptics.crossValidationMeanDeltaE.toFixed(1)}
+ >
+ )}
+ >
+ )}
+
+ )}
+ {averagePredictionConfidence !== null && minimumPredictionConfidence !== null && (
+
+ Prediction confidence {(averagePredictionConfidence * 100).toFixed(0)}% avg
+ {' / '}
+ {(minimumPredictionConfidence * 100).toFixed(0)}% lowest
+ {[...predictionMethods.entries()].map(([method, count]) => (
+
+ {' / '}
+ {count} {method}
+
+ ))}
+
+ )}
+
+ {model.trainingObservationCount} training choices {' / '}
+ {model.trainingDistinctStackCount} training stacks {' / '}
+ {model.heldOutCount} held-out choices {' / '}
+ {model.heldOutDistinctStackCount} held-out stacks
+
+ {(model.applied || physicalFitApplied || localEvidenceCount > 0) && (
+
+ {(comparedCoverage * 100).toFixed(0)}% compared {' / '}
+ {(localCoverage * 100).toFixed(0)}% local current-palette coverage
+
+ )}
+ {gateDetail && (model.observationCount > 0 || model.noneCount > 0) && (
+
{gateDetail}
+ )}
+
+ );
+}
+
+type OptimizerTierValue = 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact';
+
+interface OptimizerTierMeta {
+ value: OptimizerTierValue;
+ label: string;
+}
+
+const OPTIMIZER_TIERS: readonly OptimizerTierMeta[] = [
+ {
+ value: 'fast',
+ label: 'Fast',
+ },
+ {
+ value: 'balanced',
+ label: 'Balanced',
+ },
+ {
+ value: 'thorough',
+ label: 'Thorough',
+ },
+ {
+ value: 'deep',
+ label: 'Deep',
+ },
+ {
+ value: 'exact',
+ label: 'Exact base order',
+ },
+];
+
+function formatBaseOrderCount(count: number): string {
+ if (count >= 1_000_000_000) return `${(count / 1_000_000_000).toFixed(1)}B`;
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
+ return count.toLocaleString();
+}
interface AutoPaintSliceData {
virtualSwatches: Swatch[];
@@ -70,6 +339,20 @@ interface AutoPaintTabProps {
handleDeleteProfile: (id: string) => void;
handleExportProfile: () => void;
handleImportFile: (e: React.ChangeEvent) => void;
+ handleRegisterPaletteProof: (
+ snapshot: FinalPrintableStackSnapshot,
+ proof: PaletteProofSpec
+ ) => PaletteProofRecord;
+ handleSetPaletteTargetResponse: (
+ proofId: string,
+ column: number,
+ response: PaletteTargetResponse | null
+ ) => void;
+ handleCompletePaletteProofEvaluation: (proofId: string) => void;
+ handleReopenPaletteProofEvaluation: (proofId: string) => void;
+ handleDeletePaletteProof: (proofId: string) => void;
+ handleUpsertStackMatrixCalibration: (record: StackMatrixCalibrationV1) => void;
+ handleDeleteStackMatrixCalibration: (matrixId: string) => void;
// Auto-paint state
autoPaintMaxHeight: number | undefined;
@@ -77,31 +360,49 @@ interface AutoPaintTabProps {
autoPaintResult?: AutoPaintResult;
autoPaintSliceData?: AutoPaintSliceData;
isComputing?: boolean;
+ progress?: number;
error?: string;
+ printableFeatureSimulation?: PrintableFeatureSimulation;
+ printableFeatureIsComputing?: boolean;
+ printLayerHeight: number;
calibrationLayerHeight: number;
setCalibrationLayerHeight: (v: number) => void;
+ firstLayerHeight: number;
// Image colors
filteredCount: number;
imageSwatches: Array<{ hex: string; count?: number }>;
+ paletteProofImageSrc: string | null;
// Enhanced matching options
enhancedColorMatch: boolean;
setEnhancedColorMatch: (v: boolean) => void;
- allowRepeatedSwaps: boolean;
- setAllowRepeatedSwaps: (v: boolean) => void;
+ preserveSeparation: boolean;
+ setPreserveSeparation: (v: boolean) => void;
+ separationMaxDeltaE: number;
+ setSeparationMaxDeltaE: (v: number) => void;
+ failOnSeparationError: boolean;
+ setFailOnSeparationError: (v: boolean) => void;
+ maxRepeatedSwaps: AutoPaintRepeatLimit;
+ setMaxRepeatedSwaps: (v: AutoPaintRepeatLimit) => void;
+ transitionOpacity: AutoPaintTransitionOpacity;
+ setTransitionOpacity: (v: AutoPaintTransitionOpacity) => void;
heightDithering: boolean;
setHeightDithering: (v: boolean) => void;
ditherLineWidth: number;
setDitherLineWidth: (v: number) => void;
+ omitAtRiskPixels: boolean;
+ setOmitAtRiskPixels: (v: boolean) => void;
- // Flat Paint (flat face-down print)
+ // Flat Paint
flatPaint: boolean;
setFlatPaint: (v: boolean) => void;
+ flatPaintFaceUp: boolean;
+ setFlatPaintFaceUp: (v: boolean) => void;
// Optimizer options
- optimizerAlgorithm: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto';
- setOptimizerAlgorithm: (v: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto') => 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';
@@ -134,25 +435,50 @@ export default function AutoPaintTab({
handleDeleteProfile,
handleExportProfile,
handleImportFile,
+ handleRegisterPaletteProof,
+ handleSetPaletteTargetResponse,
+ handleCompletePaletteProofEvaluation,
+ handleReopenPaletteProofEvaluation,
+ handleDeletePaletteProof,
+ handleUpsertStackMatrixCalibration,
+ handleDeleteStackMatrixCalibration,
autoPaintMaxHeight,
setAutoPaintMaxHeight,
autoPaintResult,
autoPaintSliceData,
isComputing = false,
+ progress = 0,
error,
+ printableFeatureSimulation,
+ printableFeatureIsComputing = false,
+ printLayerHeight,
calibrationLayerHeight,
+ firstLayerHeight,
filteredCount,
imageSwatches,
+ paletteProofImageSrc,
enhancedColorMatch,
setEnhancedColorMatch,
- allowRepeatedSwaps,
- setAllowRepeatedSwaps,
+ preserveSeparation,
+ setPreserveSeparation,
+ separationMaxDeltaE,
+ setSeparationMaxDeltaE,
+ failOnSeparationError,
+ setFailOnSeparationError,
+ maxRepeatedSwaps,
+ setMaxRepeatedSwaps,
+ transitionOpacity,
+ setTransitionOpacity,
heightDithering,
setHeightDithering,
ditherLineWidth,
setDitherLineWidth,
+ omitAtRiskPixels,
+ setOmitAtRiskPixels,
flatPaint,
setFlatPaint,
+ flatPaintFaceUp,
+ setFlatPaintFaceUp,
optimizerAlgorithm,
setOptimizerAlgorithm,
optimizerSeed,
@@ -160,6 +486,10 @@ export default function AutoPaintTab({
regionWeightingMode,
setRegionWeightingMode,
}: AutoPaintTabProps) {
+ const activeProfile = React.useMemo(
+ () => profiles.find((profile) => profile.id === activeProfileId),
+ [activeProfileId, profiles]
+ );
const {
result: nextBestResult,
isComputing: isNextBestComputing,
@@ -175,37 +505,45 @@ export default function AutoPaintTab({
const [localDitherLineWidth, setLocalDitherLineWidth] = React.useState(
ditherLineWidth.toString()
);
+ const [localSeparationMaxDeltaE, setLocalSeparationMaxDeltaE] = React.useState(
+ separationMaxDeltaE.toString()
+ );
+ React.useEffect(() => {
+ setLocalSeparationMaxDeltaE(separationMaxDeltaE.toString());
+ }, [separationMaxDeltaE]);
const [localOptimizerSeed, setLocalOptimizerSeed] = React.useState(
optimizerSeed?.toString() ?? ''
);
+ const exactBaseOrderCount = React.useMemo(
+ () => getExactBaseOrderCount(filaments.length),
+ [filaments.length]
+ );
+ const exactBaseOrderIsLarge = exactBaseOrderCount >= 1_000_000 || filaments.length >= 9;
- // Calibration wizard state
- const [calibratingFilamentId, setCalibratingFilamentId] = React.useState(null);
- const [calibrationWizardOpen, setCalibrationWizardOpen] = React.useState(false);
+ // Calibration dialog state
+ const [calibrationDialogOpen, setCalibrationDialogOpen] = React.useState(false);
- const calibratingFilament = filaments.find((f) => f.id === calibratingFilamentId);
+ // Built-in templates are read-only: no overwrite, rename, or delete
+ const isTemplateActive = activeProfileId !== null && isTemplateProfileId(activeProfileId);
- const handleOpenCalibrationWizard = React.useCallback((id: string) => {
- setCalibratingFilamentId(id);
- setCalibrationWizardOpen(true);
+ const handleOpenCalibration = React.useCallback(() => {
+ setCalibrationDialogOpen(true);
}, []);
- const handleCloseCalibrationWizard = React.useCallback(() => {
- setCalibrationWizardOpen(false);
- // Clear the ID after a short delay to avoid visible state change
- setTimeout(() => setCalibratingFilamentId(null), 300);
+ const handleCloseCalibration = React.useCallback(() => {
+ setCalibrationDialogOpen(false);
}, []);
- const handleCalibrationComplete = React.useCallback(
- (result: CalibrationResult) => {
- if (calibratingFilamentId) {
- updateFilament(calibratingFilamentId, {
- td: result.tdSingleValue,
- calibration: result,
+ const handleApplyCalibration = React.useCallback(
+ (updates: CalibrationApplyUpdate[]) => {
+ for (const update of updates) {
+ updateFilament(update.id, {
+ td: update.td,
+ calibration: update.calibration,
});
}
},
- [calibratingFilamentId, updateFilament]
+ [updateFilament]
);
React.useEffect(() => {
@@ -218,15 +556,24 @@ export default function AutoPaintTab({
return (
-
-
-
Auto-paint
-
- Define filament colors and transmission distances for automatic painting
-
-
-
-
+
+ {isComputing && (
+
+ )}
+ {error && !isComputing && }
+ {activeProfileId && isDirty && (
+
+ )}
+ >
+ }
+ >
{/* Profiles Section */}
@@ -242,7 +589,7 @@ export default function AutoPaintTab({
-
+
{profiles.length === 0 ? (
No saved profiles
@@ -250,8 +597,8 @@ export default function AutoPaintTab({
) : (
profiles.map((p) => (
-
-
+
+
{p.filaments.slice(0, 4).map((f, i) => (
)}
-
{p.name}
+
{p.name}
))
)}
+ {TEMPLATE_PROFILES.length > 0 && (
+ <>
+
+ Templates
+
+
+ Unofficial reference filament sets based on supplier
+ color charts. Not affiliated with, endorsed by, or
+ sponsored by any manufacturer; names identify the
+ referenced products only.
+
+ {TEMPLATE_PROFILES.map((p) => (
+
+
+
+ {p.filaments.slice(0, 4).map((f, i) => (
+
+ ))}
+ {p.filaments.length > 4 && (
+
+ +{p.filaments.length - 4}
+
+ )}
+
+
{p.name}
+
+
+ ))}
+ >
+ )}
@@ -280,8 +663,12 @@ export default function AutoPaintTab({
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary cursor-pointer flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
- title="Save changes to current profile"
- disabled={!activeProfileId || !isDirty}
+ title={
+ isTemplateActive
+ ? 'Templates are read-only — use Save as new profile'
+ : 'Save changes to current profile'
+ }
+ disabled={!activeProfileId || !isDirty || isTemplateActive}
onClick={handleOverwriteProfile}
>
@@ -333,8 +720,12 @@ export default function AutoPaintTab({
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary cursor-pointer flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
- title="Rename selected profile"
- disabled={!activeProfileId}
+ title={
+ isTemplateActive
+ ? 'Templates cannot be renamed'
+ : 'Rename selected profile'
+ }
+ disabled={!activeProfileId || isTemplateActive}
>
@@ -366,11 +757,13 @@ export default function AutoPaintTab({
+
+
{/* Import */}
- {/* Delete */}
- {activeProfileId && (
-
handleDeleteProfile(activeProfileId)}
- >
-
-
- )}
+
+
+ {/* Delete — always rendered so the strip width stays stable */}
+
activeProfileId && handleDeleteProfile(activeProfileId)}
+ >
+
+
{/* Import feedback */}
@@ -417,6 +815,15 @@ export default function AutoPaintTab({
{importFeedback}
)}
+
+ {/* Persistent template notice — estimates need calibration */}
+ {isTemplateActive && (
+
+ Template hiding distances are estimated from color — calibrate before
+ printing. Colors are the supplier's advertised values. Use "Save as new
+ profile" to keep an editable copy.
+
+ )}
@@ -432,20 +839,39 @@ export default function AutoPaintTab({
filament={f}
onUpdate={updateFilament}
onRemove={removeFilament}
- onCalibrate={handleOpenCalibrationWizard}
/>
))}
)}
- 0
+ ? 'grid grid-cols-[repeat(auto-fit,minmax(7.5rem,1fr))] gap-2'
+ : ''
+ }
>
-
- Add Filament
-
+
+
+ Add Filament
+
+
+ {filaments.length > 0 && (
+ handleOpenCalibration()}
+ className="w-full text-xs gap-1.5 h-8 cursor-pointer"
+ >
+
+ Calibrate
+
+ )}
+
{/* Max Height Constraint */}
{filaments.length > 0 && (
@@ -511,111 +937,280 @@ export default function AutoPaintTab({
)}
{isComputing && (
-
-
-
Optimizing filament order...
+
+
+
+
+ {printableFeatureIsComputing
+ ? 'Analyzing printable detail…'
+ : 'Optimizing filament order…'}
+
+
+ {!printableFeatureIsComputing &&
+ `${Math.round(progress * 100)}%`}
+
+
+
)}
{error && !isComputing && (
-
{error}
+
+
{error}
+
+ No new Auto-paint model was created. The preview may still
+ show the previous build.
+
+
)}
)}
- {/* Enhanced matching options */}
+ {/* Effective printable feature size */}
{filaments.length > 0 && (
-
+
- Enhanced color matching
+ Effective line width
-
+ setLocalDitherLineWidth(event.target.value)
+ }
+ onBlur={() => {
+ let value = parseFloat(localDitherLineWidth);
+ if (Number.isNaN(value)) {
+ setLocalDitherLineWidth(ditherLineWidth.toString());
+ return;
+ }
+ value = Math.max(0.1, Math.min(2, value));
+ setDitherLineWidth(value);
+ setLocalDitherLineWidth(value.toString());
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') event.currentTarget.blur();
+ }}
+ className="ml-auto h-7 w-20 text-xs"
/>
+ mm
+ setDitherLineWidth(0.42)}
+ className="h-7 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
+ title="Reset to default (0.42mm)"
+ >
+ Reset
+
-
+
- Allow repeated filament swaps
+ Omit at-risk colors from matching
-
+ )}
+
+ {/* Enhanced matching options */}
+ {filaments.length > 0 && (
+
+
+
- Height dithering
+ Enhanced color matching
- {heightDithering && enhancedColorMatch && (
-
-
- Line width
-
-
{
- 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"
+
+
+
+ Total repeat limit
+
+
+ setMaxRepeatedSwaps(
+ Number(value) as AutoPaintRepeatLimit
+ )
+ }
+ disabled={!enhancedColorMatch}
+ >
+
+
+
+
+
+ Off
+
+
+ Up to 2 extra appearances
+
+
+ Up to 4 extra appearances
+
+
+ Up to 6 extra appearances
+
+
+ Up to 8 extra appearances
+
+
+ Up to 12 extra appearances
+
+
+
+
+
+
+ Preserve color separation
+
+
- mm
- setDitherLineWidth(0.42)}
- className="h-7 px-1.5 text-[10px] text-muted-foreground hover:text-foreground ml-auto"
- title="Reset to default (0.42mm)"
+
+ {preserveSeparation && enhancedColorMatch && (
+
+
+
+ Unique-match limit (ΔE)
+
+
+ setLocalSeparationMaxDeltaE(event.target.value)
+ }
+ onBlur={() => {
+ const parsed = Number(localSeparationMaxDeltaE);
+ const normalized =
+ localSeparationMaxDeltaE.trim() === '' ||
+ !Number.isFinite(parsed)
+ ? separationMaxDeltaE
+ : normalizeSeparationMaxDeltaE(parsed);
+ setSeparationMaxDeltaE(normalized);
+ setLocalSeparationMaxDeltaE(
+ normalized.toString()
+ );
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.currentTarget.blur();
+ }
+ }}
+ className="h-7 w-20 text-right font-mono text-xs font-semibold"
+ />
+
+
+
+ Require a unique match for every color
+
+
+
+
+ Colors without a unique match inside the limit are
+ dropped and merged into preserved colors. Requiring
+ every color rejects the result instead.
+
+ {autoPaintResult && (
+
+ )}
+
+ )}
+
+
- Reset
-
+ Height dithering
+
+
- )}
+
)}
@@ -637,6 +1232,25 @@ export default function AutoPaintTab({
onCheckedChange={setFlatPaint}
/>
+
+
+
+ Face-up, no clear layer
+
+
+
+
)}
@@ -646,20 +1260,14 @@ export default function AutoPaintTab({
className={`space-y-3 pt-2 transition-opacity ${enhancedColorMatch ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}
>
-
-
- Optimizer Settings
-
-
- Advanced filament ordering optimization (requires enhanced color
- matching)
-
-
+
+ Optimizer Settings
+
Algorithm
@@ -675,32 +1283,35 @@ export default function AutoPaintTab({
-
- Auto (smart selection)
-
- 8}
- >
- Exhaustive (≤8 filaments)
-
-
- Simulated Annealing
-
-
- Genetic Algorithm
-
+ {OPTIMIZER_TIERS.map((tier) => (
+
+ {tier.label}
+
+ ))}
+ {optimizerAlgorithm === 'exact' && (
+
+ Exact base order will score about{' '}
+ {formatBaseOrderCount(exactBaseOrderCount)} base orders
+ before repeat refinement.
+
+ )}
Region priority
@@ -728,17 +1339,52 @@ export default function AutoPaintTab({
+
+
+ Transition detail
+
+
+ setTransitionOpacity(
+ Number(value) as AutoPaintTransitionOpacity
+ )
+ }
+ disabled={!enhancedColorMatch}
+ >
+
+
+
+
+
+ Compact (80% opacity)
+
+
+ Detailed (90% opacity)
+
+
+ Maximum (95% opacity)
+
+
+
+
Seed (optional)
setLocalOptimizerSeed(e.target.value)}
onBlur={() => {
@@ -777,7 +1423,6 @@ export default function AutoPaintTab({
-
Transition Zones
@@ -796,7 +1441,33 @@ export default function AutoPaintTab({
)}
-
+
+ {/* 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 =
@@ -806,46 +1477,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)}
+
);
}
@@ -872,13 +1535,9 @@ export default function AutoPaintTab({
{/* Overall Confidence Indicator */}
{autoPaintResult && (
+
-
-
- Result Confidence
-
+
Result Confidence
@@ -886,46 +1545,33 @@ export default function AutoPaintTab({
{(autoPaintResult.confidence * 100).toFixed(0)}%)
-
-
-
Calibration
-
- {(
- autoPaintResult.confidenceFactors.calibrationQuality *
- 100
- ).toFixed(0)}
- %
-
-
-
-
Coverage
-
- {(
- autoPaintResult.confidenceFactors.filamentCoverage * 100
- ).toFixed(0)}
- %
-
-
-
-
Compression
+
+
- {(
- autoPaintResult.confidenceFactors.compressionImpact *
- 100
- ).toFixed(0)}
- %
-
+ className="h-full rounded-full bg-current"
+ style={{
+ width: `${Math.max(4, Math.min(100, Math.round(autoPaintResult.confidence * 100)))}%`,
+ }}
+ />
+
+
+
+
+
{autoPaintResult.confidence < 0.7 && (
- 💡 Tip: Calibrate your filaments for better accuracy
+ Tip: calibrate your filaments for better accuracy.
)}
{/* Optimizer Metadata */}
@@ -933,10 +1579,39 @@ export default function AutoPaintTab({
-
Optimizer Performance
+
+ {autoPaintResult.optimizerMetadata.cacheHit && (
+
+ Cache hit
+
+ )}
+
+ {autoPaintResult.optimizerMetadata.optimality ===
+ 'exact'
+ ? 'Exact optimum'
+ : 'Best found'}
+
+ {autoPaintResult.optimizerMetadata
+ .singleRemovalMinimal && (
+
+ No removable run
+
+ )}
+
@@ -954,8 +1629,30 @@ export default function AutoPaintTab({
Quality Score
-
- {autoPaintResult.optimizerMetadata.score.toFixed(2)}
+
+ {autoPaintResult.colorSeparation?.satisfied ===
+ false
+ ? 'Partial palette'
+ : Number.isFinite(
+ autoPaintResult.optimizerMetadata.score
+ )
+ ? autoPaintResult.optimizerMetadata.score.toFixed(
+ 2
+ )
+ : 'Unavailable'}
@@ -967,20 +1664,6 @@ export default function AutoPaintTab({
-
- {autoPaintResult.optimizerMetadata.cacheHit && (
-
-
- Cache hit
-
- )}
- {autoPaintResult.optimizerMetadata.converged && (
-
-
- Converged
-
- )}
-
)}
@@ -996,19 +1679,21 @@ export default function AutoPaintTab({
disabled={isNextBestComputing}
onClick={() => requestNextBestSuggestion(filaments, imageSwatches)}
>
- {isNextBestComputing ? (
+ {isNextBestComputing && (
- ) : (
-
)}
- {isNextBestComputing ? 'Finding suggestion...' : 'Suggest next filament'}
+ {isNextBestComputing
+ ? 'Finding suggestion...'
+ : 'Suggest next filament'}
{nextBestResult?.candidate && (
{nextBestResult.candidate.hex.toUpperCase()}
@@ -1019,13 +1704,15 @@ export default function AutoPaintTab({
>
Est. ΔE{' '}
- +{nextBestResult.candidate.improvementPct.toFixed(1)}%
+ +
+ {nextBestResult.candidate.improvementPct.toFixed(1)}
+ %
-
- TD:{' '}
+
+ HD:{' '}
{nextBestResult.candidate.td.toFixed(2)}
@@ -1054,7 +1741,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,
@@ -1081,24 +1771,29 @@ export default function AutoPaintTab({
)}
-
+
- {/* Calibration Wizard */}
- {calibratingFilament && (
-
- )}
+ {/* Calibration Dialog */}
+
);
}
diff --git a/src/components/CanvasPreview.tsx b/src/components/CanvasPreview.tsx
index 0a7da6c9..e5876f3b 100644
--- a/src/components/CanvasPreview.tsx
+++ b/src/components/CanvasPreview.tsx
@@ -8,6 +8,18 @@ import React, {
useMemo,
} from 'react';
import { applyAdjustments, isAllDefault } from '../lib/applyAdjustments';
+import { Check, Move, X } from 'lucide-react';
+import {
+ brushSpans,
+ floodFill,
+ hardenAlphaToColor,
+ parseHexColor,
+ rgbToHex,
+ stampSpansIntoSurface,
+ strokeLinePoints,
+ wrapTextLines,
+} from '../lib/touchup';
+import type { TouchUpTool } from '../types';
export interface CanvasPreviewHandle {
redraw: () => void;
@@ -18,7 +30,9 @@ export interface CanvasPreviewHandle {
exportImageBlob: () => Promise
;
hasValidCropSelection: () => boolean;
onCropSelectionChange?: (hasValid: boolean) => void;
- exportAdjustedImageBlob?: () => Promise;
+ exportAdjustedImageBlob?: (
+ adjustmentsOverride?: Record
+ ) => Promise;
}
interface Props {
@@ -27,10 +41,63 @@ interface Props {
showCheckerboard?: boolean;
adjustments?: Record;
onCropSelectionChange?: (hasValid: boolean) => void;
+ /** Active 2D touch-up tool; while set, left-drag edits instead of panning. */
+ touchUpTool?: TouchUpTool | null;
+ touchUpColor?: string;
+ touchUpBrushSize?: number;
+ touchUpTextSize?: number;
+ /**
+ * Receives the edited full-resolution PNG after each committed edit and
+ * returns the object URL it was published under, so the preview can keep
+ * its live canvases when that same URL comes back as the new imageSrc.
+ */
+ onTouchUpCommit?: (blob: Blob) => string | void;
+ onTouchUpPickColor?: (hex: string) => void;
+ /** Called when the user presses Escape while a tool is active. */
+ onTouchUpExit?: () => void;
+}
+
+interface ActiveTouchStroke {
+ pointerId: number;
+ lastPoint: { x: number; y: number } | null;
+ spans: ReturnType;
+ rgba: [number, number, number, number];
+ /** CPU-side working copy of the image; stamped in JS, blitted per event. */
+ buffer: ImageData;
+ changed: boolean;
+ /** Pending region of `buffer` not yet blitted to the offscreen canvases. */
+ dirty: { x0: number; y0: number; x1: number; y1: number } | null;
+}
+
+/** In-progress text placement, in image-pixel coordinates. */
+interface TextDraft {
+ /** Monotonic id so a freshly placed box grabs keyboard focus once. */
+ id: number;
+ x: number;
+ y: number;
+ /** Wrap width of the box in image pixels. */
+ width: number;
+ text: string;
}
const CanvasPreview = forwardRef(
- ({ imageSrc, isCropMode, showCheckerboard, adjustments, onCropSelectionChange }, ref) => {
+ (
+ {
+ imageSrc,
+ isCropMode,
+ showCheckerboard,
+ adjustments,
+ onCropSelectionChange,
+ touchUpTool,
+ touchUpColor,
+ touchUpBrushSize,
+ touchUpTextSize,
+ onTouchUpCommit,
+ onTouchUpPickColor,
+ onTouchUpExit,
+ },
+ ref
+ ) => {
const canvasRef = useRef(null);
const previewContainerRef = useRef(null);
const imgRef = useRef(null);
@@ -64,6 +131,19 @@ const CanvasPreview = forwardRef(
const originalCanvasRef = useRef(null);
const processedCanvasRef = useRef(null);
const lastAdjSigRef = useRef('');
+ const mountedRef = useRef(true);
+ const imageLoadGenerationRef = useRef(0);
+ const activeTouchStrokeRef = useRef(null);
+ // Object URLs of our own pending commits. When one of them arrives back
+ // as imageSrc, the live canvases already hold exactly those pixels and
+ // are kept as-is — drawing never blocks on the round trip.
+ const selfCommitSrcsRef = useRef>(new Set());
+ // Bumped only when the image is replaced from outside the touch-up
+ // tools (undo, quantize, crop, …); invalidates in-flight commits.
+ const externalImageGenerationRef = useRef(0);
+ const touchUpCommitChainRef = useRef>(Promise.resolve());
+ const onTouchUpCommitRef = useRef(onTouchUpCommit);
+ onTouchUpCommitRef.current = onTouchUpCommit;
const hasAdjustments = useMemo(
() => !!adjustments && !isAllDefault(adjustments),
@@ -95,8 +175,12 @@ const CanvasPreview = forwardRef(
const img = imgRef.current;
if (!img) return;
- const iw = img.naturalWidth;
- const ih = img.naturalHeight;
+ // Prefer the live offscreen canvas dimensions: while a committed
+ // touch-up edit's image is still loading, the original canvas is
+ // the source of truth and keeps the preview from blanking out.
+ const original = originalCanvasRef.current;
+ const iw = original?.width || img.naturalWidth;
+ const ih = original?.height || img.naturalHeight;
if (!iw || !ih) return;
const baseScale = Math.min(cw / iw, ch / ih);
@@ -121,7 +205,6 @@ const CanvasPreview = forwardRef(
// to show the exact pixel values after quantization.
ctx.imageSmoothingEnabled = false;
// imageSmoothingQuality is not relevant when smoothing is disabled
- const original = originalCanvasRef.current;
if (hasAdjustments) {
const sig = adjustmentsSig;
// Recompute processed canvas only if signature changed or missing.
@@ -206,8 +289,11 @@ const CanvasPreview = forwardRef(
if (!container || !img) return null;
const cw = container.clientWidth;
const ch = container.clientHeight;
- const iw = img.naturalWidth;
- const ih = img.naturalHeight;
+ // Same fallback as drawToCanvas: the offscreen canvas keeps layout
+ // (and tool coordinates) stable while a committed edit reloads.
+ const original = originalCanvasRef.current;
+ const iw = original?.width || img.naturalWidth;
+ const ih = original?.height || img.naturalHeight;
if (!iw || !ih) return null;
const baseScale = Math.min(cw / iw, ch / ih);
const dw = iw * baseScale;
@@ -218,8 +304,23 @@ const CanvasPreview = forwardRef(
};
useEffect(() => {
+ const loadGeneration = ++imageLoadGenerationRef.current;
+ // Our own commits come back with canvases already holding exactly
+ // these pixels; keep the canvases (and any in-progress stroke)
+ // untouched so drawing stays continuous across history commits.
+ const isSelfCommit = !!imageSrc && selfCommitSrcsRef.current.delete(imageSrc);
+ if (!isSelfCommit) {
+ selfCommitSrcsRef.current.clear();
+ externalImageGenerationRef.current += 1;
+ activeTouchStrokeRef.current = null;
+ // A text draft's coordinates belong to the replaced image.
+ if (textDraftRef.current) setTextDraft(null);
+ }
+
if (!imageSrc) {
imgRef.current = null;
+ originalCanvasRef.current = null;
+ processedCanvasRef.current = null;
setImageLoaded(false);
const canvas = canvasRef.current;
if (canvas) {
@@ -237,7 +338,7 @@ const CanvasPreview = forwardRef(
// available we'll fall back to the existing offset.
const container = previewContainerRef.current;
let prevCenterImageCoord: { x: number; y: number } | null = null;
- if (container && imgRef.current) {
+ if (!isSelfCommit && container && imgRef.current) {
const rect = container.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
@@ -252,8 +353,22 @@ const CanvasPreview = forwardRef(
const img = new Image();
imgRef.current = img;
- setImageLoaded(false); // Reset loading state
+ if (!isSelfCommit) setImageLoaded(false); // Reset loading state
img.onload = () => {
+ if (
+ !mountedRef.current ||
+ loadGeneration !== imageLoadGenerationRef.current ||
+ imgRef.current !== img
+ ) {
+ return;
+ }
+ if (isSelfCommit) {
+ // The img element only feeds the export helpers here; the
+ // offscreen canvases already show exactly these pixels.
+ setImageLoaded(true);
+ drawToCanvas();
+ return;
+ }
// preserve current zoom (don't reset) and try to restore the
// same image coordinate under the container center so the view
// doesn't jump when swapping the image source.
@@ -289,11 +404,24 @@ const CanvasPreview = forwardRef(
drawToCanvas();
};
+ img.onerror = () => {
+ if (
+ !mountedRef.current ||
+ loadGeneration !== imageLoadGenerationRef.current ||
+ imgRef.current !== img
+ ) {
+ return;
+ }
+ setImageLoaded(false);
+ };
img.src = imageSrc;
return () => {
- imgRef.current = null;
- originalCanvasRef.current = null;
- processedCanvasRef.current = null;
+ img.onload = null;
+ img.onerror = null;
+ if (imageLoadGenerationRef.current === loadGeneration) {
+ imageLoadGenerationRef.current += 1;
+ }
+ if (imgRef.current === img) imgRef.current = null;
};
}, [imageSrc, drawToCanvas]);
@@ -387,10 +515,454 @@ const CanvasPreview = forwardRef(
};
onWheelCanvasRef.current = onWheelCanvas;
+ // === 2D touch-up tools ===
+
+ const touchUpActive = !!touchUpTool && !isCropMode && imageLoaded;
+ const showBrushOutline =
+ touchUpActive && (touchUpTool === 'brush' || touchUpTool === 'eraser');
+ const brushOutlineRef = useRef(null);
+ const onTouchUpExitRef = useRef(onTouchUpExit);
+ onTouchUpExitRef.current = onTouchUpExit;
+
+ // In-progress text placement: edited directly on the image, movable,
+ // and width-resizable (word wrap), then rasterized on commit.
+ const [textDraft, setTextDraft] = useState(null);
+ const textDraftRef = useRef(textDraft);
+ textDraftRef.current = textDraft;
+ const textDraftIdRef = useRef(0);
+ const textDraftBoxRef = useRef(null);
+ const textDraftAreaRef = useRef(null);
+ const commitTextDraftRef = useRef<() => void>(() => {});
+ const syncTextDraftGeometryRef = useRef<() => void>(() => {});
+ const touchUpFontSize = Math.max(4, Math.round(touchUpTextSize ?? 24));
+ const TEXT_DRAFT_LINE_HEIGHT = 1.25;
+
+ useEffect(() => {
+ if (!touchUpTool) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ // First Escape discards an open text draft, second exits the tool.
+ if (textDraftRef.current) {
+ setTextDraft(null);
+ return;
+ }
+ onTouchUpExitRef.current?.();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [touchUpTool]);
+
+ // Leaving the text tool (or entering crop) commits whatever was typed
+ // instead of silently dropping it.
+ useEffect(() => {
+ if (touchUpTool === 'text' && !isCropMode) return;
+ if (textDraftRef.current) commitTextDraftRef.current();
+ }, [touchUpTool, isCropMode]);
+
+ const clientToImagePixel = (clientX: number, clientY: number) => {
+ const container = previewContainerRef.current;
+ const layout = computeImageLayout();
+ if (!container || !layout) return null;
+ const rect = container.getBoundingClientRect();
+ const scale = layout.baseScale * (zoomRef.current || 1);
+ return {
+ x: Math.floor((clientX - rect.left - (offsetRef.current.x + layout.dx)) / scale),
+ y: Math.floor((clientY - rect.top - (offsetRef.current.y + layout.dy)) / scale),
+ scale,
+ };
+ };
+
+ const isInsideImage = (x: number, y: number) => {
+ const original = originalCanvasRef.current;
+ return !!original && x >= 0 && y >= 0 && x < original.width && y < original.height;
+ };
+
+ // Invalidate the adjustments cache so the next draw recomputes the
+ // processed canvas from the freshly edited original.
+ const invalidateProcessedCache = () => {
+ processedCanvasRef.current = null;
+ lastAdjSigRef.current = '';
+ };
+
+ // Stamps one stroke point into the CPU buffer (no canvas readbacks)
+ // and grows the pending dirty rect by the stamp's clamped bounding box.
+ const stampStrokePoint = (stroke: ActiveTouchStroke, x: number, y: number) => {
+ if (!stampSpansIntoSurface(stroke.buffer, x, y, stroke.spans, stroke.rgba)) return;
+ stroke.changed = true;
+
+ let x0 = x;
+ let x1 = x;
+ let y0 = y;
+ let y1 = y;
+ for (const span of stroke.spans) {
+ if (x + span.dx0 < x0) x0 = x + span.dx0;
+ if (x + span.dx1 > x1) x1 = x + span.dx1;
+ if (y + span.dy < y0) y0 = y + span.dy;
+ if (y + span.dy > y1) y1 = y + span.dy;
+ }
+ x0 = Math.max(0, x0);
+ y0 = Math.max(0, y0);
+ x1 = Math.min(stroke.buffer.width - 1, x1);
+ y1 = Math.min(stroke.buffer.height - 1, y1);
+ if (x1 < x0 || y1 < y0) return;
+
+ const dirty = stroke.dirty;
+ if (!dirty) {
+ stroke.dirty = { x0, y0, x1, y1 };
+ } else {
+ dirty.x0 = Math.min(dirty.x0, x0);
+ dirty.y0 = Math.min(dirty.y0, y0);
+ dirty.x1 = Math.max(dirty.x1, x1);
+ dirty.y1 = Math.max(dirty.y1, y1);
+ }
+ };
+
+ // Blits the pending buffer region onto the offscreen canvases.
+ // putImageData writes raw RGBA (including transparent pixels), which
+ // is exactly right for hard-edged edits; the processed canvas gets the
+ // same raw pixels so live feedback survives active adjustments, and a
+ // single recompute at commit time reconciles the adjusted view.
+ const blitStrokeDirty = (stroke: ActiveTouchStroke) => {
+ const dirty = stroke.dirty;
+ if (!dirty) return;
+ stroke.dirty = null;
+ const w = dirty.x1 - dirty.x0 + 1;
+ const h = dirty.y1 - dirty.y0 + 1;
+ for (const canvas of [originalCanvasRef.current, processedCanvasRef.current]) {
+ const ctx = canvas?.getContext('2d');
+ if (!ctx) continue;
+ ctx.putImageData(stroke.buffer, 0, 0, dirty.x0, dirty.y0, w, h);
+ }
+ requestDraw();
+ };
+
+ // Commits are serialized so rapid strokes land in history in order.
+ // Drawing never waits on a commit: the offscreen canvases stay the
+ // source of truth, and the imageSrc round trip is absorbed by the
+ // self-commit fast path in the image-loading effect.
+ const queueTouchUpCommit = () => {
+ const original = originalCanvasRef.current;
+ const commitCallback = onTouchUpCommitRef.current;
+ if (!original || !commitCallback) return;
+ // Capture this edit now, before a later pointer event mutates the
+ // shared live canvas while an earlier history commit is encoding.
+ const snapshot = document.createElement('canvas');
+ snapshot.width = original.width;
+ snapshot.height = original.height;
+ const snapshotContext = snapshot.getContext('2d');
+ if (!snapshotContext) return;
+ snapshotContext.imageSmoothingEnabled = false;
+ snapshotContext.drawImage(original, 0, 0);
+ const externalGeneration = externalImageGenerationRef.current;
+ touchUpCommitChainRef.current = touchUpCommitChainRef.current.then(async () => {
+ if (!mountedRef.current) return;
+ // The image was replaced from outside (undo, quantize, …)
+ // after this edit was made — the edit is gone by design.
+ if (externalImageGenerationRef.current !== externalGeneration) return;
+
+ const blob = await new Promise((resolve) =>
+ snapshot.toBlob((result) => resolve(result), 'image/png')
+ );
+ if (
+ !mountedRef.current ||
+ externalImageGenerationRef.current !== externalGeneration
+ ) {
+ return;
+ }
+ if (blob) {
+ const url = commitCallback(blob);
+ if (typeof url === 'string') selfCommitSrcsRef.current.add(url);
+ } else {
+ // Encoding failed — restore the canvases from the last
+ // committed image so the preview matches the history.
+ const img = imgRef.current;
+ const liveOriginal = originalCanvasRef.current;
+ const octx = liveOriginal?.getContext('2d');
+ if (img && liveOriginal && octx) {
+ octx.clearRect(0, 0, liveOriginal.width, liveOriginal.height);
+ octx.imageSmoothingEnabled = false;
+ octx.drawImage(img, 0, 0);
+ }
+ }
+ // One recompute per commit keeps the adjusted preview exact
+ // without paying for it on every pointer move.
+ invalidateProcessedCache();
+ requestDraw();
+ });
+ };
+
+ // Rasterizes the draft box: canvas-measured word wrap at the draft
+ // width, hardened to the exact tool color, drawn at the box origin.
+ const commitTextDraft = () => {
+ const draft = textDraftRef.current;
+ setTextDraft(null);
+ if (!draft || !draft.text.trim()) return;
+ const rgb = parseHexColor(touchUpColor ?? '#000000');
+ if (!rgb) return;
+ const original = originalCanvasRef.current;
+ const octx = original?.getContext('2d');
+ if (!original || !octx) return;
+
+ const fontSize = touchUpFontSize;
+ const lineHeight = Math.round(fontSize * TEXT_DRAFT_LINE_HEIGHT);
+ const font = `bold ${fontSize}px sans-serif`;
+ const scratch = document.createElement('canvas');
+ const measureCtx = scratch.getContext('2d');
+ if (!measureCtx) return;
+ measureCtx.font = font;
+ const lines = wrapTextLines(draft.text, draft.width, (s) =>
+ measureCtx.measureText(s).width
+ );
+ scratch.width = Math.max(1, Math.ceil(draft.width) + 2);
+ scratch.height = Math.max(1, lines.length * lineHeight + 2);
+ const sctx = scratch.getContext('2d');
+ if (!sctx) return;
+ // Resizing the canvas reset the context state — set font again.
+ sctx.font = font;
+ sctx.textBaseline = 'top';
+ const baselinePad = Math.round((lineHeight - fontSize) / 2);
+ lines.forEach((line, index) => {
+ if (line) sctx.fillText(line, 1, 1 + index * lineHeight + baselinePad);
+ });
+ // Harden anti-aliased glyph edges to the exact target color so
+ // text does not leak blended colors into the palette.
+ const stamp = sctx.getImageData(0, 0, scratch.width, scratch.height);
+ hardenAlphaToColor(stamp.data, rgb);
+ sctx.putImageData(stamp, 0, 0);
+ for (const canvas of [originalCanvasRef.current, processedCanvasRef.current]) {
+ const ctx = canvas?.getContext('2d');
+ if (!ctx) continue;
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(scratch, draft.x - 1, draft.y - 1);
+ }
+ requestDraw();
+ queueTouchUpCommit();
+ };
+ commitTextDraftRef.current = commitTextDraft;
+
+ // Positions the draft box over its image-pixel rect. Applied outside
+ // React so panning and zooming keep the box glued to the image.
+ const syncTextDraftGeometry = () => {
+ const el = textDraftBoxRef.current;
+ const draft = textDraftRef.current;
+ if (!el || !draft) return;
+ const layout = computeImageLayout();
+ if (!layout) return;
+ const scale = layout.baseScale * (zoomRef.current || 1);
+ el.style.left = `${draft.x * scale + offsetRef.current.x + layout.dx}px`;
+ el.style.top = `${draft.y * scale + offsetRef.current.y + layout.dy}px`;
+ el.style.width = `${draft.width * scale}px`;
+ el.style.fontSize = `${touchUpFontSize * scale}px`;
+ const area = textDraftAreaRef.current;
+ if (area) {
+ // Grow the textarea to its wrapped content.
+ area.style.height = '0px';
+ area.style.height = `${Math.max(area.scrollHeight, touchUpFontSize * scale * TEXT_DRAFT_LINE_HEIGHT)}px`;
+ }
+ };
+ syncTextDraftGeometryRef.current = syncTextDraftGeometry;
+
+ useEffect(() => {
+ syncTextDraftGeometryRef.current();
+ });
+
+ // A freshly placed draft box grabs keyboard focus so typing starts
+ // immediately after the click.
+ useEffect(() => {
+ if (textDraft?.id) textDraftAreaRef.current?.focus();
+ }, [textDraft?.id]);
+
+ // Dragging the move handle repositions the box; dragging the right
+ // edge changes its wrap width. Both are in image-pixel space.
+ const startTextDraftDrag = (
+ e: React.PointerEvent,
+ mode: 'move' | 'resize'
+ ) => {
+ const draft = textDraftRef.current;
+ if (!draft || e.button !== 0) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const startClientX = e.clientX;
+ const startClientY = e.clientY;
+ const start = { x: draft.x, y: draft.y, width: draft.width };
+ const original = originalCanvasRef.current;
+ const iw = original?.width ?? draft.width;
+ const ih = original?.height ?? 0;
+
+ const onMove = (ev: PointerEvent) => {
+ const layout = computeImageLayout();
+ if (!layout) return;
+ const scale = layout.baseScale * (zoomRef.current || 1);
+ const dxImg = (ev.clientX - startClientX) / scale;
+ const dyImg = (ev.clientY - startClientY) / scale;
+ setTextDraft((current) => {
+ if (!current) return current;
+ if (mode === 'move') {
+ return {
+ ...current,
+ x: Math.round(
+ Math.max(
+ -current.width + 8,
+ Math.min(iw - 8, start.x + dxImg)
+ )
+ ),
+ y: Math.round(
+ Math.max(
+ -touchUpFontSize,
+ Math.min(ih - 4, start.y + dyImg)
+ )
+ ),
+ };
+ }
+ const minWidth = Math.max(16, touchUpFontSize * 2);
+ return {
+ ...current,
+ width: Math.round(
+ Math.max(minWidth, Math.min(iw, start.width + dxImg))
+ ),
+ };
+ });
+ };
+ const onUp = () => {
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ };
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ };
+
+ const onTouchUpPointerDown = (e: React.PointerEvent) => {
+ const tool = touchUpTool;
+ if (!tool || !touchUpActive || e.button !== 0) return;
+ const original = originalCanvasRef.current;
+ const octx = original?.getContext('2d');
+ if (!original || !octx) return;
+ const pt = clientToImagePixel(e.clientX, e.clientY);
+ if (!pt || !isInsideImage(pt.x, pt.y)) return;
+
+ e.preventDefault();
+
+ if (tool === 'brush' || tool === 'eraser') {
+ const rgb = tool === 'eraser' ? null : parseHexColor(touchUpColor ?? '#000000');
+ if (tool === 'brush' && !rgb) return;
+ // One readback per stroke: every stamp afterwards works on this
+ // CPU buffer and is blitted back with putImageData.
+ const stroke: ActiveTouchStroke = {
+ pointerId: e.pointerId,
+ lastPoint: { x: pt.x, y: pt.y },
+ spans: brushSpans(touchUpBrushSize ?? 1),
+ rgba: rgb ? [rgb[0], rgb[1], rgb[2], 255] : [0, 0, 0, 0],
+ buffer: octx.getImageData(0, 0, original.width, original.height),
+ changed: false,
+ dirty: null,
+ };
+ stampStrokePoint(stroke, pt.x, pt.y);
+ blitStrokeDirty(stroke);
+ activeTouchStrokeRef.current = stroke;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ return;
+ }
+
+ if (tool === 'picker') {
+ const pixel = octx.getImageData(pt.x, pt.y, 1, 1).data;
+ if (pixel[3] > 0) onTouchUpPickColor?.(rgbToHex(pixel[0], pixel[1], pixel[2]));
+ return;
+ }
+
+ if (tool === 'fill') {
+ const rgb = parseHexColor(touchUpColor ?? '#000000');
+ if (!rgb) return;
+ const surface = octx.getImageData(0, 0, original.width, original.height);
+ if (!floodFill(surface, pt.x, pt.y, [rgb[0], rgb[1], rgb[2], 255])) return;
+ octx.putImageData(surface, 0, 0);
+ const pctx = processedCanvasRef.current?.getContext('2d');
+ if (pctx) pctx.putImageData(surface, 0, 0);
+ requestDraw();
+ queueTouchUpCommit();
+ return;
+ }
+
+ if (tool === 'text') {
+ // Clicking outside an open draft commits it, then a fresh box
+ // opens at the click point and takes keyboard focus.
+ if (textDraftRef.current) commitTextDraft();
+ const iw = original.width;
+ const ih = original.height;
+ const minWidth = Math.max(16, touchUpFontSize * 2);
+ const width = Math.max(minWidth, Math.min(iw - pt.x, Math.round(iw * 0.4)));
+ setTextDraft({
+ id: ++textDraftIdRef.current,
+ x: Math.max(0, Math.min(iw - minWidth, pt.x)),
+ y: Math.max(0, Math.min(ih - touchUpFontSize, pt.y)),
+ width,
+ text: '',
+ });
+ }
+ };
+
+ const onTouchUpPointerMove = (e: React.PointerEvent) => {
+ const stroke = activeTouchStrokeRef.current;
+ if (!stroke || stroke.pointerId !== e.pointerId) return;
+ e.preventDefault();
+
+ const pt = clientToImagePixel(e.clientX, e.clientY);
+ if (!pt) return;
+ // Points outside the image are stamped too (clipped per span) so a
+ // fast stroke across the image edge stays connected.
+ const points = stroke.lastPoint
+ ? strokeLinePoints(stroke.lastPoint.x, stroke.lastPoint.y, pt.x, pt.y)
+ : [[pt.x, pt.y] as [number, number]];
+ for (const [x, y] of points) stampStrokePoint(stroke, x, y);
+ stroke.lastPoint = { x: pt.x, y: pt.y };
+ blitStrokeDirty(stroke);
+ };
+
+ const finishTouchUpStroke = (e: React.PointerEvent) => {
+ const stroke = activeTouchStrokeRef.current;
+ if (!stroke || stroke.pointerId !== e.pointerId) return;
+
+ activeTouchStrokeRef.current = null;
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }
+ blitStrokeDirty(stroke);
+ if (stroke.changed) queueTouchUpCommit();
+ };
+
+ // Size-accurate hover outline for brush/eraser, updated via direct DOM
+ // writes on pointermove to avoid re-rendering per pointer event.
+ const updateBrushOutline = (clientX: number, clientY: number) => {
+ const el = brushOutlineRef.current;
+ if (!el) return;
+ const pt = clientToImagePixel(clientX, clientY);
+ const layout = computeImageLayout();
+ if (!pt || !layout || !isInsideImage(pt.x, pt.y)) {
+ el.style.display = 'none';
+ return;
+ }
+ const size = Math.max(1, Math.floor(touchUpBrushSize ?? 1));
+ const originX = pt.x - Math.floor((size - 1) / 2);
+ const originY = pt.y - Math.floor((size - 1) / 2);
+ el.style.display = 'block';
+ el.style.left = `${originX * pt.scale + offsetRef.current.x + layout.dx}px`;
+ el.style.top = `${originY * pt.scale + offsetRef.current.y + layout.dy}px`;
+ el.style.width = `${size * pt.scale}px`;
+ el.style.height = `${size * pt.scale}px`;
+ };
+
+ const hideBrushOutline = () => {
+ const el = brushOutlineRef.current;
+ if (el) el.style.display = 'none';
+ };
+
const startPan = (e: React.MouseEvent) => {
// prevent browser's native drag behavior when dragging quickly
e.preventDefault();
- if (e.button !== 0) return;
+ if (e.button === 0 && touchUpActive) {
+ return;
+ }
+ // Middle-drag always pans, so the view stays movable with a tool active.
+ if (e.button !== 0 && e.button !== 1) return;
panningRef.current = true;
panStartXRef.current = e.clientX;
panStartYRef.current = e.clientY;
@@ -406,6 +978,8 @@ const CanvasPreview = forwardRef(
y: panStartOffsetRef.current.y + dy,
};
drawToCanvas();
+ // Keep the text draft box glued to the image while panning.
+ syncTextDraftGeometryRef.current();
};
const onUp = () => {
panningRef.current = false;
@@ -576,12 +1150,23 @@ const CanvasPreview = forwardRef(
}, [adjustmentsSig, requestDraw]);
useEffect(() => {
+ mountedRef.current = true;
return () => {
+ mountedRef.current = false;
+ imageLoadGenerationRef.current += 1;
+ externalImageGenerationRef.current += 1;
+ activeTouchStrokeRef.current = null;
+ originalCanvasRef.current = null;
+ processedCanvasRef.current = null;
if (drawRafRef.current !== null) {
cancelAnimationFrame(drawRafRef.current);
+ // Reset so requestDraw works again after a StrictMode
+ // remount; a stale id here permanently gates scheduling.
+ drawRafRef.current = null;
}
if (selectionRafRef.current !== null) {
cancelAnimationFrame(selectionRafRef.current);
+ selectionRafRef.current = null;
}
};
}, []);
@@ -643,10 +1228,12 @@ const CanvasPreview = forwardRef(
return hasValidCropSelection;
},
exportImageBlob: async (): Promise => {
- const img = imgRef.current;
- if (!img) return null;
- const iw = img.naturalWidth;
- const ih = img.naturalHeight;
+ if (!imageLoaded) return null;
+ const source = originalCanvasRef.current ?? imgRef.current;
+ if (!source) return null;
+ const iw = source instanceof HTMLCanvasElement ? source.width : source.naturalWidth;
+ const ih =
+ source instanceof HTMLCanvasElement ? source.height : source.naturalHeight;
if (!iw || !ih) return null;
const outCanvas = document.createElement('canvas');
outCanvas.width = iw;
@@ -657,12 +1244,14 @@ const CanvasPreview = forwardRef(
// the source image exactly (no interpolation artifacts)
ctx.imageSmoothingEnabled = false;
ctx.imageSmoothingQuality = 'low';
- ctx.drawImage(img, 0, 0, iw, ih);
+ ctx.drawImage(source, 0, 0, iw, ih);
return await new Promise((resolve) =>
outCanvas.toBlob((b) => resolve(b), 'image/png')
);
},
- exportAdjustedImageBlob: async (): Promise => {
+ exportAdjustedImageBlob: async (
+ adjustmentsOverride?: Record
+ ): Promise => {
const img = imgRef.current;
if (!img) return null;
const iw = img.naturalWidth;
@@ -675,14 +1264,26 @@ const CanvasPreview = forwardRef(
if (!ctx) return null;
ctx.imageSmoothingEnabled = false;
ctx.imageSmoothingQuality = 'low';
- // Prefer processed canvas if exists, else original or image element
- if (processedCanvasRef.current) {
- ctx.drawImage(processedCanvasRef.current, 0, 0, iw, ih);
- } else if (originalCanvasRef.current) {
- ctx.drawImage(originalCanvasRef.current, 0, 0, iw, ih);
- } else {
- ctx.drawImage(img, 0, 0, iw, ih);
+
+ let source = originalCanvasRef.current;
+ if (!source) {
+ source = document.createElement('canvas');
+ source.width = iw;
+ source.height = ih;
+ const sourceCtx = source.getContext('2d');
+ if (!sourceCtx) return null;
+ sourceCtx.imageSmoothingEnabled = false;
+ sourceCtx.drawImage(img, 0, 0, iw, ih);
}
+ const sourceCtx = source.getContext('2d');
+ if (!sourceCtx) return null;
+ const sourceData = sourceCtx.getImageData(0, 0, iw, ih);
+ const values = adjustmentsOverride ?? adjustments ?? {};
+ ctx.putImageData(
+ isAllDefault(values) ? sourceData : applyAdjustments(sourceData, values),
+ 0,
+ 0
+ );
return await new Promise((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/png')
);
@@ -693,28 +1294,126 @@ const CanvasPreview = forwardRef(
{
+ onTouchUpPointerMove(e);
+ if (showBrushOutline) updateBrushOutline(e.clientX, e.clientY);
+ }}
+ onPointerUp={finishTouchUpStroke}
+ onPointerCancel={finishTouchUpStroke}
+ onPointerLeave={showBrushOutline ? hideBrushOutline : undefined}
onMouseDown={startPan}
onDragStart={(e) => e.preventDefault()}
- style={
- showCheckerboard
+ style={{
+ ...(showCheckerboard
? {
background:
'repeating-conic-gradient(rgba(255,255,255,0.1) 0% 25%, rgba(0,0,0,0.1) 0% 50%) 50% / 16px 16px',
}
- : undefined
- }
+ : null),
+ ...(touchUpActive
+ ? {
+ cursor: touchUpTool === 'text' ? 'text' : 'crosshair',
+ touchAction: 'none',
+ }
+ : null),
+ }}
>
+ {showBrushOutline ? (
+
+ ) : null}
+ {touchUpActive && touchUpTool === 'text' && textDraft ? (
+
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+
+ startTextDraftDrag(e, 'move')}
+ className="h-6 w-6 flex items-center justify-center rounded cursor-move text-muted-foreground hover:bg-accent hover:text-foreground"
+ >
+
+
+
+
+
+ setTextDraft(null)}
+ className="h-6 w-6 flex items-center justify-center rounded cursor-pointer text-muted-foreground hover:bg-accent hover:text-destructive"
+ >
+
+
+
+