diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f46aa..06b98c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,26 @@ All notable changes to Kromacut are documented in this file. ### Added +- **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. +- **Reddit community links** - Added r/kromacut links to the app header and README, and use branded Discord, Reddit, and GitHub icons in the header toolbar. + ### Changed +- **Calibration image sampler** - The sampler now shows a circular brush over the exact image area it averages, plus a marker for the last captured sample, making it easier to avoid patch edges and glare. +- **Auto-paint enhanced matching** - Optimizer scoring now follows the same layer-snapped, Max Height-compressed printable stack used by preview and export, scores realized print error in CIEDE2000 with a weighted p95 tail term, and uses complete target-color/cache inputs so repeated runs are deterministic. +- **Auto-paint optimizer controls** - Replaced the older optimizer choices with deterministic effort tiers (Fast, Balanced, Thorough, Deep, and Exact base order), selector-based repeated swaps, transition-detail endpoints, and explicit stable seed handling. Legacy saved values migrate to the nearest current tier. +- **Auto-paint optical model** - Beer-Lambert blending now runs in linear-light sRGB, and calibrated filaments use measured RGB-channel TDs for blend simulation and transition-zone thickness. Recalibrate profiles created with earlier releases before using them for new prints. +- **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 +- **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 edge cases** - Blank seeds now resolve to stable cacheable values, optimizer cache keys include all target clusters and tuning inputs, and exceptionally tall stacks stop at the intended 500-layer slice-data limit. +- **Desktop 3MF export reliability** - 3MF model XML now streams into the archive in bounded chunks, avoiding desktop WebView `FileReader` `NotReadableError` failures and `RangeError: Invalid string length` on large exports. + ## v3.1.0 - 2026-06-18 ### Added diff --git a/README.md b/README.md index 053e906..92b0358 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ # 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. Kromacut is a browser-first app that converts images into multi-color lithophane 3D prints. It offers two powerful workflows: -**Auto-paint mode** — Define your actual filaments (color + Transmission Distance), and Kromacut automatically computes optimal layer stacks using physically accurate Beer-Lambert optical blending. Features include a calibration wizard, advanced optimizer (simulated annealing/genetic algorithms), and region weighting for spatial priority. +**Auto-paint mode** — Define your actual filaments (color + Transmission Distance), and Kromacut automatically computes optimal layer stacks using physically accurate Beer-Lambert optical blending. Features include a calibration wizard, five deterministic optimizer effort tiers, and region weighting for spatial priority. **Manual mode** — Reduce images to a small palette, manually tweak per-color layer heights and ordering, and fine-tune every aspect of the stack with complete control. @@ -45,7 +45,7 @@ Another minimal test you can try yourself in the app header: the Transmission Di - Plain-text 3D print instructions that describe layer heights and exact layers where filament swaps are required. - Copy-to-clipboard button for the print instructions (produces a clean, copyable plain-text plan). - **Filament calibration wizard** — Accurately determine Transmission Distance (TD) values through measured samples with confidence scoring. -- **Advanced optimizer** — Simulated annealing and genetic algorithms find optimal filament ordering for complex multi-color prints. +- **Advanced optimizer** — Five deterministic effort tiers search for optimal filament ordering, from quick previews to exact base-order enumeration. - **Region weighting** — Prioritize accuracy in specific image areas (center, edges) during auto-paint optimization. ## Notable implementation details @@ -66,7 +66,7 @@ Another minimal test you can try yourself in the app header: the Transmission Di 3. Click **Add Filament** and configure your filaments: - Use the **calibration wizard** (calibrate icon) to measure accurate TD values, or - Enter TD values manually. -4. Enable **Enhanced color matching** for optimal results (auto-selects best algorithm). +4. Enable **Enhanced color matching** for optimal results; start with the **Balanced** optimizer tier. 5. (Optional) Set **Region weighting** to Center or Edge to prioritize important areas. 6. Use the **layer-by-layer preview slider** to verify transitions. 7. Export via **Download STL** or **Download 3MF** and follow the print plan. @@ -112,8 +112,8 @@ Auto-paint is an automated layer-generation mode that replaces the manual palett ### 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. +- **Beer-Lambert optical blending**: The algorithm simulates how light transmits through stacked filament layers using the Beer-Lambert law: `transmission = 10^(-thickness / TD)`. It blends in linear-light sRGB before converting back for display and color matching, which better models the color you see from thin semi-transparent layers. +- **Transition zones**: Each filament in the stack needs enough vertical space to visually transition from the color below it to its own pure color. The algorithm simulates adding layers one at a time until the blended color converges (DeltaE < 2.3 — the "just noticeable difference" threshold) or reaches the selected opacity endpoint. **Compact** uses 80% opacity, **Detailed** uses 90% (one TD), and **Maximum** uses 95% (the same endpoint as the foundation layer). Higher endpoints retain more printable intermediate colors but create taller stacks. - **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) @@ -134,18 +134,24 @@ Each filament row in the Auto-paint tab includes a **calibration wizard** to hel 4. **Automatic TD calculation** — The wizard performs exponential regression on your measurements to compute the optimal TD value with a confidence score (High/Medium/Low/Very Low). 5. **Save profile** — Keep calibrated filaments in a reusable profile for future projects. -Calibrated filaments display a confidence badge next to their TD value. Higher confidence = more accurate optical simulation = better print results. +Calibrated filaments display a confidence badge next to their TD value. Higher confidence = more accurate optical simulation = better print results. Auto-paint uses calibrated red, green, and blue TD values for both preview blending and transition-zone thickness, so calibration can change the generated stack height and swap plan. ### 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 +160,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 +170,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 +179,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 @@ -228,7 +238,7 @@ Click **Add to filaments** to insert the suggestion directly into the filament l 2. Switch to the **Auto-paint** tab (inside the 3D controls panel). 3. Click **Add Filament** and configure each filament's color and TD to match your real filament stock. - **Tip:** Use the calibration wizard (calibrate icon on each row) to measure accurate TD values. -4. (Optional) Enable **Enhanced color matching** for better results with complex images. The optimizer will automatically select the best algorithm (exhaustive, simulated annealing, or genetic) based on your filament count. +4. (Optional) Enable **Enhanced color matching** for better results with complex images. Start with the **Balanced** optimizer tier, then use Thorough, Deep, or Exact base order when you want to spend more search effort. 5. (Optional) Set **Region weighting** to Center or Edge to prioritize accuracy in visually important areas. 6. The 3D preview updates automatically. Adjust **Max Height** if the model is too tall. 7. Use the **layer-by-layer preview slider** at the bottom of the 3D view to verify color transitions. @@ -253,7 +263,7 @@ Transmission Distance (TD) is the concept HueForge uses to produce perceptual in - **Automatic Beer-Lambert blending** — Kromacut simulates light transmission through stacked filament layers using physically accurate optical models. - **Filament-based workflow** — Define your actual filaments (color + TD values) and let the algorithm compute optimal layer stacks automatically. - **Calibration wizard** — Measure accurate TD values from physical test prints for each filament. -- **Advanced optimizer** — Simulated annealing and genetic algorithms find the best filament ordering for complex images. +- **Advanced optimizer** — Deterministic effort tiers search for the best filament ordering for complex images, from quick beam previews to exact base-order enumeration. - **Transition zones** — Automatically calculated vertical zones where each filament blends from the color below to its own pure color. See the [Auto-paint section](#auto-paint) above for full details. diff --git a/package.json b/package.json index 9b40379..e3aa684 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "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", diff --git a/scripts/generate-auto-paint-goldens.ts b/scripts/generate-auto-paint-goldens.ts new file mode 100644 index 0000000..6c60be8 --- /dev/null +++ b/scripts/generate-auto-paint-goldens.ts @@ -0,0 +1,67 @@ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createServer } from 'vite'; + +import { autoPaintGoldenScenarios } from '../tests/autoPaintGoldenFixtures.ts'; + +type AutoPaintModule = typeof import('../src/lib/autoPaint.ts'); + +const LAYER_HEIGHT = 0.08; +const FIRST_LAYER_HEIGHT = 0.16; + +async function loadAutoPaintModule(): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { noDiscovery: true }, + resolve: { alias: { '@': resolve(process.cwd(), 'src') } }, + root: process.cwd(), + server: { hmr: false, middlewareMode: true }, + }); + + try { + return (await server.ssrLoadModule('/src/lib/autoPaint.ts')) as AutoPaintModule; + } finally { + await server.close(); + } +} + +const { generateAutoLayers } = await loadAutoPaintModule(); +const goldens = Object.fromEntries( + autoPaintGoldenScenarios().map((scenario) => { + const result = generateAutoLayers( + scenario.filaments, + scenario.imageSwatches, + LAYER_HEIGHT, + FIRST_LAYER_HEIGHT, + undefined, + scenario.enhancedColorMatch, + scenario.allowRepeatedSwaps, + { algorithm: 'balanced', seed: scenario.seed }, + 'uniform', + scenario.imageDimensions + ); + + return [ + scenario.name, + { + filamentOrder: result.filamentOrder, + transitionZones: result.transitionZones.map((zone) => ({ + filamentId: zone.filamentId, + startHeight: zone.startHeight, + endHeight: zone.endHeight, + idealThickness: zone.idealThickness, + actualThickness: zone.actualThickness, + })), + totalHeight: result.totalHeight, + compressionRatio: result.compressionRatio, + }, + ]; + }) +); + +const outputPath = resolve('tests', 'assets', 'auto-paint-goldens.json'); +writeFileSync(outputPath, `${JSON.stringify(goldens, null, 2)}\n`); +console.log(`Wrote ${Object.keys(goldens).length} auto-paint goldens to ${outputPath}`); diff --git a/src/App.tsx b/src/App.tsx index aab12b8..2815858 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,12 @@ import React, { useEffect, useRef, useState } from 'react'; import ThreeDControls from './components/ThreeDControls'; -import type { ThreeDControlsStateShape } from './types'; +import { + AUTO_PAINT_REPEAT_LIMITS, + AUTO_PAINT_TRANSITION_OPACITIES, + type AutoPaintRepeatLimit, + type AutoPaintTransitionOpacity, + type ThreeDControlsStateShape, +} from './types'; import ThreeDView from './components/ThreeDView'; import logo from './assets/logo.png'; import tdTestImg from './assets/tdTest.png'; @@ -27,6 +33,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'; @@ -77,12 +84,28 @@ type AutoPaintPersisted = Pick< | 'optimizerSeed' | 'regionWeightingMode' | 'enhancedColorMatch' + | 'preserveSeparation' | 'allowRepeatedSwaps' + | 'maxRepeatedSwaps' + | 'transitionOpacity' | 'heightDithering' | 'ditherLineWidth' | 'flatPaint' >; +function isOneOf(value: unknown, values: T): value is T[number] { + return typeof value === 'number' && values.includes(value as T[number]); +} + +function normalizeRepeatLimit(value: unknown, legacyEnabled: unknown): AutoPaintRepeatLimit { + if (isOneOf(value, AUTO_PAINT_REPEAT_LIMITS)) return value; + return legacyEnabled === true ? 4 : 0; +} + +function normalizeTransitionOpacity(value: unknown): AutoPaintTransitionOpacity { + return isOneOf(value, AUTO_PAINT_TRANSITION_OPACITIES) ? value : 0.9; +} + const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { try { const raw = localStorage.getItem(AUTOPAINT_STORAGE_KEY); @@ -100,11 +123,16 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { return { filaments: parsed.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, @@ -242,7 +270,11 @@ 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 +293,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 +307,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, @@ -684,6 +720,7 @@ function App(): React.ReactElement | null { builtModelState.autoPaintResult?.filamentOrder } enhancedColorMatch={builtModelState.enhancedColorMatch} + preserveSeparation={builtModelState.preserveSeparation} heightDithering={builtModelState.heightDithering} ditherLineWidth={builtModelState.ditherLineWidth} smoothMeshing={builtModelState.smoothMeshing} diff --git a/src/assets/discord.svg b/src/assets/discord.svg new file mode 100644 index 0000000..9d7796b --- /dev/null +++ b/src/assets/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/src/assets/github.svg b/src/assets/github.svg new file mode 100644 index 0000000..538ec5b --- /dev/null +++ b/src/assets/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/src/assets/reddit.svg b/src/assets/reddit.svg new file mode 100644 index 0000000..ea03883 --- /dev/null +++ b/src/assets/reddit.svg @@ -0,0 +1 @@ +Reddit \ No newline at end of file diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 5f93e40..ae048f6 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -27,13 +27,43 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { TabsContent } from '@/components/ui/tabs'; import type { AutoPaintResult, TransitionZone } from '../lib/autoPaint'; import type { AutoPaintProfile } from '../lib/profileManager'; -import type { Filament, Swatch } from '../types'; +import type { AutoPaintRepeatLimit, AutoPaintTransitionOpacity, Filament, Swatch } from '../types'; import type { CalibrationResult } from '../lib/calibration'; import FilamentRow from './FilamentRow'; import { FilamentCalibrationWizard } from './FilamentCalibrationWizard'; import { getConfidenceLabel, getConfidenceColor } from '../lib/calibration'; import { useNextBestColorWorker } from '../hooks/useNextBestColorWorker'; +type OptimizerTierValue = 'fast' | 'balanced' | 'thorough' | 'deep' | 'exact'; + +interface OptimizerTierMeta { + value: OptimizerTierValue; + label: string; +} + +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', + }, +]; + interface AutoPaintSliceData { virtualSwatches: Swatch[]; colorSliceHeights: number[]; @@ -77,6 +107,7 @@ interface AutoPaintTabProps { autoPaintResult?: AutoPaintResult; autoPaintSliceData?: AutoPaintSliceData; isComputing?: boolean; + progress?: number; error?: string; calibrationLayerHeight: number; setCalibrationLayerHeight: (v: number) => void; @@ -88,8 +119,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 +135,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 +174,19 @@ export default function AutoPaintTab({ autoPaintResult, autoPaintSliceData, isComputing = false, + progress = 0, error, calibrationLayerHeight, filteredCount, imageSwatches, enhancedColorMatch, setEnhancedColorMatch, - allowRepeatedSwaps, - setAllowRepeatedSwaps, + preserveSeparation, + setPreserveSeparation, + maxRepeatedSwaps, + setMaxRepeatedSwaps, + transitionOpacity, + setTransitionOpacity, heightDithering, setHeightDithering, ditherLineWidth, @@ -221,9 +261,6 @@ export default function AutoPaintTab({

Auto-paint

-

- Define filament colors and transmission distances for automatic painting -

@@ -511,9 +548,24 @@ export default function AutoPaintTab({
)} {isComputing && ( -
- - Optimizing filament order... +
+
+ + + Optimizing filament order… + + + {Math.round(progress * 100)}% + +
+
+
+
)} {error && !isComputing && ( @@ -540,82 +592,134 @@ export default function AutoPaintTab({ onCheckedChange={setEnhancedColorMatch} />
-
-
)} @@ -646,20 +750,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 +773,22 @@ export default function AutoPaintTab({ - - Auto (smart selection) - - 8} - > - Exhaustive (≤8 filaments) - - - Simulated Annealing - - - Genetic Algorithm - + {OPTIMIZER_TIERS.map((tier) => ( + + {tier.label} + + ))}
@@ -728,17 +816,52 @@ export default function AutoPaintTab({
+
+ + +
setLocalOptimizerSeed(e.target.value)} onBlur={() => { @@ -1001,14 +1124,18 @@ export default function AutoPaintTab({ ) : ( )} - {isNextBestComputing ? 'Finding suggestion...' : 'Suggest next filament'} + {isNextBestComputing + ? 'Finding suggestion...' + : 'Suggest next filament'} {nextBestResult?.candidate && (
{nextBestResult.candidate.hex.toUpperCase()} @@ -1019,7 +1146,9 @@ export default function AutoPaintTab({ > Est. ΔE{' '} - +{nextBestResult.candidate.improvementPct.toFixed(1)}% + + + {nextBestResult.candidate.improvementPct.toFixed(1)} + %
@@ -1054,7 +1183,10 @@ export default function AutoPaintTab({ className="w-full h-7 text-xs mt-0.5" onClick={() => { suggestionCountRef.current += 1; - const nn = String(suggestionCountRef.current).padStart(2, '0'); + const nn = String(suggestionCountRef.current).padStart( + 2, + '0' + ); addFilamentWithProps({ color: nextBestResult.candidate!.hex, td: nextBestResult.candidate!.td, diff --git a/src/components/FilamentCalibrationWizard.tsx b/src/components/FilamentCalibrationWizard.tsx index 4ee74c4..b757a1e 100644 --- a/src/components/FilamentCalibrationWizard.tsx +++ b/src/components/FilamentCalibrationWizard.tsx @@ -24,8 +24,6 @@ import { cn } from '@/lib/utils'; import { calculateTDFromMeasurements, DEFAULT_WHITE_REFERENCE, - normalizeCalibrationMeasurements, - rgbToTransmission, getCalibrationInstructions, getRecommendedLayerCounts, canCalculateTD, @@ -42,6 +40,12 @@ type WizardStep = 'intro' | 'print' | 'measure' | 'results'; type SamplerTarget = 'white-reference' | 'measurement'; type RgbInputState = { r: string; g: string; b: string }; +type SamplerPoint = { x: number; y: number }; + +// The brush stays a consistent size on screen, then scales to the source image +// so the outlined area is the exact area being averaged. +const SAMPLER_BRUSH_RADIUS_PX = 16; +const SAMPLER_BRUSH_DIAMETER_PX = SAMPLER_BRUSH_RADIUS_PX * 2; const buildRgbInputState = (rgb?: CalibrationRgb): RgbInputState => ({ r: String(rgb?.[0] ?? DEFAULT_WHITE_REFERENCE[0]), @@ -67,12 +71,13 @@ function sampleAverageRgb( ctx: CanvasRenderingContext2D, centerX: number, centerY: number, - radius = 2 + radius: number ): CalibrationRgb { - const startX = Math.max(0, centerX - radius); - const startY = Math.max(0, centerY - radius); - const endX = Math.min(ctx.canvas.width - 1, centerX + radius); - const endY = Math.min(ctx.canvas.height - 1, centerY + radius); + const roundedRadius = Math.max(1, Math.round(radius)); + const startX = Math.max(0, centerX - roundedRadius); + const startY = Math.max(0, centerY - roundedRadius); + const endX = Math.min(ctx.canvas.width - 1, centerX + roundedRadius); + const endY = Math.min(ctx.canvas.height - 1, centerY + roundedRadius); const width = endX - startX + 1; const height = endY - startY + 1; const imageData = ctx.getImageData(startX, startY, width, height).data; @@ -82,13 +87,22 @@ function sampleAverageRgb( let totalB = 0; let samples = 0; - for (let i = 0; i < imageData.length; i += 4) { - const alpha = imageData[i + 3] / 255; - if (alpha <= 0) continue; - totalR += imageData[i] * alpha; - totalG += imageData[i + 1] * alpha; - totalB += imageData[i + 2] * alpha; - samples += alpha; + for (let y = startY; y <= endY; y++) { + for (let x = startX; x <= endX; x++) { + const distanceX = x - centerX; + const distanceY = y - centerY; + if (distanceX * distanceX + distanceY * distanceY > roundedRadius * roundedRadius) { + continue; + } + + const pixelIndex = ((y - startY) * width + (x - startX)) * 4; + const alpha = imageData[pixelIndex + 3] / 255; + if (alpha <= 0) continue; + totalR += imageData[pixelIndex] * alpha; + totalG += imageData[pixelIndex + 1] * alpha; + totalB += imageData[pixelIndex + 2] * alpha; + samples += alpha; + } } if (samples <= 0) return [0, 0, 0]; @@ -132,6 +146,8 @@ export function FilamentCalibrationWizard({ const [samplerTarget, setSamplerTarget] = useState('measurement'); const [pickerImageSrc, setPickerImageSrc] = useState(null); const [pickerStatus, setPickerStatus] = useState(null); + const [samplerPoint, setSamplerPoint] = useState(null); + const [lastSamplePoint, setLastSamplePoint] = useState(null); const [currentLayers, setCurrentLayers] = useState(''); const [currentRGB, setCurrentRGB] = useState({ r: '', g: '', b: '' }); const [result, setResult] = useState(null); @@ -156,8 +172,10 @@ export function FilamentCalibrationWizard({ const reader = new FileReader(); reader.onload = () => { setPickerImageSrc(typeof reader.result === 'string' ? reader.result : null); + setSamplerPoint(null); + setLastSamplePoint(null); setPickerStatus( - 'Image loaded. Click anywhere on it to sample RGB into the selected target.' + 'Image loaded. Move the circular brush over a uniform area, then click to sample it.' ); }; reader.readAsDataURL(file); @@ -180,32 +198,50 @@ export function FilamentCalibrationWizard({ ctx.drawImage(image, 0, 0); }, []); + const getSamplerCoordinates = useCallback((event: React.MouseEvent) => { + const image = pickerImageRef.current; + const canvas = pickerCanvasRef.current; + if (!image || !canvas) return null; + + const rect = image.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + + const normalizedX = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const normalizedY = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)); + + return { + imageX: Math.min(canvas.width - 1, Math.floor(normalizedX * canvas.width)), + imageY: Math.min(canvas.height - 1, Math.floor(normalizedY * canvas.height)), + displayPoint: { x: normalizedX * 100, y: normalizedY * 100 }, + imageRadius: Math.max(1, (SAMPLER_BRUSH_RADIUS_PX / rect.width) * canvas.width), + }; + }, []); + + const handleSamplerMove = useCallback( + (event: React.MouseEvent) => { + const coordinates = getSamplerCoordinates(event); + if (coordinates) setSamplerPoint(coordinates.displayPoint); + }, + [getSamplerCoordinates] + ); + const handleSamplerClick = useCallback( (event: React.MouseEvent) => { - const image = pickerImageRef.current; const canvas = pickerCanvasRef.current; - if (!image || !canvas) return; + const coordinates = getSamplerCoordinates(event); + if (!canvas || !coordinates) return; const ctx = canvas.getContext('2d'); if (!ctx) return; - const rect = image.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min( - canvas.width - 1, - Math.floor(((event.clientX - rect.left) / rect.width) * canvas.width) - ) + const rgb = sampleAverageRgb( + ctx, + coordinates.imageX, + coordinates.imageY, + coordinates.imageRadius ); - const y = Math.max( - 0, - Math.min( - canvas.height - 1, - Math.floor(((event.clientY - rect.top) / rect.height) * canvas.height) - ) - ); - - const rgb = sampleAverageRgb(ctx, x, y, 2); + setSamplerPoint(coordinates.displayPoint); + setLastSamplePoint(coordinates.displayPoint); if (samplerTarget === 'white-reference') { setWhiteReferenceInput(rgbToInputState(rgb)); @@ -219,7 +255,7 @@ export function FilamentCalibrationWizard({ }.` ); }, - [samplerTarget] + [getSamplerCoordinates, samplerTarget] ); const handleAddMeasurement = useCallback(() => { @@ -229,18 +265,16 @@ export function FilamentCalibrationWizard({ const b = Math.max(0, Math.min(255, Number.parseInt(currentRGB.b, 10) || 0)); const rgb: CalibrationRgb = [r, g, b]; - const transmission = rgbToTransmission(rgb, whiteReference); const newMeasurement: CalibrationMeasurement = { layers, rgb, - transmission, }; setMeasurements((prev) => [...prev, newMeasurement]); setCurrentLayers(''); setCurrentRGB({ r: '', g: '', b: '' }); - }, [currentLayers, currentRGB, whiteReference]); + }, [currentLayers, currentRGB]); const handleRemoveMeasurement = useCallback((index: number) => { setMeasurements((prev) => prev.filter((_, i) => i !== index)); @@ -254,10 +288,6 @@ export function FilamentCalibrationWizard({ } try { - const normalizedMeasurements = normalizeCalibrationMeasurements( - measurements, - whiteReference - ); const { td, tdSingleValue, confidence } = calculateTDFromMeasurements( measurements, calibrationLayerHeight, @@ -267,7 +297,7 @@ export function FilamentCalibrationWizard({ const calibrationResult: CalibrationResult = { color: filamentColor, - measurements: normalizedMeasurements, + measurements, whiteReference, td, tdSingleValue, @@ -296,6 +326,8 @@ export function FilamentCalibrationWizard({ setSamplerTarget('measurement'); setPickerImageSrc(null); setPickerStatus(null); + setSamplerPoint(null); + setLastSamplePoint(null); setResult(null); setErrorMessage(null); } @@ -312,6 +344,8 @@ export function FilamentCalibrationWizard({ setSamplerTarget('measurement'); setPickerImageSrc(null); setPickerStatus(null); + setSamplerPoint(null); + setLastSamplePoint(null); setCurrentLayers(''); setCurrentRGB({ r: '', g: '', b: '' }); setResult(null); @@ -459,7 +493,7 @@ export function FilamentCalibrationWizard({

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

- Uploaded calibration sample +
+ Uploaded calibration sample setSamplerPoint(null)} + onClick={handleSamplerClick} + draggable={false} + /> + {lastSamplePoint && ( +
{pickerStatus ?? - 'Click the image to capture a sample into the selected target.'} + 'Move the circular brush over a uniform area, then click to capture it.'}
) : ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6cdcd5c..50a6a93 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -7,12 +7,10 @@ import { CheckCircle2, Download, Image, - Github, Heart, Loader2, Moon, Sun, - MessageCircle, RefreshCw, Settings, X, @@ -41,6 +39,9 @@ import { subscribeToUpdateCheckOnStartup, } from '@/lib/updatePreferences'; import logo from '../assets/logo.png'; +import discordIcon from '../assets/discord.svg'; +import githubIcon from '../assets/github.svg'; +import redditIcon from '../assets/reddit.svg'; interface Props { onLoadTest: () => void; @@ -197,6 +198,24 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT Load TD Test + @@ -225,7 +246,9 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT aria-label="GitHub" title="GitHub" > - + + + GitHub @@ -268,7 +291,10 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT onClick={(event) => event.stopPropagation()} >
-

+

Settings