diff --git a/.gitignore b/.gitignore index 4cbec2b1..9b6ef9b4 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ test-results/ src-tauri/target/ src-tauri/gen/ src-tauri/WixTools/ + +# Agent workspace files +.agents/ diff --git a/AGENTS.md b/AGENTS.md index 03796648..8e526452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,12 @@ geometry, slicer behavior, and print instructions at the same time. ## Domain Notes -- **Transmission Distance (TD):** Used by auto-paint to model light through thin filament layers - with Beer-Lambert-style optical simulation. TD is not just a display value; changing how it is - stored or rounded can change generated layer stacks. +- **Hiding Distance (HD):** Used by auto-paint to model light through thin filament layers with + Beer-Lambert-style optical simulation. `filament.td` stores the frontlit hiding distance in mm + (profile schema v2); the field keeps its historical `td` name for data compatibility. Conventional + backlit/lithophane Transmission Distance values are an *input format* only — converted ×0.1 + (`FRONTLIT_TD_SCALE`) at entry or during the one-time v1→v2 migration, never stored. HD is not + just a display value; changing how it is stored or rounded can change generated layer stacks. - **Auto-paint layers:** Auto-paint chooses physical filament stacks for target image colors. The worker path exists so optimizer choices such as exhaustive, simulated annealing, and genetic search do not freeze the UI. diff --git a/CHANGELOG.md b/CHANGELOG.md index 396752af..f0836ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ All notable changes to Kromacut are documented in this file. +## v3.2.0 - unreleased + +### Added + +- **Frontlit filament calibration** - Replaced the photo-based TD calibration with a camera-free workflow. Pick one or more filaments, choose the base layer each prints over (defaults to your darkest filament; dark filaments can use a lighter base for contrast), then download a calibration wedge (STL for any printer, or a multi-material 3MF for AMS) that prints 1..N filament layers over that base alongside an opaque reference rail, and report the layer count at which each filament/base patch first matches the rail. Quick mode keeps the single-base scalar calibration; Accurate mode repeats the read over multiple bases to fit measured RGB-channel TDs directly, stores the read set (plus optional merge points), and fits a shared session JND when enough multi-base data is present. Kromacut converts those reads into a frontlit Transmission Distance using a perceptual just-noticeable-difference model, with a live predicted-vs-reference preview and confidence rating. Read entry stays tied to the base filaments and print settings of the downloaded wedge, so changing layer height or max layers afterward does not reinterpret the physical print. Results can be entered partially: saving calibrates the filaments you finished and leaves the rest untouched, so a wedge printed for eight filaments can be read a few at a time. Filaments with nothing entered are marked and discarded on save, and one with only some of its Accurate-mode base reads filled in is flagged rather than dropped silently. The 3MF assigns parts to your real profile-filament slots (no invented base filament) and lays each tile out as an independently-arrangeable object. A calibration is tied to the swatch color it was measured for: editing a filament's color deactivates its calibration (the row falls back to a color-estimated hiding distance) and reverting the color reactivates it, so auto-paint never blends with measurements from a different material. The old backlit photo wizard, its image sampler, and stored RGB measurements are removed, and legacy stored RGB calibrations are stripped on load; recalibrate any previously calibrated filaments. +- **Landing page and `/app` web route** - Added a responsive product landing page at `/`, moved the browser tool to `/app`, preserved `/docs/...` routes and same-origin localStorage, and added a returning-user launch preference with `/?landing=1` preview support. The app logo provides a direct route back to the landing page. The landing page includes the core workflow, existing product visuals, desktop releases, documentation, GitHub, Discord, Patreon, and Reddit links, plus persistent System, Dark, and Light theme controls with tailored styling for each theme. +- **Route-aware SEO and deployment output** - Added route helpers, landing structured data, app `noindex` metadata, a stable PWA manifest identity with `/app` as its start URL, and static `dist/app/index.html` output for GitHub Pages refreshes. +- **Route and landing smoke coverage** - Added focused route-selection tests and Playwright coverage for the landing CTA, direct `/app` loading, and same-origin storage persistence. +- **Frontlit filament calibration** - Replaced the photo-based TD calibration with a camera-free workflow. Pick one or more filaments, choose the base layer each prints over (defaults to your darkest filament; dark filaments can use a lighter base for contrast), then download a calibration wedge (STL for any printer, or a multi-material 3MF for AMS) that prints 1..N filament layers over that base alongside an opaque reference rail, and report the layer count at which each filament/base patch first matches the rail. Quick mode keeps the single-base scalar calibration; Accurate mode repeats the read over multiple bases to fit measured RGB-channel TDs directly, stores the read set (plus optional merge points), and fits a shared session JND when enough multi-base data is present. Kromacut converts those reads into a frontlit Transmission Distance using a perceptual just-noticeable-difference model, with a live predicted-vs-reference preview and confidence rating. Read entry stays tied to the base filaments and print settings of the downloaded wedge, so changing layer height or max layers afterward does not reinterpret the physical print. The 3MF assigns parts to your real profile-filament slots (no invented base filament) and lays each tile out as an independently-arrangeable object. A calibration is tied to the swatch color it was measured for: editing a filament's color deactivates its calibration (the row falls back to a color-estimated hiding distance) and reverting the color reactivates it, so auto-paint never blends with measurements from a different material. The old backlit photo wizard, its image sampler, and stored RGB measurements are removed, and legacy stored RGB calibrations are stripped on load; recalibrate any previously calibrated filaments. +- **Preserve color separation (Auto-paint)** - Enhanced color matching can keep each distinct 2D image color mapped to a distinct printable color when the stack exposes enough printable colors, preserving gradients that would otherwise collapse onto a flat surface. It is mutually exclusive with Height dithering because both modes change the same printable height map. +- **Auto-paint optimization progress** - Enhanced matching now reports approximate search progress while the background optimizer runs. +- **Auto-paint test and benchmark coverage** - Added deterministic stack goldens, layer-invariant regression coverage, realized CIEDE2000 quality-budget tests, and an on-demand benchmark for fixture profiles using the same printable-stack mapper as the optimizer. +- **Filament profile import fixture** - Added a schema-v2 frontlit-calibrated 8-color `.kfil` fixture with import coverage to guard against accidental hiding-distance rescaling. +- **Collapsible control groups** - Every settings card in 2D and 3D mode (Adjustments, Resize Image, Dedither, Quantization Settings, Image colors, 3D Print Settings, Auto-paint, Color Slice Heights, and Print Instructions) can now be collapsed from its header to save sidebar space. Collapsed groups are remembered across sessions, and important indicators stay visible while collapsed: color counts, busy spinners, modified-settings dots, unsaved-profile markers, and quick actions like reset and copy. +- **3D inspection view modes** - The 3D preview toolbar now offers Shaded, Transparent, and Wireframe modes for inspecting surfaces, layers, and mesh edges. Wireframe uses thin, layer-colored feature edges, and the selected view is remembered without changing print settings or STL/3MF export content. +- **Auto-paint preview color toggle** - The 3D preview toolbar now offers a Simulated/Physical color toggle for Auto-paint results, switching between the estimated blended appearance and the real physical filament colors stacked at each layer without a model rebuild. Only shown for Auto-paint models, the selection is remembered, and it never changes STL/3MF export content. +- **2D touch-up tools** - The 2D preview toolbar now includes five hard-edged pixel tools (Brush, Eraser, Fill, Text, and color picker) for direct image editing with palette-safe colors, an on-canvas text box you type into directly with live move, resize, and word wrap, one undo/redo step per edit, and live non-blocking drawing with adjustments staying non-destructive. +- **Calibration theory docs** - New in-app documentation page explaining the science behind filament calibration: the frontlit Beer-Lambert optical model and hiding distance, why the wedge's reference-rail comparison is reliable without a camera, how a single patch read becomes a hiding distance through a just-noticeable-difference solve, per-channel measurement with multiple bases, the session JND fit, and what the confidence score reflects — illustrated with three new diagrams. The Calibrate Filaments dialog links straight to it. +- **Reddit community links** - Added r/kromacut links to the app settings and README. +- **Experimental multi-plate mode** - Added an experimental mode for multi-plate projects, where one image may be separated into multiple prints. + +### Changed + +- **Dependency security updates** - Updated the desktop runtime to Tauri 2.11.1, fixing an origin-confusion issue that could let a crafted remote page invoke local-only IPC commands on Windows, along with patched openssl, rustls-webpki, and rand crates. Development tooling moved to patched releases (Vite 7.3.6, Rollup, PostCSS, esbuild, Babel, and friends), clearing every Dependabot alert that is fixable without a Tauri major upgrade. +- **Hiding Distance replaces TD** - Filament opacity is now expressed as a frontlit **Hiding Distance (HD)** in millimetres: the depth at which a filament visually hides what's beneath it, matching how prints are actually viewed. Stored values become single-meaning (profile schema v2): uncalibrated values from older profiles, `.kfil`/`.kapp` files, and saved app state are converted automatically (×0.1, one time) on load or import, and the old internal frontlit scaling layer is removed — this also fixes calibrated filaments being double-scaled in the recommended-height estimate. A convert button on each filament row accepts conventional backlit/lithophane TD values (≈10× the HD) and converts them on entry, the wand estimate now produces HD directly, and per-channel values are visible in tooltips. Uncalibrated filaments now blend with per-channel hiding distances estimated from their color (previously channel-uniform), so uncalibrated previews and stacks are more color-faithful; the next-filament suggestion simulates blends in linear light with the same per-channel hiding distances auto-paint renders with (measured channels when calibrated, color-derived otherwise) and recommends HDs on the stored scale. +- **Auto-paint enhanced matching** - Optimizer scoring now follows the same layer-snapped, Max Height-compressed printable stack used by preview and export, scores realized print error in CIEDE2000 with a weighted p95 tail term, and uses complete target-color/cache inputs so repeated runs are deterministic. +- **Auto-paint optimizer controls** - Replaced the older optimizer choices with deterministic effort tiers (Fast, Balanced, Thorough, Deep, and Exact base order), selector-based repeated swaps, transition-detail endpoints, and explicit stable seed handling. Selecting Exact base order shows the estimated number of base orders it will score before running. Legacy saved values migrate to the nearest current tier. +- **Auto-paint optical model** - Beer-Lambert blending now runs in linear-light sRGB, and calibrated filaments use measured RGB-channel TDs for blend simulation and transition-zone thickness. Recalibrate profiles created with earlier releases before using them for new prints. +- **Height dithering kernel** - Height dithering now uses an error-conserving Stucki kernel instead of Floyd-Steinberg, spreading quantization error over a wider area while keeping the existing block-aware dot sizing and edge protection. + +### Fixed + +- **HueForge spool CSV/TSV imports** - BOM-prefixed HueForge spool files now import correctly when a UTF-8 byte-order mark precedes the first header. +- **Settings documentation link** - Opening Docs from Settings now remains in the documentation when Docs is already active instead of unexpectedly returning to the app. +- **Auto-paint Max Height** - Auto-paint now plans, scores, previews, and exports the same layer-aligned stack. Height caps round down to a valid printable layer boundary, so a generated model no longer exceeds the requested maximum by adding a final whole layer. +- **Auto-paint region priority** - Center and Edge priority now use the actual locations of each image color. Center prioritizes colors near the image middle; Edge prioritizes colors near the outer border. The optimizer no longer allocates a full-image weight map or guesses location from color brightness. +- **Auto-paint calibration confidence** - Color edits that deactivate a calibration now re-anchor the filament's estimated hiding distance to the new color without deleting the stored calibration, and result confidence now scores the filament sequence actually printed after variable-length optimization omits unused filaments. +- **Auto-paint edge cases** - Blank seeds now resolve to stable cacheable values, optimizer cache keys include all target clusters, tuning inputs, preserve-separation mode, and active calibration state, beam-search ties use locale-independent ordering, exact base-order search discloses its estimated base-order count before running, zero-HD blend helpers agree on the opaque-filament limit, and exceptionally tall stacks stop at the intended 500-layer slice-data limit. +- **Filament profile validation** - Imported `.kfil`/`.kapp` profile filaments now reject zero or negative hiding distances instead of letting invalid raw HD values reach the UI. +- **Desktop 3MF export reliability** - 3MF model XML now streams into the archive in bounded chunks, avoiding desktop WebView `FileReader` `NotReadableError` failures and `RangeError: Invalid string length` on large exports. +- **Smooth meshing 3MF integrity** - Smoothed cap faces are now validated in the 3MF exporter's exact integer coordinate units instead of epsilon-compared floats, so faces that would collapse at serialized precision get re-triangulated during meshing rather than silently dropped at export. This removes the pinhole open/non-manifold edges slicers flagged on large smooth-meshed 3MF exports (STL exports were never affected). + ## v3.1.0 - 2026-06-18 ### Added @@ -9,6 +51,7 @@ All notable changes to Kromacut are documented in this file. - **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. The selected mode persists across page refreshes. - **Flat Paint mode (experimental)** - Added a Flat Paint option to Auto-paint that builds a uniform, face-down slab: each pixel column's layer order is reversed so the artwork sits flat against the build plate (pre-mirrored for face-down printing) under a transparent carrier layer, the back is filled with the foundation filament so every layer has the full footprint, and 3MF export merges the parts into one object per physical filament for AMS/toolchanger printers. Includes flat-mode print instructions, a performance warning for tall stacks, mutual exclusion with Smooth Meshing, and regression tests covering the layout, meshing, STL compaction, and 3MF grouping. - **Desktop update settings** - Added desktop-only settings to manually check for updates and control whether update notices run on startup. +- **HueForge spool CSV/TSV import** - Auto-paint filament profiles can now be imported from a HueForge spool library CSV or TSV export. Each spool is imported as a filament entry with its HueForge UUID preserved as the filament ID, named `--` (e.g. `Inland Basic-Light Brown-#bf9c81`). Column order is flexible and quoted fields with embedded commas or newlines are handled per RFC 4180. - **Next-best-color suggestion** — "Suggest next filament" button in the Auto-paint panel recommends the single filament addition that would most reduce the average color error (ΔE) across the image. The result card shows the suggested hex color, a recommended starting TD, an estimated ΔE improvement, the proportion of image pixels that benefit, and an isolation score. Clicking "Add to filaments" inserts the suggestion directly into the filament list with a `Kromacut-Suggestion-NN` name. This is an inventory-planning heuristic — re-run auto-paint after adding the suggestion to see the actual result. ### Changed @@ -32,7 +75,7 @@ All notable changes to Kromacut are documented in this file. - **Project license** - Kromacut is now licensed under `AGPL-3.0-only` instead of MIT so redistributed or hosted modified versions must stay open under the same copyleft terms - **Web metadata** - Improved the page title, search description, canonical URL, social preview tags, and web app manifest metadata for hosted Kromacut pages - **Filament profile extension** - Auto-paint filament profile exports now use `.kfil` by default while continuing to import legacy `.kapp` files -- **Smooth meshing performance** - Smooth mesh generation now uses one fast welded-grid algorithm with deterministic boundary-chain smoothing in a bounded sub-pixel envelope and fan-triangulated caps instead of contour tracing and cap cleanup, avoiding hangs and browser memory blowups on complex image layers +- **Smooth meshing performance** - Smooth mesh generation now uses one fast welded-grid algorithm with deterministic boundary-chain smoothing in a bounded sub-pixel envelope and boundary-preserving caps validated at slicer precision, avoiding inverted preview faces, open exports, hangs, and browser memory blowups on complex image layers ### Fixed diff --git a/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..22895f20 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ # Kromacut -[![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white)](https://www.patreon.com/cw/vycdev) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/nU63sFMcnX) [![YouTube](https://img.shields.io/badge/YouTube-@vycdev-red?logo=youtube&logoColor=white)](https://www.youtube.com/@vycdev) [![Release](https://img.shields.io/github/v/release/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) [![Repo size](https://img.shields.io/github/repo-size/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut) [![Total downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/total?label=total%20downloads&cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases) [![Latest downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/latest/total?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) +[![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white)](https://www.patreon.com/cw/vycdev) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/nU63sFMcnX) [![Reddit](https://img.shields.io/badge/Reddit-r%2Fkromacut-FF4500?logo=reddit&logoColor=white)](https://www.reddit.com/r/kromacut/) [![YouTube](https://img.shields.io/badge/YouTube-@vycdev-red?logo=youtube&logoColor=white)](https://www.youtube.com/@vycdev) [![Release](https://img.shields.io/github/v/release/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) [![Repo size](https://img.shields.io/github/repo-size/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut) [![Total downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/total?label=total%20downloads&cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases) [![Latest downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/latest/total?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) Open-source HueForge-style tool for converting images into stacked, color-layered 3D prints. +**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 | | Fuji Sliced | Printed Result | -Another minimal test you can try yourself in the app header: the Transmission Distance (TD) test image. +Another minimal test you can try yourself: the included Transmission Distance (TD) test image (`src/assets/tdTest.png`). | TD Test | |---| @@ -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) Keeps distinct 2D image colors assigned to distinct printable colors when the stack has enough printable colors. 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 3MF Export -## 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/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..cb57d411 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kromacut", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kromacut", - "version": "3.1.0", + "version": "3.2.0", "license": "AGPL-3.0-only", "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -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..f113fcd0 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "kromacut", "private": true, - "version": "3.1.0", + "version": "3.2.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", "test:e2e": "playwright test --grep @smoke", "test:e2e:matrix": "playwright test --grep @matrix", "test:e2e:stress": "playwright test --grep @stress", @@ -27,6 +29,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", diff --git a/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..42f8d132 100644 --- a/public/version.json +++ b/public/version.json @@ -1,5 +1,5 @@ { - "version": "3.1.0", + "version": "3.2.0", "download_url": "https://github.com/vycdev/Kromacut/releases/latest", - "release_notes": "Kromacut 3.1.0 adds orthographic preview mode, experimental Flat Paint, desktop update settings, and next-best-color filament suggestions." + "release_notes": "Upcoming Kromacut 3.2.0 release with new features and improvements." } diff --git a/scripts/generate-auto-paint-goldens.ts b/scripts/generate-auto-paint-goldens.ts new file mode 100644 index 00000000..e42bf817 --- /dev/null +++ b/scripts/generate-auto-paint-goldens.ts @@ -0,0 +1,65 @@ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createServer } from 'vite'; + +import { autoPaintGoldenScenarios } from '../tests/autoPaintGoldenFixtures.ts'; + +type AutoPaintModule = typeof import('../src/lib/autoPaint.ts'); + +const LAYER_HEIGHT = 0.08; +const FIRST_LAYER_HEIGHT = 0.16; + +async function loadAutoPaintModule(): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { noDiscovery: true }, + resolve: { alias: { '@': resolve(process.cwd(), 'src') } }, + root: process.cwd(), + server: { hmr: false, middlewareMode: true }, + }); + + try { + return (await server.ssrLoadModule('/src/lib/autoPaint.ts')) as AutoPaintModule; + } finally { + await server.close(); + } +} + +const { generateAutoLayers } = await loadAutoPaintModule(); +const goldens = Object.fromEntries( + autoPaintGoldenScenarios().map((scenario) => { + const result = generateAutoLayers( + scenario.filaments, + scenario.imageSwatches, + LAYER_HEIGHT, + FIRST_LAYER_HEIGHT, + undefined, + scenario.enhancedColorMatch, + scenario.allowRepeatedSwaps, + { algorithm: '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..07b38936 100644 --- a/scripts/generate-docs-seo-pages.mjs +++ b/scripts/generate-docs-seo-pages.mjs @@ -110,6 +110,15 @@ 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-', + }; + const diagramName = Object.keys(diagramPrefixes).find((name) => clean.includes(name)); + if (diagramName) { + return findBuiltAsset(diagramPrefixes[diagramName]) ?? clean; + } return clean; } @@ -136,12 +145,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 `${escapeHtml(alt)}`; + return protect(`${escapeHtml(alt)}`); }); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, href) => { @@ -149,16 +166,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 +341,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>/, `<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 +372,8 @@ function renderStaticRoot(doc, docs, docsBySlug) { return `
    @@ -362,6 +401,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 +425,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 +492,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/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 99efa6e9..e1458290 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 = "3.2.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..cfbae608 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kromacut" -version = "3.1.0" +version = "3.2.0" description = "Kromacut - Multi-color lithophane 3D print generator" authors = ["vycdev"] license = "AGPL-3.0-only" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 34058649..3b89c1ea 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Kromacut", - "version": "3.1.0", + "version": "3.2.0", "identifier": "com.kromacut.lithophane", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index aab12b84..59dcd9f8 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 } 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,7 @@ import { useAppHandlers, type ExportProgressStep } from './hooks/useAppHandlers' import { useProcessingState } from './hooks/useProcessingState'; import { useBuildWarning } from './hooks/useBuildWarning'; import { clampProgress } from './lib/progress'; +import { normalizeOptimizerTier } from './lib/optimizer'; import ResizableSplitter from './components/ResizableSplitter'; import { ControlsPanel } from './components/ControlsPanel'; import { usePaletteManager } from './hooks/usePaletteManager'; @@ -35,8 +44,22 @@ 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, sanitizeProfileFilament } from './lib/profileManager'; +import PrintUnlockEffect from './components/PrintUnlockEffect'; +import { + getMultiPlateEnabled, + subscribeToMultiPlateEnabled, +} from './lib/experimentalFeatures'; import { AlertDialog, AlertDialogContent, @@ -77,12 +100,33 @@ type AutoPaintPersisted = Pick< | 'optimizerSeed' | 'regionWeightingMode' | 'enhancedColorMatch' + | 'preserveSeparation' | 'allowRepeatedSwaps' + | 'maxRepeatedSwaps' + | 'transitionOpacity' | 'heightDithering' | 'ditherLineWidth' | 'flatPaint' >; +// Schema v2: filament `td` values store frontlit hiding distances. State +// persisted without this marker predates the change and carries uncalibrated +// tds on the conventional backlit scale, which must be migrated once on load. +const AUTOPAINT_SCHEMA_VERSION = 2; + +function isOneOf(value: unknown, values: T): value is T[number] { + return typeof value === 'number' && values.includes(value as T[number]); +} + +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 +134,19 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const parsed = JSON.parse(raw) as any; if (!parsed || !Array.isArray(parsed.filaments)) return null; + const 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 filaments = + persistedSchema >= AUTOPAINT_SCHEMA_VERSION + ? sanitized + : sanitized.map(migrateLegacyFilamentTd); // Migrate legacy `autoPaintEnabled` boolean → `paintMode` const paintMode: 'manual' | 'autopaint' = parsed.paintMode === 'autopaint' || parsed.paintMode === 'manual' @@ -98,13 +155,18 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { ? 'autopaint' : 'manual'; return { - filaments: parsed.filaments, + filaments, paintMode, - optimizerAlgorithm: parsed.optimizerAlgorithm, + optimizerAlgorithm: normalizeOptimizerTier(parsed.optimizerAlgorithm), optimizerSeed: parsed.optimizerSeed, regionWeightingMode: parsed.regionWeightingMode, enhancedColorMatch: parsed.enhancedColorMatch ?? false, - allowRepeatedSwaps: parsed.allowRepeatedSwaps ?? false, + preserveSeparation: parsed.preserveSeparation ?? false, + maxRepeatedSwaps: normalizeRepeatLimit( + parsed.maxRepeatedSwaps, + parsed.allowRepeatedSwaps + ), + transitionOpacity: normalizeTransitionOpacity(parsed.transitionOpacity), heightDithering: parsed.heightDithering ?? false, ditherLineWidth: parsed.ditherLineWidth, flatPaint: parsed.flatPaint ?? false, @@ -116,13 +178,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); @@ -156,6 +243,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, @@ -203,6 +295,10 @@ function App(): React.ReactElement | null { const [mode, setMode] = useState<'2d' | '3d'>('2d'); const [docsOpen, setDocsOpen] = useState(() => parseDocsLocation(window.location) !== null); const [isOrtho, setIsOrtho] = useState(loadCameraMode); + const [previewRenderMode, setPreviewRenderMode] = + useState(loadPreviewRenderMode); + const [previewColorMode, setPreviewColorMode] = + useState(loadPreviewColorMode); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 const [exportStep, setExportStep] = useState({ @@ -242,7 +338,9 @@ function App(): React.ReactElement | null { regionWeightingMode: autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode, enhancedColorMatch: autopaintHydrated.enhancedColorMatch ?? prev.enhancedColorMatch, - allowRepeatedSwaps: autopaintHydrated.allowRepeatedSwaps ?? prev.allowRepeatedSwaps, + preserveSeparation: autopaintHydrated.preserveSeparation ?? prev.preserveSeparation, + maxRepeatedSwaps: autopaintHydrated.maxRepeatedSwaps ?? prev.maxRepeatedSwaps, + transitionOpacity: autopaintHydrated.transitionOpacity ?? prev.transitionOpacity, heightDithering: autopaintHydrated.heightDithering ?? prev.heightDithering, ditherLineWidth: autopaintHydrated.ditherLineWidth ?? prev.ditherLineWidth, flatPaint: autopaintHydrated.flatPaint ?? prev.flatPaint, @@ -261,7 +359,9 @@ function App(): React.ReactElement | null { optimizerSeed: threeDState.optimizerSeed, regionWeightingMode: threeDState.regionWeightingMode, enhancedColorMatch: threeDState.enhancedColorMatch, - allowRepeatedSwaps: threeDState.allowRepeatedSwaps, + preserveSeparation: threeDState.preserveSeparation, + maxRepeatedSwaps: threeDState.maxRepeatedSwaps, + transitionOpacity: threeDState.transitionOpacity, heightDithering: threeDState.heightDithering, ditherLineWidth: threeDState.ditherLineWidth, flatPaint: threeDState.flatPaint, @@ -273,7 +373,9 @@ function App(): React.ReactElement | null { threeDState.optimizerSeed, threeDState.regionWeightingMode, threeDState.enhancedColorMatch, - threeDState.allowRepeatedSwaps, + threeDState.preserveSeparation, + threeDState.maxRepeatedSwaps, + threeDState.transitionOpacity, threeDState.heightDithering, threeDState.ditherLineWidth, threeDState.flatPaint, @@ -305,23 +407,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 +437,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 +446,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 +512,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 +541,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,30 +554,22 @@ 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 && ( +
    + +
    + )} +
    -
    + - {/* 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/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..e62baf2f 100644 --- a/src/components/AdjustmentsPanel.tsx +++ b/src/components/AdjustmentsPanel.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; -import { Card } from '@/components/ui/card'; +import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; import { Check, RotateCcw } from 'lucide-react'; export type SliderDef = { @@ -120,12 +120,14 @@ export const AdjustmentsPanel: React.FC = React.memo( }, [onBake, onCommit]); return ( - -
    -
    -

    Adjustments

    -

    Fine-tune image properties

    -
    + : undefined + } + actions={ -
    -
    + } + >
    {defs.map((s) => { const displayVal = values[s.key]; @@ -188,7 +190,7 @@ export const AdjustmentsPanel: React.FC = React.memo( ); })}
    -
    +
    - + ); } ); diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 5f93e400..7f808827 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'; @@ -27,13 +26,71 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { TabsContent } from '@/components/ui/tabs'; import type { AutoPaintResult, TransitionZone } from '../lib/autoPaint'; import type { AutoPaintProfile } from '../lib/profileManager'; -import type { Filament, Swatch } from '../types'; -import type { CalibrationResult } from '../lib/calibration'; +import type { AutoPaintRepeatLimit, AutoPaintTransitionOpacity, Filament, Swatch } from '../types'; import FilamentRow from './FilamentRow'; -import { FilamentCalibrationWizard } from './FilamentCalibrationWizard'; +import { + FilamentCalibrationDialog, + type CalibrationApplyUpdate, +} from './FilamentCalibrationDialog'; import { getConfidenceLabel, getConfidenceColor } from '../lib/calibration'; +import { getExactBaseOrderCount } from '../lib/optimizer'; import { useNextBestColorWorker } from '../hooks/useNextBestColorWorker'; +/** 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 ( +
    +
    {label}
    +
    + {pct}% +
    +
    +
    +
    +
    + ); +} + +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[]; colorSliceHeights: number[]; @@ -77,9 +134,11 @@ interface AutoPaintTabProps { autoPaintResult?: AutoPaintResult; autoPaintSliceData?: AutoPaintSliceData; isComputing?: boolean; + progress?: number; error?: string; calibrationLayerHeight: number; setCalibrationLayerHeight: (v: number) => void; + firstLayerHeight: number; // Image colors filteredCount: number; @@ -88,8 +147,12 @@ interface AutoPaintTabProps { // Enhanced matching options enhancedColorMatch: boolean; setEnhancedColorMatch: (v: boolean) => void; - allowRepeatedSwaps: boolean; - setAllowRepeatedSwaps: (v: boolean) => void; + preserveSeparation: boolean; + setPreserveSeparation: (v: boolean) => void; + maxRepeatedSwaps: AutoPaintRepeatLimit; + setMaxRepeatedSwaps: (v: AutoPaintRepeatLimit) => void; + transitionOpacity: AutoPaintTransitionOpacity; + setTransitionOpacity: (v: AutoPaintTransitionOpacity) => void; heightDithering: boolean; setHeightDithering: (v: boolean) => void; ditherLineWidth: number; @@ -100,8 +163,8 @@ interface AutoPaintTabProps { setFlatPaint: (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'; @@ -139,14 +202,20 @@ export default function AutoPaintTab({ autoPaintResult, autoPaintSliceData, isComputing = false, + progress = 0, error, calibrationLayerHeight, + firstLayerHeight, filteredCount, imageSwatches, enhancedColorMatch, setEnhancedColorMatch, - allowRepeatedSwaps, - setAllowRepeatedSwaps, + preserveSeparation, + setPreserveSeparation, + maxRepeatedSwaps, + setMaxRepeatedSwaps, + transitionOpacity, + setTransitionOpacity, heightDithering, setHeightDithering, ditherLineWidth, @@ -178,34 +247,33 @@ export default function AutoPaintTab({ 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); - - const calibratingFilament = filaments.find((f) => f.id === calibratingFilamentId); + // Calibration dialog state + const [calibrationDialogOpen, setCalibrationDialogOpen] = React.useState(false); - 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 +286,24 @@ export default function AutoPaintTab({ return ( - -
    -

    Auto-paint

    -

    - Define filament colors and transmission distances for automatic painting -

    -
    -
    - + + {isComputing && ( + + )} + {error && !isComputing && } + {activeProfileId && isDirty && ( + + )} + + } + > {/* Profiles Section */}
    @@ -366,11 +443,13 @@ export default function AutoPaintTab({ +
    + {/* Import */} - {/* Delete */} - {activeProfileId && ( - - )} +
    + + {/* Delete — always rendered so the strip width stays stable */} +
    {/* Import feedback */} @@ -432,20 +512,33 @@ export default function AutoPaintTab({ filament={f} onUpdate={updateFilament} onRemove={removeFilament} - onCalibrate={handleOpenCalibrationWizard} /> ))}
    )} - +
    0 ? 'grid grid-cols-2 gap-2' : ''}> + + + {filaments.length > 0 && ( + + )} +
    {/* Max Height Constraint */} {filaments.length > 0 && ( @@ -511,9 +604,24 @@ export default function AutoPaintTab({
    )} {isComputing && ( -
    - - Optimizing filament order... +
    +
    + + + Optimizing filament order… + + + {Math.round(progress * 100)}% + +
    +
    +
    +
    )} {error && !isComputing && ( @@ -540,82 +648,134 @@ export default function AutoPaintTab({ onCheckedChange={setEnhancedColorMatch} />
    -
    -
    )} @@ -646,20 +806,14 @@ export default function AutoPaintTab({ className={`space-y-3 pt-2 transition-opacity ${enhancedColorMatch ? 'opacity-100' : 'opacity-40 pointer-events-none'}`} >
    -
    - -

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

    -
    +
    @@ -675,32 +829,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. +
    + )}
    @@ -728,17 +885,52 @@ export default function AutoPaintTab({
    +
    + + +
    setLocalOptimizerSeed(e.target.value)} onBlur={() => { @@ -777,7 +969,6 @@ export default function AutoPaintTab({
    - Transition Zones @@ -796,7 +987,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 +1023,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)} +
    ); } @@ -873,12 +1082,7 @@ export default function AutoPaintTab({ {autoPaintResult && (
    -
    - - Result Confidence -
    + Result Confidence @@ -886,46 +1090,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 +1124,21 @@ export default function AutoPaintTab({
    - Optimizer Performance + + {autoPaintResult.optimizerMetadata.cacheHit && ( + + Cache hit + + )} + {autoPaintResult.optimizerMetadata.converged && ( + + Converged + + )} +
    @@ -967,20 +1169,6 @@ export default function AutoPaintTab({
    -
    - {autoPaintResult.optimizerMetadata.cacheHit && ( - - - Cache hit - - )} - {autoPaintResult.optimizerMetadata.converged && ( - - - Converged - - )} -
    )}
    @@ -996,19 +1184,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 +1209,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 +1246,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 +1276,17 @@ export default function AutoPaintTab({
    )}
    - + - {/* Calibration Wizard */} - {calibratingFilament && ( - - )} + {/* Calibration Dialog */} + ); } diff --git a/src/components/CanvasPreview.tsx b/src/components/CanvasPreview.tsx index 0a7da6c9..d2079455 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,443 @@ 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 externalGeneration = externalImageGenerationRef.current; + touchUpCommitChainRef.current = touchUpCommitChainRef.current.then(async () => { + const original = originalCanvasRef.current; + const commitCallback = onTouchUpCommitRef.current; + if (!original || !commitCallback || !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) => + original.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 octx = original.getContext('2d'); + if (img && octx) { + octx.clearRect(0, 0, original.width, original.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 +967,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 +1139,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; } }; }, []); @@ -662,7 +1236,9 @@ const CanvasPreview = forwardRef( 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 +1251,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,18 +1281,116 @@ 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()} + > +
    + + + +
    +