diff --git a/.gitattributes b/.gitattributes
index 94430f8a..6418a28b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,2 @@
tests/assets/performance/**/*.3mf filter=lfs diff=lfs merge=lfs -text
+src-tauri/linux/*.sh text eol=lf
diff --git a/.gitignore b/.gitignore
index a8a2423e..87369bc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ dist
dist-ssr
playwright-report/
test-results/
+tmp/
*.local
# Editor directories and files
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbce21df..987c4b70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,33 @@ All notable changes to Kromacut are documented in this file.
## Unreleased
+## v4.2.0 - 2026-10-19
+
+### Upgrade notes
+
+- **Saved smoothing settings carry over.** Smooth Meshing that was on becomes **Medium**, which builds the same geometry as before; Smooth Meshing that was off becomes **None**. Choose another strength and rebuild to change the result.
+- **Rebuild Height dithering and Flat Paint models before printing.** Height dithering now produces neighboring-layer patterns when enabled, and Flat Paint resolves diagonal color contacts differently, so rebuilt previews and exports can differ from 4.1.0 results. Check the new model in your slicer.
+- **Opening files from the desktop needs an installed app.** Desktop packages register `.kfil`, legacy `.kapp`, and `.kpal` files with Kromacut; portable Linux AppImages open them only after desktop integration. Existing profiles and palettes remain compatible.
+
+### Added
+
+- **Legal pages in Settings** - Privacy & local data and Terms & conditions are accessible from Settings in both the web and desktop apps. Links follow the selected language and open in a new browser tab or the desktop system browser, keeping the workspace open.
+- **Smooth Meshing with Flat Paint** - All smoothing strengths now work with face-down and face-up Flat Paint. Shared color boundaries, carrier, and backing stay joined while the slab remains flat. Small shared joins resolve diagonal corner contacts without overlapping color regions. Boundary preparation and cap generation yield to keep dense images and long slabs responsive. Ordinary smooth meshing retains its previous geometry. Updated localized usage and export guides. Print instructions and 3MF metadata record the built strength; added topology, responsiveness, export, and workflow regressions.
+- **Smooth Meshing strengths** - Choose None, Minimal, Medium, or Aggressive for preview and exported geometry. Medium preserves the previous enabled result; saved on/off settings migrate to Medium/None. Aggressive smooths remaining diagonal stair steps with a stable finishing pass inside the existing movement limit, while avoiding redundant boundary-map copies between passes. Print instructions and 3MF metadata record the built strength. Added topology and export regression coverage for every preset, strength-ordering checks across diagonal orientations, and a repeatable comparison benchmark.
+- **Desktop file associations** - Desktop packages register `.kfil` and legacy `.kapp` filament profiles and `.kpal` palettes with Kromacut file icons. Opening a file launches Kromacut or delivers it to the existing window, imports through the normal validation/migration and calibration-preservation rules, and selects the resulting saved item, including existing content duplicates. External imports wait until an open palette editor, calibration dialog, profile Rename/Save New form, or filament-row edit is finished or cancelled, preserving drafts and the profile being edited. Name/HD inputs, color and TD-conversion popovers, and pending debounced color updates hold both profile and palette imports; committed changes then use the normal unsaved-edit confirmation. Overlapping manual and desktop imports merge with the latest saved list, preserving files imported while a manual read is pending. Late Palette Proof saves merge into the original profile's latest data without removing newer imports, and cannot recreate a deleted profile or attach old evidence after its filaments change. Multi-profile batches load the final saved version after duplicate handling and later updates. Profile opening protects unsaved filament edits, including newly applied calibration measurements, with a confirmation dialog; unreadable, invalid, oversized, or unpersistable imports report an error. Linux packages include MIME definitions, file-forwarding launchers, document icons, and cache refresh scripts; portable AppImages require desktop integration. Added native queue, import, packaging, and browser regression coverage, including overlapping manual/desktop imports, late proof saves after closing or changing their owner, batch selection, imports queued during editing, saving/cancelling profile forms, and retrying after a profile save failure.
+- **README video guides** - Linked the Kromacut tutorial and project introduction in side-by-side video cards with thumbnail previews.
+
+### Changed
+
+- **README organization** - Reworked the README into a concise product overview with a two-column grid of six finished prints, shorter feature descriptions, and direct documentation and download links. Kept community badges in the header, removed the obsolete TD test-image example, and separated desktop downloads from development setup.
+
+### Fixed
+
+- **Flat Paint preview and layer inspection** - Unsmoothed color regions now share diagonal-contact repairs instead of expanding independently and overlapping on the flat artwork face. Precision-sensitive corner repairs now triangulate as smaller pieces with the same footprint, fixing aborted face-up builds that left only dark backing and no layer slider. Failed builds clear incomplete geometry and show an error. Layer Preview cuts through merged backing and color parts one physical layer at a time, including keyboard stepping and wireframe inspection, while exports retain the complete model. Explicit builds regenerate unchanged inputs and restore the full preview and camera instead of leaving a cached mesh or cut on screen. Build progress explicitly counts mesh parts; the Model badge shows physical print layers, including the clear carrier when present. Added shared-boundary, precision, layer-cut, export-preservation, and browser recovery regressions.
+- **Desktop development startup on Windows** - Vite no longer watches native sources or Cargo build output, preventing intermittent `EBUSY` crashes when F5 starts the frontend while Rust executables are being linked. Dependency scanning starts from the app entry page instead of generated HTML. Tauri continues to watch native source changes.
+- **Height dithering in 3D Auto-paint** - Restored fractional height targets before error diffusion, so enabling Height Dithering can produce neighboring-layer patterns in both preview and exports. Dithering stays local to the selected printable layer, preserves exact calibrated matches and protected boundaries, and leaves ordinary matching unchanged when disabled. Added regression coverage for fractional targets, calibration safeguards, effective line width, and rebuilt STL/3MF geometry.
+- **Landing showcase artwork credits** - Original artwork credits such as NASA/JPL link to their sources again in every language; the translated credit used a tag name that rendered an empty link beside plain text. A translation check now rejects tags that cannot hold linked text; landing browser tests now cover every linked credit and the footer language selector before the sticky mobile launch button.
+
## v4.1.0 - 2026-09-26
### Upgrade notes
diff --git a/README.md b/README.md
index 507a2825..e7cfe85a 100644
--- a/README.md
+++ b/README.md
@@ -1,372 +1,143 @@
-
+# Kromacut
-# Kromacut
+**Turn images into multicolor 3D prints.** Prepare artwork, plan filament layers, and export STL or 3MF models with a free, open-source app for your browser, Windows, macOS, and Linux.
-[](https://www.patreon.com/cw/vycdev) [](https://discord.gg/nU63sFMcnX) [](https://www.reddit.com/r/kromacut/) [](https://www.youtube.com/@vycdev) [](https://github.com/vycdev/Kromacut/releases/latest) [](https://github.com/vycdev/Kromacut) [](https://github.com/vycdev/Kromacut/releases) [](https://github.com/vycdev/Kromacut/releases/latest)
+**[Open the app](https://kromacut.com/app)** | **[Download for desktop](https://github.com/vycdev/Kromacut/releases)** | [Documentation](https://kromacut.com/docs/overview) | [Website](https://kromacut.com/)
-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 + 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.
-
-Both modes include live 2D/3D previews, layer-by-layer visualization, and export to STL/3MF with detailed print instructions.
+[](https://github.com/vycdev/Kromacut/releases/latest) [](https://github.com/vycdev/Kromacut/releases) [](https://www.patreon.com/cw/vycdev) [](https://discord.gg/nU63sFMcnX) [](https://www.reddit.com/r/kromacut/) [](https://www.youtube.com/@vycdev)
## Examples
-A quick look at what Kromacut produces from a source image to a printable model:
-
-- Fuji test image (2D input) → 3D preview → per-color sliced view → real print
-
-| 2D Input | 3D Preview |
-|---|---|
-| | |
-| Sliced by Colors | Real Print |
-| | |
-
-Another minimal test you can try yourself: the included Transmission Distance (TD) test image (`src/assets/tdTest.png`).
-
-| TD Test |
-|---|
-| |
-
-## Notable Features
-
-- Image upload and preview (drag & drop or file picker).
-- Color reduction / quantization with selectable color count.
-- Inline color pickers to tweak or replace generated palette colors.
-- Per-color slice heights and a configurable base slice height.
-- Reorder colors with drag-and-drop to control stack order (darkest → lightest default ordering).
-- Live 2D preview and a 3D stacked preview rendered with three.js.
-- **Layer-by-layer preview slider** — Interactively visualize how the print builds up by dragging a slider to show/hide layers at specific heights.
-- 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** — 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
-
-- Frontend: React + TypeScript + Vite.
-- 3D Rendering: three.js with BufferGeometry. The geometry pipeline produces per-face (non-indexed) triangles so each color slice renders as a solid block (no blended vertex colors) and vertical side walls are preserved.
-- Texture sampling: we create a `CanvasTexture` from the preview canvas and use `NearestFilter` with mipmaps disabled so the texture stays crisp when mapped onto the 3D mesh. The texture UVs are adjusted (repeat/offset) so the canvas region aligns with the mesh bounding box.
-- Color ordering: palette swatches are derived from image pixels, then ordered by hue/saturation/lightness and presented from darkest to lightest by default (you can reorder manually).
-- Async mesh generation: The greedy meshing algorithm yields control back to the browser every 8ms during large mesh builds, keeping the UI responsive. Boundary vertex filtering uses binary search for optimal performance.
-
-
-## How to use
-
-### Quick start (Auto-paint mode — Recommended)
-
-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 **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.
-
-See the [Auto-paint section](#auto-paint) for complete details.
-
-### Manual mode workflow
-
-1. Upload or drag an image into the preview area.
-2. Adjust quantization settings to reduce to the desired number of colors.
-3. Tweak or replace swatches using the color pickers in the Swatches panel.
-4. Open the 3D panel → **Manual** tab to configure:
- - Per-color slice heights
- - Base slice height
- - Pixel size
- - Color order (drag-and-drop)
-5. Preview in 3D and use the layer slider to inspect the stack.
-6. Click `Download STL` or `Download 3MF` to export your model.
-7. Use the `Copy` button to copy the plain-text print plan with layer heights and swap instructions.
-
-## 3D / printing specifics and tips
-
-- Base slice height default: `0.20 mm`
-- Layer height default: `0.12 mm`
-- Layer height used to compute the exact layer numbers at which color swaps happen in the plain-text plan.
-- Per-color slice heights are snapped/multiplied to sensible values relative to `layerHeight` when the swatches change or are initialized.
-
-### Layer-by-layer preview
-
-The 3D view includes an interactive **height slider** at the bottom that lets you visualize how the print builds up layer by layer:
-
-- Drag the slider to show/hide layers progressively from base (0mm) to full height
-- Current height and layer information displayed in real-time
-- Slider step size matches your configured layer height for accurate control
-- Helps verify filament swap positions and catch potential issues before printing
-
-This is especially useful for complex multi-color prints where you need to understand exactly when each color transition occurs.
-
-## 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 hiding distance) and let the algorithm compute the optimal layer stack automatically.
-
-### Core concepts
-
-- **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 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 (`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
-
-Kromacut measures hiding distances with a camera-free printed wedge — no light meter, no photos, no color picking:
-
-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 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, choose an effort tier for the filament-order search:
-
-| Tier | Search | Best use |
-|---|---|---|
-| **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
-- **Quality score** — Lower is better (weighted DeltaE + penalties)
-- **Iterations** — How many orderings were evaluated
-- **Converged** — Whether the algorithm reached a stable solution
-- **Cache hit** — Whether results were retrieved from cache (instant)
-
-**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
-
-Prioritize accuracy in specific image regions during optimization:
-
-| Mode | Description |
-|---|---|
-| **Uniform** | All pixels weighted equally (default). |
-| **Center** | Gaussian falloff from center — faces and subjects in the middle get higher priority. |
-| **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.
-
-### Options
-
-| 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 the printable filament sequence rather than simply sorting by luminance. It may omit filaments that do not improve the result and evaluates the actual preview color-to-height path. |
-| **Extra repeated swaps** | (Requires Enhanced color matching) Lets the optimizer use a filament more than once. Choose Off, 2, 4, 6, 8, or 12 additional occurrences; more repeats may create useful blend paths but substantially expand the search. |
-| **Preserve color separation** | (Requires Enhanced color matching) Treats the configurable ΔE value as a hard eligibility limit for distinct printable colors. The optimizer searches repeat tiers progressively and ranks candidates by preserved-color count, source coverage, preserved-match error, repeats, then stack length. Strict mode rejects a result unless every 2D color receives a unique in-limit match; non-strict mode drops unmatched colors by merging their regions into surviving preserved mappings. Mutually exclusive with Height dithering. |
-| **Height dithering** | (Requires Enhanced color matching) Applies block-aware Stucki error diffusion to the quantized height map. Instead of sharp stair-steps between layer heights, dithering produces a stippled gradient that simulates intermediate heights, resulting in smoother tonal transitions in the print. Edge pixels between different heights are protected from dithering to avoid staircase artifacts. Mutually exclusive with Preserve color separation. |
-| **Flat Paint (flat face-down print)** | Builds a uniform-thickness slab printed image-side down instead of a stepped relief. Each pixel column's layer order is reversed so the artwork sits against the build plate (already mirrored — don't mirror in the slicer) under a transparent carrier layer, and the back is filled with the foundation filament so every layer has the full footprint. The result has a smooth, glass-flat face — great for bookmarks and coasters. Requires a multi-material printer (AMS/toolchanger); export as 3MF, which contains one object per filament plus the clear carrier object. Flat Paint and Smooth Meshing toggle each other off because flat prints always use the full-footprint slab layout. |
-| **Dither line width** | (Requires Height dithering) Controls the minimum dot size for the dither pattern in mm. This should roughly match your printer's line/nozzle width so dither dots are actually printable. Default: `0.42 mm`. |
-| **Optimizer algorithm** | Choose 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
+
+
+## Features
+
+- **Image editing.** Make non-destructive adjustments, crop and resize, reduce colors, and remove dithering. Retouch artwork with painting, text, and palette tools.
+- **Auto-paint.** Plan a filament stack from the colors you own. Hiding Distance measurements help predict how thin layers blend.
+- **Manual layers.** Choose the palette, arrange the print order, and set the height of each color.
+- **Calibration and profiles.** Measure your filaments, compare printed color samples, and save or share reusable profiles.
+- **3D preview.** Inspect each layer, compare simulated and physical colors, and explore shaded, transparent, or wireframe views.
+- **STL and 3MF export.** Export STL or color-aware 3MF models, with filament swap instructions for layered prints.
+- **Flat Paint.** Create uniform-thickness bookmarks, coasters, and other flat pieces, with 3MF export for multi-material printing.
+
+## Video guides
+
+
+
+## Documentation
+
+The [user guide](https://kromacut.com/docs/overview) covers the full app and is also available in the app's documentation panel.
+
+[Quick start](https://kromacut.com/docs/quick-start) | [Auto-paint](https://kromacut.com/docs/auto-paint) | [Filament calibration](https://kromacut.com/docs/calibration-workflows) | [Exporting and printing](https://kromacut.com/docs/generating-exporting-output) | [Troubleshooting](https://kromacut.com/docs/troubleshooting)
+
+## Desktop app
+
+Download the latest version from [GitHub Releases](https://github.com/vycdev/Kromacut/releases/latest).
+
+| Platform | Package |
+| --- | --- |
+| Windows | `*-setup.exe` |
+| macOS Apple Silicon | `*_aarch64.dmg` |
+| macOS Intel | `*_x86_64.dmg` |
+| Linux | `*.AppImage` or `*.deb` |
+
+Windows also has an `*_offline-setup.exe` package with the WebView2 installer included. For platform requirements and installation help, see the [desktop documentation](docs/TAURI.md#distribution-notes).
+
+## Development
+
+Built with React, TypeScript, Vite, and Three.js. Desktop builds use Tauri.
+
+Use **Node.js 22.18+** and npm:
-Auto-paint configurations (filament lists) can be saved, loaded, and shared as **profiles**:
-
-- **Save / Save New** — Persist the current filament set under a name. Overwrite an existing profile or create a new one.
-- **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`, `.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.
-
-### Transition zones panel
-
-When auto-paint is active and filaments are defined, the UI displays a **Transition Zones** panel showing:
-
-- Each zone's filament color swatch and hex code.
-- The start and end height of each zone (in mm) and its thickness (Δ).
-- A **compressed** badge on zones that have been reduced below their ideal thickness due to a Max Height constraint.
-- Total model height and total number of physical layers.
-
-### Next-best-color suggestion
-
-After running auto-paint, a **Suggest next filament** button appears at the bottom of the panel. Click it to run a blend-aware analysis that finds the single filament addition that would most reduce the average color error (ΔE) across the image.
-
-The result card shows:
-
-| Field | Description |
-|---|---|
-| **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. |
-| **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. |
-
-Click **Add to filaments** to insert the suggestion directly into the filament list (named `Kromacut-Suggestion-01`, `02`, etc.). You can then re-run auto-paint with the expanded set and repeat as many times as needed.
-
-**How it works (heuristic):** The algorithm estimates how well the current filament set covers the image's color range by checking each image color against each filament and Beer-Lambert blend curve. It identifies underserved colors (those with the largest estimated error) and generates candidates from those colors plus extrapolated positions — colors that, when optically blended with an existing filament, would be closer to the underserved target. The winner is the candidate whose addition most reduces weighted-average estimated error across all image pixels. This is an inventory-planning heuristic: the improvement numbers are relative estimates, not predictions of a specific auto-paint result.
-
-### Quick start
-
-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 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.
-8. Export the model via **Download STL** or **Download 3MF** and follow the printed swap plan.
-
-## 3MF Export (Preview)
-
-Kromacut now supports exporting directly to `.3mf` format. This file format preserves color information by splitting the model into separate objects for each color, automatically assigned to different extruders/filaments.
-
-**Disclaimer:** This feature is currently in **PREVIEW**. While Kromacut preserves colors, the rest of the settings in the slicer profile should be manually adjusted. Please report any issues or weird behaviors you encounter on the GitHub Issues page.
-
-
-
-## 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
-
-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 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 + 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.
-
-**2. Manual mode (Traditional workflow)** — For users who want complete control:
-
-- Reduce your image to a palette with *more colors* than you actually intend to print. The extra colors give you candidate shades to use as intermediate/translucent-looking layers.
-- Use the per-color slice heights and ordering controls in the 3D panel to approximate the stacked thicknesses and ordering that would produce the target intermediate shades.
-- 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 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`).
-- 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. 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.
-5. Generate and download your 3D model.
-6. Follow the layer color swap instructions and print your 3D model to see results.
-
-Preview of the included TD test image:
-
-
-
-## Native Desktop App (Tauri)
+```bash
+npm ci
+npm run dev
+```
-Kromacut can be built as a **native desktop application** for macOS, Windows, and Linux using [Tauri](https://tauri.app/), offering better performance than the web version:
+Run the checks and create a production build:
-- **Better performance:** Native webview for faster rendering
-- **Smaller size:** compact native bundle with low runtime overhead
-- **Lower memory usage:** More efficient resource utilization
-- **Automatic updates:** Built-in update checker notifies you of new versions
+```bash
+npm run lint
+npm run test:docs
+npm test
+npm run build
+```
-The desktop app includes an automatic update checker. See [UPDATE_CHECKER.md](docs/UPDATE_CHECKER.md) for details.
+
+Desktop development
-### Building
+Install Rust and the [Tauri platform prerequisites](https://tauri.app/start/prerequisites/), then run:
```bash
-# Install Rust (one-time setup)
-curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
-source "$HOME/.cargo/env"
-
-# Install dependencies
-npm install
-
-# Run in development mode
npm run tauri:dev
-
-# Build production app
npm run tauri:build
```
-The production build creates:
-- `Kromacut.app` in `src-tauri/target/release/bundle/macos/`
-- `Kromacut_0.1.0_aarch64.dmg` installer in `src-tauri/target/release/bundle/dmg/`
-
-### Releases
-
-Pre-built native apps are automatically released on GitHub:
-
-1. Download the latest release from the [Releases page](https://github.com/vycdev/Kromacut/releases)
-2. Choose the right file for your platform:
- - **macOS Apple Silicon (M1/M2/M3)**: `*_aarch64.dmg`
- - **macOS Intel**: `*_x86_64.dmg`
- - **Windows**: `*-setup.exe`
- - **Windows offline**: `*_offline-setup.exe`
- - **Linux**: `*.AppImage` or `*.deb`
-3. Install and launch
+Build artifacts are written to `src-tauri/target/release/bundle/`. See [TAURI.md](docs/TAURI.md) for build, packaging, and release details.
-**macOS "Kromacut is damaged" error?** The app isn't code-signed. Run `sudo xattr -d com.apple.quarantine /Applications/Kromacut.app` in Terminal to fix it.
+
-**Windows SmartScreen warning?** The app isn't signed. Click "More info" → "Run anyway".
-
-**Windows WebView2 requirement?** Kromacut uses Tauri, which runs on Microsoft Edge WebView2 on Windows. The standard Windows installers include the small WebView2 bootstrapper, and the setup installer checks for a recent enough WebView2 runtime. Use the offline setup installer on machines without internet access.
-
-> **Note:** To eliminate the macOS quarantine requirement entirely, the app would need to be signed with an Apple Developer ID certificate and notarized through Apple's notarization service. This requires an [Apple Developer Program membership](https://developer.apple.com/programs/) ($99/year). See [TAURI.md](docs/TAURI.md#code-signing--notarization-future-enhancement) for details.
+## Contributing
-To create a new release, see [TAURI.md](docs/TAURI.md#automated-releases-github).
+Bug reports, feature suggestions, and pull requests are welcome. Use [GitHub Issues](https://github.com/vycdev/Kromacut/issues), and discuss larger changes before starting work.
-See [TAURI.md](docs/TAURI.md) for complete documentation on development, building, and distribution.
+Read [AGENTS.md](AGENTS.md) for repository guidance and testing expectations. The [Changelog](CHANGELOG.md) tracks releases and upcoming changes.
-## Star History
+
+Star history
[](https://www.star-history.com/#vycdev/kromacut&type=date&legend=top-left)
-## Contributing
-
-Contributions welcome. Open issues or PRs for bugs, improvements, or feature suggestions. If you plan a larger change (architecture, algorithms), open an issue first describing the approach so we can discuss it.
+
## License
-Kromacut is free software licensed under the GNU Affero General Public License v3.0 only (`AGPL-3.0-only`). See the `LICENSE` file in the repository.
-
-Copyright (C) 2025 vycdev. Redistribution and modified versions must preserve the copyright and license notices. Modified desktop distributions must provide the corresponding source under the AGPL, and modified hosted/network versions must also offer source access to their users.
+[GNU Affero General Public License v3.0 only](LICENSE) (`AGPL-3.0-only`). Copyright (C) 2025 vycdev.
diff --git a/content/tutorial-thumbnail.png b/content/tutorial-thumbnail.png
new file mode 100644
index 00000000..da26b6bb
Binary files /dev/null and b/content/tutorial-thumbnail.png differ
diff --git a/package-lock.json b/package-lock.json
index 55267e75..93c805d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "kromacut",
- "version": "4.1.0",
+ "version": "4.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kromacut",
- "version": "4.1.0",
+ "version": "4.2.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/package.json b/package.json
index 8eba27f3..597c07b7 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "kromacut",
"private": true,
- "version": "4.1.0",
+ "version": "4.2.0",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"benchmark:calibrated-exact": "node --expose-gc --no-warnings --experimental-strip-types tests/benchmark/calibratedOptimizer.ts",
"benchmark:calibrated-series": "node scripts/benchmark-calibrated-series.mjs",
"benchmark:hotpaths": "node --no-warnings --experimental-strip-types tests/benchmark/performanceHotPaths.ts",
+ "benchmark:meshing": "node --no-warnings --experimental-strip-types tests/benchmark/smoothMeshing.ts",
"profile:calibrated": "node scripts/profile-calibrated.mjs",
"profile:compare": "node scripts/compare-calibrated-profiles.mjs",
"test:e2e": "playwright test --grep @smoke",
diff --git a/public/version.json b/public/version.json
index 865c28a1..0f011c3d 100644
--- a/public/version.json
+++ b/public/version.json
@@ -1,19 +1,19 @@
{
- "version": "4.1.0",
- "download_url": "https://github.com/vycdev/Kromacut/releases/tag/v4.1.0",
- "release_notes": "Kromacut 4.1.0 adds 12 languages across the app, guides, and public pages; improves Stack Matrix foundations and layout; preserves more fine detail during optional color cleanup; and restores desktop Hiding Distance STL/3MF downloads. Effective line width is now in 3D Print Settings. Linux AppImages support delta updates through compatible external tools. Existing profiles and calibration records remain compatible; back up important profiles and check new prints in your slicer.",
+ "version": "4.2.0",
+ "download_url": "https://github.com/vycdev/Kromacut/releases/tag/v4.2.0",
+ "release_notes": "Kromacut 4.2.0 adds Smooth Meshing strengths (None, Minimal, Medium, and Aggressive), which now also work with Flat Paint, and lets the desktop app open .kfil, .kapp, and .kpal files directly. Height dithering in Auto-paint works again, Flat Paint and Layer Preview are more reliable, and Settings now links to Privacy and Terms. Saved smoothing settings carry over as Medium or None; existing profiles and palettes remain compatible. Rebuild Height dithering and Flat Paint models before printing, back up important profiles, and check new prints in your slicer.",
"release_notes_localized": {
- "en": "Kromacut 4.1.0 adds 12 languages across the app, guides, and public pages; improves Stack Matrix foundations and layout; preserves more fine detail during optional color cleanup; and restores desktop Hiding Distance STL/3MF downloads. Effective line width is now in 3D Print Settings. Linux AppImages support delta updates through compatible external tools. Existing profiles and calibration records remain compatible; back up important profiles and check new prints in your slicer.",
- "fr": "Kromacut 4.1.0 propose 12 langues dans l’application, les guides et les pages publiques ; améliore les bases et la disposition des matrices Stack Matrix ; préserve davantage de détails fins lors du nettoyage facultatif des couleurs ; et rétablit les téléchargements STL/3MF de calibration Hiding Distance dans l’application de bureau. La largeur de ligne effective se trouve désormais dans les paramètres d’impression 3D. Les AppImages Linux prennent en charge les mises à jour différentielles via des outils externes compatibles. Les profils et les données de calibration existants restent compatibles ; sauvegardez vos profils importants et vérifiez les nouvelles impressions dans votre logiciel de découpage.",
- "de": "Kromacut 4.1.0 bietet 12 Sprachen in der App, den Anleitungen und den öffentlichen Seiten, verbessert die Fundamente und Anordnung von Stack Matrix, erhält bei der optionalen Farbbereinigung mehr feine Details und stellt die STL/3MF-Downloads für die Hiding-Distance-Kalibrierung in der Desktop-App wieder her. Die effektive Linienbreite befindet sich jetzt in den 3D-Druckeinstellungen. Linux-AppImages unterstützen Delta-Updates über kompatible externe Programme. Bestehende Profile und Kalibrierungsdaten bleiben kompatibel; sichern Sie wichtige Profile und prüfen Sie neue Drucke im Slicer.",
- "it": "Kromacut 4.1.0 offre 12 lingue nell’app, nelle guide e nelle pagine pubbliche; migliora le basi e la disposizione di Stack Matrix; conserva più dettagli fini durante la pulizia facoltativa dei colori; e ripristina i download STL/3MF per la calibrazione Hiding Distance nell’app desktop. La larghezza effettiva della linea si trova ora nelle impostazioni di stampa 3D. Le AppImage Linux supportano gli aggiornamenti differenziali tramite strumenti esterni compatibili. I profili e i dati di calibrazione esistenti restano compatibili; esegui una copia di sicurezza dei profili importanti e verifica le nuove stampe nello slicer.",
- "ro": "Kromacut 4.1.0 oferă 12 limbi în aplicație, ghiduri și paginile publice; îmbunătățește fundația și aranjarea Stack Matrix; păstrează mai multe detalii fine la curățarea opțională a culorilor; și restabilește descărcările STL/3MF pentru calibrarea Hiding Distance în aplicația desktop. Lățimea efectivă a liniei se află acum în setările de imprimare 3D. AppImage-urile Linux acceptă actualizări diferențiale prin instrumente externe compatibile. Profilurile și datele de calibrare existente rămân compatibile; fă copii de siguranță ale profilurilor importante și verifică imprimările noi în slicer.",
- "es": "Kromacut 4.1.0 incorpora 12 idiomas en la aplicación, las guías y las páginas públicas; mejora las bases y la distribución de Stack Matrix; conserva más detalles finos durante la limpieza opcional de colores; y restablece las descargas STL/3MF de calibración Hiding Distance en la aplicación de escritorio. El ancho de línea efectivo está ahora en los ajustes de impresión 3D. Las AppImage de Linux admiten actualizaciones diferenciales mediante herramientas externas compatibles. Los perfiles y los datos de calibración existentes siguen siendo compatibles; haz copias de seguridad de los perfiles importantes y revisa las nuevas impresiones en tu laminador.",
- "ja": "Kromacut 4.1.0は、アプリ、ガイド、公開ページ全体で12言語に対応し、Stack Matrixの土台と配置を改善しました。オプションの色クリーンアップで細かなディテールをより多く保持し、デスクトップアプリでHiding DistanceキャリブレーションのSTL/3MFダウンロードを復旧しました。実効ライン幅は3D印刷設定に移動しました。Linux版AppImageは、対応する外部ツールによる差分更新をサポートします。既存のプロファイルとキャリブレーション記録の互換性は維持されます。重要なプロファイルをバックアップし、新しい印刷データをスライサーで確認してください。",
- "zh-CN": "Kromacut 4.1.0 为应用、指南和公开页面提供了 12 种语言;改进了 Stack Matrix 的底座和布局;在可选的颜色清理过程中保留更多精细细节;并恢复了桌面应用中 Hiding Distance 校准的 STL/3MF 下载。有效线宽现已移至 3D 打印设置。Linux AppImage 支持通过兼容的外部工具进行增量更新。现有配置文件和校准记录仍然兼容;请备份重要配置文件,并在切片软件中检查新的打印模型。",
- "hi": "Kromacut 4.1.0 में ऐप, गाइड और सार्वजनिक पृष्ठों के लिए 12 भाषाएँ जोड़ी गई हैं; Stack Matrix की आधार परत और लेआउट में सुधार किया गया है; वैकल्पिक रंग सफ़ाई के दौरान अधिक बारीक विवरण सुरक्षित रहते हैं; और डेस्कटॉप ऐप में Hiding Distance कैलिब्रेशन के STL/3MF डाउनलोड फिर से काम करते हैं। प्रभावी लाइन चौड़ाई अब 3D प्रिंट सेटिंग्स में है। Linux AppImage संगत बाहरी टूल के माध्यम से डेल्टा अपडेट का समर्थन करती हैं। मौजूदा प्रोफ़ाइल और कैलिब्रेशन रिकॉर्ड संगत बने रहते हैं; महत्वपूर्ण प्रोफ़ाइल का बैकअप लें और नए प्रिंट को अपने स्लाइसर में जाँचें।",
- "pt-PT": "O Kromacut 4.1.0 disponibiliza 12 idiomas na aplicação, nos guias e nas páginas públicas; melhora as bases e a disposição da Stack Matrix; preserva mais detalhes finos durante a limpeza opcional de cores; e repõe as transferências STL/3MF da calibração Hiding Distance na aplicação de computador. A largura efetiva da linha encontra-se agora nas definições de impressão 3D. As AppImages Linux suportam atualizações diferenciais através de ferramentas externas compatíveis. Os perfis e os registos de calibração existentes continuam compatíveis; faça cópias de segurança dos perfis importantes e verifique as novas impressões no seu programa de corte.",
- "uk": "Kromacut 4.1.0 додає 12 мов для застосунку, посібників і публічних сторінок; покращує основу та розташування комірок Stack Matrix; зберігає більше дрібних деталей під час необов’язкового очищення кольорів; і відновлює завантаження STL/3MF для калібрування Hiding Distance у настільному застосунку. Ефективна ширина лінії тепер міститься в налаштуваннях 3D-друку. Linux AppImage підтримують дельта-оновлення через сумісні зовнішні інструменти. Наявні профілі та записи калібрування залишаються сумісними; створіть резервні копії важливих профілів і перевіряйте нові моделі для друку у слайсері.",
- "bn": "Kromacut 4.1.0-তে অ্যাপ, গাইড এবং সর্বজনীন পৃষ্ঠাগুলোতে ১২টি ভাষা যোগ করা হয়েছে; Stack Matrix-এর ভিত্তি ও বিন্যাস উন্নত করা হয়েছে; ঐচ্ছিক রঙ পরিষ্কারের সময় আরও সূক্ষ্ম বিবরণ অক্ষত থাকে; এবং ডেস্কটপ অ্যাপে Hiding Distance ক্যালিব্রেশনের STL/3MF ডাউনলোড আবার কাজ করে। কার্যকর লাইন প্রস্থ এখন 3D প্রিন্ট সেটিংসে রয়েছে। Linux AppImage সামঞ্জস্যপূর্ণ বাহ্যিক সরঞ্জামের মাধ্যমে ডেল্টা আপডেট সমর্থন করে। বিদ্যমান প্রোফাইল ও ক্যালিব্রেশন রেকর্ড সামঞ্জস্যপূর্ণ থাকে; গুরুত্বপূর্ণ প্রোফাইলের ব্যাকআপ নিন এবং নতুন প্রিন্টগুলো আপনার স্লাইসারে পরীক্ষা করুন।"
+ "en": "Kromacut 4.2.0 adds Smooth Meshing strengths (None, Minimal, Medium, and Aggressive), which now also work with Flat Paint, and lets the desktop app open .kfil, .kapp, and .kpal files directly. Height dithering in Auto-paint works again, Flat Paint and Layer Preview are more reliable, and Settings now links to Privacy and Terms. Saved smoothing settings carry over as Medium or None; existing profiles and palettes remain compatible. Rebuild Height dithering and Flat Paint models before printing, back up important profiles, and check new prints in your slicer.",
+ "fr": "Kromacut 4.2.0 ajoute des niveaux de Maillage lissé (Aucun, Minimal, Moyen et Intensif), désormais compatibles avec la Peinture à plat, et permet à l’application de bureau d’ouvrir directement les fichiers .kfil, .kapp et .kpal. Le Tramage en hauteur de la Peinture automatique fonctionne de nouveau, la Peinture à plat et l’Aperçu des couches sont plus fiables, et les Paramètres renvoient désormais vers les pages Confidentialité et Conditions d’utilisation. Les réglages de lissage enregistrés deviennent Moyen ou Aucun ; les profils et palettes existants restent compatibles. Reconstruisez les modèles utilisant le Tramage en hauteur ou la Peinture à plat avant d’imprimer, sauvegardez vos profils importants et vérifiez les nouvelles impressions dans votre logiciel de découpage.",
+ "de": "Kromacut 4.2.0 bietet Stärkestufen für Geglättetes Netz (Keine, Minimal, Mittel und Stark), die jetzt auch mit Flat Paint funktionieren, und die Desktop-App öffnet .kfil-, .kapp- und .kpal-Dateien direkt. Höhen-Dithering in Auto-paint funktioniert wieder, Flat Paint und die Schichtvorschau arbeiten zuverlässiger, und die Einstellungen verlinken jetzt auf Datenschutz und Nutzungsbedingungen. Gespeicherte Glättungseinstellungen werden als Mittel oder Keine übernommen; bestehende Profile und Paletten bleiben kompatibel. Erstellen Sie Modelle mit Höhen-Dithering oder Flat Paint vor dem Drucken neu, sichern Sie wichtige Profile und prüfen Sie neue Drucke im Slicer.",
+ "it": "Kromacut 4.2.0 aggiunge i livelli di Mesh levigata (Nessuna, Minima, Media e Intensa), ora compatibili anche con Flat Paint, e consente all’app desktop di aprire direttamente i file .kfil, .kapp e .kpal. Il Dithering dell’altezza in Auto-paint funziona di nuovo, Flat Paint e l’Anteprima degli strati sono più affidabili e le Impostazioni ora rimandano a Privacy e Termini e condizioni. Le impostazioni di levigatura salvate diventano Media o Nessuna; i profili e le palette esistenti restano compatibili. Ricostruisci i modelli con Dithering dell’altezza o Flat Paint prima di stampare, esegui una copia di sicurezza dei profili importanti e verifica le nuove stampe nello slicer.",
+ "ro": "Kromacut 4.2.0 adaugă niveluri pentru Geometrie netezită (Fără, Minimă, Medie și Intensă), care funcționează acum și cu Flat Paint, iar aplicația desktop poate deschide direct fișierele .kfil, .kapp și .kpal. Opțiunea Dithering al înălțimii din Auto-paint funcționează din nou, Flat Paint și Previzualizarea straturilor sunt mai fiabile, iar Setările includ acum linkuri către Confidențialitate și Termeni și condiții. Setările de netezire salvate devin Medie sau Fără; profilurile și paletele existente rămân compatibile. Reconstruiește modelele cu Dithering al înălțimii sau Flat Paint înainte de imprimare, fă copii de siguranță ale profilurilor importante și verifică imprimările noi în slicer.",
+ "es": "Kromacut 4.2.0 añade niveles de Malla suavizada (Ninguno, Mínimo, Medio e Intenso), que ahora también funcionan con Pintura plana, y permite que la aplicación de escritorio abra directamente archivos .kfil, .kapp y .kpal. El Tramado de altura de la Pintura automática vuelve a funcionar, la Pintura plana y la Vista previa de capas son más fiables, y los Ajustes ahora enlazan a Privacidad y Términos y condiciones. Los ajustes de suavizado guardados pasan a Medio o Ninguno; los perfiles y paletas existentes siguen siendo compatibles. Reconstruye los modelos con Tramado de altura o Pintura plana antes de imprimir, haz copias de seguridad de los perfiles importantes y revisa las nuevas impresiones en tu laminador.",
+ "ja": "Kromacut 4.2.0では、滑らかなメッシュの強さ(なし、最小、中、強)を選べるようになり、フラット印刷でも使用できます。デスクトップアプリで.kfil、.kapp、.kpalファイルを直接開けるようになりました。自動ペイントの高さディザリングが再び機能し、フラット印刷とレイヤープレビューの信頼性が向上し、設定からプライバシーと利用規約を開けるようになりました。保存済みのスムージング設定は「中」または「なし」として引き継がれ、既存のプロファイルとパレットの互換性は維持されます。高さディザリングまたはフラット印刷を使うモデルは印刷前に再ビルドし、重要なプロファイルをバックアップして、新しい印刷データをスライサーで確認してください。",
+ "zh-CN": "Kromacut 4.2.0 新增平滑网格强度(无、轻度、中度、强度),现在也可用于平面打印模式;桌面应用现可直接打开 .kfil、.kapp 和 .kpal 文件。自动配色中的高度抖动已恢复正常,平面打印模式和层预览更加可靠,设置中现已提供隐私与使用条款链接。已保存的平滑设置将转换为“中度”或“无”;现有配置文件和调色板仍然兼容。使用高度抖动或平面打印模式的模型请在打印前重新生成,请备份重要配置文件,并在切片软件中检查新的打印模型。",
+ "hi": "Kromacut 4.2.0 में चिकनी मेशिंग के स्तर (कोई नहीं, न्यूनतम, मध्यम और तीव्र) जोड़े गए हैं, जो अब समतल प्रिंट मोड के साथ भी काम करते हैं, और डेस्कटॉप ऐप अब .kfil, .kapp और .kpal फ़ाइलें सीधे खोल सकता है। स्वचालित रंगाई में ऊँचाई डिथरिंग फिर से काम करती है, समतल प्रिंट मोड और परत पूर्वावलोकन अधिक भरोसेमंद हैं, और सेटिंग्स में अब गोपनीयता और नियम व शर्तों के लिंक हैं। सहेजी गई स्मूदिंग सेटिंग्स मध्यम या कोई नहीं के रूप में बनी रहती हैं; मौजूदा प्रोफ़ाइल और पैलेट संगत बने रहते हैं। ऊँचाई डिथरिंग या समतल प्रिंट मोड वाले मॉडल प्रिंट से पहले फिर से बनाएँ, महत्वपूर्ण प्रोफ़ाइल का बैकअप लें और नए प्रिंट को अपने स्लाइसर में जाँचें।",
+ "pt-PT": "O Kromacut 4.2.0 adiciona níveis de Malha suavizada (Nenhuma, Mínima, Média e Intensa), que agora também funcionam com a Pintura plana, e permite que a aplicação de computador abra diretamente ficheiros .kfil, .kapp e .kpal. O Pontilhamento em altura da Pintura automática volta a funcionar, a Pintura plana e a Pré-visualização de camadas estão mais fiáveis, e as Definições incluem agora ligações para Privacidade e Termos e condições. As definições de suavização guardadas passam a Média ou Nenhuma; os perfis e as paletas existentes continuam compatíveis. Reconstrua os modelos com Pontilhamento em altura ou Pintura plana antes de imprimir, faça cópias de segurança dos perfis importantes e verifique as novas impressões no seu programa de corte.",
+ "uk": "Kromacut 4.2.0 додає рівні параметра «Згладжена сітка» (Без згладжування, Мінімальне, Середнє та Інтенсивне), які тепер працюють і з Flat Paint, а настільний застосунок може безпосередньо відкривати файли .kfil, .kapp і .kpal. Дизеринг висоти в Auto-paint знову працює, Flat Paint і Перегляд шарів стали надійнішими, а в Налаштуваннях з’явилися посилання на Конфіденційність і Умови користування. Збережені налаштування згладжування переходять у Середнє або Без згладжування; наявні профілі та палітри залишаються сумісними. Перед друком перебудуйте моделі з дизерингом висоти або Flat Paint, створіть резервні копії важливих профілів і перевіряйте нові моделі для друку у слайсері.",
+ "bn": "Kromacut 4.2.0-তে মসৃণ মেশের স্তর (কোনোটিই নয়, ন্যূনতম, মাঝারি ও তীব্র) যোগ করা হয়েছে, যা এখন সমতল প্রিন্ট মোডেও কাজ করে, এবং ডেস্কটপ অ্যাপ এখন সরাসরি .kfil, .kapp ও .kpal ফাইল খুলতে পারে। স্বয়ংক্রিয় রং-এর উচ্চতার ডিথারিং আবার কাজ করে, সমতল প্রিন্ট মোড ও স্তরের প্রিভিউ আরও নির্ভরযোগ্য হয়েছে, এবং সেটিংসে এখন গোপনীয়তা ও নিয়ম ও শর্তাবলির লিংক রয়েছে। সংরক্ষিত মসৃণকরণ সেটিং মাঝারি বা কোনোটিই নয় হিসেবে বহাল থাকে; বিদ্যমান প্রোফাইল ও প্যালেট সামঞ্জস্যপূর্ণ থাকে। উচ্চতার ডিথারিং বা সমতল প্রিন্ট মোড ব্যবহার করা মডেল প্রিন্টের আগে আবার তৈরি করুন, গুরুত্বপূর্ণ প্রোফাইলের ব্যাকআপ নিন এবং নতুন প্রিন্টগুলো আপনার স্লাইসারে পরীক্ষা করুন।"
}
}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index f836110d..a6214923 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -81,6 +81,137 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.116",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "atk"
version = "0.18.2"
@@ -188,6 +319,19 @@ dependencies = [
"objc2",
]
+[[package]]
+name = "blocking"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
[[package]]
name = "borsh"
version = "1.6.0"
@@ -431,6 +575,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -858,6 +1011,33 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.116",
+]
+
[[package]]
name = "env_filter"
version = "0.1.4"
@@ -892,7 +1072,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
]
[[package]]
@@ -1062,6 +1262,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1469,6 +1682,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
[[package]]
name = "hex"
version = "0.4.3"
@@ -1907,7 +2126,7 @@ dependencies = [
[[package]]
name = "kromacut"
-version = "4.1.0"
+version = "4.2.0"
dependencies = [
"log",
"reqwest 0.12.28",
@@ -1918,6 +2137,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-log",
+ "tauri-plugin-single-instance",
"tokio",
]
@@ -2489,6 +2709,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "pango"
version = "0.18.3"
@@ -2514,6 +2744,12 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2728,6 +2964,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2773,6 +3020,20 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3278,7 +3539,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3877,6 +4138,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -4188,6 +4460,22 @@ dependencies = [
"time",
]
+[[package]]
+name = "tauri-plugin-single-instance"
+version = "2.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "windows-sys 0.60.2",
+ "zbus",
+]
+
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -4300,7 +4588,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4655,9 +4943,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
+ "tracing-attributes",
"tracing-core",
]
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.116",
+]
+
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -4707,6 +5007,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -5150,7 +5461,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5591,6 +5902,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "winreg"
version = "0.55.0"
@@ -5792,6 +6112,76 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.4",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate 3.4.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow 1.0.4",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.39"
@@ -5877,3 +6267,44 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zvariant"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 1.0.4",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+dependencies = [
+ "proc-macro-crate 3.4.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.6",
+ "winnow 1.0.4",
+]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index a1a80ec6..60532f84 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "kromacut"
-version = "4.1.0"
+version = "4.2.0"
description = "Kromacut - Multi-color lithophane 3D print generator"
authors = ["vycdev"]
license = "AGPL-3.0-only"
@@ -27,3 +27,4 @@ reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
+tauri-plugin-single-instance = "2"
diff --git a/src-tauri/linux/kromacut-mime.xml b/src-tauri/linux/kromacut-mime.xml
new file mode 100644
index 00000000..f821957c
--- /dev/null
+++ b/src-tauri/linux/kromacut-mime.xml
@@ -0,0 +1,14 @@
+
+
+
+ Kromacut filament profile
+
+
+
+
+
+ Kromacut color palette
+
+
+
+
diff --git a/src-tauri/linux/kromacut.desktop b/src-tauri/linux/kromacut.desktop
new file mode 100644
index 00000000..a139a83f
--- /dev/null
+++ b/src-tauri/linux/kromacut.desktop
@@ -0,0 +1,14 @@
+[Desktop Entry]
+Categories={{categories}}
+{{#if comment}}
+Comment={{comment}}
+{{/if}}
+Exec={{exec}} %F
+StartupWMClass={{exec}}
+Icon={{icon}}
+Name={{name}}
+Terminal=false
+Type=Application
+{{#if mime_type}}
+MimeType={{mime_type}};
+{{/if}}
diff --git a/src-tauri/linux/refresh-file-associations.sh b/src-tauri/linux/refresh-file-associations.sh
new file mode 100644
index 00000000..0e38a0d7
--- /dev/null
+++ b/src-tauri/linux/refresh-file-associations.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# Package install/removal: refresh MIME globs and application handler metadata.
+if command -v update-mime-database >/dev/null 2>&1; then
+ update-mime-database /usr/share/mime || true
+fi
+if command -v update-desktop-database >/dev/null 2>&1; then
+ update-desktop-database /usr/share/applications || true
+fi
+exit 0
diff --git a/src-tauri/macos/Info.plist b/src-tauri/macos/Info.plist
new file mode 100644
index 00000000..77338842
--- /dev/null
+++ b/src-tauri/macos/Info.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName Kromacut filament profile
+ CFBundleTypeExtensions kfil kapp
+ CFBundleTypeRole Editor
+ CFBundleTypeIconFile kromacut-document.icns
+ LSHandlerRank Owner
+ LSItemContentTypes com.kromacut.filament-profile
+
+
+ CFBundleTypeName Kromacut color palette
+ CFBundleTypeExtensions kpal
+ CFBundleTypeRole Editor
+ CFBundleTypeIconFile kromacut-document.icns
+ LSHandlerRank Owner
+ LSItemContentTypes com.kromacut.palette
+
+
+
+
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index da5a578d..47202e77 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -7,6 +7,8 @@ use std::process::Command;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager, State};
+mod opened_files;
+use opened_files::{enqueue_and_notify, take_opened_file, OpenedFiles};
const RELEASES_URL: &str = "https://github.com/vycdev/Kromacut/releases";
@@ -211,6 +213,27 @@ fn open_releases_page() -> Result<(), String> {
open_external_url(RELEASES_URL)
}
+fn legal_page_url(path: &str) -> Result {
+ let segments: Vec<_> = path.strip_prefix('/').unwrap_or("").split('/').collect();
+ let supported = match segments.as_slice() {
+ ["privacy" | "terms"] => true,
+ [language, "privacy" | "terms"] => matches!(
+ *language,
+ "fr" | "de" | "it" | "ro" | "es" | "ja" | "zh-CN" | "hi" | "pt-PT" | "uk" | "bn"
+ ),
+ _ => false,
+ };
+ if !supported {
+ return Err("Unsupported legal page".to_string());
+ }
+ Ok(format!("https://kromacut.com{path}"))
+}
+
+#[tauri::command]
+fn open_legal_page(path: String) -> Result<(), String> {
+ open_external_url(&legal_page_url(&path)?)
+}
+
fn open_external_url(url: &str) -> Result<(), String> {
#[cfg(target_os = "windows")]
let result = Command::new("cmd").args(["/C", "start", "", url]).spawn();
@@ -223,25 +246,40 @@ fn open_external_url(url: &str) -> Result<(), String> {
result
.map(|_| ())
- .map_err(|e| format!("Failed to open releases page: {}", e))
+ .map_err(|e| format!("Failed to open web page: {}", e))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(DiagnosticWriterState::default())
+ .manage(OpenedFiles::default())
+ .plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
+ enqueue_and_notify(
+ app,
+ args.into_iter().skip(1).map(PathBuf::from),
+ Path::new(&cwd),
+ );
+ }))
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
check_for_updates,
get_app_version,
open_releases_page,
+ open_legal_page,
+ take_opened_file,
begin_auto_paint_diagnostic,
append_auto_paint_diagnostic,
finish_auto_paint_diagnostic,
open_auto_paint_diagnostics_directory
])
.setup(|app| {
+ #[cfg(any(target_os = "windows", target_os = "linux"))]
+ app.state::().enqueue(
+ std::env::args_os().skip(1).map(PathBuf::from),
+ &std::env::current_dir()?,
+ );
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
@@ -251,8 +289,17 @@ pub fn run() {
}
Ok(())
})
- .run(tauri::generate_context!())
- .expect("error while running tauri application");
+ .build(tauri::generate_context!())
+ .expect("error while building tauri application")
+ .run(|_app, _event| {
+ #[cfg(target_os = "macos")]
+ if let tauri::RunEvent::Opened { urls } = _event {
+ let paths = urls.into_iter().filter_map(|url| url.to_file_path().ok());
+ if let Ok(cwd) = std::env::current_dir() {
+ enqueue_and_notify(_app, paths, &cwd);
+ }
+ }
+ });
}
#[cfg(test)]
@@ -263,6 +310,38 @@ mod tests {
use std::fs::{create_dir_all, read_to_string, remove_dir_all, File};
use std::io::Cursor;
+ #[test]
+ fn legal_page_urls_only_allow_published_notices() {
+ for language in [
+ "", "fr", "de", "it", "ro", "es", "ja", "zh-CN", "hi", "pt-PT", "uk", "bn",
+ ] {
+ for page in ["privacy", "terms"] {
+ let path = if language.is_empty() {
+ format!("/{page}")
+ } else {
+ format!("/{language}/{page}")
+ };
+ assert_eq!(
+ super::legal_page_url(&path).unwrap(),
+ format!("https://kromacut.com{path}")
+ );
+ }
+ }
+ for path in [
+ "https://example.com/privacy",
+ "//example.com/privacy",
+ "/app",
+ "/xx/privacy",
+ "/ro/../privacy",
+ "/privacy?next=example.com",
+ "/privacy&calc",
+ "privacy",
+ "/terms/",
+ ] {
+ assert!(super::legal_page_url(path).is_err(), "{path}");
+ }
+ }
+
fn diagnostic_test_directory(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"kromacut-{name}-{}-{}",
@@ -278,6 +357,22 @@ mod tests {
assert!(is_different_version("3.0.0", "2.9.9"));
}
+ #[test]
+ fn platform_file_association_configs_are_valid_tauri_configs() {
+ let base: serde_json::Value =
+ serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
+ for (platform, field) in [
+ (include_str!("../tauri.linux.conf.json"), "linux"),
+ (include_str!("../tauri.macos.conf.json"), "macOS"),
+ ] {
+ let mut merged = base.clone();
+ let overlay: serde_json::Value = serde_json::from_str(platform).unwrap();
+ merged["bundle"][field] = overlay["bundle"][field].clone();
+ let config: tauri::Config = serde_json::from_value(merged).unwrap();
+ assert_eq!(config.bundle.file_associations.unwrap().len(), 2);
+ }
+ }
+
#[test]
fn ignores_exact_version_matches() {
assert!(!is_different_version("2.6.0", "2.6.0"));
diff --git a/src-tauri/src/opened_files.rs b/src-tauri/src/opened_files.rs
new file mode 100644
index 00000000..f3628042
--- /dev/null
+++ b/src-tauri/src/opened_files.rs
@@ -0,0 +1,197 @@
+use serde::Serialize;
+use std::collections::VecDeque;
+use std::fs::File;
+use std::io::Read;
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+use tauri::{AppHandle, Emitter, Manager, State};
+
+const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
+const MAX_PENDING_FILES: usize = 64;
+
+#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
+#[serde(rename_all = "lowercase")]
+pub enum FileKind {
+ Profile,
+ Palette,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenedFile {
+ name: String,
+ kind: FileKind,
+ content: Option,
+ error: Option,
+}
+
+#[derive(Default)]
+pub struct OpenedFiles(pub Mutex>);
+
+fn file_kind(path: &Path) -> Option {
+ match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
+ "kfil" | "kapp" => Some(FileKind::Profile),
+ "kpal" => Some(FileKind::Palette),
+ _ => None,
+ }
+}
+
+impl OpenedFiles {
+ pub fn enqueue(&self, paths: impl IntoIterator- , cwd: &Path) {
+ let mut pending = self.0.lock().unwrap_or_else(|error| error.into_inner());
+ for path in paths {
+ if file_kind(&path).is_none() {
+ continue;
+ }
+ let absolute = if path.is_absolute() {
+ path
+ } else {
+ cwd.join(path)
+ };
+ if pending.len() < MAX_PENDING_FILES && !pending.contains(&absolute) {
+ pending.push_back(absolute);
+ }
+ }
+ }
+}
+
+pub fn enqueue_and_notify(app: &AppHandle, paths: impl IntoIterator
- , cwd: &Path) {
+ app.state::
().enqueue(paths, cwd);
+ // The event is only a wake-up. The queue remains available until the UI takes it.
+ let _ = app.emit("kromacut-opened-files", ());
+ if let Some(window) = app.get_webview_window("main") {
+ let _ = window.unminimize();
+ let _ = window.show();
+ let _ = window.set_focus();
+ }
+}
+
+fn read_opened_file(path: PathBuf) -> OpenedFile {
+ let name = path
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .into_owned();
+ let kind = file_kind(&path).expect("only supported files enter the queue");
+ let result = (|| -> Result {
+ let metadata = std::fs::metadata(&path).map_err(|error| error.to_string())?;
+ if !metadata.is_file() {
+ return Err("Not a regular file".into());
+ }
+ if metadata.len() > MAX_FILE_BYTES {
+ return Err("File exceeds the 32 MiB import limit".into());
+ }
+ let file = File::open(&path).map_err(|error| error.to_string())?;
+ if !file
+ .metadata()
+ .map_err(|error| error.to_string())?
+ .is_file()
+ {
+ return Err("Not a regular file".into());
+ }
+ let mut content = String::new();
+ file.take(MAX_FILE_BYTES + 1)
+ .read_to_string(&mut content)
+ .map_err(|error| error.to_string())?;
+ if content.len() as u64 > MAX_FILE_BYTES {
+ return Err("File exceeds the 32 MiB import limit".into());
+ }
+ Ok(content)
+ })();
+ match result {
+ Ok(content) => OpenedFile {
+ name,
+ kind,
+ content: Some(content),
+ error: None,
+ },
+ Err(error) => OpenedFile {
+ name,
+ kind,
+ content: None,
+ error: Some(error),
+ },
+ }
+}
+
+#[tauri::command]
+pub async fn take_opened_file(state: State<'_, OpenedFiles>) -> Result, String> {
+ let path = state
+ .0
+ .lock()
+ .unwrap_or_else(|error| error.into_inner())
+ .pop_front();
+ match path {
+ Some(path) => tauri::async_runtime::spawn_blocking(move || read_opened_file(path))
+ .await
+ .map(Some)
+ .map_err(|error| error.to_string()),
+ None => Ok(None),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn queue_accepts_only_owned_extensions_and_resolves_relative_paths() {
+ let queue = OpenedFiles::default();
+ let cwd = std::env::current_dir().unwrap();
+ queue.enqueue(
+ [
+ "My spools.KFIL",
+ "old.kapp",
+ "colors.kpal",
+ "picture.png",
+ "model.3mf",
+ "raw.json",
+ ]
+ .map(PathBuf::from),
+ &cwd,
+ );
+ let pending = queue.0.lock().unwrap();
+ assert_eq!(pending.len(), 3);
+ assert_eq!(pending[0], cwd.join("My spools.KFIL"));
+ assert_eq!(file_kind(&pending[1]), Some(FileKind::Profile));
+ assert_eq!(file_kind(&pending[2]), Some(FileKind::Palette));
+ }
+
+ #[test]
+ fn duplicate_wakeups_do_not_duplicate_pending_files_but_reopening_is_allowed() {
+ let queue = OpenedFiles::default();
+ let cwd = std::env::current_dir().unwrap();
+ queue.enqueue([PathBuf::from("one.kfil"), cwd.join("one.kfil")], &cwd);
+ assert_eq!(queue.0.lock().unwrap().len(), 1);
+ queue.0.lock().unwrap().pop_front();
+ queue.enqueue([PathBuf::from("one.kfil")], &cwd);
+ assert_eq!(queue.0.lock().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn reader_reports_missing_directory_and_oversized_files_without_importing() {
+ let directory = std::env::temp_dir().join(format!(
+ "kromacut-open-files-{}-{}",
+ std::process::id(),
+ super::super::unix_timestamp_millis().unwrap()
+ ));
+ std::fs::create_dir_all(&directory).unwrap();
+ assert!(read_opened_file(directory.join("missing.kfil"))
+ .error
+ .is_some());
+ let folder = directory.join("folder.kpal");
+ std::fs::create_dir(&folder).unwrap();
+ assert!(read_opened_file(folder).content.is_none());
+ let huge = directory.join("huge.kfil");
+ File::create(&huge)
+ .unwrap()
+ .set_len(MAX_FILE_BYTES + 1)
+ .unwrap();
+ assert!(read_opened_file(huge).error.unwrap().contains("32 MiB"));
+ let valid = directory.join("valid.kapp");
+ std::fs::write(&valid, "\u{feff}{\"name\":\"Unicode 日本語\"}").unwrap();
+ let opened = read_opened_file(valid);
+ assert_eq!(opened.kind, FileKind::Profile);
+ assert!(opened.content.unwrap().contains("日本語"));
+ std::fs::remove_dir_all(directory).unwrap();
+ }
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 7bfc3702..c045ff79 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": "4.1.0",
+ "version": "4.2.0",
"identifier": "com.kromacut.lithophane",
"build": {
"frontendDist": "../dist",
@@ -29,6 +29,32 @@
"bundle": {
"active": true,
"targets": "all",
+ "fileAssociations": [
+ {
+ "ext": ["kfil", "kapp"],
+ "name": "Kromacut.FilamentProfile",
+ "description": "Kromacut filament profile",
+ "mimeType": "application/x-kromacut-filament-profile",
+ "role": "Editor",
+ "rank": "Owner",
+ "exportedType": {
+ "identifier": "com.kromacut.filament-profile",
+ "conformsTo": ["public.json"]
+ }
+ },
+ {
+ "ext": ["kpal"],
+ "name": "Kromacut.Palette",
+ "description": "Kromacut color palette",
+ "mimeType": "application/x-kromacut-palette",
+ "role": "Editor",
+ "rank": "Owner",
+ "exportedType": {
+ "identifier": "com.kromacut.palette",
+ "conformsTo": ["public.json"]
+ }
+ }
+ ],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -42,7 +68,8 @@
"silent": true
},
"nsis": {
- "minimumWebview2Version": "114.0.1823.67"
+ "minimumWebview2Version": "114.0.1823.67",
+ "installerHooks": "windows/file-associations.nsh"
}
},
"macOS": {
diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json
new file mode 100644
index 00000000..a5b6a532
--- /dev/null
+++ b/src-tauri/tauri.linux.conf.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
+ "bundle": {
+ "linux": {
+ "deb": {
+ "postInstallScript": "linux/refresh-file-associations.sh",
+ "postRemoveScript": "linux/refresh-file-associations.sh",
+ "desktopTemplate": "linux/kromacut.desktop",
+ "files": {
+ "/usr/share/mime/packages/kromacut.xml": "linux/kromacut-mime.xml",
+ "/usr/share/icons/hicolor/32x32/mimetypes/application-x-kromacut-filament-profile.png": "icons/32x32.png",
+ "/usr/share/icons/hicolor/128x128/mimetypes/application-x-kromacut-filament-profile.png": "icons/128x128.png",
+ "/usr/share/icons/hicolor/32x32/mimetypes/application-x-kromacut-palette.png": "icons/32x32.png",
+ "/usr/share/icons/hicolor/128x128/mimetypes/application-x-kromacut-palette.png": "icons/128x128.png"
+ }
+ },
+ "rpm": {
+ "postInstallScript": "linux/refresh-file-associations.sh",
+ "postRemoveScript": "linux/refresh-file-associations.sh",
+ "desktopTemplate": "linux/kromacut.desktop",
+ "files": {
+ "/usr/share/mime/packages/kromacut.xml": "linux/kromacut-mime.xml",
+ "/usr/share/icons/hicolor/32x32/mimetypes/application-x-kromacut-filament-profile.png": "icons/32x32.png",
+ "/usr/share/icons/hicolor/128x128/mimetypes/application-x-kromacut-filament-profile.png": "icons/128x128.png",
+ "/usr/share/icons/hicolor/32x32/mimetypes/application-x-kromacut-palette.png": "icons/32x32.png",
+ "/usr/share/icons/hicolor/128x128/mimetypes/application-x-kromacut-palette.png": "icons/128x128.png"
+ }
+ },
+ "appimage": {
+ "files": {
+ "/usr/share/mime/packages/kromacut.xml": "linux/kromacut-mime.xml",
+ "/usr/share/icons/hicolor/32x32/mimetypes/application-x-kromacut-filament-profile.png": "icons/32x32.png",
+ "/usr/share/icons/hicolor/128x128/mimetypes/application-x-kromacut-filament-profile.png": "icons/128x128.png",
+ "/usr/share/icons/hicolor/32x32/mimetypes/application-x-kromacut-palette.png": "icons/32x32.png",
+ "/usr/share/icons/hicolor/128x128/mimetypes/application-x-kromacut-palette.png": "icons/128x128.png"
+ }
+ }
+ }
+ }
+}
diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json
new file mode 100644
index 00000000..c9b9e5b1
--- /dev/null
+++ b/src-tauri/tauri.macos.conf.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
+ "bundle": {
+ "macOS": {
+ "files": {
+ "Resources/kromacut-document.icns": "icons/icon.icns"
+ },
+ "infoPlist": "macos/Info.plist"
+ }
+ }
+}
diff --git a/src-tauri/windows/file-associations.nsh b/src-tauri/windows/file-associations.nsh
new file mode 100644
index 00000000..fdd3a9f8
--- /dev/null
+++ b/src-tauri/windows/file-associations.nsh
@@ -0,0 +1,37 @@
+; Tauri includes FileAssociation.nsh before this hook file. Retain its
+; registration calls while preserving the original choice across reinstalls,
+; quoting executable/document paths, and respecting later changes on removal.
+!include LogicLib.nsh
+!ifndef KROMACUT_ASSOCIATIONS_ROOT
+ !define KROMACUT_ASSOCIATIONS_ROOT "Software\Classes"
+!endif
+
+!macroundef APP_ASSOCIATE
+!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
+ ReadRegStr $R0 SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}" ""
+ ${If} $R0 != "${FILECLASS}"
+ WriteRegStr SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}" "${FILECLASS}_backup" "$R0"
+ ${EndIf}
+ WriteRegStr SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}" "" "${FILECLASS}"
+ WriteRegStr SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\${FILECLASS}" "" `${DESCRIPTION}`
+ WriteRegStr SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\${FILECLASS}\DefaultIcon" "" `${ICON}`
+ WriteRegStr SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\${FILECLASS}\shell" "" "open"
+ WriteRegStr SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
+ WriteRegStr SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\${FILECLASS}\shell\open\command" "" '$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"'
+!macroend
+
+!macroundef APP_UNASSOCIATE
+!macro APP_UNASSOCIATE EXT FILECLASS
+ ReadRegStr $R0 SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}" ""
+ ${If} $R0 == "${FILECLASS}"
+ ReadRegStr $R0 SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}" "${FILECLASS}_backup"
+ ${If} $R0 == ""
+ DeleteRegValue SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}" ""
+ ${Else}
+ WriteRegStr SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}" "" "$R0"
+ ${EndIf}
+ ${EndIf}
+ DeleteRegValue SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}" "${FILECLASS}_backup"
+ DeleteRegKey /ifempty SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\.${EXT}"
+ DeleteRegKey SHCTX "${KROMACUT_ASSOCIATIONS_ROOT}\${FILECLASS}"
+!macroend
diff --git a/src/App.tsx b/src/App.tsx
index 2d5b5baa..b0e36e18 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -46,6 +46,8 @@ import { normalizeSeparationMaxDeltaE } from './lib/autoPaint';
import ResizableSplitter from './components/ResizableSplitter';
import { ControlsPanel } from './components/ControlsPanel';
import { usePaletteManager } from './hooks/usePaletteManager';
+import { useDesktopFileOpen } from './hooks/useDesktopFileOpen';
+import { DesktopFileDialog } from './components/DesktopFileDialog';
import { UpdateChecker } from './components/UpdateChecker';
import ProgressOverlay from './components/ProgressOverlay';
import DocsPage from './components/docs/DocsPage';
@@ -263,7 +265,27 @@ function App(): React.ReactElement | null {
handleDeletePalette,
handleExportPalette,
handleImportFile: handleImportPaletteFile,
+ importText: importPaletteText,
} = usePaletteManager();
+ const [paletteEditorOpen, setPaletteEditorOpen] = useState(false);
+ const [calibrationDialogOpen, setCalibrationDialogOpen] = useState(false);
+ const [profileSaveNewOpen, setProfileSaveNewOpen] = useState(false);
+ const [profileRenameOpen, setProfileRenameOpen] = useState(false);
+ const [filamentEditing, setFilamentEditing] = useState(false);
+ const { file: queuedDesktopFile, finish: finishDesktopFile } = useDesktopFileOpen();
+ // OS file opens can arrive through an active editor. Keep the request queued
+ // until its draft is saved or dismissed, before changing either workspace.
+ const desktopFile =
+ paletteEditorOpen ||
+ calibrationDialogOpen ||
+ profileSaveNewOpen ||
+ profileRenameOpen ||
+ filamentEditing
+ ? null
+ : queuedDesktopFile;
+ const handledPaletteFile = useRef(null);
+ const [failedPaletteFileId, setFailedPaletteFileId] = useState(null);
+ const [completedPaletteFileId, setCompletedPaletteFileId] = useState(null);
// Keep the postprocess color count in sync with the selected palette's
// enabled size. Edits, per-color toggles, clones, and imports all change
@@ -390,6 +412,28 @@ function App(): React.ReactElement | null {
};
}, []);
const [docsOpen, setDocsOpen] = useState(() => isDocsRoute(window.location.pathname));
+ useEffect(() => {
+ if (!desktopFile || desktopFile.content === null) return;
+ setDocsOpen(false);
+ if (isDocsRoute(window.location.pathname)) window.history.pushState(null, '', toolPath);
+ if (desktopFile.kind === 'profile') {
+ handleModeChange('3d');
+ } else if (handledPaletteFile.current !== desktopFile.id) {
+ handledPaletteFile.current = desktopFile.id;
+ handleModeChange('2d');
+ try {
+ if (importPaletteText(desktopFile.content, true))
+ setCompletedPaletteFileId(desktopFile.id);
+ else setFailedPaletteFileId(desktopFile.id);
+ } catch (error) {
+ console.error('Could not import the opened palette', error);
+ setFailedPaletteFileId(desktopFile.id);
+ }
+ }
+ }, [desktopFile, handleModeChange, importPaletteText, toolPath]);
+ useEffect(() => {
+ if (completedPaletteFileId !== null) finishDesktopFile(completedPaletteFileId);
+ }, [completedPaletteFileId, finishDesktopFile]);
const [isOrtho, setIsOrtho] = useState(loadCameraMode);
const [previewRenderMode, setPreviewRenderMode] =
useState(loadPreviewRenderMode);
@@ -630,6 +674,7 @@ function App(): React.ReactElement | null {
exportObjectTo3MFBlob(obj, {
layerHeight: builtModelState.layerHeight,
firstLayerHeight: builtModelState.slicerFirstLayerHeight,
+ smoothMeshingStrength: builtModelState.smoothMeshingStrength,
layerFilamentColors: builtModelAutoPaint
? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex)
: undefined,
@@ -798,6 +843,8 @@ function App(): React.ReactElement | null {
onDeletePalette={handleDeletePalette}
onExportPalette={handleExportPalette}
onImportPaletteFile={handleImportPaletteFile}
+ paletteEditorOpen={paletteEditorOpen}
+ onPaletteEditorOpenChange={setPaletteEditorOpen}
/>
)}
@@ -941,7 +1002,9 @@ function App(): React.ReactElement | null {
printableFeaturePixels={
builtModelState.printableFeaturePixels
}
- smoothMeshing={builtModelState.smoothMeshing}
+ smoothMeshingStrength={
+ builtModelState.smoothMeshingStrength
+ }
isOrtho={isOrtho}
flatPaint={builtFlatPaint}
flatPaintFaceUp={!!builtModelState.flatPaintFaceUp}
@@ -1072,6 +1135,16 @@ function App(): React.ReactElement | null {
{/* Update checker for Tauri desktop app */}
+ {desktopFile &&
+ (desktopFile.content === null || failedPaletteFileId === desktopFile.id) && (
+ {
+ setFailedPaletteFileId(null);
+ finishDesktopFile(desktopFile.id);
+ }}
+ />
+ )}
{printing && }
>
diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx
index d6834bdf..b6b6ae72 100644
--- a/src/components/AutoPaintTab.tsx
+++ b/src/components/AutoPaintTab.tsx
@@ -334,12 +334,15 @@ interface AutoPaintSliceData {
}
interface AutoPaintTabProps {
+ calibrationDialogOpen: boolean;
+ onCalibrationDialogOpenChange: (open: boolean) => void;
// Filament state
filaments: Filament[];
addFilament: () => void;
addFilamentWithProps: (props: { color: string; td: number; name: string }) => void;
removeFilament: (id: string) => void;
updateFilament: (id: string, updates: Partial>) => void;
+ onFilamentEditingChange: (id: string, editing: boolean) => void;
// Profile state
profiles: AutoPaintProfile[];
@@ -431,11 +434,14 @@ interface AutoPaintTabProps {
}
export default function AutoPaintTab({
+ calibrationDialogOpen,
+ onCalibrationDialogOpenChange,
filaments,
addFilament,
addFilamentWithProps,
removeFilament,
updateFilament,
+ onFilamentEditingChange,
profiles,
activeProfileId,
isDirty,
@@ -537,19 +543,16 @@ export default function AutoPaintTab({
);
const exactBaseOrderIsLarge = exactBaseOrderCount >= 1_000_000 || filaments.length >= 9;
- // Calibration dialog state
- const [calibrationDialogOpen, setCalibrationDialogOpen] = React.useState(false);
-
// Built-in templates are read-only: no overwrite, rename, or delete
const isTemplateActive = activeProfileId !== null && isTemplateProfileId(activeProfileId);
const handleOpenCalibration = React.useCallback(() => {
- setCalibrationDialogOpen(true);
- }, []);
+ onCalibrationDialogOpenChange(true);
+ }, [onCalibrationDialogOpenChange]);
const handleCloseCalibration = React.useCallback(() => {
- setCalibrationDialogOpen(false);
- }, []);
+ onCalibrationDialogOpenChange(false);
+ }, [onCalibrationDialogOpenChange]);
const handleApplyCalibration = React.useCallback(
(updates: CalibrationApplyUpdate[]) => {
@@ -863,6 +866,7 @@ export default function AutoPaintTab({
filament={f}
onUpdate={updateFilament}
onRemove={removeFilament}
+ onEditingChange={onFilamentEditingChange}
/>
))}
diff --git a/src/components/ControlsPanel.tsx b/src/components/ControlsPanel.tsx
index edda1018..f4b17463 100644
--- a/src/components/ControlsPanel.tsx
+++ b/src/components/ControlsPanel.tsx
@@ -56,6 +56,8 @@ interface Props {
onDeletePalette: (id: string) => void;
onExportPalette: (id: string) => void;
onImportPaletteFile: (e: React.ChangeEvent) => void;
+ paletteEditorOpen: boolean;
+ onPaletteEditorOpenChange: (open: boolean) => void;
}
export const ControlsPanel: React.FC = ({
@@ -82,6 +84,8 @@ export const ControlsPanel: React.FC = ({
onDeletePalette,
onExportPalette,
onImportPaletteFile,
+ paletteEditorOpen,
+ onPaletteEditorOpenChange,
}) => {
const { t } = useTranslation('workspace');
// Local state for relaxed typing
@@ -299,6 +303,8 @@ export const ControlsPanel: React.FC = ({
onDeletePalette={onDeletePalette}
onExportPalette={onExportPalette}
onImportFile={onImportPaletteFile}
+ dialogOpen={paletteEditorOpen}
+ onDialogOpenChange={onPaletteEditorOpenChange}
/>
diff --git a/src/components/DesktopFileDialog.tsx b/src/components/DesktopFileDialog.tsx
new file mode 100644
index 00000000..5e6fed80
--- /dev/null
+++ b/src/components/DesktopFileDialog.tsx
@@ -0,0 +1,63 @@
+import { useTranslation } from 'react-i18next';
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+ AlertDialogAction,
+} from './ui/alert-dialog';
+
+interface Props {
+ name: string;
+ onDismiss: () => void;
+ onConfirm?: () => void;
+}
+
+/** Uses the app's existing dialog and buttons for OS-open errors and unsaved edits. */
+export function DesktopFileDialog({ name, onDismiss, onConfirm }: Props) {
+ const { t } = useTranslation('common');
+ return (
+
{
+ if (!open) onDismiss();
+ }}
+ >
+
+
+
+ {t(onConfirm ? 'desktopFiles.unsavedTitle' : 'desktopFiles.failedTitle')}
+
+
+ {t(
+ onConfirm
+ ? 'desktopFiles.unsavedDescription'
+ : name
+ ? 'desktopFiles.failedDescription'
+ : 'desktopFiles.unavailable',
+ { name }
+ )}
+
+
+
+
+ {t(onConfirm ? 'desktopFiles.keepEdits' : 'desktopFiles.close')}
+
+ {onConfirm && (
+ {
+ event.preventDefault();
+ onConfirm();
+ }}
+ >
+ {t('desktopFiles.openProfile')}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/DesktopProfileImport.tsx b/src/components/DesktopProfileImport.tsx
new file mode 100644
index 00000000..9fbb1473
--- /dev/null
+++ b/src/components/DesktopProfileImport.tsx
@@ -0,0 +1,67 @@
+import { useEffect, useRef, useState } from 'react';
+import type { DesktopFileRequest } from '../lib/desktopFileInbox';
+import { DesktopFileDialog } from './DesktopFileDialog';
+
+interface Props {
+ file: DesktopFileRequest | null;
+ hasUnsavedChanges: boolean;
+ importText: (content: string, name: string, selectExisting?: boolean) => boolean;
+ onImported: () => void;
+ onFinished: (id: number) => void;
+}
+
+export function DesktopProfileImport({
+ file,
+ hasUnsavedChanges,
+ importText,
+ onImported,
+ onFinished,
+}: Props) {
+ const handled = useRef
(null);
+ const [dialog, setDialog] = useState<{ file: DesktopFileRequest; confirm: boolean } | null>(
+ null
+ );
+ const [completedId, setCompletedId] = useState(null);
+ const callbacks = useRef({ importText, onImported, onFinished });
+ callbacks.current = { importText, onImported, onFinished };
+
+ const importFile = (request: DesktopFileRequest) => {
+ let imported = false;
+ try {
+ imported = callbacks.current.importText(request.content!, request.name, true);
+ } catch (error) {
+ console.error('Could not import the opened profile', error);
+ }
+ if (imported) {
+ callbacks.current.onImported();
+ setCompletedId(request.id);
+ setDialog(null);
+ } else {
+ setDialog({ file: request, confirm: false });
+ }
+ };
+ const runImport = useRef(importFile);
+ runImport.current = importFile;
+ useEffect(() => {
+ if (!file || handled.current === file.id) return;
+ handled.current = file.id;
+ if (hasUnsavedChanges) setDialog({ file, confirm: true });
+ else runImport.current(file);
+ }, [file, hasUnsavedChanges]);
+ // Advance only after the imported profiles/filaments have committed to React state.
+ useEffect(() => {
+ if (completedId !== null) callbacks.current.onFinished(completedId);
+ }, [completedId]);
+
+ if (!dialog) return null;
+ return (
+ {
+ callbacks.current.onFinished(dialog.file.id);
+ setDialog(null);
+ }}
+ onConfirm={dialog.confirm ? () => runImport.current(dialog.file) : undefined}
+ />
+ );
+}
diff --git a/src/components/FilamentRow.tsx b/src/components/FilamentRow.tsx
index 4953a2bb..25ab97ea 100644
--- a/src/components/FilamentRow.tsx
+++ b/src/components/FilamentRow.tsx
@@ -25,12 +25,14 @@ interface FilamentRowProps {
filament: Filament;
onUpdate: (id: string, updates: Partial>) => void;
onRemove: (id: string) => void;
+ onEditingChange: (id: string, editing: boolean) => void;
}
const FilamentRow = React.memo(function FilamentRow({
filament,
onUpdate,
onRemove,
+ onEditingChange,
}: FilamentRowProps) {
const { t } = useTranslation('printing');
// Local state for the input value to allow free typing
@@ -63,6 +65,16 @@ const FilamentRow = React.memo(function FilamentRow({
// "Convert from TD" popover state (conventional backlit/lithophane value).
const [convertDraft, setConvertDraft] = useState('');
const [convertOpen, setConvertOpen] = useState(false);
+ const [colorOpen, setColorOpen] = useState(false);
+ const [inputFocused, setInputFocused] = useState(false);
+ const editing = inputFocused || colorOpen || convertOpen || localColor !== filament.color;
+
+ // OS opens do not blur the active input. Hold them until drafts are committed,
+ // including color changes still waiting for the debounced parent update.
+ useEffect(() => {
+ onEditingChange(filament.id, editing);
+ }, [filament.id, editing, onEditingChange]);
+ useEffect(() => () => onEditingChange(filament.id, false), [filament.id, onEditingChange]);
// Sync local TD state if prop changes externally
useEffect(() => {
@@ -111,7 +123,12 @@ const FilamentRow = React.memo(function FilamentRow({
};
const handleNameKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
+ if (e.key === 'Enter') {
+ // A queued import may focus its confirmation as this edit commits.
+ // Do not let the same Enter also activate that dialog's button.
+ e.preventDefault();
+ (e.target as HTMLInputElement).blur();
+ }
};
const handleBlur = () => {
@@ -140,15 +157,39 @@ const FilamentRow = React.memo(function FilamentRow({
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
+ e.preventDefault();
handleBlur();
(e.target as HTMLInputElement).blur();
}
};
return (
-
+
{
+ // Portal inputs are protected by their popover's open state;
+ // unmounting them does not reliably fire a blur event.
+ const focused =
+ event.target instanceof HTMLInputElement &&
+ event.currentTarget.contains(event.target);
+ if (focused) onEditingChange(filament.id, true);
+ setInputFocused(focused);
+ }}
+ onBlurCapture={(event) => {
+ setInputFocused(
+ event.relatedTarget instanceof HTMLInputElement &&
+ event.currentTarget.contains(event.relatedTarget)
+ );
+ }}
+ >
{/* Color Picker Popover */}
-
+ {
+ if (open) onEditingChange(filament.id, true);
+ setColorOpen(open);
+ }}
+ >
{/* Convert from conventional (backlit/lithophane) TD */}
-
+ {
+ if (open) onEditingChange(filament.id, true);
+ setConvertOpen(open);
+ }}
+ >
setConvertDraft(e.target.value)}
onKeyDown={(e) => {
- if (e.key === 'Enter') handleConvertFromTd();
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleConvertFromTd();
+ }
}}
placeholder={t('filamentRow.eG40')}
className="h-8 text-sm"
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 52b75daa..e14f402d 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -20,6 +20,7 @@ import {
MessageCircle,
FileJson,
FolderOpen,
+ ExternalLink,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch';
@@ -50,8 +51,8 @@ import {
} from '@/lib/experimentalFeatures';
import logo from '../assets/logo.png';
import redditIcon from '../assets/reddit.svg';
-import { landingPath } from '@/lib/routes';
-import { isTauri } from '@tauri-apps/api/core';
+import { landingPath, publicPath } from '@/lib/routes';
+import { invoke, isTauri } from '@tauri-apps/api/core';
import {
getAutoPaintDiagnosticsEnabled,
saveAutoPaintDiagnosticsEnabled,
@@ -68,6 +69,10 @@ interface Props {
const appVersion = __APP_VERSION__;
type UpdateCheckStatus = 'idle' | 'checking' | 'available' | 'current' | 'error';
+const legalLinks = [
+ { path: '/privacy', labelKey: 'public:navigation.privacy' },
+ { path: '/terms', labelKey: 'public:navigation.terms' },
+] as const;
export const Header: React.FC = ({ docsOpen, onBackToApp, onOpenDocs }) => {
const { t, i18n } = useTranslation('common');
@@ -79,6 +84,7 @@ export const Header: React.FC = ({ docsOpen, onBackToApp, onOpenDocs }) =
getAutoPaintDiagnosticsEnabled()
);
const [diagnosticsError, setDiagnosticsError] = React.useState('');
+ const [legalLinkFailed, setLegalLinkFailed] = React.useState(false);
const [updateStatus, setUpdateStatus] = React.useState('idle');
const [availableUpdate, setAvailableUpdate] = React.useState(null);
const releaseNotes = availableUpdate
@@ -245,6 +251,25 @@ export const Header: React.FC = ({ docsOpen, onBackToApp, onOpenDocs }) =
}
};
+ const handleLegalLinkClick = async (
+ event: React.MouseEvent,
+ path: string
+ ) => {
+ if (!isDesktopApp) {
+ setSettingsOpen(false);
+ return;
+ }
+ event.preventDefault();
+ setLegalLinkFailed(false);
+ try {
+ await invoke('open_legal_page', { path });
+ setSettingsOpen(false);
+ } catch (error) {
+ console.error('Failed to open legal page:', error);
+ setLegalLinkFailed(true);
+ }
+ };
+
return (
@@ -458,6 +483,43 @@ export const Header: React.FC
= ({ docsOpen, onBackToApp, onOpenDocs }) =
Patreon
+
+ {legalLinkFailed && (
+
+ {t('settings.openLinkFailed')}
+
+ )}
diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx
index dad18658..b0061d65 100644
--- a/src/components/LandingPage.tsx
+++ b/src/components/LandingPage.tsx
@@ -387,7 +387,7 @@ function ShowcaseCard({ items }: { items: ShowcaseItem[] }) {
i18nKey="landing.originalArtwork"
values={{ title: artwork.label }}
components={{
- source: (
+ credit: (
void;
onExportPalette: (id: string) => void;
onImportFile: (e: React.ChangeEvent) => void;
+ dialogOpen: boolean;
+ onDialogOpenChange: (open: boolean) => void;
}
/** Check if a string is a valid hex color (#RGB or #RRGGBB). */
@@ -82,9 +84,10 @@ export const PaletteManager: React.FC = ({
onDeletePalette,
onExportPalette,
onImportFile,
+ dialogOpen,
+ onDialogOpenChange: setDialogOpen,
}) => {
const { t } = useTranslation('workspace');
- const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editor, setEditor] = useState({
name: '',
@@ -97,7 +100,7 @@ export const PaletteManager: React.FC = ({
setEditingId(null);
setEditor({ name: '', colors: [{ hex: '#FF0000', enabled: true, name: '' }] });
setDialogOpen(true);
- }, []);
+ }, [setDialogOpen]);
const openEdit = useCallback(() => {
const cp = customPalettes.find((p) => p.id === selectedPalette);
@@ -113,7 +116,7 @@ export const PaletteManager: React.FC = ({
})),
});
setDialogOpen(true);
- }, [customPalettes, selectedPalette]);
+ }, [customPalettes, selectedPalette, setDialogOpen]);
const handleSave = useCallback(() => {
const trimmed = editor.name.trim();
@@ -129,7 +132,7 @@ export const PaletteManager: React.FC = ({
onCreatePalette(trimmed, colors, disabledColors, colorNames);
}
setDialogOpen(false);
- }, [editor, editingId, onCreatePalette, onUpdatePalette]);
+ }, [editor, editingId, onCreatePalette, onUpdatePalette, setDialogOpen]);
const addColor = useCallback(() => {
setEditor((prev) => ({
diff --git a/src/components/PrintInstructions.tsx b/src/components/PrintInstructions.tsx
index 5ea49736..b572c35f 100644
--- a/src/components/PrintInstructions.tsx
+++ b/src/components/PrintInstructions.tsx
@@ -1,6 +1,7 @@
import { Trans, useTranslation } from 'react-i18next';
import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard';
import type { SwapEntry } from '../hooks/useSwapPlan';
+import type { SmoothMeshingStrength } from '../types';
interface PrintInstructionsProps {
swapPlan: SwapEntry[];
@@ -14,6 +15,7 @@ interface PrintInstructionsProps {
flatPaint?: boolean;
/** Flat Paint is printed face-up without a transparent carrier. */
flatPaintFaceUp?: boolean;
+ smoothMeshingStrength?: SmoothMeshingStrength;
}
export default function PrintInstructions({
@@ -26,8 +28,15 @@ export default function PrintInstructions({
colorCount = 0,
flatPaint = false,
flatPaintFaceUp = false,
+ smoothMeshingStrength = 'none',
}: PrintInstructionsProps) {
const { t } = useTranslation('printing');
+ const strengthKeys = {
+ none: 'printSettingsCard.smoothingNone',
+ minimal: 'printSettingsCard.smoothingMinimal',
+ medium: 'printSettingsCard.smoothingMedium',
+ aggressive: 'printSettingsCard.smoothingAggressive',
+ };
return (
+
+ {t('printSettingsCard.smoothMeshing')}:{' '}
+ {t(strengthKeys[smoothMeshingStrength])}
+
void;
onSlicerFirstLayerHeightChange: (v: number) => void;
onPixelSizeChange: (v: number) => void;
onEffectiveLineWidthChange: (v: number) => void;
- onSmoothMeshingChange: (v: boolean) => void;
+ onSmoothMeshingStrengthChange: (v: SmoothMeshingStrength) => void;
onReset: () => void;
allDefault?: boolean;
}
@@ -106,12 +113,12 @@ export default function PrintSettingsCard({
pixelSize,
effectiveLineWidth,
modelSizeEstimate,
- smoothMeshing,
+ smoothMeshingStrength,
onLayerHeightChange,
onSlicerFirstLayerHeightChange,
onPixelSizeChange,
onEffectiveLineWidthChange,
- onSmoothMeshingChange,
+ onSmoothMeshingStrengthChange,
onReset,
allDefault = false,
}: PrintSettingsCardProps) {
@@ -324,17 +331,38 @@ export default function PrintSettingsCard({
{t('printSettingsCard.smoothMeshing')}
- {t(
- 'printSettingsCard.smoothConnectedColorBoundaryEdgesWithFastWeldedTopology'
- )}
+ {t('printSettingsCard.smoothingHelp')}
-
+
+ onSmoothMeshingStrengthChange(value as SmoothMeshingStrength)
+ }
+ >
+
+
+
+
+
+ {t('printSettingsCard.smoothingNone')}
+
+
+ {t('printSettingsCard.smoothingMinimal')}
+
+
+ {t('printSettingsCard.smoothingMedium')}
+
+
+ {t('printSettingsCard.smoothingAggressive')}
+
+
+
diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx
index 6bd630a3..5bf1f289 100644
--- a/src/components/ThreeDControls.tsx
+++ b/src/components/ThreeDControls.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard';
import ThreeDColorRow from './ThreeDColorRow';
import { Sortable, SortableContent, SortableOverlay } from '@/components/ui/sortable';
@@ -28,6 +28,7 @@ import type {
AutoPaintRepeatLimit,
AutoPaintTransitionOpacity,
Swatch,
+ SmoothMeshingStrength,
ThreeDControlsStateShape,
} from '../types';
import PrintSettingsCard from './PrintSettingsCard';
@@ -36,6 +37,8 @@ import AutoPaintTab from './AutoPaintTab';
import type { ImageDimensions } from '../hooks/useSwatches';
import { concealPrintableFeatureBuffers } from '../lib/printableFeatures.ts';
import { shouldApplyInitialProfileFilaments } from '../lib/threeDWorkLifecycle';
+import type { DesktopFileRequest } from '../lib/desktopFileInbox';
+import { DesktopProfileImport } from './DesktopProfileImport';
// Re-export types for backward compatibility
export type { Filament, ThreeDControlsStateShape } from '../types';
@@ -63,6 +66,15 @@ interface ThreeDControlsProps {
persisted?: ThreeDControlsStateShape | null;
/** Whether `persisted.filaments` belongs to an authoritative saved or in-session workspace. */
hasAutoPaintWorkingState?: boolean;
+ desktopProfileFile?: DesktopFileRequest | null;
+ onDesktopFileFinished?: (id: number) => void;
+ calibrationDialogOpen: boolean;
+ onCalibrationDialogOpenChange: (open: boolean) => void;
+ profileSaveNewOpen: boolean;
+ onProfileSaveNewOpenChange: (open: boolean) => void;
+ profileRenameOpen: boolean;
+ onProfileRenameOpenChange: (open: boolean) => void;
+ onFilamentEditingChange: (editing: boolean) => void;
}
export default function ThreeDControls({
@@ -76,6 +88,15 @@ export default function ThreeDControls({
onSettingsChange,
persisted,
hasAutoPaintWorkingState = false,
+ desktopProfileFile = null,
+ onDesktopFileFinished,
+ calibrationDialogOpen,
+ onCalibrationDialogOpenChange,
+ profileSaveNewOpen,
+ onProfileSaveNewOpenChange,
+ profileRenameOpen,
+ onProfileRenameOpenChange,
+ onFilamentEditingChange,
}: ThreeDControlsProps) {
const { t } = useTranslation('printing');
// --- Filaments ---
@@ -91,7 +112,26 @@ export default function ThreeDControls({
});
// --- Profiles ---
- const profileManager = useProfileManager({ filaments, setFilaments });
+ const profileManager = useProfileManager({
+ filaments,
+ setFilaments,
+ showSaveNewPopover: profileSaveNewOpen,
+ setShowSaveNewPopover: onProfileSaveNewOpenChange,
+ showRenamePopover: profileRenameOpen,
+ setShowRenamePopover: onProfileRenameOpenChange,
+ });
+ const [profileReady, setProfileReady] = useState(false);
+ const editingFilaments = useRef(new Set());
+ const handleFilamentEditingChange = useCallback(
+ (id: string, editing: boolean) => {
+ if (editingFilaments.current.has(id) === editing) return;
+ if (editing) editingFilaments.current.add(id);
+ else editingFilaments.current.delete(id);
+ onFilamentEditingChange(editingFilaments.current.size > 0);
+ },
+ [onFilamentEditingChange]
+ );
+ useEffect(() => () => onFilamentEditingChange(false), [onFilamentEditingChange]);
// Apply initial filaments from profile if available (one-time)
const [appliedProfileInit] = useState(() => {
@@ -110,6 +150,7 @@ export default function ThreeDControls({
if (appliedProfileInit) {
setFilaments(appliedProfileInit);
}
+ setProfileReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -119,10 +160,10 @@ export default function ThreeDControls({
// --- Print Settings ---
const [initialPrintSettings] = useState(() => {
const stored = loadPrintSettingsFromStorage();
- const storedSmoothMeshing =
- stored?.smoothMeshing ??
- persisted?.smoothMeshing ??
- DEFAULT_PRINT_SETTINGS.smoothMeshing;
+ const storedSmoothMeshingStrength =
+ stored?.smoothMeshingStrength ??
+ persisted?.smoothMeshingStrength ??
+ DEFAULT_PRINT_SETTINGS.smoothMeshingStrength;
return {
layerHeight:
stored?.layerHeight ?? persisted?.layerHeight ?? DEFAULT_PRINT_SETTINGS.layerHeight,
@@ -132,7 +173,7 @@ export default function ThreeDControls({
DEFAULT_PRINT_SETTINGS.slicerFirstLayerHeight,
pixelSize:
stored?.pixelSize ?? persisted?.pixelSize ?? DEFAULT_PRINT_SETTINGS.pixelSize,
- smoothMeshing: storedSmoothMeshing,
+ smoothMeshingStrength: storedSmoothMeshingStrength,
};
});
@@ -141,7 +182,9 @@ export default function ThreeDControls({
initialPrintSettings.slicerFirstLayerHeight
);
const [pixelSize, setPixelSize] = useState(initialPrintSettings.pixelSize);
- const [smoothMeshing, setSmoothMeshing] = useState(initialPrintSettings.smoothMeshing);
+ const [smoothMeshingStrength, setSmoothMeshingStrength] = useState(
+ initialPrintSettings.smoothMeshingStrength
+ );
const [calibrationLayerHeight, setCalibrationLayerHeight] = useState(
persisted?.calibrationLayerHeight ?? initialPrintSettings.layerHeight
);
@@ -210,13 +253,9 @@ export default function ThreeDControls({
}, []);
const flatPaintActive = paintMode === 'autopaint' && flatPaint;
- const effectiveSmoothMeshing = flatPaintActive ? false : smoothMeshing;
- const handleSmoothMeshingChange = useCallback((enabled: boolean) => {
- setSmoothMeshing(enabled);
- if (enabled) {
- setFlatPaint(false);
- }
+ const handleSmoothMeshingStrengthChange = useCallback((strength: SmoothMeshingStrength) => {
+ setSmoothMeshingStrength(strength);
}, []);
const handleFlatPaintChange = useCallback((enabled: boolean) => {
@@ -244,7 +283,7 @@ export default function ThreeDControls({
optimizerAlgorithm,
optimizerSeed,
regionWeightingMode,
- smoothMeshing,
+ smoothMeshingStrength,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -266,7 +305,7 @@ export default function ThreeDControls({
optimizerAlgorithm,
optimizerSeed,
regionWeightingMode,
- smoothMeshing,
+ smoothMeshingStrength,
]);
useEffect(() => {
@@ -274,9 +313,9 @@ export default function ThreeDControls({
layerHeight,
slicerFirstLayerHeight,
pixelSize,
- smoothMeshing,
+ smoothMeshingStrength,
});
- }, [layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing]);
+ }, [layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshingStrength]);
// --- Color Slicing ---
const {
@@ -300,7 +339,7 @@ export default function ThreeDControls({
setLayerHeight(DEFAULT_PRINT_SETTINGS.layerHeight);
setSlicerFirstLayerHeight(DEFAULT_PRINT_SETTINGS.slicerFirstLayerHeight);
setPixelSize(DEFAULT_PRINT_SETTINGS.pixelSize);
- setSmoothMeshing(DEFAULT_PRINT_SETTINGS.smoothMeshing);
+ setSmoothMeshingStrength(DEFAULT_PRINT_SETTINGS.smoothMeshingStrength);
setDitherLineWidth(DEFAULT_EFFECTIVE_LINE_WIDTH);
resetHeightsToValues(
DEFAULT_PRINT_SETTINGS.layerHeight,
@@ -450,6 +489,7 @@ export default function ThreeDControls({
disabled: isInstructionOverLimit,
flatPaint: instructionFlatPaint,
flatPaintFaceUp: instructionFlatPaintFaceUp,
+ smoothMeshingStrength: builtState?.smoothMeshingStrength ?? smoothMeshingStrength,
});
// --- Apply handler ---
@@ -502,7 +542,7 @@ export default function ThreeDControls({
fingerprint: printableFeatureSimulation.fingerprint,
}),
calibrationLayerHeight,
- smoothMeshing,
+ smoothMeshingStrength,
});
return;
}
@@ -522,7 +562,7 @@ export default function ThreeDControls({
optimizerSeed,
regionWeightingMode,
calibrationLayerHeight,
- smoothMeshing,
+ smoothMeshingStrength,
});
}, [
onChange,
@@ -550,7 +590,7 @@ export default function ThreeDControls({
optimizerSeed,
regionWeightingMode,
calibrationLayerHeight,
- smoothMeshing,
+ smoothMeshingStrength,
autoPaintResult,
autoPaintSliceData,
printableFeatureSimulation,
@@ -567,6 +607,18 @@ export default function ThreeDControls({
return (
+ {profileReady && onDesktopFileFinished && (
+
0)
+ }
+ importText={profileManager.importText}
+ onImported={() => setPaintMode('autopaint')}
+ onFinished={onDesktopFileFinished}
+ />
+ )}
{/* Apply button */}
@@ -640,6 +692,9 @@ export default function ThreeDControls({
{/* Auto-paint Tab */}
) : (
(null);
const [isBuilding, setIsBuilding] = useState(false);
- const [activeBuildSmoothMeshing, setActiveBuildSmoothMeshing] = useState(smoothMeshing);
+ const [buildError, setBuildError] = useState(null);
+ const [activeBuildSmoothMeshingStrength, setActiveBuildSmoothMeshingStrength] =
+ useState(smoothMeshingStrength);
const [buildProgress, setBuildProgress] = useState(0);
const [buildOverlayStep, setBuildOverlayStep] = useState(null);
const [modelDimensions, setModelDimensions] = useState<{
@@ -576,15 +586,7 @@ export default function ThreeDView({
const modelGroup = modelGroupRef.current;
if (!modelGroup || previewHeight === null) return;
- const minHeight = Math.min(previewMinHeight, previewHeight);
- const maxHeight = Math.max(previewMinHeight, previewHeight);
- modelGroup.traverse((child) => {
- if (child instanceof THREE.Mesh && child.userData.baseZ !== undefined) {
- const baseZ = child.userData.baseZ as number;
- const topZ = (child.userData.topZ as number | undefined) ?? baseZ;
- child.visible = topZ > minHeight && baseZ < maxHeight;
- }
- });
+ applyPreviewLayerRange(modelGroup, previewMinHeight, previewHeight);
syncWireframeOverlayVisibility();
requestRender();
@@ -596,6 +598,15 @@ export default function ThreeDView({
syncWireframeOverlayVisibility,
]);
+ const flatPreviewHeights = useMemo(
+ () => previewLayerHeights(maxModelHeight, layerHeight, slicerFirstLayerHeight),
+ [maxModelHeight, layerHeight, slicerFirstLayerHeight]
+ );
+ const flatPreviewIndex = (height: number) => {
+ const index = flatPreviewHeights.findIndex((boundary) => boundary >= height - 1e-6);
+ return index < 0 ? flatPreviewHeights.length - 1 : index;
+ };
+
const snapPreviewHeight = (value: number): number => {
const bounded = Math.max(0, Math.min(maxModelHeight, value));
if (layerHeight <= 0) return bounded;
@@ -690,8 +701,12 @@ export default function ThreeDView({
};
const handlePreviewRangeChange = (value: number[]) => {
- const low = snapPreviewHeight(value[0] ?? 0);
- const high = snapPreviewHeight(value[1] ?? maxModelHeight);
+ const low = flatPaint
+ ? (flatPreviewHeights[value[0]] ?? 0)
+ : snapPreviewHeight(value[0] ?? 0);
+ const high = flatPaint
+ ? (flatPreviewHeights[value[1]] ?? maxModelHeight)
+ : snapPreviewHeight(value[1] ?? maxModelHeight);
setPreviewMinHeight(Math.min(low, high));
setPreviewHeight(Math.max(low, high));
};
@@ -699,7 +714,6 @@ export default function ThreeDView({
// 2. Rebuild mesh geometry only when the parent sends an explicit build signal.
const buildTokenRef = useRef(0);
const debounceTimerRef = useRef(null);
- const lastParamsKeyRef = useRef(null);
const lastRebuildRef = useRef(rebuildSignal);
const lastImageSrcRef = useRef(imageSrc);
@@ -727,8 +741,8 @@ export default function ThreeDView({
lastImageSrcRef.current = imageSrc;
if (!imageSrc) {
+ setBuildError(null);
buildTokenRef.current++;
- lastParamsKeyRef.current = null;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -747,8 +761,8 @@ export default function ThreeDView({
}
if (imageChanged) {
+ setBuildError(null);
buildTokenRef.current++;
- lastParamsKeyRef.current = null;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -771,7 +785,6 @@ export default function ThreeDView({
// Don't build if there are no layers configured
if (!colorOrder || colorOrder.length === 0 || !swatches || swatches.length === 0) {
buildTokenRef.current++;
- lastParamsKeyRef.current = null;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -784,52 +797,26 @@ export default function ThreeDView({
return;
}
- const buildSmoothMeshing = smoothMeshing && !flatPaint;
-
- // Stable key of inputs to avoid duplicate builds when references unchanged
- const paramsKey = JSON.stringify({
- imageSrc,
- baseSliceHeight,
- layerHeight,
- slicerFirstLayerHeight,
- colorSliceHeights,
- colorOrder,
- swatches: swatches.map((s) => s.hex),
- // Filament colors shape Flat Paint geometry (zone merging + export groups)
- filamentSwatches: filamentSwatches?.map((s) => s.hex),
- pixelSize,
- heightScale,
- stepped,
- pixelColumns,
- autoPaintEnabled,
- autoPaintTotalHeight,
- autoPaintFilamentOrder, // Include filament order to detect optimizer changes
- autoPaintFinalStackFingerprint: autoPaintFinalStack?.fingerprint,
- enhancedColorMatch,
- preserveSeparation,
- separationMaxDeltaE,
- heightDithering,
- ditherLineWidth,
- printableFeatureFingerprint: printableFeaturePixels?.fingerprint,
- smoothMeshing,
- flatPaint,
- flatPaintFaceUp,
- });
- if (paramsKey === lastParamsKeyRef.current) {
- onBuildStarted?.();
- return; // nothing changed logically
- }
- lastParamsKeyRef.current = paramsKey;
+ const buildSmoothMeshingStrength = smoothMeshingStrength;
+ const buildSmoothMeshing = buildSmoothMeshingStrength !== 'none';
- // Debounce rapid changes (e.g., dragging slider)
+ // The build signal already guards against incidental rerenders. Every
+ // explicit Build must regenerate geometry and reset the inspection cut,
+ // even with identical settings (including after a development update).
+ // Coalesce rapidly repeated build requests.
if (debounceTimerRef.current !== null) window.clearTimeout(debounceTimerRef.current);
const token = ++buildTokenRef.current;
- setActiveBuildSmoothMeshing(buildSmoothMeshing);
+ setActiveBuildSmoothMeshingStrength(buildSmoothMeshingStrength);
debounceTimerRef.current = window.setTimeout(() => {
debounceTimerRef.current = null;
const buildStartedAt = performance.now();
// mark that a build is in progress for the overlay
setIsBuilding(true);
+ setBuildError(null);
+ setModelDimensions(null);
+ setMaxModelHeight(0);
+ setPreviewMinHeight(0);
+ setPreviewHeight(null);
onBuildStarted?.();
pushProgress(0);
setBuildOverlayStep(null);
@@ -840,7 +827,7 @@ export default function ThreeDView({
pixelSize,
layerHeight,
slicerFirstLayerHeight,
- smoothMeshing: buildSmoothMeshing,
+ smoothMeshingStrength: buildSmoothMeshingStrength,
autoPaintEnabled,
enhancedColorMatch,
heightDithering,
@@ -889,6 +876,7 @@ export default function ThreeDView({
const YIELD_MS = 12;
let lastYield = performance.now();
const meshBuildMetrics: E2ELayerBuildMetrics[] = [];
+ let flatModelHeight: number | undefined;
const buildStepCount = Math.max(1, colorOrder.length + 1);
const pushScanDetail = (label: string, progress: number) => {
pushBuildOverlayStep({
@@ -1160,18 +1148,20 @@ export default function ThreeDView({
});
}
- // --- Pass 1: Compute continuous (un-snapped) heights ---
- // We deliberately do NOT snap to the layer grid here.
- // The RGB cache is still valid because it stores the ideal
- // continuous height; dithering happens spatially in Pass 2.
- // Shared final-stack mappings seed every source color so
- // optimizer scoring and preview choose the same printable
- // layer. Recomputed assignments only fill missing source colors.
+ // --- Pass 1: Compute per-color height targets ---
+ // Keep fractional targets until spatial dithering in Pass 2.
+ // Without dithering, preserve the shared mapper's discrete
+ // choices. With dithering, recover fractional targets only
+ // between adjacent layers around those choices. Otherwise
+ // this cache bypasses projection and makes dithering a no-op.
const colorHeightCache = autoPaintFinalStack
? createFinalStackTargetHeightCache(
autoPaintFinalStack.targetMappings,
minModelH,
- maxModelH
+ maxModelH,
+ heightDithering && !preserveSeparation
+ ? { palette: autoPaintFinalStack.palette, layerHeight }
+ : undefined
)
: new Map();
for (const [key, height] of separationHeights) {
@@ -1269,187 +1259,15 @@ export default function ThreeDView({
);
}
- // --- Pass 2: Quantize heights (with optional dithering) ---
- // The continuous height map has sub-layer precision, but
- // the 3D model must use discrete layer heights. When
- // heightDithering is ON, block-aware Stucki error
- // diffusion produces dots sized to the printer's line width
- // so the dither pattern is actually printable. Edges
- // between different quantized heights are protected from
- // dithering to avoid staircase artifacts that expose wrong
- // colors. When OFF, simple rounding is used.
- if (layerHeight > 0 && heightDithering) {
- // --- Step 2a: Snap everything to the grid first ---
- const snappedMap = new Float32Array(boxW * boxH);
- for (let mi = 0; mi < boxW * boxH; mi++) {
- const h = pixelHeightMap[mi];
- if (h <= 0) {
- snappedMap[mi] = 0;
- continue;
- }
- const delta = Math.max(0, h - slicerFirstLayerHeight);
- let s =
- slicerFirstLayerHeight +
- Math.round(delta / layerHeight) * layerHeight;
- s = Math.max(minModelH, Math.min(maxModelH, s));
- snappedMap[mi] = s;
- }
-
- // --- Step 2b: Identify edge pixels ---
- // A pixel is on an edge if any of its 4-connected
- // neighbors has a different snapped height. Dithering
- // these would create jagged staircases that expose the
- // wrong filament color, so we leave them at their
- // nearest-round height.
- const isEdge = new Uint8Array(boxW * boxH);
- for (let y = 0; y < boxH; y++) {
- for (let x = 0; x < boxW; x++) {
- const mi = y * boxW + x;
- const sh = snappedMap[mi];
- if (sh <= 0) continue;
- if (
- (x > 0 &&
- snappedMap[mi - 1] > 0 &&
- snappedMap[mi - 1] !== sh) ||
- (x < boxW - 1 &&
- snappedMap[mi + 1] > 0 &&
- snappedMap[mi + 1] !== sh) ||
- (y > 0 &&
- snappedMap[mi - boxW] > 0 &&
- snappedMap[mi - boxW] !== sh) ||
- (y < boxH - 1 &&
- snappedMap[mi + boxW] > 0 &&
- snappedMap[mi + boxW] !== sh)
- ) {
- isEdge[mi] = 1;
- }
- }
- }
-
- // --- Step 2c: Block-aware Stucki error diffusion ---
- // The block size ensures dither dots are at least as
- // wide as the printer's line width (in pixels).
- const blockSize = Math.max(1, Math.round(ditherLineWidth / pixelSize));
- const bW = Math.ceil(boxW / blockSize);
- const bH = Math.ceil(boxH / blockSize);
-
- // Compute average continuous height per block
- const blockAvg = new Float64Array(bW * bH);
- const blockCnt = new Uint32Array(bW * bH);
- const blockHasEdge = new Uint8Array(bW * bH);
- for (let y = 0; y < boxH; y++) {
- for (let x = 0; x < boxW; x++) {
- const mi = y * boxW + x;
- const h = pixelHeightMap[mi]; // still continuous
- if (h <= 0) continue;
- const bx = Math.floor(x / blockSize);
- const by = Math.floor(y / blockSize);
- const bi = by * bW + bx;
- blockAvg[bi] += h;
- blockCnt[bi]++;
- if (isEdge[mi]) blockHasEdge[bi] = 1;
- }
- }
- for (let bi = 0; bi < bW * bH; bi++) {
- if (blockCnt[bi] > 0) blockAvg[bi] /= blockCnt[bi];
- }
-
- // Dither at block level using the Stucki kernel: a
- // wider, error-conserving diffusion than Floyd-
- // Steinberg for smoother tone and finer detail.
- // Offsets are scan-relative (dx along the serpentine
- // direction, dy downward); weights are pre-divided by 42.
- const STUCKI_KERNEL: ReadonlyArray =
- [
- [1, 0, 8 / 42],
- [2, 0, 4 / 42],
- [-2, 1, 2 / 42],
- [-1, 1, 4 / 42],
- [0, 1, 8 / 42],
- [1, 1, 4 / 42],
- [2, 1, 2 / 42],
- [-2, 2, 1 / 42],
- [-1, 2, 2 / 42],
- [0, 2, 4 / 42],
- [1, 2, 2 / 42],
- [2, 2, 1 / 42],
- ];
- const errBuf = new Float64Array(bW * bH);
- const blockSnapped = new Float32Array(bW * bH);
-
- for (let by = 0; by < bH; by++) {
- const ltr = by % 2 === 0;
- for (let bxi = 0; bxi < bW; bxi++) {
- const bx = ltr ? bxi : bW - 1 - bxi;
- const bi = by * bW + bx;
- if (blockCnt[bi] === 0) continue;
-
- let snapped: number;
- if (blockHasEdge[bi]) {
- // Edge block: no dithering, use simple snap
- const delta = Math.max(
- 0,
- blockAvg[bi] - slicerFirstLayerHeight
- );
- snapped =
- slicerFirstLayerHeight +
- Math.round(delta / layerHeight) * layerHeight;
- } else {
- const adjusted = blockAvg[bi] + errBuf[bi];
- const delta = Math.max(
- 0,
- adjusted - slicerFirstLayerHeight
- );
- snapped =
- slicerFirstLayerHeight +
- Math.round(delta / layerHeight) * layerHeight;
- }
- snapped = Math.max(minModelH, Math.min(maxModelH, snapped));
- blockSnapped[bi] = snapped;
-
- if (!blockHasEdge[bi]) {
- const err = blockAvg[bi] + errBuf[bi] - snapped;
- const dir = ltr ? 1 : -1;
- for (let k = 0; k < STUCKI_KERNEL.length; k++) {
- const [dx, dy, weight] = STUCKI_KERNEL[k];
- const tx = bx + dir * dx;
- const ty = by + dy;
- if (tx < 0 || tx >= bW || ty >= bH) continue;
- errBuf[ty * bW + tx] += err * weight;
- }
- }
- }
- }
-
- // Write block-level results back to pixel map
- for (let y = 0; y < boxH; y++) {
- for (let x = 0; x < boxW; x++) {
- const mi = y * boxW + x;
- if (pixelHeightMap[mi] <= 0) continue;
- const bx = Math.floor(x / blockSize);
- const by = Math.floor(y / blockSize);
- const bi = by * bW + bx;
- if (blockHasEdge[bi]) {
- // Edge blocks: use per-pixel snap
- pixelHeightMap[mi] = snappedMap[mi];
- } else {
- pixelHeightMap[mi] = blockSnapped[bi];
- }
- }
- }
- } else if (layerHeight > 0) {
- // Simple grid snap without error diffusion
- for (let mi = 0; mi < boxW * boxH; mi++) {
- const h = pixelHeightMap[mi];
- if (h <= 0) continue;
- const delta = Math.max(0, h - slicerFirstLayerHeight);
- let snapped =
- slicerFirstLayerHeight +
- Math.round(delta / layerHeight) * layerHeight;
- snapped = Math.max(minModelH, Math.min(maxModelH, snapped));
- pixelHeightMap[mi] = snapped;
- }
- }
+ quantizeHeightMap(pixelHeightMap, boxW, boxH, {
+ layerHeight,
+ slicerFirstLayerHeight,
+ minModelH,
+ maxModelH,
+ heightDithering: heightDithering && !preserveSeparation,
+ ditherLineWidth,
+ pixelSize,
+ });
} else {
// === STANDARD: Luminance-based mapping ===
// First, find the luminance range of the image
@@ -1555,6 +1373,7 @@ export default function ThreeDView({
});
const partCount = Math.max(1, layout.parts.length);
+ flatModelHeight = layout.totalHeight;
const scanSpanEnd = 1 / (colorOrder.length + 1);
const pushPartDetail = (
partIndex: number,
@@ -1563,7 +1382,7 @@ export default function ThreeDView({
) => {
const stepProgress = clampProgress(progress);
pushBuildOverlayStep({
- stepLabel: `Flat Paint part ${partIndex + 1} of ${partCount}: ${label}`,
+ stepLabel: `Flat Paint mesh part ${partIndex + 1} of ${partCount}: ${label}`,
stepIndex: Math.min(partCount + 1, partIndex + 2),
stepCount: partCount + 1,
stepProgress,
@@ -1577,6 +1396,14 @@ export default function ThreeDView({
);
};
+ const meshFlatMask = await createFlatPaintMesher(
+ orientedCounts,
+ boxW,
+ boxH,
+ pixelSize,
+ buildSmoothMeshingStrength
+ );
+ if (token !== buildTokenRef.current) return;
const flatMeshCache = new WeakMap>();
const partIdxForProgress = (part: (typeof layout.parts)[number]) =>
layout.parts.indexOf(part);
@@ -1584,25 +1411,16 @@ export default function ThreeDView({
const cached = flatMeshCache.get(part.mask);
if (cached) return cached;
- const promise = generateGreedyMesh(
- part.mask,
- boxW,
- boxH,
- 1,
- 0,
- pixelSize,
- 1,
- {
- yieldIntervalMs: 8,
- onProgress: (progress: MeshProgress) => {
- pushPartDetail(
- partIdxForProgress(part),
- progress.label,
- progressInSpan(0, 0.9, progress.progress)
- );
- },
- }
- );
+ const promise = meshFlatMask(part.mask, {
+ yieldIntervalMs: 8,
+ onProgress: (progress: MeshProgress) => {
+ pushPartDetail(
+ partIdxForProgress(part),
+ progress.label,
+ progressInSpan(0, 0.9, progress.progress)
+ );
+ },
+ });
flatMeshCache.set(part.mask, promise);
return promise;
};
@@ -1612,9 +1430,7 @@ export default function ThreeDView({
if (token !== buildTokenRef.current) return;
if (part.activeCount === 0) continue;
- // Flat Paint always uses the greedy mesher: smoothed
- // boundaries would open gaps between side-by-side
- // color regions inside the slab.
+ // Reuse the shared XY tessellation at each slab Z range.
const generatedMesh = remapMeshZRange(
await getFlatMaskMesh(part),
part.baseZ,
@@ -1639,7 +1455,7 @@ export default function ThreeDView({
height: boxH,
pixelSize,
topZ: part.topZ * heightScale,
- compactHeightfield: true,
+ compactHeightfield: false,
}
);
const isCarrier = part.kind === 'carrier';
@@ -1657,6 +1473,7 @@ export default function ThreeDView({
// Store slab Z range for the preview slider
mesh.userData.baseZ = part.baseZ;
mesh.userData.topZ = part.topZ;
+ mesh.userData.kromacutFlatPaintHeightScale = heightScale;
// Export metadata: one 3MF object per physical filament
mesh.userData.kromacutExportGroup = part.exportGroup;
mesh.userData.kromacutFilamentHex = part.filamentHex;
@@ -1746,6 +1563,7 @@ export default function ThreeDView({
buildSmoothMeshing ? generateSmoothMesh : generateGreedyMesh
)(activePixels, boxW, boxH, thickness, baseZ, pixelSize, heightScale, {
yieldIntervalMs: 8,
+ strength: buildSmoothMeshingStrength,
onProgress: meshProgressReporter(buildLayerIndex),
});
meshBuildMetrics.push({
@@ -1922,6 +1740,7 @@ export default function ThreeDView({
buildSmoothMeshing ? generateSmoothMesh : generateGreedyMesh
)(activePixels, boxW, boxH, thickness, baseZ, pixelSize, heightScale, {
yieldIntervalMs: 8,
+ strength: buildSmoothMeshingStrength,
onProgress: meshProgressReporter(buildLayerIndex),
});
meshBuildMetrics.push({
@@ -2033,9 +1852,9 @@ export default function ThreeDView({
};
setModelDimensions(nextModelDimensions);
// Set max height for layer preview slider
- setMaxModelHeight(box.max.z);
+ setMaxModelHeight(flatModelHeight ?? box.max.z);
setPreviewMinHeight(0);
- setPreviewHeight(box.max.z); // Start at full height
+ setPreviewHeight(flatModelHeight ?? box.max.z); // Start at full height
setHoveredSegment(null);
updateE2EBuild({
status: 'complete',
@@ -2053,7 +1872,7 @@ export default function ThreeDView({
pixelSize,
layerHeight,
slicerFirstLayerHeight,
- smoothMeshing: buildSmoothMeshing,
+ smoothMeshingStrength: buildSmoothMeshingStrength,
autoPaintEnabled,
enhancedColorMatch,
heightDithering,
@@ -2107,9 +1926,8 @@ export default function ThreeDView({
pushProgress(1);
};
- const finishCurrentBuild = (failed = false) => {
+ const finishCurrentBuild = () => {
if (token !== buildTokenRef.current) return;
- if (failed) lastParamsKeyRef.current = null;
setIsBuilding(false);
};
@@ -2117,8 +1935,7 @@ export default function ThreeDView({
const img = await loadImage();
if (token !== buildTokenRef.current) return;
if (!img) {
- finishCurrentBuild(true);
- return;
+ throw new Error('Unable to load the model image');
}
const w = img.naturalWidth;
const h = img.naturalHeight;
@@ -2128,8 +1945,7 @@ export default function ThreeDView({
c.height = h;
const cx = c.getContext('2d');
if (!cx) {
- finishCurrentBuild(true);
- return;
+ throw new Error('Unable to read the model image');
}
cx.drawImage(img, 0, 0, w, h);
const originalImageData = cx.getImageData(0, 0, w, h).data;
@@ -2186,7 +2002,32 @@ export default function ThreeDView({
})().catch((buildError) => {
if (token !== buildTokenRef.current) return;
console.error('[ThreeDView] build failed:', buildError);
- finishCurrentBuild(true);
+ // Parts are rendered progressively. Never leave an unfinished
+ // backing on screen or expose it as a completed export.
+ clearWireframeOverlay();
+ modelGroup.traverse((object) => {
+ if (!(object instanceof THREE.Mesh)) return;
+ object.geometry.dispose();
+ const materials = Array.isArray(object.material)
+ ? object.material
+ : [object.material];
+ materials.forEach((material) => material.dispose());
+ });
+ modelGroup.clear();
+ clearLastMeshRef();
+ setModelDimensions(null);
+ setMaxModelHeight(0);
+ setPreviewMinHeight(0);
+ setPreviewHeight(null);
+ const error = buildError instanceof Error ? buildError.message : String(buildError);
+ setBuildError(error);
+ updateE2EBuild({
+ status: 'failed',
+ error,
+ elapsedMs: performance.now() - buildStartedAt,
+ });
+ requestRender();
+ finishCurrentBuild();
});
}, 120);
}, [
@@ -2213,7 +2054,7 @@ export default function ThreeDView({
heightDithering,
ditherLineWidth,
printableFeaturePixels,
- smoothMeshing,
+ smoothMeshingStrength,
flatPaint,
flatPaintFaceUp,
cameraRef,
@@ -2233,8 +2074,15 @@ export default function ThreeDView({
previewHeight !== null && previewMinHeight > 0.0001
? `${previewMinHeight.toFixed(2)} - ${previewHeight.toFixed(2)} mm`
: `${(previewHeight ?? 0).toFixed(2)} mm`;
- const previewMinPercent = heightToPercent(previewMinHeight, maxModelHeight);
- const previewMaxPercent = heightToPercent(previewHeight ?? maxModelHeight, maxModelHeight);
+ const previewMinPercent = flatPaint
+ ? heightToPercent(flatPreviewIndex(previewMinHeight), flatPreviewHeights.length - 1)
+ : heightToPercent(previewMinHeight, maxModelHeight);
+ const previewMaxPercent = flatPaint
+ ? heightToPercent(
+ flatPreviewIndex(previewHeight ?? maxModelHeight),
+ flatPreviewHeights.length - 1
+ )
+ : heightToPercent(previewHeight ?? maxModelHeight, maxModelHeight);
const previewSelectionLeft = Math.min(previewMinPercent, previewMaxPercent);
const previewSelectionRight = Math.max(previewMinPercent, previewMaxPercent);
const previewSelectionClip = `inset(0 ${sliderRightInsetPercentCss(
@@ -2243,10 +2091,22 @@ export default function ThreeDView({
return (
+ {buildError && (
+
+
{t('threeDView.buildFailed')}
+
+ {t('threeDView.buildFailedDetails', { error: buildError })}
+
+
+ )}
{isBuilding && (
)}
{/* Layer Preview Slider */}
@@ -2316,11 +2178,18 @@ export default function ThreeDView({
)}
diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md
index ee8a2022..7fc9d6b4 100644
--- a/src/docs/3d-mode.md
+++ b/src/docs/3d-mode.md
@@ -74,7 +74,16 @@ Manual controls and swap instructions support **64 colors**. Reduce larger palet
## Smooth Meshing
-With smoothing off, outlines follow the square pixel grid. With it on, connected boundaries are smoothed into welded geometry. The difference is exported, not a preview filter.
+Choose a smoothing strength for the physical model. The selected strength applies to the 3D preview, STL, and 3MF when you click **Build 3D Model**.
+
+| Strength | Use it for |
+| --- | --- |
+| **None** | Pixel art, exact square-grid edges, or the fastest generation. Matches the previous disabled setting. |
+| **Minimal** | A light cleanup of jagged corners while retaining more pixel detail. |
+| **Medium** | The familiar result from the previous enabled setting. |
+| **Aggressive** | Stronger smoothing along curved or diagonal outlines, including remaining stair steps. Keeps the same bounded movement as Medium. |
+
+All smoothing presets keep boundary movement below half a source pixel and preserve welded caps and walls. Smoothing changes exported geometry; it does not add image detail or repair unprintably small features. Rebuild and inspect the slicer preview after changing the strength. Existing saved on/off settings load as Medium/None.

@@ -82,7 +91,7 @@ _Schematic contour comparison, not a slicer simulation._
Use it for curved or diagonal outlines that look too stair-stepped. Leave it off for deliberate pixel art or exact grid edges. Neither choice repairs unprintably small details or invents missing source resolution.
-Smooth Meshing is inactive during [Flat Paint](flat-paint). Turning Smooth Meshing on disables Flat Paint; Flat Paint uses its full-footprint slab construction instead.
+Smooth Meshing also works with [Flat Paint](flat-paint). Both orientations keep a flat slab and shared color boundaries; choose a strength and rebuild to apply it.
## Auto-paint
diff --git a/src/docs/auto-paint.md b/src/docs/auto-paint.md
index 85f88b2f..39e1de30 100644
--- a/src/docs/auto-paint.md
+++ b/src/docs/auto-paint.md
@@ -135,7 +135,7 @@ Height dithering can distribute rounding error across small blocks at neighborin
_Schematic mechanism, not a guaranteed before-and-after result. Different top heights do not mean additional spool colors._
-Many current Auto-paint mappings already select a discrete printable layer before this step. Those already-snapped regions have no fractional-height error to distribute and may remain unchanged when dithering is enabled. Use the rebuilt preview and slicer to check whether this particular image actually gains height variation; enabling the toggle does not guarantee more tones or visible dots.
+With dithering enabled, Auto-paint looks for an intermediate height between the selected layer and an adjacent printable layer when their blend improves the predicted match. Exact color matches and calibrated target matches stay at their selected heights; regions with no useful neighboring blend remain unchanged. Rebuild the model after changing the setting, then compare the preview and slicer output. Turning dithering off restores ordinary discrete matching.
Dot size follows **Effective line width** in **3D Print Settings** relative to **Pixel Size**, rounded to a whole-pixel block size. This is approximate, not an exact minimum-width guarantee. Edge regions avoid the same dithering treatment to reduce boundary artifacts. Check the slicer for tiny islands and extra travel.
diff --git a/src/docs/flat-paint.md b/src/docs/flat-paint.md
index b1372fa8..1502f75d 100644
--- a/src/docs/flat-paint.md
+++ b/src/docs/flat-paint.md
@@ -15,6 +15,10 @@ Use a multi-material workflow such as AMS, CFS, or a toolchanger with suitable s
_Conceptual cross-sections. Colors identify materials; the labels identify the viewing face._
+The build progress counts mesh parts, not printed layers: several colored parts can share one layer. The Model badge shows the physical layer count, including the clear carrier when used. Layer Preview cuts the slab one physical layer at a time; both handles also cut through merged backing and color regions. Export always includes the complete model. Build 3D Model always regenerates the model, even with unchanged settings, and restores the full layer range and initial camera view.
+
+If a build fails, Kromacut removes the incomplete model and displays the error in the preview. Press Build 3D Model to retry; a successful build restores the model dimensions and Layer Preview.
+
## Default: Face-down With Clear Carrier
Enable **Flat Paint** and leave **Face-up, no clear layer** off.
@@ -47,7 +51,7 @@ These layouts have different geometry. **Build 3D Model** again after changing t
| Flat Paint, default | Bottom through clear carrier | Mirrored, reversed columns with foundation behind. | Per-filament objects plus carrier. |
| Flat Paint, face-up | Exposed flat top | Normal order with foundation below shorter columns. | Per-filament objects; no carrier. |
-Flat Paint and **Smooth Meshing** do not operate together. The slab uses full-footprint side-by-side regions instead of smoothed contours. Enabling Smooth Meshing disables Flat Paint; select the desired layout and rebuild when switching.
+**Smooth Meshing** works with both Flat Paint orientations. Choose None, Minimal, Medium, or Aggressive and rebuild. Smoothing softens the outer silhouette and shared color boundaries while keeping the slab flat, its layer heights, and its material stacks. Diagonal corner contacts use small shared joins so regions stay closed without overlapping. Print instructions and 3MF record the built strength.
## Export And Check
diff --git a/src/docs/generating-exporting-output.md b/src/docs/generating-exporting-output.md
index 57ce8a69..b115b3b8 100644
--- a/src/docs/generating-exporting-output.md
+++ b/src/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ An Auto-paint preview may show dozens of blended colors made from only a few rea
STL does not carry filament colors or automatic spool assignments. Use the copied swap plan with the slicer's color-change controls. A 3MF is still a model, not ready-to-run G-code: choose your own printer, nozzle, filament profiles, temperatures and speeds, then slice it.
-For **Flat Paint** models the download menu offers only 3MF: the model contains one object per physical filament and, in the default face-down layout, a transparent carrier object. The optional face-up layout omits that carrier. An uncolored single-geometry STL of either flat slab would be useless. Flat Paint turns off **Smooth Meshing** because the flat slab layout does not use smoothed boundary contours.
+For **Flat Paint** models the download menu offers only 3MF: the model contains one object per physical filament and, in the default face-down layout, a transparent carrier object. The optional face-up layout omits that carrier. An uncolored single-geometry STL of either flat slab would be useless. Both orientations support every **Smooth Meshing** strength. Rebuild after changing the strength to apply it to the preview and exported geometry.
## Print Instructions
diff --git a/src/docs/quick-start.md b/src/docs/quick-start.md
index 6fe3c734..3ba1e189 100644
--- a/src/docs/quick-start.md
+++ b/src/docs/quick-start.md
@@ -49,7 +49,7 @@ Click **3D**. Set the print basics first:
- **Pixel Size (XY)** controls the physical width and depth of each image pixel.
- **Layer Height** should match the slicer layer height you plan to use.
- **First Layer Height** should match your slicer first-layer setting.
-- **Smooth Meshing** can soften connected color boundaries for smoother geometry.
+- **Smooth Meshing** offers None, Minimal, Medium, and Aggressive strengths for model outlines; Medium preserves the original smoothing.
## Choose Manual Or Auto-paint
diff --git a/src/docs/settings-and-controls.md b/src/docs/settings-and-controls.md
index b9eb4cf9..6d53f2cb 100644
--- a/src/docs/settings-and-controls.md
+++ b/src/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ The **Language** selector changes the interface, documentation, diagrams, and pu
Public pages also offer a language selector. Translated documentation uses shareable language-prefixed links, such as `/ro/docs/overview`. Page names and section anchors remain stable across languages. File extensions, numeric model data, user-entered names, and original community artwork titles are not translated.
-The settings dialog includes links to the documentation, Discord, Reddit, GitHub, and Patreon, and shows the current Kromacut version.
+The settings dialog includes links to the documentation, Discord, Reddit, GitHub, and Patreon, and shows the current Kromacut version. **Privacy & local data** and **Terms & conditions** are also available here and open in your browser in the selected language.
## Workspace Modes
@@ -123,4 +123,16 @@ A completed trace includes basic runtime information, the active filament and ca
Recording is intended for investigations and may create large files. Leave it disabled for ordinary printing when you do not need a trace.
+## Opening files from your desktop
+
+Desktop installations associate `.kfil` and legacy `.kapp` files with filament profiles, and `.kpal` files with palettes. Double-click a file to import and select it in Kromacut. If the app is already running, the file opens in its existing window. Normal validation, migration, duplicate handling, and calibration-preservation rules apply.
+
+Files opened from your desktop wait while a palette editor, calibration dialog, or profile Rename or Save New form is open. Finish or cancel that session to continue the queued imports; typed names and the profile being edited stay unchanged until then.
+
+Imports also wait while you edit a filament name or HD field, or while its color picker or **Convert from TD** popover is open. Leave the field or close the picker or popover to resume the queued imports; unsaved filament changes still require your choice before another profile replaces them.
+
+Before replacing unsaved filament edits, Kromacut offers **Keep edits** or **Open profile**. Keep your edits to save them first; opening the profile discards those edits. Files opened this way must be smaller than 32 MiB. Generic JSON, images, and models retain their usual import workflows.
+
+On Linux, portable AppImages require desktop integration for file associations. If your system has not selected Kromacut as the default, use **Open with** in your file manager.
+
Next: [Troubleshooting](troubleshooting).
diff --git a/src/docs/troubleshooting.md b/src/docs/troubleshooting.md
index 1837c777..a1ea6cc6 100644
--- a/src/docs/troubleshooting.md
+++ b/src/docs/troubleshooting.md
@@ -55,7 +55,7 @@ Check the **Appearance model** row separately. A high summary score does not gua
## A Toggle Turned Another Toggle Off
-Two pairs are intentionally exclusive: **Smooth Meshing** and **Flat Paint**, and **Preserve color separation** and **Height dithering**. The first pair chooses different geometry layouts; the second chooses different ways to assign source colors to printable heights. See [Flat Paint](flat-paint) and [Auto-paint](auto-paint).
+**Preserve color separation** and **Height dithering** are intentionally exclusive because they assign source colors to printable heights differently. **Smooth Meshing** and **Flat Paint** can be used together; rebuild after changing either setting. See [Flat Paint](flat-paint) and [Auto-paint](auto-paint).
## Color Separation Cannot Find A Result
@@ -79,7 +79,7 @@ Large images, many colors, many layers, and smooth meshing all increase build ti
- Cropping the image.
- Reducing color count.
-- Turning off **Smooth Meshing**.
+- Selecting **None** for **Smooth Meshing**.
- Downsampling the image with **Resize Image**. Merely lowering Pixel Size makes the same mesh smaller, not simpler.
- Simplifying Auto-paint options.
diff --git a/src/hooks/useDesktopFileOpen.ts b/src/hooks/useDesktopFileOpen.ts
new file mode 100644
index 00000000..471ea03b
--- /dev/null
+++ b/src/hooks/useDesktopFileOpen.ts
@@ -0,0 +1,25 @@
+import { useState, useSyncExternalStore } from 'react';
+import { invoke, isTauri } from '@tauri-apps/api/core';
+import { listen } from '@tauri-apps/api/event';
+import { createDesktopFileInbox, type DesktopOpenedFile } from '../lib/desktopFileInbox';
+
+let inbox: ReturnType | undefined;
+const emptySnapshot = () => null;
+const emptySubscribe = () => () => {};
+const emptyFinish = () => {};
+
+export function useDesktopFileOpen() {
+ const [desktop] = useState(isTauri);
+ if (desktop && !inbox) {
+ inbox = createDesktopFileInbox({
+ listen: (onFiles) => listen('kromacut-opened-files', onFiles),
+ take: () => invoke('take_opened_file'),
+ });
+ }
+ const file = useSyncExternalStore(
+ inbox?.subscribe ?? emptySubscribe,
+ inbox?.getSnapshot ?? emptySnapshot,
+ emptySnapshot
+ );
+ return { file, finish: inbox?.finish ?? emptyFinish };
+}
diff --git a/src/hooks/usePaletteManager.ts b/src/hooks/usePaletteManager.ts
index 70b88252..ff5d4c51 100644
--- a/src/hooks/usePaletteManager.ts
+++ b/src/hooks/usePaletteManager.ts
@@ -34,9 +34,7 @@ function toPalette(cp: CustomPalette): Palette & { custom: true; totalColors: nu
size: enabled.length,
custom: true,
totalColors: cp.colors.length,
- ...(cp.colorNames
- ? { colorNames: cp.colorNames.filter((_, i) => !disabled.has(i)) }
- : {}),
+ ...(cp.colorNames ? { colorNames: cp.colorNames.filter((_, i) => !disabled.has(i)) } : {}),
};
}
@@ -49,9 +47,16 @@ const paletteStorageFailure = (action: string) =>
`${action} could not be saved. Existing palettes were left unchanged; free browser storage and try again.`;
export function usePaletteManager() {
- const [customPalettes, setCustomPalettes] = useState(() =>
+ const [customPalettes, setCustomPalettesState] = useState(() =>
loadCustomPalettes(RESERVED_PALETTE_IDS)
);
+ const customPalettesRef = useRef(customPalettes);
+ // Successful writes update the import snapshot synchronously, before React renders.
+ // A delayed FileReader must merge with every palette saved since its read began.
+ const setCustomPalettes = useCallback((updated: CustomPalette[]) => {
+ customPalettesRef.current = updated;
+ setCustomPalettesState(updated);
+ }, []);
const [importFeedback, setImportFeedback] = useState(null);
const importInputRef = useRef(null);
@@ -95,7 +100,7 @@ export function usePaletteManager() {
// Auto-select the new palette
setSelectedPalette(newPal.id);
},
- [customPalettes, setSelectedPalette]
+ [customPalettes, setCustomPalettes, setSelectedPalette]
);
const handleUpdatePalette = useCallback(
@@ -116,7 +121,7 @@ export function usePaletteManager() {
setCustomPalettes(updated);
setImportFeedback(null);
},
- [customPalettes]
+ [customPalettes, setCustomPalettes]
);
const handleClonePalette = useCallback(
@@ -152,7 +157,7 @@ export function usePaletteManager() {
setSelectedPalette(clone.id);
setImportFeedback(`Cloned as "${clone.name}"`);
},
- [customPalettes, setSelectedPalette]
+ [customPalettes, setCustomPalettes, setSelectedPalette]
);
const handleDeletePalette = useCallback(
@@ -168,7 +173,7 @@ export function usePaletteManager() {
setSelectedPalette('auto');
}
},
- [customPalettes, selectedPalette, setSelectedPalette]
+ [customPalettes, selectedPalette, setCustomPalettes, setSelectedPalette]
);
// ---- Import / Export ----
@@ -190,44 +195,56 @@ export function usePaletteManager() {
[customPalettes]
);
+ const importText = useCallback(
+ (content: string, selectExisting = false): boolean => {
+ const incoming = parseCustomPaletteFile(content.replace(/^\uFEFF/, ''));
+ if (!incoming) {
+ setImportFeedback('Invalid palette file');
+ return false;
+ }
+ const result = importCustomPalettes(
+ customPalettesRef.current,
+ incoming,
+ RESERVED_PALETTE_IDS
+ );
+ if (result.imported.length > 0) {
+ if (!saveCustomPalettes(result.palettes)) {
+ setImportFeedback(paletteStorageFailure('The palette import'));
+ return false;
+ }
+ setCustomPalettes(result.palettes);
+ }
+
+ const parts: string[] = [];
+ if (result.imported.length > 0) parts.push(`${result.imported.length} imported`);
+ if (result.overwritten.length > 0)
+ parts.push(`${result.overwritten.length} overwritten`);
+ if (result.skipped.length > 0)
+ parts.push(`${result.skipped.length} skipped (duplicates)`);
+ if (result.renamed.length > 0) parts.push(`${result.renamed.length} renamed`);
+ setImportFeedback(parts.join(', ') || 'No palettes found');
+
+ // Auto-select the first imported palette
+ const first = (selectExisting ? result.resolved : result.imported)[0];
+ if (first) {
+ setSelectedPalette(first.id);
+ }
+ return result.resolved.length > 0;
+ },
+ [setCustomPalettes, setSelectedPalette]
+ );
+
const handleImportFile = useCallback(
(e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
- reader.onload = () => {
- const incoming = parseCustomPaletteFile(reader.result as string);
- if (!incoming) {
- setImportFeedback('Invalid palette file');
- return;
- }
- const result = importCustomPalettes(customPalettes, incoming, RESERVED_PALETTE_IDS);
- if (result.imported.length > 0) {
- if (!saveCustomPalettes(result.palettes)) {
- setImportFeedback(paletteStorageFailure('The palette import'));
- return;
- }
- setCustomPalettes(result.palettes);
- }
-
- const parts: string[] = [];
- if (result.imported.length > 0) parts.push(`${result.imported.length} imported`);
- if (result.overwritten.length > 0)
- parts.push(`${result.overwritten.length} overwritten`);
- if (result.skipped.length > 0)
- parts.push(`${result.skipped.length} skipped (duplicates)`);
- if (result.renamed.length > 0) parts.push(`${result.renamed.length} renamed`);
- setImportFeedback(parts.join(', ') || 'No palettes found');
-
- // Auto-select the first imported palette
- if (result.imported.length > 0) {
- setSelectedPalette(result.imported[0].id);
- }
- };
+ reader.onload = () => importText(reader.result as string);
+ reader.onerror = () => setImportFeedback('Invalid palette file');
reader.readAsText(file);
e.target.value = '';
},
- [customPalettes, setSelectedPalette]
+ [importText]
);
// Clear import feedback after a few seconds
@@ -260,5 +277,6 @@ export function usePaletteManager() {
handleDeletePalette,
handleExportPalette,
handleImportFile,
+ importText,
};
}
diff --git a/src/hooks/useProfileManager.ts b/src/hooks/useProfileManager.ts
index a7bc7e7e..e302efbb 100644
--- a/src/hooks/useProfileManager.ts
+++ b/src/hooks/useProfileManager.ts
@@ -49,9 +49,21 @@ export interface UseProfileManagerOptions {
filaments: Filament[];
/** Setter to replace the filament list when loading a profile. */
setFilaments: (filaments: Filament[]) => void;
+ /** App-owned editor state also defers incoming desktop files. */
+ showSaveNewPopover: boolean;
+ setShowSaveNewPopover: (open: boolean) => void;
+ showRenamePopover: boolean;
+ setShowRenamePopover: (open: boolean) => void;
}
-export function useProfileManager({ filaments, setFilaments }: UseProfileManagerOptions) {
+export function useProfileManager({
+ filaments,
+ setFilaments,
+ showSaveNewPopover,
+ setShowSaveNewPopover,
+ showRenamePopover,
+ setShowRenamePopover,
+}: UseProfileManagerOptions) {
const [initialState] = useState(() => {
const loadedProfiles = loadProfiles();
const lastId = loadLastProfileId();
@@ -68,13 +80,18 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
};
});
- const [profiles, setProfiles] = useState(initialState.profiles);
+ const [profiles, setProfilesState] = useState(initialState.profiles);
+ const profilesRef = useRef(profiles);
+ // Successful writes update the import snapshot synchronously, before React renders.
+ // A delayed FileReader must merge with every profile saved since its read began.
+ const setProfiles = useCallback((updated: AutoPaintProfile[]) => {
+ profilesRef.current = updated;
+ setProfilesState(updated);
+ }, []);
const [activeProfileId, setActiveProfileId] = useState(
initialState.activeProfileId
);
- const [showSaveNewPopover, setShowSaveNewPopover] = useState(false);
const [saveProfileName, setSaveProfileName] = useState('');
- const [showRenamePopover, setShowRenamePopover] = useState(false);
const [renameProfileName, setRenameProfileName] = useState('');
const [importFeedback, setImportFeedback] = useState(null);
const importInputRef = useRef(null);
@@ -114,7 +131,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE);
}
},
- [filaments, profiles]
+ [filaments, profiles, setProfiles, setShowSaveNewPopover]
);
// Save (overwrite): updates existing profile in-place (templates are read-only)
@@ -129,7 +146,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
) {
setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE);
}
- }, [activeProfileId, filaments, profiles]);
+ }, [activeProfileId, filaments, profiles, setProfiles]);
const handleRenameProfile = useCallback(
(name: string) => {
@@ -149,7 +166,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE);
}
},
- [activeProfileId, profiles]
+ [activeProfileId, profiles, setProfiles, setShowRenamePopover]
);
const handleLoadProfile = useCallback(
@@ -189,7 +206,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE);
}
},
- [profiles, activeProfileId]
+ [profiles, activeProfileId, setProfiles]
);
const handleExportProfile = useCallback(async () => {
@@ -225,8 +242,17 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
'Save or overwrite the edited filament profile before tracking a Palette Proof'
);
}
- const record = buildPaletteProofRecord(activeProfile.filaments, snapshot, proof);
- const updated = profiles.map((profile) =>
+ // Export can finish after its dialog closes and another profile is imported.
+ // Keep the captured owner, but merge into its latest saved data and list.
+ const owner = profilesRef.current.find((profile) => profile.id === activeProfileId);
+ if (!owner) {
+ throw new Error('Save a named filament profile before tracking a Palette Proof');
+ }
+ if (!profileFilamentsEqual(owner.filaments, activeProfile.filaments)) {
+ throw new Error('Palette Proof does not match the final printable stack');
+ }
+ const record = buildPaletteProofRecord(owner.filaments, snapshot, proof);
+ const updated = profilesRef.current.map((profile) =>
profile.id === activeProfileId
? {
...profile,
@@ -241,7 +267,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
setProfiles(updated);
return record;
},
- [activeProfile, activeProfileId, isDirty, profiles]
+ [activeProfile, activeProfileId, isDirty, setProfiles]
);
const handleSetPaletteTargetResponse = useCallback(
@@ -268,7 +294,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
}
setProfiles(updated);
},
- [activeProfile, activeProfileId, isDirty, profiles]
+ [activeProfile, activeProfileId, isDirty, profiles, setProfiles]
);
const handleCompletePaletteProofEvaluation = useCallback(
@@ -290,7 +316,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
}
setProfiles(updated);
},
- [activeProfile, activeProfileId, isDirty, profiles]
+ [activeProfile, activeProfileId, isDirty, profiles, setProfiles]
);
const handleReopenPaletteProofEvaluation = useCallback(
@@ -312,7 +338,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
}
setProfiles(updated);
},
- [activeProfile, activeProfileId, isDirty, profiles]
+ [activeProfile, activeProfileId, isDirty, profiles, setProfiles]
);
const handleDeletePaletteProof = useCallback(
@@ -334,7 +360,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
}
setProfiles(updated);
},
- [activeProfile, activeProfileId, isDirty, profiles]
+ [activeProfile, activeProfileId, isDirty, profiles, setProfiles]
);
const handleUpsertStackMatrixCalibration = useCallback(
@@ -358,7 +384,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
}
setProfiles(updated);
},
- [activeProfile, activeProfileId, isDirty, profiles]
+ [activeProfile, activeProfileId, isDirty, profiles, setProfiles]
);
const handleDeleteStackMatrixCalibration = useCallback(
@@ -380,7 +406,52 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
}
setProfiles(updated);
},
- [activeProfile, activeProfileId, isDirty, profiles]
+ [activeProfile, activeProfileId, isDirty, profiles, setProfiles]
+ );
+
+ const importText = useCallback(
+ (content: string, fileName: string, selectExisting = false): boolean => {
+ const text = content.replace(/^\uFEFF/, '');
+ const isCSV = /\.(csv|tsv)$/i.test(fileName);
+ const profileName = fileName.replace(/\.(csv|tsv)$/i, '') || 'HueForge Import';
+ const incoming = isCSV ? parseHueForgeCSV(text, profileName) : parseProfileFile(text);
+ if (!incoming) {
+ setImportFeedback('Invalid profile file');
+ return false;
+ }
+ const result = importProfiles(profilesRef.current, incoming, RESERVED_PROFILE_IDS);
+ if (result.imported.length > 0) {
+ if (!saveProfilesToStorage(result.profiles)) {
+ setImportFeedback(
+ 'Import could not be saved. Existing profiles were left unchanged; free browser storage and try again.'
+ );
+ return false;
+ }
+ setProfiles(result.profiles);
+ }
+
+ // Build feedback message
+ const parts: string[] = [];
+ if (result.imported.length > 0) parts.push(`${result.imported.length} imported`);
+ if (result.overwritten.length > 0)
+ parts.push(`${result.overwritten.length} overwritten`);
+ if (result.skipped.length > 0)
+ parts.push(`${result.skipped.length} skipped (duplicates)`);
+ if (result.renamed.length > 0) parts.push(`${result.renamed.length} renamed`);
+ setImportFeedback(parts.join(', ') || 'No profiles found');
+
+ // A later entry in a batch may overwrite the first accepted profile.
+ // Select its final saved version, not the earlier resolved object.
+ const firstId = (selectExisting ? result.resolved : result.imported)[0]?.id;
+ const first = result.profiles.find((profile) => profile.id === firstId);
+ if (first) {
+ setActiveProfileId(first.id);
+ saveLastProfileId(first.id);
+ setFilaments(first.filaments.map((f) => ({ ...f })));
+ }
+ return result.resolved.length > 0;
+ },
+ [setProfiles, setFilaments]
);
const handleImportFile = useCallback(
@@ -388,50 +459,12 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
- reader.onload = () => {
- const content = reader.result as string;
- const isCSV = /\.(csv|tsv)$/i.test(file.name);
- const profileName = file.name.replace(/\.(csv|tsv)$/i, '') || 'HueForge Import';
- const incoming = isCSV
- ? parseHueForgeCSV(content, profileName)
- : parseProfileFile(content);
- if (!incoming) {
- console.error('Invalid profile file');
- return;
- }
- const result = importProfiles(profiles, incoming, RESERVED_PROFILE_IDS);
- if (result.imported.length > 0) {
- if (!saveProfilesToStorage(result.profiles)) {
- setImportFeedback(
- 'Import could not be saved. Existing profiles were left unchanged; free browser storage and try again.'
- );
- return;
- }
- setProfiles(result.profiles);
- }
-
- // Build feedback message
- const parts: string[] = [];
- if (result.imported.length > 0) parts.push(`${result.imported.length} imported`);
- if (result.overwritten.length > 0)
- parts.push(`${result.overwritten.length} overwritten`);
- if (result.skipped.length > 0)
- parts.push(`${result.skipped.length} skipped (duplicates)`);
- if (result.renamed.length > 0) parts.push(`${result.renamed.length} renamed`);
- setImportFeedback(parts.join(', ') || 'No profiles found');
-
- // Auto-load the first imported profile
- if (result.imported.length > 0) {
- const first = result.imported[0];
- setActiveProfileId(first.id);
- saveLastProfileId(first.id);
- setFilaments(first.filaments.map((f) => ({ ...f })));
- }
- };
+ reader.onload = () => importText(reader.result as string, file.name);
+ reader.onerror = () => setImportFeedback('Invalid profile file');
reader.readAsText(file);
e.target.value = '';
},
- [profiles, setFilaments]
+ [importText]
);
// Clear import feedback after a few seconds
@@ -487,6 +520,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager
handleDeleteProfile,
handleExportProfile,
handleImportFile,
+ importText,
handleRegisterPaletteProof,
handleSetPaletteTargetResponse,
handleCompletePaletteProofEvaluation,
diff --git a/src/hooks/useSwapPlan.ts b/src/hooks/useSwapPlan.ts
index 6e6393d7..140c9bff 100644
--- a/src/hooks/useSwapPlan.ts
+++ b/src/hooks/useSwapPlan.ts
@@ -1,6 +1,7 @@
import { useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { translateRuntimeMessage } from '../lib/runtimeMessages';
-import type { Swatch } from '../types';
+import type { Swatch, SmoothMeshingStrength } from '../types';
import type { AutoPaintResult } from '../lib/autoPaint';
export type SwapEntry =
@@ -20,6 +21,7 @@ export interface UseSwapPlanOptions {
flatPaint?: boolean;
/** Flat Paint is printed face-up without a transparent carrier. */
flatPaintFaceUp?: boolean;
+ smoothMeshingStrength?: SmoothMeshingStrength;
}
export function useSwapPlan({
@@ -33,7 +35,9 @@ export function useSwapPlan({
disabled = false,
flatPaint = false,
flatPaintFaceUp = false,
+ smoothMeshingStrength = 'none',
}: UseSwapPlanOptions) {
+ const { t } = useTranslation('printing');
const swapPlan = useMemo(() => {
if (disabled || flatPaint) {
return [] as SwapEntry[];
@@ -138,6 +142,14 @@ export function useSwapPlan({
lines.push('---------------------');
lines.push(`Layer height: ${layerHeight.toFixed(3)} mm`);
lines.push(`First layer height: ${slicerFirstLayerHeight.toFixed(3)} mm`);
+ const strength = smoothMeshingStrength;
+ const strengthKeys = {
+ none: 'printSettingsCard.smoothingNone',
+ minimal: 'printSettingsCard.smoothingMinimal',
+ medium: 'printSettingsCard.smoothingMedium',
+ aggressive: 'printSettingsCard.smoothingAggressive',
+ };
+ lines.push(`${t('printSettingsCard.smoothMeshing')}: ${t(strengthKeys[strength])}`);
lines.push('Recommended: Layer loops: 1; Infill: 100%');
lines.push('');
diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts
index 585518ba..f6f2a3b3 100644
--- a/src/lib/autoPaint.ts
+++ b/src/lib/autoPaint.ts
@@ -2408,23 +2408,122 @@ export function selectHybridPrintableCandidateIndex(
return paletteIndex;
}
+type DitherPaletteEntry = Pick<
+ FinalStackPaletteEntrySnapshot,
+ 'height' | 'predictedLab' | 'surfaceEligible' | 'exactAnchorTargetLab' | 'predictionConfidence'
+>;
+
/**
- * Seed preview colors from the final-stack mappings produced by the shared
- * optimizer mapper instead of independently re-projecting those source colors.
+ * Recover a fractional target locally around the shared mapper's chosen layer.
+ * Never re-project onto a remote part of the stack: that would discard hybrid
+ * reranking and calibrated assignments. Only an improving mixture of this layer
+ * and one physically adjacent, surface-eligible layer may be dithered.
+ */
+function projectDitherTargetHeight(
+ target: Lab,
+ paletteIndex: number,
+ palette: readonly DitherPaletteEntry[],
+ layerHeight: number,
+ minimumHeight: number,
+ maximumHeight: number
+): number | undefined {
+ const selected = palette[paletteIndex];
+ if (!selected || selected.surfaceEligible === false || !(layerHeight > 0)) return undefined;
+ const asLab = ([L, a, b]: readonly [number, number, number]): Lab => ({ L, a, b });
+ if (
+ selected.exactAnchorTargetLab &&
+ optimizerColorDistance(target, asLab(selected.exactAnchorTargetLab)) <=
+ EXACT_ANCHOR_TARGET_DE
+ ) {
+ return selected.height;
+ }
+ const selectedLab = asLab(selected.predictedLab);
+ const confidence = (entry: DitherPaletteEntry) =>
+ Math.max(0, Math.min(1, entry.predictionConfidence?.confidence ?? 1));
+ const selectedConfidence = confidence(selected);
+ let bestCost =
+ realizedColorError(selectedLab, target) +
+ (1 - selectedConfidence) * PREDICTION_UNCERTAINTY_PENALTY;
+ let height = selected.height;
+ for (const neighborIndex of [paletteIndex - 1, paletteIndex + 1]) {
+ const neighbor = palette[neighborIndex];
+ if (
+ !neighbor ||
+ neighbor.surfaceEligible === false ||
+ neighbor.height < minimumHeight - 1e-6 ||
+ neighbor.height > maximumHeight + 1e-6 ||
+ Math.abs(Math.abs(neighbor.height - selected.height) - layerHeight) > 1e-6
+ )
+ continue;
+ const neighborLab = asLab(neighbor.predictedLab);
+ const dL = neighborLab.L - selectedLab.L;
+ const da = neighborLab.a - selectedLab.a;
+ const db = neighborLab.b - selectedLab.b;
+ const lengthSquared = dL * dL + da * da + db * db;
+ // Indistinguishable colors do not justify adding surface texture.
+ if (lengthSquared < 0.25) continue;
+ const t = Math.max(
+ 0,
+ Math.min(
+ 1,
+ ((target.L - selectedLab.L) * dL +
+ (target.a - selectedLab.a) * da +
+ (target.b - selectedLab.b) * db) /
+ lengthSquared
+ )
+ );
+ if (t <= 1e-6 || t >= 1 - 1e-6) continue;
+ const projectedLab = {
+ L: selectedLab.L + t * dL,
+ a: selectedLab.a + t * da,
+ b: selectedLab.b + t * db,
+ };
+ const projectedConfidence =
+ selectedConfidence + t * (confidence(neighbor) - selectedConfidence);
+ const cost =
+ realizedColorError(projectedLab, target) +
+ (1 - projectedConfidence) * PREDICTION_UNCERTAINTY_PENALTY;
+ if (cost < bestCost - 1e-9) {
+ bestCost = cost;
+ height = selected.height + t * (neighbor.height - selected.height);
+ }
+ }
+ return height;
+}
+
+/**
+ * Seed preview colors from the shared final-stack mappings. Dithering may recover
+ * fractional targets between adjacent printable layers; ordinary matching keeps
+ * the optimizer's exact discrete assignments. Older snapshots retain those too.
*/
export function createFinalStackTargetHeightCache(
targetMappings: readonly {
targetColor: { rgb: readonly [number, number, number] };
projectedHeight: number;
+ targetLab?: readonly [number, number, number];
+ paletteIndex?: number;
}[],
minimumHeight: number,
- maximumHeight: number
+ maximumHeight: number,
+ dithering?: { palette: readonly DitherPaletteEntry[]; layerHeight: number }
): Map {
const cache = new Map();
for (const mapping of targetMappings) {
const [r, g, b] = mapping.targetColor.rgb;
const key = ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff);
- cache.set(key, Math.max(minimumHeight, Math.min(maximumHeight, mapping.projectedHeight)));
+ const target = mapping.targetLab;
+ const height =
+ dithering && target && mapping.paletteIndex !== undefined
+ ? (projectDitherTargetHeight(
+ { L: target[0], a: target[1], b: target[2] },
+ mapping.paletteIndex,
+ dithering.palette,
+ dithering.layerHeight,
+ minimumHeight,
+ maximumHeight
+ ) ?? mapping.projectedHeight)
+ : mapping.projectedHeight;
+ cache.set(key, Math.max(minimumHeight, Math.min(maximumHeight, height)));
}
return cache;
}
diff --git a/src/lib/desktopFileInbox.ts b/src/lib/desktopFileInbox.ts
new file mode 100644
index 00000000..40c3161f
--- /dev/null
+++ b/src/lib/desktopFileInbox.ts
@@ -0,0 +1,83 @@
+/** Native OS-opened documents. Only the Rust queue can grant access to a path. */
+export interface DesktopOpenedFile {
+ name: string;
+ kind: 'profile' | 'palette';
+ content: string | null;
+ error: string | null;
+}
+
+export interface DesktopFileRequest extends DesktopOpenedFile {
+ id: number;
+}
+
+interface FileTransport {
+ listen: (onFiles: () => void) => Promise;
+ take: () => Promise;
+}
+
+/** Retains an in-flight request across React remounts and drains one document at a time. */
+export function createDesktopFileInbox(transport: FileTransport) {
+ const subscribers = new Set<() => void>();
+ let current: DesktopFileRequest | null = null;
+ let sequence = 0;
+ let started = false;
+ let loading = false;
+ let requested = false;
+ const notify = () => subscribers.forEach((subscriber) => subscriber());
+
+ async function requestNext() {
+ requested = true;
+ if (loading || current) return;
+ loading = true;
+ try {
+ // A wake-up during an empty take must trigger another take, not be lost.
+ while (requested && !current) {
+ requested = false;
+ const opened = await transport.take();
+ if (opened) current = { ...opened, id: ++sequence };
+ }
+ } catch (error) {
+ current = {
+ id: ++sequence,
+ name: '',
+ kind: 'profile',
+ content: null,
+ error: String(error),
+ };
+ } finally {
+ loading = false;
+ notify();
+ }
+ }
+
+ return {
+ subscribe(subscriber: () => void) {
+ subscribers.add(subscriber);
+ if (!started) {
+ started = true;
+ // Register before reading the startup queue. Events carry no file data.
+ void transport
+ .listen(() => void requestNext())
+ .then(
+ () => void requestNext(),
+ (error) => {
+ console.error('Could not listen for opened files', error);
+ void requestNext();
+ }
+ );
+ }
+ return () => {
+ subscribers.delete(subscriber);
+ };
+ },
+ getSnapshot: () => current,
+ finish(id: number) {
+ if (current?.id !== id) return;
+ const failedTransport = current.name === '' && current.content === null;
+ current = null;
+ notify();
+ // An unavailable IPC bridge should not produce an endless error-dialog loop.
+ if (!failedTransport) void requestNext();
+ },
+ };
+}
diff --git a/src/lib/export3mf.ts b/src/lib/export3mf.ts
index 1526f3da..53df25e0 100644
--- a/src/lib/export3mf.ts
+++ b/src/lib/export3mf.ts
@@ -3,10 +3,12 @@ import * as THREE from 'three';
import { MINIMAL_PROJECT_SETTINGS, KROMACUT_CONFIG } from './slicerDefaults';
import { clampProgress, exportMeshProgress, exportZipProgress, progressInSpan } from './progress';
import { normalizeHexColor } from './colorUtils';
+import type { SmoothMeshingStrength } from '../types';
export interface Export3MFOptions {
layerHeight?: number;
firstLayerHeight?: number;
+ smoothMeshingStrength?: SmoothMeshingStrength;
layerFilamentColors?: string[]; // Optional per-layer filament colors (hex) for export
onProgress?: (progress: number) => void;
onZipProgress?: (progress: { percent: number; currentFile?: string | null }) => void;
@@ -317,10 +319,14 @@ ${metadataContentTypes}${metadataContentTypes ? '\n' : ''}
// Header and BaseMaterials
let header = `
-
+
1
Kromacut_Print
`;
+ if (options?.smoothMeshingStrength !== undefined) {
+ header += ` ${options.smoothMeshingStrength}
+`;
+ }
if (options?.layerHeight !== undefined) {
header += ` ${options.layerHeight}
`;
@@ -466,8 +472,7 @@ ${metadataContentTypes}${metadataContentTypes ? '\n' : ''}
const pos = geom.getAttribute('position');
const index = geom.getIndex();
const source = getKromacutExportGeometry(geom);
- const memberCollectStart =
- COLLECT_START + (collectSpan * memberIdx) / memberCount;
+ const memberCollectStart = COLLECT_START + (collectSpan * memberIdx) / memberCount;
const memberCollectSpan = collectSpan / memberCount;
const reportCollect = (fraction: number) => {
reportMeshProgress(
@@ -571,7 +576,11 @@ ${metadataContentTypes}${metadataContentTypes ? '\n' : ''}
} else {
const getExportVertex = (vertexIndex: number) => {
v.fromBufferAttribute(pos, vertexIndex).applyMatrix4(mesh.matrixWorld);
- return addCoordVertex(toCoordUnits(v.x), toCoordUnits(v.y), toCoordUnits(v.z));
+ return addCoordVertex(
+ toCoordUnits(v.x),
+ toCoordUnits(v.y),
+ toCoordUnits(v.z)
+ );
};
const addAttributeTriangle = (a: number, b: number, c: number) => {
diff --git a/src/lib/flatPaintMeshing.ts b/src/lib/flatPaintMeshing.ts
new file mode 100644
index 00000000..55c9e653
--- /dev/null
+++ b/src/lib/flatPaintMeshing.ts
@@ -0,0 +1,344 @@
+import { Vector2 } from 'three';
+import type { SmoothMeshingStrength } from '../types/index.ts';
+import {
+ createBoundaryVertexMapperAsync,
+ triangulateBoundaryPreservingVertices,
+ type MeshData,
+ type MeshYieldOptions,
+} from './meshing.ts';
+
+type Point = { key: string; x: number; y: number };
+type Rect = { x: number; y: number; w: number; h: number };
+
+// Keep each synchronous triangulation bounded, including successful caps.
+const MAX_CAP_BOUNDARY_VERTICES = 1024;
+
+function createYield(options: MeshYieldOptions) {
+ let lastYield = performance.now();
+ return async () => {
+ if (performance.now() - lastYield < (options.yieldIntervalMs ?? 8)) return;
+ await (
+ options.onYield ?? (() => new Promise((r) => requestAnimationFrame(() => r())))
+ )();
+ lastYield = performance.now();
+ };
+}
+
+/**
+ * One XY tessellation for every class and the carrier. The same class footprint
+ * is reused at every Z level, so color interfaces and the carrier meet exactly.
+ * None keeps grid vertices fixed, but shares corner repairs too: repairing
+ * each binary mask independently can expand neighboring colors into each other.
+ */
+export async function createFlatPaintMesher(
+ layerCounts: Uint16Array | Uint8Array,
+ width: number,
+ height: number,
+ pixelSize: number,
+ strength: SmoothMeshingStrength,
+ options: MeshYieldOptions = {}
+): Promise<(mask: Uint8Array, options?: MeshYieldOptions) => Promise> {
+ const maybeYield = createYield(options);
+ const stride = width + 1;
+ const getCell = (x: number, y: number) =>
+ x >= 0 && y >= 0 && x < width && y < height ? layerCounts[y * width + x] : 0;
+ const neighbors = new Map>();
+ const addEdge = (a: number, b: number) => {
+ if (!neighbors.has(a)) neighbors.set(a, new Set());
+ if (!neighbors.has(b)) neighbors.set(b, new Set());
+ neighbors.get(a)!.add(b);
+ neighbors.get(b)!.add(a);
+ };
+
+ // Label transitions include both internal color boundaries and transparency.
+ for (let y = 0; strength !== 'none' && y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const label = getCell(x, y);
+ if (!label) continue;
+ const key = y * stride + x;
+ if (getCell(x, y - 1) !== label) addEdge(key, key + 1);
+ if (getCell(x + 1, y) !== label) addEdge(key + 1, key + stride + 1);
+ if (getCell(x, y + 1) !== label) addEdge(key + stride, key + stride + 1);
+ if (getCell(x - 1, y) !== label) addEdge(key, key + stride);
+ }
+ await maybeYield();
+ }
+ // Pin junctions rather than pulling a three/four-color intersection toward
+ // one of its branches. All other chains use the existing strength presets.
+ const mapVertex =
+ strength === 'none'
+ ? (x: number, y: number): [number, number] => [x, y]
+ : await createBoundaryVertexMapperAsync(
+ neighbors,
+ width,
+ height,
+ strength,
+ true,
+ options
+ );
+
+ // A repeated diagonal class must connect across an edge, not a zero-width
+ // contact. Assign a small shared diamond to one incident cell. The other
+ // cells trim that same diamond from their corners; no mask is independently
+ // expanded, and no whole source pixel is recolored or added.
+ const diamondOwners = new Map();
+ const specialCells = new Uint8Array(width * height);
+ for (let y = 1; y < height; y++) {
+ for (let x = 1; x < width; x++) {
+ const a = getCell(x - 1, y - 1);
+ const b = getCell(x, y - 1);
+ const c = getCell(x - 1, y);
+ const d = getCell(x, y);
+ const nw = a > 0 && d > 0 && ((a === d && a !== b && a !== c) || (!b && !c));
+ const ne = b > 0 && c > 0 && ((b === c && b !== a && b !== d) || (!a && !d));
+ if (!nw && !ne) continue;
+ const owner = nw && (!ne || a < b) ? (y - 1) * width + x - 1 : (y - 1) * width + x;
+ diamondOwners.set(y * stride + x, owner);
+ for (const index of [
+ (y - 1) * width + x - 1,
+ (y - 1) * width + x,
+ y * width + x - 1,
+ y * width + x,
+ ])
+ specialCells[index] = 1;
+ }
+ await maybeYield();
+ }
+
+ const vertex = (x: number, y: number): Point => {
+ const [mx, my] = mapVertex(x, y);
+ return { key: `v${y * stride + x}`, x: mx, y: my };
+ };
+ const directions = [
+ [0, -1],
+ [1, 0],
+ [0, 1],
+ [-1, 0],
+ ] as const;
+ const diamondPoint = (x: number, y: number, direction: number): Point => {
+ const center = vertex(x, y);
+ const [dx, dy] = directions[direction];
+ const neighbor = vertex(x + dx, y + dy);
+ return {
+ key: `d${y * stride + x}/${direction}`,
+ x: center.x + (neighbor.x - center.x) * 0.1,
+ y: center.y + (neighbor.y - center.y) * 0.1,
+ };
+ };
+ const cellPolygon = (x: number, y: number, includeOwnedDiamonds = true): Point[] => {
+ const polygon: Point[] = [];
+ const corners = [
+ [x, y],
+ [x + 1, y],
+ [x + 1, y + 1],
+ [x, y + 1],
+ ];
+ const incoming = [2, 3, 0, 1];
+ const outgoing = [1, 2, 3, 0];
+ for (let corner = 0; corner < 4; corner++) {
+ const [vx, vy] = corners[corner];
+ const owner = diamondOwners.get(vy * stride + vx);
+ if (owner === undefined) {
+ polygon.push(vertex(vx, vy));
+ } else if (includeOwnedDiamonds && owner === y * width + x) {
+ for (let step = 0; step < 4; step++) {
+ polygon.push(diamondPoint(vx, vy, (incoming[corner] + step) % 4));
+ }
+ } else {
+ polygon.push(diamondPoint(vx, vy, incoming[corner]));
+ polygon.push(diamondPoint(vx, vy, outgoing[corner]));
+ }
+ }
+ return polygon;
+ };
+
+ return async (mask, meshOptions = {}) => {
+ const startedAt = performance.now();
+ const yieldMesh = createYield(meshOptions);
+ const positions: number[] = [];
+ const indices: number[] = [];
+ const vertices = new Map();
+ const walls = new Map();
+ const visited = new Uint8Array(width * height);
+ let activePixelCount = 0;
+ let completedCapCells = 0;
+ let lastCapProgress = 0;
+ const reportCapsProgress = (progress: number) => {
+ lastCapProgress = Math.max(lastCapProgress, progress);
+ meshOptions.onProgress?.({
+ phase: 'caps',
+ label: strength === 'none' ? 'Building mesh caps' : 'Building smooth mesh caps',
+ progress: lastCapProgress,
+ });
+ };
+ const getVertex = (point: Point, top: boolean) => {
+ const key = `${point.key}/${top ? 1 : 0}`;
+ let index = vertices.get(key);
+ if (index === undefined) {
+ index = positions.length / 3;
+ positions.push(point.x * pixelSize, point.y * pixelSize, top ? 1 : 0);
+ vertices.set(key, index);
+ }
+ return index;
+ };
+ const emitPolygon = (polygon: Point[]) => {
+ const points = polygon.map(
+ (p) => new Vector2(Math.fround(p.x * pixelSize), Math.fround(p.y * pixelSize))
+ );
+ const rounded = points.map(
+ (p) => new Vector2(Math.round(p.x * 100_000), Math.round(p.y * 100_000))
+ );
+ const faces = triangulateBoundaryPreservingVertices(
+ points,
+ rounded,
+ Math.max(Number.EPSILON, 1e-8 * pixelSize * pixelSize)
+ );
+ if (!faces) return false;
+ const top = polygon.map((p) => getVertex(p, true));
+ const bottom = polygon.map((p) => getVertex(p, false));
+ for (const [a, b, c] of faces) {
+ indices.push(top[a], top[b], top[c], bottom[a], bottom[c], bottom[b]);
+ }
+ for (let i = 0; i < polygon.length; i++) {
+ const a = polygon[i];
+ const b = polygon[(i + 1) % polygon.length];
+ const key = a.key < b.key ? `${a.key}|${b.key}` : `${b.key}|${a.key}`;
+ // Interior edges cancel between conforming cap polygons.
+ if (walls.has(key)) walls.delete(key);
+ else walls.set(key, [a, b]);
+ }
+ return true;
+ };
+ const emitRectangle = async (rectangle: Rect) => {
+ const reportChunks = 2 * (rectangle.w + rectangle.h) > MAX_CAP_BOUNDARY_VERTICES;
+ const pending = [rectangle];
+ while (pending.length > 0) {
+ const { x, y, w, h } = pending.pop()!;
+ if (2 * (w + h) <= MAX_CAP_BOUNDARY_VERTICES) {
+ const polygon: Point[] = [];
+ // Every grid edge is split identically across classes,
+ // the carrier, and differently merged rectangles.
+ for (let dx = 0; dx < w; dx++) polygon.push(vertex(x + dx, y));
+ for (let dy = 0; dy < h; dy++) polygon.push(vertex(x + w, y + dy));
+ for (let dx = w; dx > 0; dx--) polygon.push(vertex(x + dx, y + h));
+ for (let dy = h; dy > 0; dy--) polygon.push(vertex(x, y + dy));
+ if (emitPolygon(polygon)) {
+ completedCapCells += w * h;
+ if (reportChunks) {
+ reportCapsProgress((completedCapCells / (width * height)) * 0.9);
+ }
+ await yieldMesh();
+ continue;
+ }
+ }
+ // Split before large triangulations, and on precision failure.
+ // Unit edge IDs stay shared; interior walls cancel exactly.
+ // LIFO order emits the left/upper half first, as before.
+ if (w >= h && w > 1) {
+ const left = Math.floor(w / 2);
+ pending.push({ x: x + left, y, w: w - left, h }, { x, y, w: left, h });
+ } else if (h > 1) {
+ const upper = Math.floor(h / 2);
+ pending.push({ x, y: y + upper, w, h: h - upper }, { x, y, w, h: upper });
+ } else {
+ throw new Error(
+ 'Unable to triangulate a smoothed cap without breaking topology'
+ );
+ }
+ }
+ };
+ reportCapsProgress(0);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ // Dense rows may contain many small special-cell caps.
+ if (x % 128 === 0) await yieldMesh();
+ const index = y * width + x;
+ if (mask[index]) activePixelCount++;
+ if (!mask[index] || visited[index]) continue;
+ if (specialCells[index]) {
+ visited[index] = 1;
+ if (!emitPolygon(cellPolygon(x, y))) {
+ // A concave corner repair can defeat ear clipping when
+ // almost-collinear edges round differently in Float32
+ // and 3MF coordinates. Split off the owned diamonds:
+ // the same footprint and boundary vertices are retained,
+ // and the internal edges cancel just like rectangle caps.
+ const pieces = [cellPolygon(x, y, false)];
+ for (const [vx, vy] of [
+ [x, y],
+ [x + 1, y],
+ [x + 1, y + 1],
+ [x, y + 1],
+ ]) {
+ if (diamondOwners.get(vy * stride + vx) === index) {
+ pieces.push(
+ directions.map((_, direction) =>
+ diamondPoint(vx, vy, direction)
+ )
+ );
+ }
+ }
+ if (!pieces.every(emitPolygon)) {
+ throw new Error(
+ `Unable to triangulate a Flat Paint cap at pixel (${x}, ${y}) without breaking topology`
+ );
+ }
+ }
+ completedCapCells++;
+ continue;
+ }
+ let w = 1;
+ while (
+ x + w < width &&
+ mask[index + w] &&
+ !visited[index + w] &&
+ !specialCells[index + w]
+ )
+ w++;
+ let h = 1;
+ while (y + h < height) {
+ let extend = true;
+ for (let dx = 0; dx < w; dx++) {
+ const next = (y + h) * width + x + dx;
+ if (!mask[next] || visited[next] || specialCells[next]) {
+ extend = false;
+ break;
+ }
+ }
+ if (!extend) break;
+ h++;
+ await yieldMesh();
+ }
+ for (let dy = 0; dy < h; dy++)
+ visited.fill(1, (y + dy) * width + x, (y + dy) * width + x + w);
+ await emitRectangle({ x, y, w, h });
+ }
+ reportCapsProgress(((y + 1) / height) * 0.9);
+ await yieldMesh();
+ }
+ for (const [a, b] of walls.values()) {
+ const ab = getVertex(a, false);
+ const bb = getVertex(b, false);
+ const at = getVertex(a, true);
+ const bt = getVertex(b, true);
+ indices.push(ab, bb, bt, ab, bt, at);
+ await yieldMesh();
+ }
+ meshOptions.onProgress?.({
+ phase: 'walls',
+ label: strength === 'none' ? 'Mesh geometry complete' : 'Smooth mesh geometry complete',
+ progress: 1,
+ });
+ return {
+ positions: new Float32Array(positions),
+ indices,
+ metrics: {
+ mesher: strength === 'none' ? 'greedy' : 'smooth',
+ elapsedMs: performance.now() - startedAt,
+ activePixelCount,
+ vertexCount: positions.length / 3,
+ triangleCount: indices.length / 3,
+ },
+ };
+ };
+}
diff --git a/src/lib/heightDithering.ts b/src/lib/heightDithering.ts
new file mode 100644
index 00000000..fba1f56a
--- /dev/null
+++ b/src/lib/heightDithering.ts
@@ -0,0 +1,207 @@
+/**
+ * Quantize a height map in place, optionally distributing fractional heights
+ * with block-aware Stucki diffusion. Zero pixels are transparent.
+ */
+export function quantizeHeightMap(
+ pixelHeightMap: Float32Array,
+ boxW: number,
+ boxH: number,
+ {
+ layerHeight,
+ slicerFirstLayerHeight,
+ minModelH,
+ maxModelH,
+ heightDithering,
+ ditherLineWidth,
+ pixelSize,
+ }: {
+ layerHeight: number;
+ slicerFirstLayerHeight: number;
+ minModelH: number;
+ maxModelH: number;
+ heightDithering: boolean;
+ ditherLineWidth: number;
+ pixelSize: number;
+ }
+): void {
+ // --- Pass 2: Quantize heights (with optional dithering) ---
+ // The continuous height map has sub-layer precision, but
+ // the 3D model must use discrete layer heights. When
+ // heightDithering is ON, block-aware Stucki error
+ // diffusion approximates dots at the printer's line width.
+ // Actual printability still needs checking in the slicer. Edges
+ // between different quantized heights are protected from
+ // dithering to avoid staircase artifacts that expose wrong
+ // colors. When OFF, simple rounding is used.
+ if (layerHeight > 0 && heightDithering) {
+ // --- Step 2a: Snap everything to the grid first ---
+ const snappedMap = new Float32Array(boxW * boxH);
+ for (let mi = 0; mi < boxW * boxH; mi++) {
+ const h = pixelHeightMap[mi];
+ if (h <= 0) {
+ snappedMap[mi] = 0;
+ continue;
+ }
+ const delta = Math.max(0, h - slicerFirstLayerHeight);
+ let s = slicerFirstLayerHeight + Math.round(delta / layerHeight) * layerHeight;
+ s = Math.max(minModelH, Math.min(maxModelH, s));
+ snappedMap[mi] = s;
+ }
+
+ // --- Step 2b: Identify edge pixels ---
+ // A pixel is on an edge if any of its 4-connected
+ // neighbors has a different snapped height. Dithering
+ // these would create jagged staircases that expose the
+ // wrong filament color, so we leave them at their
+ // nearest-round height.
+ const isEdge = new Uint8Array(boxW * boxH);
+ for (let y = 0; y < boxH; y++) {
+ for (let x = 0; x < boxW; x++) {
+ const mi = y * boxW + x;
+ const sh = snappedMap[mi];
+ if (sh <= 0) continue;
+ if (
+ (x > 0 && snappedMap[mi - 1] > 0 && snappedMap[mi - 1] !== sh) ||
+ (x < boxW - 1 && snappedMap[mi + 1] > 0 && snappedMap[mi + 1] !== sh) ||
+ (y > 0 && snappedMap[mi - boxW] > 0 && snappedMap[mi - boxW] !== sh) ||
+ (y < boxH - 1 && snappedMap[mi + boxW] > 0 && snappedMap[mi + boxW] !== sh)
+ ) {
+ isEdge[mi] = 1;
+ }
+ }
+ }
+
+ // --- Step 2c: Block-aware Stucki error diffusion ---
+ // Approximate the requested line width with whole-pixel blocks.
+ const blockSize = Math.max(1, Math.round(ditherLineWidth / pixelSize));
+ const bW = Math.ceil(boxW / blockSize);
+ const bH = Math.ceil(boxH / blockSize);
+
+ // Compute average continuous height per block
+ const blockAvg = new Float64Array(bW * bH);
+ const blockCnt = new Uint32Array(bW * bH);
+ const blockHasEdge = new Uint8Array(bW * bH);
+ for (let y = 0; y < boxH; y++) {
+ for (let x = 0; x < boxW; x++) {
+ const mi = y * boxW + x;
+ const h = pixelHeightMap[mi]; // still continuous
+ if (h <= 0) continue;
+ const bx = Math.floor(x / blockSize);
+ const by = Math.floor(y / blockSize);
+ const bi = by * bW + bx;
+ blockAvg[bi] += h;
+ blockCnt[bi]++;
+ if (isEdge[mi] || Math.abs(h - snappedMap[mi]) <= 1e-6) {
+ // Protect exact matches even when a block also contains
+ // fractional pixels that round to the same height.
+ blockHasEdge[bi] = 1;
+ }
+ }
+ }
+ for (let bi = 0; bi < bW * bH; bi++) {
+ if (blockCnt[bi] > 0) blockAvg[bi] /= blockCnt[bi];
+ }
+
+ // Dither at block level using the Stucki kernel: a
+ // wider, error-conserving diffusion than Floyd-
+ // Steinberg for smoother tone and finer detail.
+ // Offsets are scan-relative (dx along the serpentine
+ // direction, dy downward); weights are pre-divided by 42.
+ const STUCKI_KERNEL: ReadonlyArray = [
+ [1, 0, 8 / 42],
+ [2, 0, 4 / 42],
+ [-2, 1, 2 / 42],
+ [-1, 1, 4 / 42],
+ [0, 1, 8 / 42],
+ [1, 1, 4 / 42],
+ [2, 1, 2 / 42],
+ [-2, 2, 1 / 42],
+ [-1, 2, 2 / 42],
+ [0, 2, 4 / 42],
+ [1, 2, 2 / 42],
+ [2, 2, 1 / 42],
+ ];
+ const errBuf = new Float64Array(bW * bH);
+ const blockSnapped = new Float32Array(bW * bH);
+
+ for (let by = 0; by < bH; by++) {
+ const ltr = by % 2 === 0;
+ for (let bxi = 0; bxi < bW; bxi++) {
+ const bx = ltr ? bxi : bW - 1 - bxi;
+ const bi = by * bW + bx;
+ if (blockCnt[bi] === 0) continue;
+
+ let snapped: number;
+ if (blockHasEdge[bi]) {
+ // Edge block: no dithering, use simple snap
+ const delta = Math.max(0, blockAvg[bi] - slicerFirstLayerHeight);
+ snapped =
+ slicerFirstLayerHeight + Math.round(delta / layerHeight) * layerHeight;
+ } else {
+ const adjusted = blockAvg[bi] + errBuf[bi];
+ const delta = Math.max(0, adjusted - slicerFirstLayerHeight);
+ snapped =
+ slicerFirstLayerHeight + Math.round(delta / layerHeight) * layerHeight;
+ }
+ snapped = Math.max(minModelH, Math.min(maxModelH, snapped));
+ // Keep diffusion inside the block's original adjacent
+ // layer pair. In particular, incoming error must not
+ // dither an exact match or a calibrated anchor. The
+ // tolerance removes Float32 noise at layer boundaries.
+ const layerPosition = Math.max(
+ 0,
+ (blockAvg[bi] - slicerFirstLayerHeight) / layerHeight
+ );
+ const lower = Math.max(
+ minModelH,
+ slicerFirstLayerHeight + Math.floor(layerPosition + 1e-6) * layerHeight
+ );
+ const upper = Math.min(
+ maxModelH,
+ slicerFirstLayerHeight + Math.ceil(layerPosition - 1e-6) * layerHeight
+ );
+ snapped = Math.max(lower, Math.min(upper, snapped));
+ blockSnapped[bi] = snapped;
+
+ if (!blockHasEdge[bi]) {
+ const err = blockAvg[bi] + errBuf[bi] - snapped;
+ const dir = ltr ? 1 : -1;
+ for (let k = 0; k < STUCKI_KERNEL.length; k++) {
+ const [dx, dy, weight] = STUCKI_KERNEL[k];
+ const tx = bx + dir * dx;
+ const ty = by + dy;
+ if (tx < 0 || tx >= bW || ty >= bH) continue;
+ errBuf[ty * bW + tx] += err * weight;
+ }
+ }
+ }
+ }
+
+ // Write block-level results back to pixel map
+ for (let y = 0; y < boxH; y++) {
+ for (let x = 0; x < boxW; x++) {
+ const mi = y * boxW + x;
+ if (pixelHeightMap[mi] <= 0) continue;
+ const bx = Math.floor(x / blockSize);
+ const by = Math.floor(y / blockSize);
+ const bi = by * bW + bx;
+ if (blockHasEdge[bi]) {
+ // Edge blocks: use per-pixel snap
+ pixelHeightMap[mi] = snappedMap[mi];
+ } else {
+ pixelHeightMap[mi] = blockSnapped[bi];
+ }
+ }
+ }
+ } else if (layerHeight > 0) {
+ // Simple grid snap without error diffusion
+ for (let mi = 0; mi < boxW * boxH; mi++) {
+ const h = pixelHeightMap[mi];
+ if (h <= 0) continue;
+ const delta = Math.max(0, h - slicerFirstLayerHeight);
+ let snapped = slicerFirstLayerHeight + Math.round(delta / layerHeight) * layerHeight;
+ snapped = Math.max(minModelH, Math.min(maxModelH, snapped));
+ pixelHeightMap[mi] = snapped;
+ }
+ }
+}
diff --git a/src/lib/meshing.ts b/src/lib/meshing.ts
index e67fe88c..2977d0e3 100644
--- a/src/lib/meshing.ts
+++ b/src/lib/meshing.ts
@@ -1,4 +1,5 @@
import { ShapeUtils, Vector2 } from 'three';
+import type { SmoothMeshingStrength } from '../types/index.ts';
export interface MeshData {
positions: Float32Array;
@@ -27,7 +28,7 @@ export interface MeshProgress {
progress: number;
}
-interface MeshYieldOptions {
+export interface MeshYieldOptions {
yieldIntervalMs?: number;
onYield?: () => Promise;
onProgress?: (progress: MeshProgress) => void;
@@ -36,6 +37,11 @@ interface MeshYieldOptions {
interface GridMeshOptions extends MeshYieldOptions {
mesher: 'greedy' | 'smooth';
smoothBoundary: boolean;
+ strength?: SmoothMeshingStrength;
+}
+
+interface SmoothMeshOptions extends MeshYieldOptions {
+ strength?: SmoothMeshingStrength;
}
interface Rect {
@@ -47,9 +53,15 @@ interface Rect {
type PixelMask = Uint8Array | Uint8ClampedArray | boolean[];
-const SMOOTH_VERTEX_MAX_MOVE = 0.49;
-const SMOOTH_BOUNDARY_ITERATIONS = 10;
-const SMOOTH_BOUNDARY_STRENGTH = 0.75;
+// Medium is byte-for-byte compatible with the original toggle. Aggressive starts
+// with that result, then uses one stable half-strength pass to remove residual
+// stair-step oscillation without enlarging the movement envelope.
+// Every move stays below half a pixel so neighboring grid vertices cannot cross.
+const SMOOTH_PRESETS = {
+ minimal: { maxMove: 0.2, iterations: 2, relaxation: 0.5, finalRelaxation: 0.5 },
+ medium: { maxMove: 0.49, iterations: 10, relaxation: 0.75, finalRelaxation: 0.75 },
+ aggressive: { maxMove: 0.49, iterations: 11, relaxation: 0.75, finalRelaxation: 0.5 },
+} as const;
const THREE_MF_COORD_SCALE = 100_000;
function progressInUnitSpan(index: number, count: number, start: number, span: number) {
@@ -127,10 +139,7 @@ function meshLabel(mesher: 'greedy' | 'smooth', label: string) {
}
function triangleArea2(a: Vector2, b: Vector2, c: Vector2) {
- return (
- (b.x - a.x) * (c.y - a.y) -
- (b.y - a.y) * (c.x - a.x)
- );
+ return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}
function pointInsideTriangle(
@@ -234,7 +243,7 @@ function triangulateBoundaryByEarClipping(
* Earcut may omit collinear boundary vertices. Restore those vertices so cap
* edges still pair exactly with wall edges, then validate both mesh and slicer precision.
*/
-function triangulateBoundaryPreservingVertices(
+export function triangulateBoundaryPreservingVertices(
points: Vector2[],
roundedPoints: Vector2[],
areaEpsilon: number
@@ -274,8 +283,7 @@ function triangulateBoundaryPreservingVertices(
for (const vertex of chain) {
const point = roundedPoints[vertex];
if (Math.abs(triangleArea2(a, point, b)) > collinearEpsilon) return null;
- const projection =
- (point.x - a.x) * segmentX + (point.y - a.y) * segmentY;
+ const projection = (point.x - a.x) * segmentX + (point.y - a.y) * segmentY;
if (
projection < previousProjection - collinearEpsilon ||
projection < -collinearEpsilon ||
@@ -384,7 +392,8 @@ function createGridVertexMapper(
meshingPixels: PixelMask,
width: number,
height: number,
- smoothBoundary: boolean
+ smoothBoundary: boolean,
+ strength: SmoothMeshingStrength = 'medium'
) {
if (!smoothBoundary) {
return (x: number, y: number): [number, number] => [x, y];
@@ -424,6 +433,22 @@ function createGridVertexMapper(
}
}
+ return createBoundaryVertexMapper(boundaryNeighbors, width, height, strength);
+}
+
+type GridVertexMapper = (x: number, y: number) => [number, number];
+
+/** Chunk the shared rule without changing the order of any vertex calculations. */
+function* relaxBoundaryVertices(
+ boundaryNeighbors: Map>,
+ width: number,
+ height: number,
+ strength: SmoothMeshingStrength,
+ pinJunctions: boolean
+): Generator {
+ const { maxMove, iterations, relaxation, finalRelaxation } =
+ SMOOTH_PRESETS[strength === 'none' ? 'medium' : strength];
+ const stride = width + 1;
const clampToBounds = ([x, y]: [number, number]): [number, number] => [
Math.max(0, Math.min(width, x)),
Math.max(0, Math.min(height, y)),
@@ -438,58 +463,75 @@ function createGridVertexMapper(
const dy = targetY - originY;
const distance = Math.hypot(dx, dy);
- if (distance <= SMOOTH_VERTEX_MAX_MOVE || distance <= 1e-8) {
+ if (distance <= maxMove || distance <= 1e-8) {
return clampToBounds([targetX, targetY]);
}
- const scale = SMOOTH_VERTEX_MAX_MOVE / distance;
+ const scale = maxMove / distance;
return clampToBounds([originX + dx * scale, originY + dy * scale]);
};
- const positions = new Map();
+ let positions = new Map();
const origins = new Map();
-
- for (const key of boundaryNeighbors.keys()) {
- const x = key % stride;
- const y = Math.floor(key / stride);
- origins.set(key, [x, y]);
- positions.set(key, [x, y]);
+ const movableNeighbors = pinJunctions ? new Map>() : boundaryNeighbors;
+ const chunkSize = 256;
+ let processed = 0;
+
+ for (const [key, neighbors] of boundaryNeighbors) {
+ // Pinned junctions always use their original grid coordinates. Keep
+ // them out of every subsequent pass, especially on dense label grids.
+ if (!pinJunctions || neighbors.size === 2) {
+ const x = key % stride;
+ const y = Math.floor(key / stride);
+ origins.set(key, [x, y]);
+ positions.set(key, [x, y]);
+ if (pinJunctions) movableNeighbors.set(key, neighbors);
+ }
+ if (++processed % chunkSize === 0) yield (processed / boundaryNeighbors.size) * 0.1;
}
+ yield 0.1;
- for (let iteration = 0; iteration < SMOOTH_BOUNDARY_ITERATIONS; iteration++) {
+ for (let iteration = 0; iteration < iterations; iteration++) {
const nextPositions = new Map();
+ const iterationRelaxation = iteration === iterations - 1 ? finalRelaxation : relaxation;
+ processed = 0;
- for (const [key, neighbors] of boundaryNeighbors) {
+ for (const [key, neighbors] of movableNeighbors) {
const origin = origins.get(key)!;
const current = positions.get(key)!;
if (neighbors.size < 2) {
nextPositions.set(key, current);
- continue;
- }
-
- let averageX = 0;
- let averageY = 0;
- for (const neighbor of neighbors) {
- const neighborPosition = positions.get(neighbor)!;
- averageX += neighborPosition[0];
- averageY += neighborPosition[1];
- }
- averageX /= neighbors.size;
- averageY /= neighbors.size;
+ } else {
+ let averageX = 0;
+ let averageY = 0;
+ for (const neighbor of neighbors) {
+ const neighborPosition = positions.get(neighbor) ?? [
+ neighbor % stride,
+ Math.floor(neighbor / stride),
+ ];
+ averageX += neighborPosition[0];
+ averageY += neighborPosition[1];
+ }
+ averageX /= neighbors.size;
+ averageY /= neighbors.size;
- const candidate = clampMove(origin[0], origin[1], [
- current[0] + (averageX - current[0]) * SMOOTH_BOUNDARY_STRENGTH,
- current[1] + (averageY - current[1]) * SMOOTH_BOUNDARY_STRENGTH,
- ]);
+ const candidate = clampMove(origin[0], origin[1], [
+ current[0] + (averageX - current[0]) * iterationRelaxation,
+ current[1] + (averageY - current[1]) * iterationRelaxation,
+ ]);
- nextPositions.set(key, candidate);
+ nextPositions.set(key, candidate);
+ }
+ if (++processed % chunkSize === 0) {
+ yield 0.1 + ((iteration + processed / movableNeighbors.size) / iterations) * 0.9;
+ }
}
- positions.clear();
- for (const [key, position] of nextPositions) {
- positions.set(key, position);
- }
+ // The next pass only reads this map. Reuse it directly rather than
+ // clearing and copying every boundary position after each iteration.
+ positions = nextPositions;
+ yield 0.1 + ((iteration + 1) / iterations) * 0.9;
}
return (x: number, y: number): [number, number] => {
@@ -498,6 +540,54 @@ function createGridVertexMapper(
};
}
+/** Ordinary smooth meshes keep their synchronous mapper and original geometry. */
+export function createBoundaryVertexMapper(
+ boundaryNeighbors: Map>,
+ width: number,
+ height: number,
+ strength: SmoothMeshingStrength,
+ pinJunctions = false
+): GridVertexMapper {
+ const work = relaxBoundaryVertices(boundaryNeighbors, width, height, strength, pinJunctions);
+ let step = work.next();
+ while (!step.done) step = work.next();
+ return step.value;
+}
+
+/** Flat Paint can yield during both graph initialization and every relaxation pass. */
+export async function createBoundaryVertexMapperAsync(
+ boundaryNeighbors: Map>,
+ width: number,
+ height: number,
+ strength: SmoothMeshingStrength,
+ pinJunctions = false,
+ options: MeshYieldOptions = {}
+): Promise {
+ const work = relaxBoundaryVertices(boundaryNeighbors, width, height, strength, pinJunctions);
+ const yieldIntervalMs = options.yieldIntervalMs ?? 8;
+ const yieldControl =
+ options.onYield ??
+ (() =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ }));
+ let lastYield = performance.now();
+ let step = work.next();
+ while (!step.done) {
+ options.onProgress?.({
+ phase: 'smoothing',
+ label: 'Generating smooth mesh',
+ progress: step.value,
+ });
+ if (performance.now() - lastYield >= yieldIntervalMs) {
+ await yieldControl();
+ lastYield = performance.now();
+ }
+ step = work.next();
+ }
+ return step.value;
+}
+
async function generateGridMesh(
activePixels: PixelMask,
width: number,
@@ -555,7 +645,8 @@ async function generateGridMesh(
meshingPixels,
width,
height,
- options.smoothBoundary
+ options.smoothBoundary,
+ options.strength
);
const getOrAddVertex = (x: number, y: number, isTop: boolean): number => {
@@ -740,10 +831,7 @@ async function generateGridMesh(
if (!options.smoothBoundary) return new Vector2(vx, vy);
const [mappedX, mappedY] = getGridVertexXY(vx, vy);
- return new Vector2(
- Math.fround(mappedX * pixelSize),
- Math.fround(mappedY * pixelSize)
- );
+ return new Vector2(Math.fround(mappedX * pixelSize), Math.fround(mappedY * pixelSize));
});
// Cap points in integer 3MF coordinate units — the exact grid the
// exporter serializes and welds on. Degeneracy checks on these
@@ -763,11 +851,7 @@ async function generateGridMesh(
: facePoints;
const capAreaEpsilon = Math.max(Number.EPSILON, 1e-8 * pixelSize * pixelSize);
const faces = options.smoothBoundary
- ? triangulateBoundaryPreservingVertices(
- facePoints,
- roundedFacePoints,
- capAreaEpsilon
- )
+ ? triangulateBoundaryPreservingVertices(facePoints, roundedFacePoints, capAreaEpsilon)
: ShapeUtils.triangulateShape(facePoints, []);
if (!faces) {
throw new Error('Unable to triangulate a smoothed cap without breaking topology');
@@ -937,7 +1021,10 @@ async function generateGridMesh(
reportProgress({
phase: 'walls',
- label: options.mesher === 'smooth' ? 'Smooth mesh geometry complete' : 'Mesh geometry complete',
+ label:
+ options.mesher === 'smooth'
+ ? 'Smooth mesh geometry complete'
+ : 'Mesh geometry complete',
progress: 1,
});
@@ -968,13 +1055,34 @@ export async function generateSmoothMesh(
zOffset: number,
pixelSize: number,
heightScale: number,
- options?: MeshYieldOptions
+ options?: SmoothMeshOptions
): Promise {
- return generateGridMesh(activePixels, width, height, thickness, zOffset, pixelSize, heightScale, {
- ...options,
- mesher: 'smooth',
- smoothBoundary: true,
- });
+ if (options?.strength === 'none') {
+ return generateGreedyMesh(
+ activePixels,
+ width,
+ height,
+ thickness,
+ zOffset,
+ pixelSize,
+ heightScale,
+ options
+ );
+ }
+ return generateGridMesh(
+ activePixels,
+ width,
+ height,
+ thickness,
+ zOffset,
+ pixelSize,
+ heightScale,
+ {
+ ...options,
+ mesher: 'smooth',
+ smoothBoundary: true,
+ }
+ );
}
/**
@@ -1005,9 +1113,18 @@ export async function generateGreedyMesh(
heightScale: number,
options?: MeshYieldOptions
): Promise {
- return generateGridMesh(activePixels, width, height, thickness, zOffset, pixelSize, heightScale, {
- ...options,
- mesher: 'greedy',
- smoothBoundary: false,
- });
+ return generateGridMesh(
+ activePixels,
+ width,
+ height,
+ thickness,
+ zOffset,
+ pixelSize,
+ heightScale,
+ {
+ ...options,
+ mesher: 'greedy',
+ smoothBoundary: false,
+ }
+ );
}
diff --git a/src/lib/paletteManager.ts b/src/lib/paletteManager.ts
index 4f6ffafb..e70ad08b 100644
--- a/src/lib/paletteManager.ts
+++ b/src/lib/paletteManager.ts
@@ -287,6 +287,8 @@ function paletteContentEqual(a: CustomPalette, b: CustomPalette): boolean {
export interface ImportPaletteResult {
palettes: CustomPalette[];
imported: CustomPalette[];
+ /** Accepted files in input order, including already-saved content matches. */
+ resolved: CustomPalette[];
skipped: string[];
overwritten: string[];
renamed: string[];
@@ -309,6 +311,7 @@ export function importCustomPalettes(
const result: ImportPaletteResult = {
palettes: [...existing],
imported: [],
+ resolved: [],
skipped: [],
overwritten: [],
renamed: [],
@@ -360,12 +363,14 @@ export function importCustomPalettes(
result.palettes[idMatch] = { ...palette, updatedAt: now };
result.overwritten.push(palette.name);
result.imported.push(result.palettes[idMatch]);
+ result.resolved.push(result.palettes[idMatch]);
continue;
}
// 2. Content match (same colors + disabled set + names) → skip
const contentMatch = result.palettes.find((p) => paletteContentEqual(p, palette));
if (contentMatch) {
+ result.resolved.push(contentMatch);
result.skipped.push(`${palette.name} (matches "${contentMatch.name}")`);
continue;
}
@@ -382,6 +387,7 @@ export function importCustomPalettes(
result.palettes.push(palette);
result.imported.push(palette);
+ result.resolved.push(palette);
}
return result;
diff --git a/src/lib/previewLayerRange.ts b/src/lib/previewLayerRange.ts
new file mode 100644
index 00000000..f85ffaef
--- /dev/null
+++ b/src/lib/previewLayerRange.ts
@@ -0,0 +1,92 @@
+import * as THREE from 'three';
+
+const HEIGHT_EPSILON = 1e-6;
+interface ClippedGeometry {
+ original: THREE.BufferAttribute;
+ preview?: THREE.BufferAttribute;
+ min: number;
+ max: number;
+}
+const clippedGeometries = new WeakMap();
+
+/** Physical boundaries, including the thicker first layer and the exact model top. */
+export function previewLayerHeights(total: number, layerHeight: number, firstLayerHeight: number) {
+ if (total <= 0 || layerHeight <= 0) return [0];
+ const heights = [0];
+ const first = Math.max(layerHeight, firstLayerHeight);
+ for (let i = 0; first + i * layerHeight < total - HEIGHT_EPSILON; i++) {
+ heights.push(first + i * layerHeight);
+ }
+ heights.push(total);
+ return heights;
+}
+
+/** Wireframe edges must be built from the full slab, even while it is cut. */
+export function previewSourcePosition(geometry: THREE.BufferGeometry) {
+ return clippedGeometries.get(geometry)?.original ?? geometry.getAttribute('position');
+}
+
+function clipSlabGeometry(geometry: THREE.BufferGeometry, min: number, max: number) {
+ let state = clippedGeometries.get(geometry);
+ if (!state) {
+ const position = geometry.getAttribute('position');
+ if (!(position instanceof THREE.BufferAttribute)) return;
+ const original = new THREE.BufferAttribute(
+ position.array,
+ position.itemSize,
+ position.normalized
+ );
+ state = { original, min: -Infinity, max: Infinity };
+ clippedGeometries.set(geometry, state);
+ }
+ if (state.min === min && state.max === max) return;
+ state.min = min;
+ state.max = max;
+ // Detach the render array from export positions once, retaining the same
+ // BufferAttribute/GPU buffer when cutting and restoring the preview.
+ if (!state.preview) {
+ state.preview = geometry.getAttribute('position') as THREE.BufferAttribute;
+ state.preview.array = state.original.array.slice();
+ }
+ if (min === -Infinity && max === Infinity) {
+ state.preview.copyArray(state.original.array);
+ } else {
+ // Flat Paint is an extrusion: moving both caps to the cut boundaries
+ // preserves a closed solid without changing the source for export.
+ for (let i = 0; i < state.original.count; i++) {
+ state.preview.setZ(i, Math.max(min, Math.min(max, state.original.getZ(i))));
+ }
+ }
+ state.preview.needsUpdate = true;
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+}
+
+/** Keep scene-only wireframe edges at the same cut as their source slab. */
+export function syncPreviewGeometryRange(
+ source: THREE.BufferGeometry,
+ target: THREE.BufferGeometry
+) {
+ const range = clippedGeometries.get(source);
+ clipSlabGeometry(target, range?.min ?? -Infinity, range?.max ?? Infinity);
+}
+
+export function applyPreviewLayerRange(root: THREE.Object3D, low: number, high: number) {
+ const min = Math.min(low, high);
+ const max = Math.max(low, high);
+ root.traverse((child) => {
+ if (!(child instanceof THREE.Mesh) || child.userData.baseZ === undefined) return;
+ const base = Number(child.userData.baseZ);
+ const top = Number(child.userData.topZ ?? base);
+ child.visible = Math.min(top, max) - Math.max(base, min) > HEIGHT_EPSILON;
+ const scale = child.userData.kromacutFlatPaintHeightScale;
+ if (typeof scale !== 'number') return;
+ const cutBottom = min > base + HEIGHT_EPSILON;
+ const cutTop = max < top - HEIGHT_EPSILON;
+ clipSlabGeometry(
+ child.geometry,
+ cutBottom ? min * scale : -Infinity,
+ cutTop ? max * scale : Infinity
+ );
+ });
+}
diff --git a/src/lib/previewWireframe.ts b/src/lib/previewWireframe.ts
index 5ce25f13..ba6d41ae 100644
--- a/src/lib/previewWireframe.ts
+++ b/src/lib/previewWireframe.ts
@@ -1,4 +1,5 @@
import * as THREE from 'three';
+import { previewSourcePosition, syncPreviewGeometryRange } from './previewLayerRange.ts';
const FALLBACK_WIREFRAME_COLOR = 0xffffff;
const FEATURE_EDGE_THRESHOLD_DEGREES = 15;
@@ -103,7 +104,7 @@ async function createFeatureEdgeGeometry(
geometry: THREE.BufferGeometry,
options: PreviewWireframeBuildOptions
): Promise {
- const position = geometry.getAttribute('position');
+ const position = previewSourcePosition(geometry);
if (!position || position.itemSize < 3) return null;
const index = geometry.getIndex();
@@ -150,9 +151,7 @@ async function createFeatureEdgeGeometry(
if (sibling) {
const normalDot =
- normalX * sibling.normalX +
- normalY * sibling.normalY +
- normalZ * sibling.normalZ;
+ normalX * sibling.normalX + normalY * sibling.normalY + normalZ * sibling.normalZ;
if (normalDot <= FEATURE_EDGE_THRESHOLD_DOT) {
appendSegment(vertices, position, edgeStart, edgeEnd);
}
@@ -299,6 +298,7 @@ export async function rebuildPreviewWireframeOverlay(
lines.matrix.copy(mesh.matrixWorld);
lines.matrixWorldNeedsUpdate = true;
lines.visible = mesh.visible;
+ syncPreviewGeometryRange(mesh.geometry, geometry);
lines.renderOrder = 2000 + layerIndex++;
lines.userData[WIRE_SOURCE_MESH_KEY] = mesh;
overlay.add(lines);
@@ -312,6 +312,14 @@ export async function rebuildPreviewWireframeOverlay(
export function syncPreviewWireframeOverlayVisibility(overlay: THREE.Group): void {
for (const child of overlay.children) {
const sourceMesh = child.userData[WIRE_SOURCE_MESH_KEY] as THREE.Mesh | undefined;
- if (sourceMesh?.isMesh) child.visible = sourceMesh.visible;
+ if (sourceMesh?.isMesh) {
+ child.visible = sourceMesh.visible;
+ if ((child as THREE.LineSegments).isLineSegments) {
+ syncPreviewGeometryRange(
+ sourceMesh.geometry,
+ (child as THREE.LineSegments).geometry
+ );
+ }
+ }
}
}
diff --git a/src/lib/printSettingsStorage.ts b/src/lib/printSettingsStorage.ts
index 2314753b..d8962456 100644
--- a/src/lib/printSettingsStorage.ts
+++ b/src/lib/printSettingsStorage.ts
@@ -1,6 +1,7 @@
/**
* Persistence helpers for 3D print settings (layer height, pixel size, etc.).
*/
+import type { SmoothMeshingStrength } from '../types/index.ts';
export const PRINT_SETTINGS_STORAGE_KEY = 'kromacut:3d-print-settings';
@@ -11,21 +12,37 @@ export const DEFAULT_PRINT_SETTINGS = {
layerHeight: 0.12,
slicerFirstLayerHeight: 0.2,
pixelSize: 0.1,
- smoothMeshing: false,
+ smoothMeshingStrength: 'none',
} as const;
export type PrintSettings = {
layerHeight: number;
slicerFirstLayerHeight: number;
pixelSize: number;
- smoothMeshing: boolean;
+ smoothMeshingStrength: SmoothMeshingStrength;
};
export const arePrintSettingsDefault = (settings: PrintSettings): boolean =>
settings.layerHeight === DEFAULT_PRINT_SETTINGS.layerHeight &&
settings.slicerFirstLayerHeight === DEFAULT_PRINT_SETTINGS.slicerFirstLayerHeight &&
settings.pixelSize === DEFAULT_PRINT_SETTINGS.pixelSize &&
- settings.smoothMeshing === DEFAULT_PRINT_SETTINGS.smoothMeshing;
+ settings.smoothMeshingStrength === DEFAULT_PRINT_SETTINGS.smoothMeshingStrength;
+
+/** Keep the existing storage key and map the old enabled toggle to the familiar preset. */
+export function normalizeSmoothMeshingStrength(
+ strength: unknown,
+ legacyEnabled?: unknown
+): SmoothMeshingStrength {
+ if (
+ strength === 'none' ||
+ strength === 'minimal' ||
+ strength === 'medium' ||
+ strength === 'aggressive'
+ ) {
+ return strength;
+ }
+ return legacyEnabled === true ? 'medium' : 'none';
+}
export const clampNumber = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value));
@@ -35,7 +52,7 @@ export const loadPrintSettingsFromStorage = (): PrintSettings | null => {
try {
const raw = window.localStorage.getItem(PRINT_SETTINGS_STORAGE_KEY);
if (!raw) return null;
- const parsed = JSON.parse(raw) as Partial;
+ const parsed = JSON.parse(raw) as Partial & { smoothMeshing?: boolean };
const layerHeight =
typeof parsed.layerHeight === 'number' && isFinite(parsed.layerHeight)
? clampNumber(parsed.layerHeight, 0.01, 10)
@@ -49,11 +66,11 @@ export const loadPrintSettingsFromStorage = (): PrintSettings | null => {
typeof parsed.pixelSize === 'number' && isFinite(parsed.pixelSize)
? clampNumber(parsed.pixelSize, 0.01, 10)
: DEFAULT_PRINT_SETTINGS.pixelSize;
- const smoothMeshing =
- typeof parsed.smoothMeshing === 'boolean'
- ? parsed.smoothMeshing
- : DEFAULT_PRINT_SETTINGS.smoothMeshing;
- return { layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing };
+ const smoothMeshingStrength = normalizeSmoothMeshingStrength(
+ parsed.smoothMeshingStrength,
+ parsed.smoothMeshing
+ );
+ return { layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshingStrength };
} catch {
return null;
}
diff --git a/src/lib/profileManager.ts b/src/lib/profileManager.ts
index 4410cdb5..7cadbe07 100644
--- a/src/lib/profileManager.ts
+++ b/src/lib/profileManager.ts
@@ -390,6 +390,8 @@ function appearanceHasEvidence(appearance: AppearanceProfileV1 | undefined): boo
export interface ImportResult {
profiles: AutoPaintProfile[];
imported: AutoPaintProfile[];
+ /** Accepted files in input order, including already-saved content matches. */
+ resolved: AutoPaintProfile[];
skipped: string[];
overwritten: string[];
renamed: string[];
@@ -412,6 +414,7 @@ export function importProfiles(
const result: ImportResult = {
profiles: [...existing],
imported: [],
+ resolved: [],
skipped: [],
overwritten: [],
renamed: [],
@@ -463,6 +466,7 @@ export function importProfiles(
result.profiles[idMatch] = { ...profile, updatedAt: now };
result.overwritten.push(profile.name);
result.imported.push(result.profiles[idMatch]);
+ result.resolved.push(result.profiles[idMatch]);
continue;
}
@@ -473,6 +477,7 @@ export function importProfiles(
appearanceEqual(p.appearance, appearance)
);
if (contentMatch) {
+ result.resolved.push(contentMatch);
result.skipped.push(`${profile.name} (matches "${contentMatch.name}")`);
continue;
}
@@ -489,6 +494,7 @@ export function importProfiles(
result.profiles.push(profile);
result.imported.push(profile);
+ result.resolved.push(profile);
}
return result;
diff --git a/src/locales/bn/common.json b/src/locales/bn/common.json
index 03a909d7..1eb05a77 100644
--- a/src/locales/bn/common.json
+++ b/src/locales/bn/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "অবৈধ প্রোফাইল ফাইল",
+ "failedTitle": "ফাইল খোলা যায়নি",
+ "failedDescription": "“{{name}}” খোলা যায়নি। এটি 32 MiB-এর ছোট, পড়া যায় এমন Kromacut ফাইল কি না এবং স্থানীয় স্টোরেজে জায়গা আছে কি না যাচাই করুন।",
+ "unavailable": "ফাইল খোলার সুবিধা সাময়িকভাবে অনুপলব্ধ। Kromacut পুনরায় চালু করে আবার চেষ্টা করুন।",
+ "unsavedTitle": "ফিলামেন্টের অসংরক্ষিত পরিবর্তন",
+ "unsavedDescription": "“{{name}}” খুললে ফিলামেন্টের অসংরক্ষিত পরিবর্তন প্রতিস্থাপিত হবে। আগে সংরক্ষণ করতে পরিবর্তন রাখুন, অথবা সেগুলি বাতিল করতে প্রোফাইল খুলুন।",
+ "keepEdits": "পরিবর্তন রাখুন",
+ "openProfile": "প্রোফাইল খুলুন",
+ "close": "বন্ধ করুন"
+ },
"colorPicker": {
"hue": "বর্ণ",
"color": "রং",
@@ -27,6 +38,7 @@
"light": "হালকা",
"resources": "সহায়িকা ও কমিউনিটি",
"resourcesDescription": "নির্দেশিকা, নতুন সংস্করণ এবং নিজের কাজ শেয়ার করার জায়গা।",
+ "openLinkFailed": "এই পৃষ্ঠাটি খোলা যায়নি। আবার চেষ্টা করুন।",
"docs": "ডকুমেন্টেশন",
"docsDescription": "কাজের ধাপ ও প্রিন্ট সেটিংস জানুন।",
"reddit": "Reddit-এ r/kromacut",
diff --git a/src/locales/bn/docs/3d-mode.md b/src/locales/bn/docs/3d-mode.md
index 6d49100d..f3d184d3 100644
--- a/src/locales/bn/docs/3d-mode.md
+++ b/src/locales/bn/docs/3d-mode.md
@@ -76,13 +76,24 @@ _উদাহরণমূলক প্রস্থচ্ছেদ। পরে
মসৃণকরণ বন্ধ থাকলে বাইরের রেখা বর্গাকার পিক্সেল গ্রিড অনুসরণ করে। চালু থাকলে সংযুক্ত সীমানা মসৃণ, জোড়া জ্যামিতিতে পরিণত হয়। এই পার্থক্য এক্সপোর্ট হয়, প্রিভিউ ফিল্টার নয়।
+প্রিভিউ ও রপ্তানিতে মডেলের প্রান্ত মসৃণ করে। মাঝারি আগের মসৃণতা বজায় রাখে। প্রয়োগ করতে আবার তৈরি করুন।
+
+| মাত্রা | ব্যবহার |
+| --- | --- |
+| **কোনোটিই নয়** | পিক্সেল আর্ট, সঠিক গ্রিড প্রান্ত বা দ্রুততম তৈরি। |
+| **ন্যূনতম** | খাঁজযুক্ত কোণ হালকাভাবে পরিষ্কার করা। |
+| **মাঝারি** | আগের সক্রিয় সেটিংয়ের পরিচিত ফলাফল। |
+| **তীব্র** | মাঝারির সমান সরণসীমার মধ্যে প্রান্ত ও বাকি সিঁড়ির মতো ধাপ আরও মসৃণ করা। |
+
+সরণ উৎস পিক্সেলের অর্ধেকের কম থাকে। মডেল আবার তৈরি করে স্লাইসারের প্রিভিউ দেখুন। পুরোনো চালু/বন্ধ সেটিং মাঝারি/কোনোটিই নয় হয়।
+

_উদাহরণমূলক কনট্যুরের তুলনা, স্লাইসারের সিমুলেশন নয়।_
বাঁকা বা তির্যক রেখা অতিরিক্ত সিঁড়ির মতো দেখালে ব্যবহার করুন। ইচ্ছাকৃত পিক্সেল আর্ট বা হুবহু গ্রিডের ধার চাইলে বন্ধ রাখুন। কোনোটিই প্রিন্ট-অযোগ্য ছোট খুঁটিনাটি মেরামত করে না বা উৎসে না থাকা রেজোলিউশন তৈরি করে না।
-[সমতল প্রিন্ট মোডে](flat-paint) মসৃণ মেশ নিষ্ক্রিয়। মসৃণ মেশ চালু করলে সমতল প্রিন্ট মোড বন্ধ হয়; সমতল প্রিন্ট মোড তার বদলে পুরো ক্ষেত্রজুড়ে পাতের নির্মাণ ব্যবহার করে।
+মসৃণ মেশ [সমতল প্রিন্ট মোডেও](flat-paint) কাজ করে। উভয় অভিমুখে পাত সমতল এবং রঙের সীমানা যৌথ থাকে; মাত্রা বেছে নিয়ে আবার তৈরি করুন।
## স্বয়ংক্রিয় রং
diff --git a/src/locales/bn/docs/auto-paint.md b/src/locales/bn/docs/auto-paint.md
index dcafdeda..74402a2d 100644
--- a/src/locales/bn/docs/auto-paint.md
+++ b/src/locales/bn/docs/auto-paint.md
@@ -135,7 +135,7 @@ _ধারণামূলক ক্রম ও বরাদ্দ, উপাদ
_পদ্ধতির উদাহরণ, নিশ্চিত আগে-পরে ফল নয়। ওপরের উচ্চতা আলাদা মানে অতিরিক্ত স্পুলের রং নয়।_
-বর্তমান স্বয়ংক্রিয় রঙের অনেক বরাদ্দ এই ধাপের আগেই পৃথক মুদ্রণযোগ্য স্তর বেছে নেয়। ইতিমধ্যে আটকানো অঞ্চলে ছড়ানোর মতো ভগ্নাংশ-উচ্চতার ত্রুটি নেই, তাই ডিথারিং চালু করলেও অপরিবর্তিত থাকতে পারে। এই ছবিতে সত্যিই উচ্চতার বৈচিত্র্য বাড়ছে কি না নতুন প্রিভিউ ও স্লাইসারে দেখুন; সুইচ চালু হলেই বেশি টোন বা দৃশ্যমান বিন্দু নিশ্চিত নয়।
+ডিথারিং চালু থাকলে, নির্বাচিত স্তর ও পাশের মুদ্রণযোগ্য স্তরের মিশ্রণ পূর্বানুমান করা রঙের মিল উন্নত করলে স্বয়ংক্রিয় রঙ তাদের মাঝের উচ্চতা খোঁজে। হুবহু রঙের মিল এবং ক্যালিব্রেট করা টার্গেটের মিল নির্বাচিত উচ্চতাতেই থাকে; উপযোগী পাশের মিশ্রণ নেই এমন অঞ্চলও অপরিবর্তিত থাকে। সেটিং বদলানোর পর মডেল আবার তৈরি করুন, তারপর প্রিভিউ ও স্লাইসারের ফল তুলনা করুন। ডিথারিং বন্ধ করলে সাধারণ পৃথক স্তরভিত্তিক মিল ফিরে আসে।
বিন্দুর আকার **পিক্সেলের আকার**-এর তুলনায় **3D প্রিন্ট সেটিংস**-এর **কার্যকর রেখার প্রস্থ** অনুসরণ করে, পূর্ণ পিক্সেলের ব্লকে রাউন্ড হয়। এটি আনুমানিক, হুবহু সর্বনিম্ন প্রস্থের নিশ্চয়তা নয়। সীমানার ত্রুটি কমাতে ধারের অঞ্চলে একই ডিথারিং প্রয়োগ হয় না। ছোট বিচ্ছিন্ন অংশ ও অতিরিক্ত যাতায়াত স্লাইসারে দেখুন।
diff --git a/src/locales/bn/docs/flat-paint.md b/src/locales/bn/docs/flat-paint.md
index 8d0c4213..edb0bfcd 100644
--- a/src/locales/bn/docs/flat-paint.md
+++ b/src/locales/bn/docs/flat-paint.md
@@ -15,6 +15,10 @@ description: ধাপওয়ালা উঁচু নকশা, ছবি
_ধারণামূলক কাটা দৃশ্য। রং উপাদান চিহ্নিত করে; লেবেল দেখার দিক চিহ্নিত করে।_
+তৈরির অগ্রগতি মেশের অংশ গণনা করে, প্রিন্ট করা স্তর নয়: একাধিক রঙিন অংশ একই স্তর ভাগ করে নিতে পারে। মডেল ব্যাজ ভৌত স্তরের সংখ্যা দেখায়, ব্যবহার করা হলে স্বচ্ছ বাহকসহ। স্তরের প্রিভিউ পাতটিকে একবারে একটি ভৌত স্তর করে কাটে; দুটি হ্যান্ডেলই একত্রিত ভিত্তি ও রঙের অঞ্চলও কাটে। এক্সপোর্টে সবসময় সম্পূর্ণ মডেল থাকে। সেটিং না বদলালেও 3D মডেল তৈরি করুন সবসময় মডেলটি নতুন করে তৈরি করে এবং পূর্ণ স্তর-পরিসর ও প্রাথমিক ক্যামেরা দৃশ্য ফিরিয়ে আনে।
+
+তৈরি করতে ব্যর্থ হলে Kromacut অসম্পূর্ণ মডেলটি সরিয়ে প্রিভিউতে ত্রুটি দেখায়। আবার চেষ্টা করতে 3D মডেল তৈরি করুন চাপুন; সফলভাবে তৈরি হলে মডেলের মাপ ও স্তরের প্রিভিউ আবার দেখা যাবে।
+
## ডিফল্ট: স্বচ্ছ বাহকসহ ছবি নিচে
**সমতল প্রিন্ট মোড** চালু করুন এবং **ছবি ওপরে, স্বচ্ছ স্তর ছাড়া** বন্ধ রাখুন।
@@ -47,7 +51,7 @@ _ধারণামূলক কাটা দৃশ্য। রং উপাদ
| সমতল প্রিন্ট, ডিফল্ট | স্বচ্ছ বাহকের মধ্য দিয়ে নিচের পৃষ্ঠ | মিরর করা, উল্টো কলাম, পেছনে ভিত্তি। | প্রতি ফিলামেন্টের অবজেক্ট ও বাহক। |
| সমতল প্রিন্ট, ছবি ওপরে | উন্মুক্ত সমতল ওপরের পৃষ্ঠ | স্বাভাবিক ক্রম, ছোট কলামের নিচে ভিত্তি। | প্রতি ফিলামেন্টের অবজেক্ট; বাহক নেই। |
-সমতল প্রিন্ট এবং **মসৃণ মেশ** একসঙ্গে চলে না। পাতটি মসৃণ বাইরের রেখার বদলে পুরো জমিজুড়ে পাশাপাশি অঞ্চল ব্যবহার করে। মসৃণ মেশ চালু করলে সমতল প্রিন্ট বন্ধ হয়; বদলানোর সময় চাওয়া বিন্যাস বেছে মডেল আবার তৈরি করুন।
+**মসৃণ মেশ** সমতল প্রিন্টের উভয় অভিমুখে কাজ করে। মাত্রা বেছে নিয়ে আবার তৈরি করুন। এটি বাইরের অবয়ব ও রঙের যৌথ সীমানা মসৃণ করে, কিন্তু পাত সমতল থাকে এবং স্তরের উচ্চতা ও উপাদানের ক্রম বজায় থাকে। ছোট যৌথ সংযোগ তির্যক কোণের স্পর্শ বন্ধ করে, যাতে অঞ্চলগুলো একে অন্যের উপর না পড়ে। প্রিন্ট নির্দেশনা ও 3MF ব্যবহৃত মাত্রা সংরক্ষণ করে।
## এক্সপোর্ট ও পরীক্ষা
diff --git a/src/locales/bn/docs/generating-exporting-output.md b/src/locales/bn/docs/generating-exporting-output.md
index 7be09800..80cdfdc3 100644
--- a/src/locales/bn/docs/generating-exporting-output.md
+++ b/src/locales/bn/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ Kromacut **কর্মক্ষমতার সতর্কতা** দেখ
STL-এ ফিলামেন্টের রং বা স্বয়ংক্রিয় স্পুল বরাদ্দ থাকে না। স্লাইসারের রং-বদলের নিয়ন্ত্রণের সঙ্গে কপি করা বদলের পরিকল্পনা ব্যবহার করুন। 3MF-ও একটি মডেল, সরাসরি চালানোর G-code নয়: নিজের প্রিন্টার, নজল, ফিলামেন্ট প্রোফাইল, তাপমাত্রা ও গতি বেছে নিয়ে তারপর স্লাইস করুন।
-**সমতল প্রিন্ট মোড**-এর মডেলে ডাউনলোড মেনুতে শুধু 3MF থাকে: এতে প্রতিটি প্রকৃত ফিলামেন্টের জন্য একটি অবজেক্ট এবং ডিফল্ট ছবি-নিচে বিন্যাসে একটি স্বচ্ছ বাহক অবজেক্ট থাকে। ঐচ্ছিক ছবি-ওপরে বিন্যাসে বাহক থাকে না। যেকোনো সমতল পাতের রংহীন একক জ্যামিতির STL কাজে আসবে না। সমতল প্রিন্ট মোড **মসৃণ মেশ** বন্ধ করে, কারণ সমতল পাতের বিন্যাস মসৃণ করা সীমানার কনট্যুর ব্যবহার করে না।
+**সমতল প্রিন্ট মোড**-এর মডেলে ডাউনলোড মেনুতে শুধু 3MF থাকে: এতে প্রতিটি প্রকৃত ফিলামেন্টের জন্য একটি অবজেক্ট এবং ডিফল্ট ছবি-নিচে বিন্যাসে একটি স্বচ্ছ বাহক অবজেক্ট থাকে। ঐচ্ছিক ছবি-ওপরে বিন্যাসে বাহক থাকে না। যেকোনো সমতল পাতের রংহীন একক জ্যামিতির STL কাজে আসবে না। উভয় অভিমুখেই **মসৃণ মেশ**-এর সব মাত্রা ব্যবহার করা যায়। মাত্রা বদলানোর পরে প্রিভিউ ও এক্সপোর্ট করা জ্যামিতিতে তা প্রয়োগ করতে মডেল আবার তৈরি করুন।
## প্রিন্টের নির্দেশনা
diff --git a/src/locales/bn/docs/settings-and-controls.md b/src/locales/bn/docs/settings-and-controls.md
index c1b45c8d..58ee797a 100644
--- a/src/locales/bn/docs/settings-and-controls.md
+++ b/src/locales/bn/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ description: হেডারের কাজ, থিম, সংরক্ষণ,
প্রকাশ্য পৃষ্ঠায়ও ভাষা নির্বাচক আছে। অনূদিত ডকুমেন্টেশনের ভাষা-উপসর্গযুক্ত লিংক শেয়ার করা যায়, যেমন `/ro/docs/overview`। ভাষা বদলালেও পৃষ্ঠার নাম ও বিভাগের অ্যাঙ্কর স্থির থাকে। ফাইলের এক্সটেনশন, সংখ্যাভিত্তিক মডেল তথ্য, ব্যবহারকারীর দেওয়া নাম ও কমিউনিটির শিল্পকর্মের মূল শিরোনাম অনুবাদ হয় না।
-সেটিংস ডায়ালগে ডকুমেন্টেশন, Discord, Reddit, GitHub ও Patreon-এর লিংক এবং বর্তমান Kromacut সংস্করণ থাকে।
+সেটিংস ডায়ালগে ডকুমেন্টেশন, Discord, Reddit, GitHub ও Patreon-এর লিংক এবং বর্তমান Kromacut সংস্করণ থাকে। **গোপনীয়তা ও স্থানীয় ডেটা** এবং **নিয়ম ও শর্তাবলি** এখানেও পাওয়া যায় এবং নির্বাচিত ভাষায় আপনার ব্রাউজারে খোলে।
## কর্মক্ষেত্রের মোড
@@ -123,4 +123,16 @@ Linux-এ, যেসব AppImage-এ আপডেটের তথ্য সং
রেকর্ডিং তদন্তের জন্য এবং বড় ফাইল তৈরি করতে পারে। সাধারণ প্রিন্টে ট্রেস দরকার না হলে বন্ধ রাখুন।
+## ডেস্কটপ থেকে ফাইল খোলা
+
+ডেস্কটপ ইনস্টলেশন `.kfil` ও পুরোনো `.kapp` ফাইলকে ফিলামেন্ট প্রোফাইলের সঙ্গে এবং `.kpal` ফাইলকে প্যালেটের সঙ্গে যুক্ত করে। Kromacut-এ আমদানি ও নির্বাচন করতে ফাইলে ডাবল-ক্লিক করুন। অ্যাপ আগে থেকেই চললে তার বর্তমান উইন্ডো ব্যবহার হয়। যাচাই, মাইগ্রেশন, সদৃশ ফাইল পরিচালনা এবং ক্যালিব্রেশন ধরে রাখার স্বাভাবিক নিয়ম প্রযোজ্য।
+
+প্যালেট এডিটর, ক্যালিব্রেশন ডায়ালগ বা প্রোফাইলের নাম পরিবর্তন বা নতুন প্রোফাইল সংরক্ষণের ফর্ম খোলা থাকলে ডেস্কটপ থেকে খোলা ফাইলগুলো অপেক্ষা করে। সারিতে থাকা আমদানিগুলো চালিয়ে যেতে সেই সেশনটি শেষ করুন বা বাতিল করুন; ততক্ষণ লেখা নাম ও সম্পাদনা করা প্রোফাইল অপরিবর্তিত থাকে।
+
+ফিলামেন্টের নাম বা HD ঘর সম্পাদনা করার সময়, অথবা তার রং নির্বাচক বা **TD থেকে রূপান্তর** পপওভার খোলা থাকলেও আমদানি অপেক্ষা করে। সারিতে থাকা আমদানি আবার শুরু করতে ঘর থেকে বেরিয়ে যান বা নির্বাচক অথবা পপওভার বন্ধ করুন; অন্য প্রোফাইল দিয়ে ফিলামেন্টের অসংরক্ষিত পরিবর্তন প্রতিস্থাপনের আগে এখনও আপনার সিদ্ধান্ত চাওয়া হয়।
+
+ফিলামেন্টের অসংরক্ষিত পরিবর্তন প্রতিস্থাপনের আগে Kromacut **পরিবর্তন রাখুন** বা **প্রোফাইল খুলুন** বিকল্প দেয়। আগে সংরক্ষণ করতে পরিবর্তন রাখুন; প্রোফাইল খুললে সেগুলি বাতিল হয়। এভাবে খোলা ফাইল 32 MiB-এর ছোট হতে হবে। সাধারণ JSON, ছবি ও মডেলের স্বাভাবিক আমদানি পদ্ধতি বজায় থাকে।
+
+Linux-এ পোর্টেবল AppImage-এর ফাইল অ্যাসোসিয়েশনের জন্য ডেস্কটপ ইন্টিগ্রেশন দরকার। সিস্টেমে Kromacut ডিফল্ট অ্যাপ না হলে ফাইল ম্যানেজারে **এর সাহায্যে খুলুন** ব্যবহার করুন।
+
পরবর্তী: [সমস্যার সমাধান](troubleshooting)।
diff --git a/src/locales/bn/docs/troubleshooting.md b/src/locales/bn/docs/troubleshooting.md
index 015ff298..06c44724 100644
--- a/src/locales/bn/docs/troubleshooting.md
+++ b/src/locales/bn/docs/troubleshooting.md
@@ -55,7 +55,7 @@ description: সাধারণ সমস্যা এবং প্রথমে
## একটি সুইচ চালু করায় অন্যটি বন্ধ হয়েছে
-দুটি জোড়া ইচ্ছাকৃতভাবে একসঙ্গে চালু রাখা যায় না: **মসৃণ মেশ** ও **সমতল প্রিন্ট মোড**, এবং **রঙের পৃথকতা বজায় রাখুন** ও **উচ্চতার ডিথারিং**। প্রথম জোড়াটি ভিন্ন জ্যামিতিক বিন্যাস বেছে নেয়; দ্বিতীয়টি উৎসের রংকে মুদ্রণযোগ্য উচ্চতায় বসানোর ভিন্ন পদ্ধতি বেছে নেয়। [সমতল প্রিন্ট মোড](flat-paint) ও [স্বয়ংক্রিয় রং](auto-paint) দেখুন।
+**রঙের পৃথকতা বজায় রাখুন** ও **উচ্চতার ডিথারিং** এখনও একসঙ্গে চালু রাখা যায় না, কারণ তারা উৎসের রংকে মুদ্রণযোগ্য উচ্চতায় ভিন্নভাবে বসায়। **মসৃণ মেশ** ও **সমতল প্রিন্ট মোড** একসঙ্গে ব্যবহার করা যায়; যেকোনো সেটিং বদলালে আবার তৈরি করুন। [সমতল প্রিন্ট মোড](flat-paint) ও [স্বয়ংক্রিয় রং](auto-paint) দেখুন।
## রঙের পৃথকতা বজায় রেখে ফল পাওয়া যাচ্ছে না
diff --git a/src/locales/bn/printing.json b/src/locales/bn/printing.json
index 3e36d671..b52288f0 100644
--- a/src/locales/bn/printing.json
+++ b/src/locales/bn/printing.json
@@ -213,6 +213,8 @@
"tooManyColors_other": "অতিরিক্ত রং ({{count, number}}টি)"
},
"threeDView": {
+ "buildFailed": "3D মডেল তৈরি করা যায়নি",
+ "buildFailedDetails": "অসম্পূর্ণ মডেলটি সরানো হয়েছে। আবার চেষ্টা করতে 3D মডেল তৈরি করুন চাপুন। ত্রুটি: {{error}}",
"layerPreview": "স্তরের প্রিভিউ",
"previewBottomLayerCutoff": "প্রিভিউয়ের নিচের স্তরের সীমা",
"previewTopLayerCutoff": "প্রিভিউয়ের ওপরের স্তরের সীমা",
@@ -240,7 +242,11 @@
"firstLayerHeight": "প্রথম স্তরের উচ্চতা",
"effectiveLineWidth": "কার্যকর রেখার প্রস্থ",
"smoothMeshing": "মসৃণ মেশ",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "দ্রুত ওয়েল্ডেড টপোলজি দিয়ে যুক্ত রঙের সীমান্ত মসৃণ করুন। এটি চালু করলে সমতল প্রিন্ট মোড বন্ধ হয়।",
+ "smoothingNone": "কোনোটিই নয়",
+ "smoothingMinimal": "ন্যূনতম",
+ "smoothingMedium": "মাঝারি",
+ "smoothingAggressive": "তীব্র",
+ "smoothingHelp": "প্রিভিউ ও রপ্তানিতে মডেলের প্রান্ত মসৃণ করে। মাঝারি আগের মসৃণতা বজায় রাখে। প্রয়োগ করতে আবার তৈরি করুন।",
"modelDimensions": "মডেল: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -335,7 +341,7 @@
"readingImageLayers": "ছবির রঙের স্তর পড়া হচ্ছে",
"buildingLayer": "{{total}}টির মধ্যে রঙের স্তর {{layer}} তৈরি হচ্ছে",
"layerProgress": "স্তর {{layer}} / {{total}}: {{label}}",
- "flatPaintPartProgress": "সমতল প্রিন্টের অংশ {{part}} / {{total}}: {{label}}",
+ "flatPaintPartProgress": "সমতল প্রিন্টের মেশ অংশ {{part}} / {{total}}: {{label}}",
"partMeshComplete": "অংশের মেশ সম্পূর্ণ",
"selectingPixels": "সক্রিয় পিক্সেল বাছা হচ্ছে",
"preparingPreview": "প্রিভিউয়ের জ্যামিতি প্রস্তুত হচ্ছে",
diff --git a/src/locales/bn/public.json b/src/locales/bn/public.json
index 61e24f13..33c02586 100644
--- a/src/locales/bn/public.json
+++ b/src/locales/bn/public.json
@@ -110,7 +110,7 @@
"photos_one": "{{count}}টি ছবি",
"photos_other": "{{count}}টি ছবি",
"openPhoto": "{{title}} — {{kind}} {{index}} পূর্ণ আকারে খুলুন",
- "originalArtwork": "মূল শিল্পকর্ম: {{title}} ",
+ "originalArtwork": "মূল শিল্পকর্ম: {{title}} ",
"themeLabel": "থিম: {{theme}}"
},
"navigation": {
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 248b3030..c44d9152 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "Ungültige Profildatei",
+ "failedTitle": "Datei konnte nicht geöffnet werden",
+ "failedDescription": "„{{name}}“ konnte nicht geöffnet werden. Prüfen Sie, ob es eine lesbare Kromacut-Datei unter 32 MiB ist und genügend lokaler Speicher verfügbar ist.",
+ "unavailable": "Das Öffnen von Dateien ist vorübergehend nicht verfügbar. Starten Sie Kromacut neu und versuchen Sie es erneut.",
+ "unsavedTitle": "Ungespeicherte Filamentänderungen",
+ "unsavedDescription": "Beim Öffnen von „{{name}}“ werden Ihre ungespeicherten Filamentänderungen ersetzt. Behalten Sie die Änderungen, um sie zuerst zu speichern, oder öffnen Sie das Profil, um sie zu verwerfen.",
+ "keepEdits": "Änderungen behalten",
+ "openProfile": "Profil öffnen",
+ "close": "Schließen"
+ },
"colorPicker": {
"hue": "Farbton",
"color": "Farbe",
@@ -27,6 +38,7 @@
"light": "Hell",
"resources": "Ressourcen und Community",
"resourcesDescription": "Anleitungen, Veröffentlichungen und Orte zum Teilen deiner Werke.",
+ "openLinkFailed": "Diese Seite konnte nicht geöffnet werden. Versuche es erneut.",
"docs": "Dokumentation",
"docsDescription": "Lerne den Arbeitsablauf und die Druckeinstellungen kennen.",
"reddit": "r/kromacut auf Reddit",
diff --git a/src/locales/de/docs/3d-mode.md b/src/locales/de/docs/3d-mode.md
index e7aab326..7fbf31e3 100644
--- a/src/locales/de/docs/3d-mode.md
+++ b/src/locales/de/docs/3d-mode.md
@@ -76,13 +76,24 @@ Manuelle Steuerung und Wechselanweisungen unterstützen **64 Farben**. Reduziere
Ohne Glättung folgen die Konturen dem quadratischen Pixelraster. Mit Glättung werden verbundene Grenzen zu geglätteter, verschweißter Geometrie. Dieser Unterschied wird exportiert; es ist kein Vorschaufilter.
+Glättet Modellkonturen in Vorschau und Exporten. Mittel entspricht der bisherigen Glättung. Zum Anwenden neu erstellen.
+
+| Stärke | Verwendung |
+| --- | --- |
+| **Keine** | Pixel-Art, exakte Rasterkanten oder schnellste Erstellung. |
+| **Minimal** | Leichte Glättung gezackter Ecken. |
+| **Mittel** | Das vertraute Ergebnis der bisherigen aktivierten Einstellung. |
+| **Stark** | Stärkere Glättung entlang der Konturen und verbleibenden Stufen bei gleicher Bewegungsgrenze wie Mittel. |
+
+Die Bewegung bleibt unter einem halben Pixel. Modell neu erstellen und Slicer-Vorschau prüfen. Bisherige Ein/Aus-Einstellungen werden zu Mittel/Keine.
+

_Schematischer Konturvergleich, keine Slicer-Simulation._
Nutze die Funktion für gekrümmte oder diagonale Konturen, die zu treppenförmig wirken. Lass sie bei absichtlicher Pixelkunst oder exakten Rasterkanten ausgeschaltet. Keine der beiden Optionen repariert unbedruckbar kleine Details oder erfindet fehlende Quellauflösung.
-Während [Flat Paint](flat-paint) ist die glatte Vernetzung inaktiv. Ihr Aktivieren deaktiviert Flat Paint; Flat Paint verwendet stattdessen seine Plattenkonstruktion über der gesamten Grundfläche.
+Glatte Vernetzung funktioniert auch mit [Flat Paint](flat-paint). Beide Ausrichtungen behalten eine flache Platte und gemeinsame Farbgrenzen; wähle eine Stärke und baue das Modell neu.
## Auto-paint
diff --git a/src/locales/de/docs/auto-paint.md b/src/locales/de/docs/auto-paint.md
index 642e16e2..710f3f98 100644
--- a/src/locales/de/docs/auto-paint.md
+++ b/src/locales/de/docs/auto-paint.md
@@ -135,7 +135,7 @@ Höhen-Dithering kann Rundungsfehler auf kleine Blöcke mit benachbarten druckba
_Schematischer Mechanismus, kein garantiertes Vorher-nachher-Ergebnis. Unterschiedliche Oberhöhen bedeuten keine zusätzlichen Spulenfarben._
-Viele aktuelle Auto-paint-Zuordnungen wählen bereits vor diesem Schritt eine diskrete druckbare Schicht. Diese bereits eingerasteten Bereiche haben keinen Zwischenhöhenfehler zu verteilen und können bei aktiviertem Dithering unverändert bleiben. Prüfe an der neu erstellten Vorschau und im Slicer, ob gerade dieses Bild tatsächlich zusätzliche Höhenvariation erhält. Das Aktivieren garantiert weder mehr Tonwerte noch sichtbare Punkte.
+Bei aktiviertem Dithering sucht Auto-paint eine Zwischenhöhe zwischen der ausgewählten und einer direkt benachbarten druckbaren Schicht, wenn deren Mischung die vorhergesagte Farbübereinstimmung verbessert. Exakte Farbtreffer und kalibrierte Zielzuordnungen behalten ihre Höhe; Bereiche ohne sinnvolle benachbarte Mischung bleiben unverändert. Erstelle das Modell nach einer Änderung neu und vergleiche Vorschau und Slicer-Ergebnis. Ohne Dithering gilt wieder die normale diskrete Zuordnung.
Die Punktgröße richtet sich nach **Effektive Linienbreite** in den **3D-Druckeinstellungen** im Verhältnis zur **Pixelgröße**, gerundet auf eine ganzzahlige Pixelblockgröße. Das ist eine Näherung, keine exakte Mindestbreitengarantie. Randbereiche werden anders behandelt, um Grenzartefakte zu verringern. Prüfe im Slicer auf winzige Inseln und zusätzliche Leerfahrten.
diff --git a/src/locales/de/docs/flat-paint.md b/src/locales/de/docs/flat-paint.md
index 1574fd28..53684d40 100644
--- a/src/locales/de/docs/flat-paint.md
+++ b/src/locales/de/docs/flat-paint.md
@@ -15,6 +15,10 @@ Verwende einen Mehrmaterialablauf wie AMS, CFS oder einen Werkzeugwechsler mit p
_Schematische Querschnitte. Farben kennzeichnen Materialien, Beschriftungen die Betrachtungsseite._
+Der Erstellungsfortschritt zählt Netzteile, nicht Druckschichten: Mehrere farbige Teile können dieselbe Schicht belegen. Die Modellanzeige zeigt die Anzahl physischer Schichten einschließlich des transparenten Trägers, falls verwendet. Die Schichtvorschau schneidet die Platte in einzelnen Druckschichten; beide Regler schneiden auch zusammengefasste Grundmaterial- und Farbbereiche. Der Export enthält immer das vollständige Modell. Die Schaltfläche 3D-Modell erstellen erzeugt das Modell auch bei unveränderten Einstellungen neu und stellt den vollständigen Schichtbereich sowie die ursprüngliche Kameraansicht wieder her.
+
+Wenn die Erstellung fehlschlägt, entfernt Kromacut das unvollständige Modell und zeigt den Fehler in der Vorschau an. Klicke zum erneuten Versuch auf 3D-Modell erstellen. Nach erfolgreicher Erstellung werden Modellabmessungen und Schichtvorschau wieder angezeigt.
+
## Standard: Bildseite unten mit transparentem Träger
Aktiviere **Flat Paint** und lass **Bildseite oben, ohne transparente Schicht** ausgeschaltet.
@@ -47,7 +51,7 @@ Diese Anordnungen haben unterschiedliche Geometrie. Klicke nach dem Umschalten e
| Flat Paint, Standard | Unterseite durch transparenten Träger | Gespiegelte, umgekehrte Spalten mit Grundmaterial dahinter. | Objekte je Filament und zusätzlicher Träger. |
| Flat Paint, Bildseite oben | Freiliegende flache Oberseite | Normale Reihenfolge mit Grundmaterial unter kürzeren Spalten. | Objekte je Filament, kein Träger. |
-Flat Paint und **Glatte Vernetzung** funktionieren nicht gleichzeitig. Die Platte verwendet nebeneinanderliegende Bereiche über der gesamten Grundfläche statt geglätteter Konturen. Das Aktivieren der glatten Vernetzung deaktiviert Flat Paint. Wähle beim Wechsel die gewünschte Anordnung und erstelle das Modell neu.
+**Glatte Vernetzung** funktioniert mit beiden Flat-Paint-Ausrichtungen. Wähle eine Stärke und baue das Modell neu. Die Glättung mildert Außenkontur und gemeinsame Farbgrenzen, während Platte, Schichthöhen und Materialstapel erhalten bleiben. Kleine gemeinsame Verbindungen schließen diagonale Eckkontakte ohne Überlappung. Druckanweisungen und 3MF halten die verwendete Stärke fest.
## Exportieren und prüfen
diff --git a/src/locales/de/docs/generating-exporting-output.md b/src/locales/de/docs/generating-exporting-output.md
index e6d9ce5d..b67f8ce5 100644
--- a/src/locales/de/docs/generating-exporting-output.md
+++ b/src/locales/de/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ Eine Auto-paint-Vorschau kann Dutzende Mischfarben aus nur wenigen tatsächliche
STL enthält keine Filamentfarben oder automatischen Spulenzuweisungen. Verwende den kopierten Wechselplan mit den Farbwechselfunktionen des Slicers. Auch eine 3MF-Datei ist weiterhin ein Modell und kein direkt ausführbarer G-Code: Wähle Drucker, Düse, Filamentprofile, Temperaturen und Geschwindigkeiten selbst und slice das Modell anschließend.
-Bei **Flat Paint** bietet das Downloadmenü nur 3MF an: Das Modell enthält je ein Objekt pro tatsächlichem Filament sowie in der Standardanordnung mit Bildseite unten ein transparentes Trägerobjekt. Die optionale Anordnung mit Bildseite oben lässt diesen Träger weg. Eine farblose STL mit nur einer Geometrie wäre bei beiden flachen Platten nutzlos. Flat Paint deaktiviert **Glatte Vernetzung**, da die flache Plattenanordnung keine geglätteten Grenzkonturen verwendet.
+Bei **Flat Paint** bietet das Downloadmenü nur 3MF an: Das Modell enthält je ein Objekt pro tatsächlichem Filament sowie in der Standardanordnung mit Bildseite unten ein transparentes Trägerobjekt. Die optionale Anordnung mit Bildseite oben lässt diesen Träger weg. Eine farblose STL mit nur einer Geometrie wäre bei beiden flachen Platten nutzlos. Beide Ausrichtungen unterstützen alle Stärken der **Glatten Vernetzung**. Baue das Modell nach einer Änderung der Stärke neu, um sie auf die Vorschau und die exportierte Geometrie anzuwenden.
## Druckanweisungen
diff --git a/src/locales/de/docs/settings-and-controls.md b/src/locales/de/docs/settings-and-controls.md
index fbb4eec5..8a9f988e 100644
--- a/src/locales/de/docs/settings-and-controls.md
+++ b/src/locales/de/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ Die Auswahl **Sprache** ändert Oberfläche, Dokumentation, Diagramme und öffen
Auch öffentliche Seiten bieten eine Sprachauswahl. Übersetzte Dokumentation verwendet teilbare Links mit Sprachpräfix, etwa `/ro/docs/overview`. Seitennamen und Abschnittsanker bleiben über die Sprachen hinweg stabil. Dateiendungen, numerische Modelldaten, selbst eingegebene Namen und Originaltitel von Community-Kunstwerken werden nicht übersetzt.
-Der Einstellungsdialog enthält Links zur Dokumentation, zu Discord, Reddit, GitHub und Patreon und zeigt die aktuelle Kromacut-Version.
+Der Einstellungsdialog enthält Links zur Dokumentation, zu Discord, Reddit, GitHub und Patreon und zeigt die aktuelle Kromacut-Version. **Datenschutz und lokale Daten** und **Nutzungsbedingungen** sind ebenfalls hier verfügbar und öffnen sich in deinem Browser in der gewählten Sprache.
## Arbeitsbereichsmodi
@@ -123,4 +123,16 @@ Eine vollständige Aufzeichnung enthält grundlegende Laufzeitinformationen, den
Die Aufzeichnung dient Untersuchungen und kann große Dateien erzeugen. Lass sie für normales Drucken deaktiviert, wenn du keine Aufzeichnung benötigst.
+## Dateien vom Desktop öffnen
+
+Desktop-Installationen verknüpfen `.kfil`-Dateien und ältere `.kapp`-Dateien mit Filamentprofilen sowie `.kpal`-Dateien mit Paletten. Doppelklicken Sie auf eine Datei, um sie in Kromacut zu importieren und auszuwählen. Läuft die App bereits, wird das vorhandene Fenster verwendet. Die üblichen Regeln für Validierung, Migration, Duplikate und den Erhalt von Kalibrierungen gelten weiterhin.
+
+Vom Desktop geöffnete Dateien warten, solange der Paletteneditor, der Kalibrierungsdialog oder ein Formular zum Umbenennen oder Speichern eines neuen Profils geöffnet ist. Schließe die Bearbeitung ab oder brich sie ab, um die wartenden Importe fortzusetzen; eingegebene Namen und das bearbeitete Profil bleiben bis dahin unverändert.
+
+Importe warten auch, während du den Namen oder das HD-Feld eines Filaments bearbeitest oder dessen Farbauswahl oder das Popover **Aus TD umrechnen** geöffnet ist. Verlasse das Feld oder schließe die Farbauswahl beziehungsweise das Popover, um die wartenden Importe fortzusetzen; bevor ein anderes Profil ungespeicherte Filamentänderungen ersetzt, musst du weiterhin entscheiden, ob du sie behalten möchtest.
+
+Vor dem Ersetzen ungespeicherter Filamentänderungen bietet Kromacut **Änderungen behalten** oder **Profil öffnen** an. Behalten Sie die Änderungen, um sie zuerst zu speichern; beim Öffnen des Profils werden sie verworfen. So geöffnete Dateien müssen kleiner als 32 MiB sein. Allgemeine JSON-Dateien, Bilder und Modelle behalten ihre üblichen Importabläufe.
+
+Unter Linux benötigen portable AppImages eine Desktop-Integration für Dateiverknüpfungen. Ist Kromacut nicht als Standard ausgewählt, verwenden Sie **Öffnen mit** im Dateimanager.
+
Weiter: [Fehlerbehebung](troubleshooting).
diff --git a/src/locales/de/docs/troubleshooting.md b/src/locales/de/docs/troubleshooting.md
index f6d28200..181305cc 100644
--- a/src/locales/de/docs/troubleshooting.md
+++ b/src/locales/de/docs/troubleshooting.md
@@ -55,7 +55,7 @@ Prüfe die Zeile **Erscheinungsmodell** gesondert. Ein hoher Gesamtwert garantie
## Ein Schalter hat einen anderen ausgeschaltet
-Zwei Paare schließen sich absichtlich aus: **Glatte Vernetzung** und **Flat Paint** sowie **Farbtrennung erhalten** und **Höhen-Dithering**. Das erste Paar wählt unterschiedliche Geometrieanordnungen; das zweite unterschiedliche Zuordnungen von Quellfarben zu druckbaren Höhen. Siehe [Flat Paint](flat-paint) und [Auto-paint](auto-paint).
+**Farbtrennung erhalten** und **Höhen-Dithering** schließen sich weiterhin aus, da sie Quellfarben unterschiedlich auf druckbare Höhen abbilden. **Glatte Vernetzung** und **Flat Paint** lassen sich gemeinsam verwenden; baue nach Änderungen neu. Siehe [Flat Paint](flat-paint) und [Auto-paint](auto-paint).
## Die Farbtrennung findet kein Ergebnis
diff --git a/src/locales/de/printing.json b/src/locales/de/printing.json
index 6e6eb208..df8aa603 100644
--- a/src/locales/de/printing.json
+++ b/src/locales/de/printing.json
@@ -212,7 +212,9 @@
"tooManyColors_one": "Zu viele Farben ({{count, number}})",
"tooManyColors_other": "Zu viele Farben ({{count, number}})"
},
- "threeDView": {
+ "threeDView": {
+ "buildFailed": "Das 3D-Modell konnte nicht erstellt werden",
+ "buildFailedDetails": "Das unvollständige Modell wurde entfernt. Klicke zum erneuten Versuch auf 3D-Modell erstellen. Fehler: {{error}}",
"layerPreview": "Schichtvorschau",
"previewBottomLayerCutoff": "Untere Schichtgrenze der Vorschau",
"previewTopLayerCutoff": "Obere Schichtgrenze der Vorschau",
@@ -240,7 +242,11 @@
"firstLayerHeight": "Höhe der ersten Schicht",
"effectiveLineWidth": "Effektive Linienbreite",
"smoothMeshing": "Geglättetes Netz",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "Zusammenhängende Farbkanten mit einer schnellen, verschweißten Topologie glätten. Die Aktivierung deaktiviert Flat Paint.",
+ "smoothingNone": "Keine",
+ "smoothingMinimal": "Minimal",
+ "smoothingMedium": "Mittel",
+ "smoothingAggressive": "Stark",
+ "smoothingHelp": "Glättet Modellkonturen in Vorschau und Exporten. Mittel entspricht der bisherigen Glättung. Zum Anwenden neu erstellen.",
"modelDimensions": "Modell: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -335,7 +341,7 @@
"readingImageLayers": "Bildfarbschichten werden gelesen",
"buildingLayer": "Farbschicht {{layer}} von {{total}} wird erstellt",
"layerProgress": "Schicht {{layer}} von {{total}}: {{label}}",
- "flatPaintPartProgress": "Flat-Paint-Teil {{part}} von {{total}}: {{label}}",
+ "flatPaintPartProgress": "Flat-Paint-Netzteil {{part}} von {{total}}: {{label}}",
"partMeshComplete": "Teilnetz fertig",
"selectingPixels": "Aktive Pixel werden ausgewählt",
"preparingPreview": "Vorschaugeometrie wird vorbereitet",
diff --git a/src/locales/de/public.json b/src/locales/de/public.json
index 8f7d5c47..a1ddb799 100644
--- a/src/locales/de/public.json
+++ b/src/locales/de/public.json
@@ -110,7 +110,7 @@
"photos_one": "{{count}} Foto",
"photos_other": "{{count}} Fotos",
"openPhoto": "{{title}} – {{kind}} {{index}} in voller Größe öffnen",
- "originalArtwork": "Originalmotiv: {{title}} ",
+ "originalArtwork": "Originalmotiv: {{title}} ",
"themeLabel": "Design: {{theme}}"
},
"navigation": {
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index b20f1176..91c53a8c 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -1,10 +1,21 @@
{
- "colorPicker": {
- "hue": "Hue",
- "color": "Color",
- "alpha": "Opacity",
- "saturationBrightness": "Saturation {{saturation}}%, Brightness {{brightness}}%"
- },
+ "desktopFiles": {
+ "invalidProfile": "Invalid profile file",
+ "failedTitle": "Could not open file",
+ "failedDescription": "Could not open “{{name}}”. Check that it is a readable Kromacut file under 32 MiB and that local storage has space.",
+ "unavailable": "File opening is temporarily unavailable. Restart Kromacut and try again.",
+ "unsavedTitle": "Unsaved filament edits",
+ "unsavedDescription": "Opening “{{name}}” will replace your unsaved filament edits. Keep your edits to save them first, or open the profile to discard them.",
+ "keepEdits": "Keep edits",
+ "openProfile": "Open profile",
+ "close": "Close"
+ },
+ "colorPicker": {
+ "hue": "Hue",
+ "color": "Color",
+ "alpha": "Opacity",
+ "saturationBrightness": "Saturation {{saturation}}%, Brightness {{brightness}}%"
+ },
"language": {
"title": "Language",
"system": "System language",
@@ -27,6 +38,7 @@
"light": "Light",
"resources": "Resources & community",
"resourcesDescription": "Guides, releases, and places to share what you make.",
+ "openLinkFailed": "Could not open this page. Try again.",
"docs": "Docs",
"docsDescription": "Learn the workflow and print settings.",
"reddit": "r/kromacut on Reddit",
diff --git a/src/locales/en/printing.json b/src/locales/en/printing.json
index 4231f207..a5c381ac 100644
--- a/src/locales/en/printing.json
+++ b/src/locales/en/printing.json
@@ -213,6 +213,8 @@
"tooManyColors_other": "Too many colors ({{count, number}})"
},
"threeDView": {
+ "buildFailed": "3D model could not be built",
+ "buildFailedDetails": "The incomplete model was removed. Press Build 3D Model to retry. Error: {{error}}",
"layerPreview": "Layer Preview",
"previewBottomLayerCutoff": "Preview bottom layer cutoff",
"previewTopLayerCutoff": "Preview top layer cutoff",
@@ -240,7 +242,11 @@
"firstLayerHeight": "First Layer Height",
"effectiveLineWidth": "Effective line width",
"smoothMeshing": "Smooth Meshing",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "Smooth connected color boundary edges with fast welded topology. Turning this on disables Flat Paint.",
+ "smoothingNone": "None",
+ "smoothingMinimal": "Minimal",
+ "smoothingMedium": "Medium",
+ "smoothingAggressive": "Aggressive",
+ "smoothingHelp": "Softens model outlines in preview and exports. Medium matches the original smoothing. Rebuild to apply.",
"modelDimensions": "Model: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -335,7 +341,7 @@
"readingImageLayers": "Reading image color layers",
"buildingLayer": "Building color layer {{layer}} of {{total}}",
"layerProgress": "Layer {{layer}} of {{total}}: {{label}}",
- "flatPaintPartProgress": "Flat Paint part {{part}} of {{total}}: {{label}}",
+ "flatPaintPartProgress": "Flat Paint mesh part {{part}} of {{total}}: {{label}}",
"partMeshComplete": "Part mesh complete",
"selectingPixels": "Selecting active pixels",
"preparingPreview": "Preparing preview geometry",
diff --git a/src/locales/en/public.json b/src/locales/en/public.json
index 215a05b0..f5f0fafc 100644
--- a/src/locales/en/public.json
+++ b/src/locales/en/public.json
@@ -110,7 +110,7 @@
"photos_one": "{{count}} photo",
"photos_other": "{{count}} photos",
"openPhoto": "Open {{title}} — {{kind}} {{index}} at full size",
- "originalArtwork": "Original artwork: {{title}} ",
+ "originalArtwork": "Original artwork: {{title}} ",
"themeLabel": "Theme: {{theme}}"
},
"navigation": {
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index e5e35935..cb306db5 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "Archivo de perfil no válido",
+ "failedTitle": "No se pudo abrir el archivo",
+ "failedDescription": "No se pudo abrir «{{name}}». Comprueba que sea un archivo Kromacut legible de menos de 32 MiB y que haya espacio en el almacenamiento local.",
+ "unavailable": "La apertura de archivos no está disponible temporalmente. Reinicia Kromacut e inténtalo de nuevo.",
+ "unsavedTitle": "Cambios de filamentos sin guardar",
+ "unsavedDescription": "Abrir «{{name}}» reemplazará los cambios de filamentos sin guardar. Conserva los cambios para guardarlos primero o abre el perfil para descartarlos.",
+ "keepEdits": "Conservar cambios",
+ "openProfile": "Abrir perfil",
+ "close": "Cerrar"
+ },
"colorPicker": {
"hue": "Matiz",
"color": "Color",
@@ -27,6 +38,7 @@
"light": "Claro",
"resources": "Recursos y comunidad",
"resourcesDescription": "Guías, versiones y espacios para compartir tus creaciones.",
+ "openLinkFailed": "No se pudo abrir esta página. Inténtalo de nuevo.",
"docs": "Documentación",
"docsDescription": "Descubre el flujo de trabajo y los ajustes de impresión.",
"reddit": "r/kromacut en Reddit",
diff --git a/src/locales/es/docs/3d-mode.md b/src/locales/es/docs/3d-mode.md
index 881dedac..915e88cd 100644
--- a/src/locales/es/docs/3d-mode.md
+++ b/src/locales/es/docs/3d-mode.md
@@ -76,13 +76,24 @@ Los controles manuales e instrucciones admiten **64 colores**. Reduce paletas ma
Sin suavizado, los contornos siguen la cuadrícula de píxeles. Con él, las fronteras conectadas se suavizan en geometría soldada. La diferencia se exporta; no es un filtro visual.
+Suaviza los contornos del modelo en la vista previa y las exportaciones. Medio conserva el suavizado original. Reconstruye para aplicar.
+
+| Intensidad | Uso |
+| --- | --- |
+| **Ninguno** | Pixel art, bordes exactos de cuadrícula o generación más rápida. |
+| **Mínimo** | Limpieza ligera de esquinas dentadas. |
+| **Medio** | El resultado habitual del ajuste activado anterior. |
+| **Intenso** | Suavizado más intenso de los contornos y los escalones restantes, con el mismo límite de desplazamiento que Medio. |
+
+El desplazamiento se mantiene por debajo de medio píxel. Reconstruye el modelo y revisa la vista previa del laminador. Los ajustes antiguos activado/desactivado pasan a Medio/Ninguno.
+

_Comparación esquemática de contornos, no simulación del laminador._
Úsala para curvas o diagonales demasiado escalonadas. Déjala desactivada para arte de píxeles intencional o bordes exactos de cuadrícula. Ninguna opción repara detalles demasiado pequeños ni inventa resolución ausente.
-Malla suavizada está inactiva en [Pintura plana](flat-paint). Activarla desactiva Pintura plana, que usa su construcción de placa de superficie completa.
+Malla suavizada también funciona con [Pintura plana](flat-paint). Ambas orientaciones mantienen una placa plana y límites compartidos entre colores; elige una intensidad y reconstruye para aplicarla.
## Pintura automática
diff --git a/src/locales/es/docs/auto-paint.md b/src/locales/es/docs/auto-paint.md
index f918888c..7a280a7a 100644
--- a/src/locales/es/docs/auto-paint.md
+++ b/src/locales/es/docs/auto-paint.md
@@ -135,7 +135,7 @@ Puede distribuir el error de redondeo en bloques pequeños a alturas imprimibles
_Mecanismo esquemático, no un antes/después garantizado. Distintos topes no significan más colores de bobina._
-Muchas asignaciones actuales ya eligen una capa discreta antes de este paso. Esas regiones no tienen error fraccionario que distribuir y pueden no cambiar al activar el tramado. Comprueba en la vista regenerada y el laminador si esta imagen concreta gana variación; activar la opción no garantiza más tonos ni puntos visibles.
+Con el tramado activado, Auto-paint busca una altura intermedia entre la capa seleccionada y una capa imprimible adyacente cuando su mezcla mejora la coincidencia prevista. Las coincidencias exactas y las de objetivos calibrados conservan la altura seleccionada; las regiones sin una mezcla vecina útil no cambian. Regenera el modelo después de modificar la opción y compara la vista previa con el resultado del laminador. Desactivar el tramado restaura la asignación discreta habitual.
El tamaño del punto sigue el **Ancho de línea efectivo** de **Ajustes de impresión 3D** respecto al **Tamaño de píxel**, redondeado a un bloque entero. Es aproximado, no una garantía exacta de anchura mínima. Los bordes evitan el mismo tratamiento para reducir artefactos. Revisa islas diminutas y desplazamientos extra en el laminador.
diff --git a/src/locales/es/docs/flat-paint.md b/src/locales/es/docs/flat-paint.md
index b27c0d84..8c1033af 100644
--- a/src/locales/es/docs/flat-paint.md
+++ b/src/locales/es/docs/flat-paint.md
@@ -15,6 +15,10 @@ Usa un flujo multimaterial como AMS, CFS o un cambiador de herramientas con un l
_Secciones conceptuales. Los colores identifican materiales; las etiquetas indican la cara de observación._
+El progreso cuenta partes de la malla, no capas impresas: varias partes de colores pueden compartir una capa. El indicador Modelo muestra el número de capas físicas, incluido el soporte transparente cuando se utiliza. La vista previa de capas corta la placa capa por capa; ambos controles también cortan las zonas de fundación y color combinadas. La exportación siempre incluye el modelo completo. El botón Generar modelo 3D regenera siempre el modelo, incluso sin cambios en los ajustes, y restablece todas las capas y la vista inicial de la cámara.
+
+Si falla la creación, Kromacut elimina el modelo incompleto y muestra el error en la vista previa. Pulsa Generar modelo 3D para volver a intentarlo; cuando finalice correctamente, volverán a aparecer las dimensiones del modelo y la vista previa de capas.
+
## Predeterminado: boca abajo con soporte transparente
Activa **Pintura plana** y deja desactivado **Boca arriba, sin capa transparente**.
@@ -47,7 +51,7 @@ Estas disposiciones tienen geometrías distintas. Vuelve a pulsar **Generar mode
| Pintura plana predeterminada | Parte inferior a través del soporte | Columnas reflejadas e invertidas con fundación detrás. | Objetos por filamento y soporte. |
| Pintura plana boca arriba | Parte superior plana expuesta | Orden normal con fundación bajo columnas cortas. | Objetos por filamento; sin soporte. |
-Pintura plana y **Malla suavizada** no funcionan juntas. La placa usa regiones contiguas que cubren toda la superficie en vez de contornos suavizados. Activar Malla suavizada desactiva Pintura plana; elige la disposición y regenera al cambiar.
+**Malla suavizada** funciona con ambas orientaciones de Pintura plana. Elige una intensidad y reconstruye. El suavizado ablanda la silueta exterior y los límites compartidos entre colores, conservando la placa plana, las alturas de capa y las pilas de materiales. Pequeñas uniones compartidas cierran los contactos diagonales sin solapamientos. Las instrucciones de impresión y el 3MF registran la intensidad utilizada.
## Exportar y comprobar
diff --git a/src/locales/es/docs/generating-exporting-output.md b/src/locales/es/docs/generating-exporting-output.md
index 61365223..7cb0a019 100644
--- a/src/locales/es/docs/generating-exporting-output.md
+++ b/src/locales/es/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ Una vista automática puede mostrar docenas de mezclas hechas con pocas bobinas
STL no contiene colores de filamento ni asignaciones automáticas de bobinas. Usa el plan copiado con los controles de cambio de color del laminador. Un 3MF sigue siendo un modelo, no G-code listo para ejecutar: elige impresora, boquilla, perfiles, temperaturas y velocidades, y después lamina.
-Para **Pintura plana**, el menú solo ofrece 3MF: contiene un objeto por filamento físico y, en la disposición predeterminada boca abajo, un soporte transparente. La disposición opcional boca arriba omite ese soporte. Un STL sin colores de una sola geometría de cualquiera de las placas no sería útil. Pintura plana desactiva **Malla suavizada** porque la disposición de placa no usa contornos suavizados.
+Para **Pintura plana**, el menú solo ofrece 3MF: contiene un objeto por filamento físico y, en la disposición predeterminada boca abajo, un soporte transparente. La disposición opcional boca arriba omite ese soporte. Un STL sin colores de una sola geometría de cualquiera de las placas no sería útil. Ambas orientaciones admiten todas las intensidades de **Malla suavizada**. Reconstruye el modelo después de cambiar la intensidad para aplicarla a la vista previa y a la geometría exportada.
## Instrucciones de impresión
diff --git a/src/locales/es/docs/settings-and-controls.md b/src/locales/es/docs/settings-and-controls.md
index 82554cbb..15fee3d6 100644
--- a/src/locales/es/docs/settings-and-controls.md
+++ b/src/locales/es/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ El selector **Idioma** cambia interfaz, documentación, diagramas y páginas pú
Las páginas públicas también ofrecen selector de idioma. La documentación traducida usa enlaces compartibles con prefijo de idioma, como `/ro/docs/overview`. Los nombres de página y anclas de sección permanecen estables entre idiomas. No se traducen extensiones, datos numéricos del modelo, nombres introducidos por el usuario ni títulos originales de obras comunitarias.
-El diálogo incluye enlaces a documentación, Discord, Reddit, GitHub y Patreon, y muestra la versión actual de Kromacut.
+El diálogo incluye enlaces a documentación, Discord, Reddit, GitHub y Patreon, y muestra la versión actual de Kromacut. **Privacidad y datos locales** y **Términos y condiciones** también están disponibles aquí y se abren en el navegador en el idioma seleccionado.
## Modos del espacio de trabajo
@@ -123,4 +123,16 @@ Una traza completa incluye información básica de ejecución, instantánea de f
El registro está pensado para investigar y puede crear archivos grandes. Déjalo desactivado en impresión normal cuando no necesites una traza.
+## Abrir archivos desde el escritorio
+
+Las instalaciones de escritorio asocian los archivos `.kfil` y los antiguos `.kapp` con perfiles de filamentos, y los archivos `.kpal` con paletas. Haz doble clic en un archivo para importarlo y seleccionarlo en Kromacut. Si la aplicación ya está abierta, se utiliza su ventana existente. Se aplican las reglas habituales de validación, migración, duplicados y conservación de la calibración.
+
+Los archivos abiertos desde el escritorio quedan en espera mientras esté abierto el editor de paletas, el cuadro de calibración o el formulario para renombrar o guardar un perfil nuevo. Termina o cancela esa sesión para continuar con las importaciones pendientes; los nombres escritos y el perfil que estás editando permanecen sin cambios hasta entonces.
+
+Las importaciones también esperan mientras editas el nombre o el campo HD de un filamento, o mientras su selector de color o el panel **Convertir desde TD** está abierto. Sal del campo o cierra el selector o el panel para reanudar las importaciones pendientes; los cambios de filamentos sin guardar siguen requiriendo tu decisión antes de que otro perfil los sustituya.
+
+Antes de reemplazar cambios de filamentos sin guardar, Kromacut ofrece **Conservar cambios** o **Abrir perfil**. Conserva los cambios para guardarlos primero; abrir el perfil los descarta. Los archivos abiertos así deben tener menos de 32 MiB. Los archivos JSON genéricos, imágenes y modelos mantienen sus flujos habituales de importación.
+
+En Linux, las AppImage portátiles requieren integración con el escritorio para asociar archivos. Si Kromacut no es la aplicación predeterminada, utiliza **Abrir con** en el gestor de archivos.
+
Siguiente: [Solución de problemas](troubleshooting).
diff --git a/src/locales/es/docs/troubleshooting.md b/src/locales/es/docs/troubleshooting.md
index f170f5fe..a3e2c5e5 100644
--- a/src/locales/es/docs/troubleshooting.md
+++ b/src/locales/es/docs/troubleshooting.md
@@ -55,7 +55,7 @@ Comprueba por separado **Modelo de apariencia**. Una puntuación global alta no
## Una opción ha desactivado otra
-Dos pares son excluyentes: **Malla suavizada** y **Pintura plana**, y **Conservar separación de colores** y **Tramado de altura**. El primero elige disposiciones geométricas distintas; el segundo, maneras distintas de asignar colores originales a alturas imprimibles. Consulta [Pintura plana](flat-paint) y [Pintura automática](auto-paint).
+**Conservar separación de colores** y **Tramado de altura** siguen siendo excluyentes porque asignan los colores originales a las alturas imprimibles de forma distinta. **Malla suavizada** y **Pintura plana** pueden usarse juntas; reconstruye después de cambiar cualquiera de los ajustes. Consulta [Pintura plana](flat-paint) y [Pintura automática](auto-paint).
## La separación de colores no encuentra resultado
diff --git a/src/locales/es/printing.json b/src/locales/es/printing.json
index f16a4b56..7c839a60 100644
--- a/src/locales/es/printing.json
+++ b/src/locales/es/printing.json
@@ -234,7 +234,9 @@
"colorCount_many": "{{count, number}} colores",
"tooManyColors_many": "Demasiados colores ({{count, number}})"
},
- "threeDView": {
+ "threeDView": {
+ "buildFailed": "No se pudo crear el modelo 3D",
+ "buildFailedDetails": "Se ha eliminado el modelo incompleto. Pulsa Crear modelo 3D para volver a intentarlo. Error: {{error}}",
"layerPreview": "Vista previa de capas",
"previewBottomLayerCutoff": "Límite inferior de capas de la vista previa",
"previewTopLayerCutoff": "Límite superior de capas de la vista previa",
@@ -262,7 +264,11 @@
"firstLayerHeight": "Altura de la primera capa",
"effectiveLineWidth": "Ancho de línea efectivo",
"smoothMeshing": "Malla suavizada",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "Suaviza los bordes conectados entre colores con una topología soldada rápida. Al activar esta opción se desactiva la pintura plana.",
+ "smoothingNone": "Ninguno",
+ "smoothingMinimal": "Mínimo",
+ "smoothingMedium": "Medio",
+ "smoothingAggressive": "Intenso",
+ "smoothingHelp": "Suaviza los contornos del modelo en la vista previa y las exportaciones. Medio conserva el suavizado original. Reconstruye para aplicar.",
"modelDimensions": "Modelo: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -365,7 +371,7 @@
"readingImageLayers": "Leyendo las capas de color de la imagen",
"buildingLayer": "Generando la capa de color {{layer}} de {{total}}",
"layerProgress": "Capa {{layer}} de {{total}}: {{label}}",
- "flatPaintPartProgress": "Pieza de pintura plana {{part}} de {{total}}: {{label}}",
+ "flatPaintPartProgress": "Parte de malla de pintura plana {{part}} de {{total}}: {{label}}",
"partMeshComplete": "Malla de la pieza completada",
"selectingPixels": "Seleccionando los píxeles activos",
"preparingPreview": "Preparando la geometría de la vista previa",
diff --git a/src/locales/es/public.json b/src/locales/es/public.json
index 2d2ff3d0..37dd1a18 100644
--- a/src/locales/es/public.json
+++ b/src/locales/es/public.json
@@ -1,6 +1,6 @@
{
"landing":{
- "importAnImage":"Importa una imagen","dropInArtworkAPhotoOrAPixelDesignAnd":"Añade una ilustración, una foto o un diseño de píxeles y conserva el original editable de forma segura.","aColorful2DImageReadyToBeImportedIntoKromacut":"Una imagen 2D colorida lista para importar en Kromacut","reducePaintColors":"Reduce y pinta los colores","tuneACompactPaletteManuallyOrLetAutoPaintMatch":"Ajusta una paleta compacta a mano o deja que la pintura automática busque correspondencias con los filamentos que tienes.","aColorLayeredImagePreviewShowingSeparatePrintableColors":"Vista previa de una imagen por capas de color que muestra colores imprimibles separados","previewEveryLayer":"Previsualiza cada capa","inspectTheStackIn3DCheckTransitionsAndSeeExactly":"Examina el apilamiento en 3D, comprueba las transiciones y ve exactamente dónde se cambia de filamento.","kromacut3DPreviewOfAStackedColorLayerPrint":"Vista previa 3D de Kromacut de una impresión por capas de color apiladas","exportAndPrint":"Exporta e imprime","downloadASlicerReadySTLOr3MFWithThePrint":"Descarga un STL o 3MF listo para el laminador, con el plan de impresión que necesitas para terminar el proyecto.","aFinishedColorfulLayeredPrintMadeFromAKromacutWorkflow":"Una impresión colorida por capas terminada con Kromacut","aColorfulHobbitsAndDragonsLayered3DPrint":"Una impresión 3D colorida por capas de Hobbits and Dragons","finishedPrint":"Impresión terminada","aSecondViewOfTheHobbitsAndDragonsLayered3D":"Otra vista de la impresión 3D por capas de Hobbits and Dragons","slicerPreviewOfTheMulticolorKingOfHeartsPlayingCard":"Vista del laminador de la impresión multicolor de la carta King of Hearts","slicerPreview":"Vista del laminador","aMulticolorLayeredKingOfHeartsPlayingCardPrint":"Una impresión multicolor por capas de la carta King of Hearts","kromacutAutoPaintPredictionForALayeredHopePosterPrint":"Predicción de pintura automática de Kromacut para una impresión por capas del cartel Hope","kromacutPrediction":"Predicción de Kromacut","autoPaintPredictionUsingTheAvailableCalibratedFilamentProfile":"Predicción de pintura automática con el perfil de filamentos calibrado disponible.","slicerPreviewOfTheLayeredHopePosterPrint":"Vista del laminador de la impresión por capas del cartel Hope","thePhysicalLayerPlanPreparedForPrintingWithoutARed":"El plan de capas físicas preparado para imprimir sin filamento rojo.","finishedLayeredHopePosterPrintShowingOrangeAndPurpleBlue":"Impresión terminada por capas del cartel Hope con desviaciones de color hacia el naranja y el azul violáceo","paleYellowMatchedMostCloselyRedPrintedOrangeCyanLeaned":"El amarillo pálido fue el más fiel; el rojo salió naranja, el cian tendió al azul violáceo y el negro reprodujo la vista previa, pero no el verde azulado oscuro del objetivo.","kromacutAutoPaintPredictionForTheGoldenWavesOfThe":"Predicción de pintura automática de Kromacut para las olas doradas del cartel Titan","text10241527MmFootprint":"Superficie de 102,4 × 152,7 mm","autoPaintWithTheEightColorCalibratedFilamentProfile":"Pintura automática con el perfil calibrado de ocho colores de filamento.","crealityPrintSlicerPreviewOfTheTitanPosterWithIts":"Vista de Creality Print del cartel Titan con su torre de cambio de filamento","crealityHi04MmNozzleAnd008Mm":"Creality Hi, boquilla de 0,4 mm y capas de 0,08 mm. Estimación del laminador: 2 h 58 min, 28,90 g incluyendo purgas y torre, seis cambios de filamento.","finishedTitanLayeredPrintWithGoldenYellowAndOrangeWave":"Impresión terminada por capas de Titan, con reflejos de olas amarillo dorado y naranja sobre un fondo oscuro","theCompletedPrintPhotographedByItsMakerLightingAndCamera":"La impresión terminada, fotografiada por su creador. La iluminación y el procesamiento de la cámara pueden afectar a los colores mostrados.","kromacutAutoPaintPredictionOfTheBatmanJiroKuwataBatmanga":"Predicción de pintura automática de Kromacut de la portada Batman Jiro Kuwata Batmanga","text6581000MmFootprint":"Superficie de 65,8 × 100,0 mm","autoPaintWithDeepOptimizationEnhancedMatchingAndPreserveColor":"Pintura automática con optimización profunda, correspondencia mejorada y conservación de la separación de colores activadas.","crealityPrintSlicerPreviewOfTheBatmangaCoverAndFilament":"Vista de Creality Print de la portada Batmanga y la torre de cambio de filamento","crealityHi04MmNozzleAnd008Mm2":"Creality Hi, boquilla de 0,4 mm y capas de 0,08 mm. Estimación del laminador: 3 h 12 min, 21,30 g incluyendo purgas y torre, ocho cambios de filamento.","finishedBatmangaLayeredPrintWithAYellowJapaneseTitleTan":"Impresión terminada por capas de Batmanga con título japonés amarillo, fondo beige y Batman en negro y gris","theCompletedPrintPhotographedByItsMakerTheLargeTitle":"La impresión terminada, fotografiada por su creador. El título grande y la silueta siguen siendo nítidos; las letras pequeñas y el sombreado fino pierden detalle a este tamaño.","kromacutAutoPaintPredictionOfNarutoLookingUpAtA":"Predicción de pintura automática de Kromacut de Naruto mirando un cielo azul","text11041902MmFootprint":"Superficie de 110,4 × 190,2 mm","autoPaintWithTheEightColorCalibratedFilamentProfile0":"Pintura automática con el perfil calibrado de ocho colores de filamento, píxeles de 0,15 mm y malla suavizada activada.","crealityPrintSlicerPreviewOfTheNarutoPrintWithSeven":"Vista de Creality Print de la impresión de Naruto con siete colores de filamento y una torre de cambio","crealityHi04MmNozzleAnd008Mm3":"Creality Hi, boquilla de 0,4 mm y capas de 0,08 mm. Estimación del laminador: 5 h 55 min, 72,10 g incluyendo purgas y torre, nueve cambios de filamento.","finishedNarutoLayeredPrintWithYellowHairOrangeClothingAnd":"Impresión terminada por capas de Naruto con pelo amarillo, ropa naranja y un cielo azul lavanda","theCompletedOvernightPrintPhotographedByItsMakerLightingAnd":"La impresión terminada durante la noche, fotografiada por su creador. La iluminación y el procesamiento de la cámara pueden afectar a los colores mostrados.","openAnyPhotoAtFullSize":"Abre cualquier foto a tamaño completo.","viewRedditPost":"Ver publicación en Reddit","communityLinks":"Enlaces de la comunidad","kromacutOnGitHub":"Kromacut en GitHub","joinKromacutOnDiscord":"Únete a Kromacut en Discord","rKromacutOnReddit":"r/kromacut en Reddit","supportKromacutOnPatreon":"Apoya a Kromacut en Patreon","support":"Apoyar","system":"Sistema","dark":"Oscuro","light":"Claro","changeTheme":"Cambiar tema","theme":"Tema","skipToWorkflow":"Saltar al flujo de trabajo","mainNavigation":"Navegación principal","howItWorks":"Cómo funciona","docs":"Documentación","releases":"Versiones","openKromacut":"Abrir Kromacut","mobileNavigation":"Navegación móvil","openSourceBrowserFirst":"Código abierto · pensado para el navegador","kromacutTransforms2DImagesIntoStackedColorLayered3DPrints":"Kromacut transforma imágenes 2D en impresiones 3D con capas de color apiladas. Prepara tu paleta, busca correspondencias con filamentos reales, previsualiza el resultado y exporta un modelo listo para el laminador.","startCreating":"Empieza a crear","readTheQuickStart":"Lee la guía de inicio rápido","freeToUse":"Uso gratuito","browserDesktop":"Navegador y escritorio","sTL3MFExport":"Exportación STL y 3MF","text3DLayerPreview":"Vista previa 3D de capas","readyToPrint":"listo para imprimir","colorLayered3DPrintPreview":"Vista previa de impresión 3D por capas de color","paletteMapped":"Paleta asignada","text4PrintableColors":"4 colores imprimibles","fromImageToObject":"De la imagen al objeto","aSimpleWorkflowWithSeriousControl":"Un proceso sencillo con un control preciso.","goFromAFlatImageToALayeredPrintWithout":"Pasa de una imagen plana a una impresión por capas sin salir del navegador. Tú tomas las decisiones creativas; Kromacut se encarga de la geometría y del plan de impresión.","original2DArtwork":"Ilustración 2D original","finishedKromacutPrint":"Impresión Kromacut terminada","builtForRealPrints":"Pensado para impresiones reales","yourFilamentYourPaletteYourModel":"Tus filamentos. Tu paleta. Tu modelo.","useManualSlicingWhenYouWantPixelLevelControlOr":"Usa el modo manual si quieres controlar cada píxel o deja que la pintura automática busque un apilamiento de colores a partir de tu perfil de filamentos calibrado. En ambos casos, revisa cada capa antes de exportar.","nonDestructiveImageAdjustments":"Ajustes de imagen no destructivos","threeJsLayerByLayer3DPreview":"Vista previa 3D capa a capa con Three.js","calibratedAutoPaintWithDeterministicSearch":"Pintura automática calibrada con búsqueda determinista","slicerFriendlySTLAndMultiMaterial3MF":"STL listos para el laminador y 3MF multimaterial","openTheTool":"Abrir la herramienta","communityShowcase":"Galería de la comunidad","madeByTheKromacutCommunity":"Creado por la comunidad Kromacut.","finishedPrintsAndBehindTheScenesPreviewsFromPeopleCreating":"Impresiones terminadas y vistas del proceso de personas que crean con Kromacut.","wantToShowcaseYourWorkOrContributeToKromacut":"¿Quieres mostrar tu trabajo o contribuir a Kromacut?","shareWhatYouMadeImproveTheProjectOrHelpThe":"Comparte tus creaciones, mejora el proyecto o ayuda a la comunidad a descubrir lo que Kromacut puede hacer.","openAPullRequest":"Proponer una contribución","readyToMakeAFlatImagePhysical":"¿Quieres convertir una imagen plana en algo físico?","startWithAnImageFinishWithAPrint":"Empieza con una imagen. Termina con una impresión.","desktopReleases":"Versiones de escritorio","openSourceToolsForTurningImagesIntoColorLayered3D":"Herramientas de código abierto para convertir imágenes en impresiones 3D por capas de color.","footerNavigation":"Navegación del pie de página","supportKromacut":"Apoyar a Kromacut","privacy":"Privacidad","terms":"Condiciones","heroTitle":"Convierte píxeles en capas imprimibles. ","creator":"Por {{creator}} ","photos_one":"{{count}} foto","photos_other":"{{count}} fotos","photos_many":"{{count}} fotos","openPhoto":"Abrir {{title}} — {{kind}} {{index}} a tamaño completo","originalArtwork":"Obra original: {{title}} ","themeLabel":"Tema: {{theme}}"
+ "importAnImage":"Importa una imagen","dropInArtworkAPhotoOrAPixelDesignAnd":"Añade una ilustración, una foto o un diseño de píxeles y conserva el original editable de forma segura.","aColorful2DImageReadyToBeImportedIntoKromacut":"Una imagen 2D colorida lista para importar en Kromacut","reducePaintColors":"Reduce y pinta los colores","tuneACompactPaletteManuallyOrLetAutoPaintMatch":"Ajusta una paleta compacta a mano o deja que la pintura automática busque correspondencias con los filamentos que tienes.","aColorLayeredImagePreviewShowingSeparatePrintableColors":"Vista previa de una imagen por capas de color que muestra colores imprimibles separados","previewEveryLayer":"Previsualiza cada capa","inspectTheStackIn3DCheckTransitionsAndSeeExactly":"Examina el apilamiento en 3D, comprueba las transiciones y ve exactamente dónde se cambia de filamento.","kromacut3DPreviewOfAStackedColorLayerPrint":"Vista previa 3D de Kromacut de una impresión por capas de color apiladas","exportAndPrint":"Exporta e imprime","downloadASlicerReadySTLOr3MFWithThePrint":"Descarga un STL o 3MF listo para el laminador, con el plan de impresión que necesitas para terminar el proyecto.","aFinishedColorfulLayeredPrintMadeFromAKromacutWorkflow":"Una impresión colorida por capas terminada con Kromacut","aColorfulHobbitsAndDragonsLayered3DPrint":"Una impresión 3D colorida por capas de Hobbits and Dragons","finishedPrint":"Impresión terminada","aSecondViewOfTheHobbitsAndDragonsLayered3D":"Otra vista de la impresión 3D por capas de Hobbits and Dragons","slicerPreviewOfTheMulticolorKingOfHeartsPlayingCard":"Vista del laminador de la impresión multicolor de la carta King of Hearts","slicerPreview":"Vista del laminador","aMulticolorLayeredKingOfHeartsPlayingCardPrint":"Una impresión multicolor por capas de la carta King of Hearts","kromacutAutoPaintPredictionForALayeredHopePosterPrint":"Predicción de pintura automática de Kromacut para una impresión por capas del cartel Hope","kromacutPrediction":"Predicción de Kromacut","autoPaintPredictionUsingTheAvailableCalibratedFilamentProfile":"Predicción de pintura automática con el perfil de filamentos calibrado disponible.","slicerPreviewOfTheLayeredHopePosterPrint":"Vista del laminador de la impresión por capas del cartel Hope","thePhysicalLayerPlanPreparedForPrintingWithoutARed":"El plan de capas físicas preparado para imprimir sin filamento rojo.","finishedLayeredHopePosterPrintShowingOrangeAndPurpleBlue":"Impresión terminada por capas del cartel Hope con desviaciones de color hacia el naranja y el azul violáceo","paleYellowMatchedMostCloselyRedPrintedOrangeCyanLeaned":"El amarillo pálido fue el más fiel; el rojo salió naranja, el cian tendió al azul violáceo y el negro reprodujo la vista previa, pero no el verde azulado oscuro del objetivo.","kromacutAutoPaintPredictionForTheGoldenWavesOfThe":"Predicción de pintura automática de Kromacut para las olas doradas del cartel Titan","text10241527MmFootprint":"Superficie de 102,4 × 152,7 mm","autoPaintWithTheEightColorCalibratedFilamentProfile":"Pintura automática con el perfil calibrado de ocho colores de filamento.","crealityPrintSlicerPreviewOfTheTitanPosterWithIts":"Vista de Creality Print del cartel Titan con su torre de cambio de filamento","crealityHi04MmNozzleAnd008Mm":"Creality Hi, boquilla de 0,4 mm y capas de 0,08 mm. Estimación del laminador: 2 h 58 min, 28,90 g incluyendo purgas y torre, seis cambios de filamento.","finishedTitanLayeredPrintWithGoldenYellowAndOrangeWave":"Impresión terminada por capas de Titan, con reflejos de olas amarillo dorado y naranja sobre un fondo oscuro","theCompletedPrintPhotographedByItsMakerLightingAndCamera":"La impresión terminada, fotografiada por su creador. La iluminación y el procesamiento de la cámara pueden afectar a los colores mostrados.","kromacutAutoPaintPredictionOfTheBatmanJiroKuwataBatmanga":"Predicción de pintura automática de Kromacut de la portada Batman Jiro Kuwata Batmanga","text6581000MmFootprint":"Superficie de 65,8 × 100,0 mm","autoPaintWithDeepOptimizationEnhancedMatchingAndPreserveColor":"Pintura automática con optimización profunda, correspondencia mejorada y conservación de la separación de colores activadas.","crealityPrintSlicerPreviewOfTheBatmangaCoverAndFilament":"Vista de Creality Print de la portada Batmanga y la torre de cambio de filamento","crealityHi04MmNozzleAnd008Mm2":"Creality Hi, boquilla de 0,4 mm y capas de 0,08 mm. Estimación del laminador: 3 h 12 min, 21,30 g incluyendo purgas y torre, ocho cambios de filamento.","finishedBatmangaLayeredPrintWithAYellowJapaneseTitleTan":"Impresión terminada por capas de Batmanga con título japonés amarillo, fondo beige y Batman en negro y gris","theCompletedPrintPhotographedByItsMakerTheLargeTitle":"La impresión terminada, fotografiada por su creador. El título grande y la silueta siguen siendo nítidos; las letras pequeñas y el sombreado fino pierden detalle a este tamaño.","kromacutAutoPaintPredictionOfNarutoLookingUpAtA":"Predicción de pintura automática de Kromacut de Naruto mirando un cielo azul","text11041902MmFootprint":"Superficie de 110,4 × 190,2 mm","autoPaintWithTheEightColorCalibratedFilamentProfile0":"Pintura automática con el perfil calibrado de ocho colores de filamento, píxeles de 0,15 mm y malla suavizada activada.","crealityPrintSlicerPreviewOfTheNarutoPrintWithSeven":"Vista de Creality Print de la impresión de Naruto con siete colores de filamento y una torre de cambio","crealityHi04MmNozzleAnd008Mm3":"Creality Hi, boquilla de 0,4 mm y capas de 0,08 mm. Estimación del laminador: 5 h 55 min, 72,10 g incluyendo purgas y torre, nueve cambios de filamento.","finishedNarutoLayeredPrintWithYellowHairOrangeClothingAnd":"Impresión terminada por capas de Naruto con pelo amarillo, ropa naranja y un cielo azul lavanda","theCompletedOvernightPrintPhotographedByItsMakerLightingAnd":"La impresión terminada durante la noche, fotografiada por su creador. La iluminación y el procesamiento de la cámara pueden afectar a los colores mostrados.","openAnyPhotoAtFullSize":"Abre cualquier foto a tamaño completo.","viewRedditPost":"Ver publicación en Reddit","communityLinks":"Enlaces de la comunidad","kromacutOnGitHub":"Kromacut en GitHub","joinKromacutOnDiscord":"Únete a Kromacut en Discord","rKromacutOnReddit":"r/kromacut en Reddit","supportKromacutOnPatreon":"Apoya a Kromacut en Patreon","support":"Apoyar","system":"Sistema","dark":"Oscuro","light":"Claro","changeTheme":"Cambiar tema","theme":"Tema","skipToWorkflow":"Saltar al flujo de trabajo","mainNavigation":"Navegación principal","howItWorks":"Cómo funciona","docs":"Documentación","releases":"Versiones","openKromacut":"Abrir Kromacut","mobileNavigation":"Navegación móvil","openSourceBrowserFirst":"Código abierto · pensado para el navegador","kromacutTransforms2DImagesIntoStackedColorLayered3DPrints":"Kromacut transforma imágenes 2D en impresiones 3D con capas de color apiladas. Prepara tu paleta, busca correspondencias con filamentos reales, previsualiza el resultado y exporta un modelo listo para el laminador.","startCreating":"Empieza a crear","readTheQuickStart":"Lee la guía de inicio rápido","freeToUse":"Uso gratuito","browserDesktop":"Navegador y escritorio","sTL3MFExport":"Exportación STL y 3MF","text3DLayerPreview":"Vista previa 3D de capas","readyToPrint":"listo para imprimir","colorLayered3DPrintPreview":"Vista previa de impresión 3D por capas de color","paletteMapped":"Paleta asignada","text4PrintableColors":"4 colores imprimibles","fromImageToObject":"De la imagen al objeto","aSimpleWorkflowWithSeriousControl":"Un proceso sencillo con un control preciso.","goFromAFlatImageToALayeredPrintWithout":"Pasa de una imagen plana a una impresión por capas sin salir del navegador. Tú tomas las decisiones creativas; Kromacut se encarga de la geometría y del plan de impresión.","original2DArtwork":"Ilustración 2D original","finishedKromacutPrint":"Impresión Kromacut terminada","builtForRealPrints":"Pensado para impresiones reales","yourFilamentYourPaletteYourModel":"Tus filamentos. Tu paleta. Tu modelo.","useManualSlicingWhenYouWantPixelLevelControlOr":"Usa el modo manual si quieres controlar cada píxel o deja que la pintura automática busque un apilamiento de colores a partir de tu perfil de filamentos calibrado. En ambos casos, revisa cada capa antes de exportar.","nonDestructiveImageAdjustments":"Ajustes de imagen no destructivos","threeJsLayerByLayer3DPreview":"Vista previa 3D capa a capa con Three.js","calibratedAutoPaintWithDeterministicSearch":"Pintura automática calibrada con búsqueda determinista","slicerFriendlySTLAndMultiMaterial3MF":"STL listos para el laminador y 3MF multimaterial","openTheTool":"Abrir la herramienta","communityShowcase":"Galería de la comunidad","madeByTheKromacutCommunity":"Creado por la comunidad Kromacut.","finishedPrintsAndBehindTheScenesPreviewsFromPeopleCreating":"Impresiones terminadas y vistas del proceso de personas que crean con Kromacut.","wantToShowcaseYourWorkOrContributeToKromacut":"¿Quieres mostrar tu trabajo o contribuir a Kromacut?","shareWhatYouMadeImproveTheProjectOrHelpThe":"Comparte tus creaciones, mejora el proyecto o ayuda a la comunidad a descubrir lo que Kromacut puede hacer.","openAPullRequest":"Proponer una contribución","readyToMakeAFlatImagePhysical":"¿Quieres convertir una imagen plana en algo físico?","startWithAnImageFinishWithAPrint":"Empieza con una imagen. Termina con una impresión.","desktopReleases":"Versiones de escritorio","openSourceToolsForTurningImagesIntoColorLayered3D":"Herramientas de código abierto para convertir imágenes en impresiones 3D por capas de color.","footerNavigation":"Navegación del pie de página","supportKromacut":"Apoyar a Kromacut","privacy":"Privacidad","terms":"Condiciones","heroTitle":"Convierte píxeles en capas imprimibles. ","creator":"Por {{creator}} ","photos_one":"{{count}} foto","photos_other":"{{count}} fotos","photos_many":"{{count}} fotos","openPhoto":"Abrir {{title}} — {{kind}} {{index}} a tamaño completo","originalArtwork":"Obra original: {{title}} ","themeLabel":"Tema: {{theme}}"
},
"navigation":{"homeLabel":"Página de inicio de Kromacut","openApp":"Abrir Kromacut","home":"Ir a la página de inicio","docs":"Explorar la documentación","privacy":"Privacidad y datos locales","terms":"Términos y condiciones"},
"legal":{"privacyEyebrow":"Primero, tus datos locales. Todo explicado.","termsEyebrow":"Uso de Kromacut.","updated":"Actualizado el {{date}}","contents":"En esta página"},
diff --git a/src/locales/fr/common.json b/src/locales/fr/common.json
index 2023d592..febb465c 100644
--- a/src/locales/fr/common.json
+++ b/src/locales/fr/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "Fichier de profil invalide",
+ "failedTitle": "Impossible d’ouvrir le fichier",
+ "failedDescription": "Impossible d’ouvrir « {{name}} ». Vérifiez qu’il s’agit d’un fichier Kromacut lisible de moins de 32 Mio et que le stockage local dispose d’espace.",
+ "unavailable": "L’ouverture des fichiers est temporairement indisponible. Redémarrez Kromacut et réessayez.",
+ "unsavedTitle": "Modifications des filaments non enregistrées",
+ "unsavedDescription": "L’ouverture de « {{name}} » remplacera vos modifications de filaments non enregistrées. Conservez-les pour les enregistrer d’abord, ou ouvrez le profil pour les abandonner.",
+ "keepEdits": "Conserver les modifications",
+ "openProfile": "Ouvrir le profil",
+ "close": "Fermer"
+ },
"colorPicker": {
"hue": "Teinte",
"color": "Couleur",
@@ -27,6 +38,7 @@
"light": "Clair",
"resources": "Ressources et communauté",
"resourcesDescription": "Guides, versions et espaces pour partager vos créations.",
+ "openLinkFailed": "Impossible d’ouvrir cette page. Réessayez.",
"docs": "Documentation",
"docsDescription": "Découvrez le fonctionnement et les paramètres d’impression.",
"reddit": "r/kromacut sur Reddit",
diff --git a/src/locales/fr/docs/3d-mode.md b/src/locales/fr/docs/3d-mode.md
index fbc05d27..50123d6b 100644
--- a/src/locales/fr/docs/3d-mode.md
+++ b/src/locales/fr/docs/3d-mode.md
@@ -76,13 +76,24 @@ Les commandes manuelles et les instructions de changement prennent en charge **6
Sans lissage, les contours suivent la grille carrée des pixels. Avec le lissage, les frontières connectées deviennent une géométrie lissée et soudée. La différence est exportée ; ce n’est pas un filtre d’aperçu.
+Adoucit les contours du modèle dans l’aperçu et les exports. Moyen reproduit le lissage d’origine. Reconstruisez pour appliquer.
+
+| Force | Utilisation |
+| --- | --- |
+| **Aucun** | Pixel art, contours carrés exacts ou génération la plus rapide. |
+| **Minimal** | Nettoyage léger des angles irréguliers. |
+| **Moyen** | Résultat familier de l’ancien réglage activé. |
+| **Intensif** | Lissage plus fort le long des contours, y compris les marches restantes, avec la même limite de déplacement que Moyen. |
+
+Le déplacement reste inférieur à un demi-pixel. Reconstruisez le modèle, puis vérifiez l’aperçu du trancheur. Les anciens réglages activé/désactivé deviennent Moyen/Aucun.
+

_Comparaison schématique de contours, pas une simulation de découpe._
Utilisez-le pour des contours courbes ou diagonaux trop crénelés. Laissez-le désactivé pour un pixel art volontaire ou des bords suivant exactement la grille. Aucun choix ne répare des détails trop petits pour être imprimés ni n’invente une résolution source manquante.
-Le maillage lissé est inactif en [Peinture à plat](flat-paint). L’activer désactive la peinture à plat, qui utilise à la place une plaque couvrant toute l’emprise.
+Le maillage lissé fonctionne aussi avec la [Peinture à plat](flat-paint). Les deux orientations conservent une plaque plane et des frontières communes entre couleurs ; choisissez une intensité et reconstruisez pour l’appliquer.
## Peinture automatique
diff --git a/src/locales/fr/docs/auto-paint.md b/src/locales/fr/docs/auto-paint.md
index fbc8a2b2..89cb834c 100644
--- a/src/locales/fr/docs/auto-paint.md
+++ b/src/locales/fr/docs/auto-paint.md
@@ -135,7 +135,7 @@ Le tramage de hauteur peut répartir l’erreur d’arrondi sur de petits blocs
_Mécanisme schématique, pas un résultat avant/après garanti. Des hauteurs de sommet différentes ne signifient pas des couleurs de bobines supplémentaires._
-De nombreuses associations actuelles de peinture automatique choisissent déjà une couche imprimable discrète avant cette étape. Ces zones déjà alignées n’ont aucune erreur de hauteur fractionnaire à répartir et peuvent rester inchangées avec le tramage. Utilisez l’aperçu régénéré et le logiciel de découpe pour vérifier si cette image gagne réellement des variations de hauteur ; activer l’option ne garantit ni davantage de tons ni des points visibles.
+Lorsque le tramage est activé, la peinture automatique cherche une hauteur intermédiaire entre la couche choisie et une couche imprimable voisine si leur mélange améliore la correspondance prédite. Les correspondances exactes et les cibles calibrées conservent leur hauteur ; les zones sans mélange voisin utile restent inchangées. Régénérez le modèle après avoir modifié l’option, puis comparez l’aperçu et le résultat dans le logiciel de découpe. Désactiver le tramage rétablit la correspondance discrète habituelle.
La taille des points suit la **Largeur de ligne effective** des **Paramètres d’impression 3D** par rapport à la **Taille du pixel**, arrondie à un bloc de pixels entiers. C’est une approximation, pas une garantie exacte de largeur minimale. Les zones de bord évitent le même traitement de tramage pour réduire les artefacts de frontière. Vérifiez les petits îlots et déplacements supplémentaires dans le logiciel de découpe.
diff --git a/src/locales/fr/docs/flat-paint.md b/src/locales/fr/docs/flat-paint.md
index f9ab987b..a9095521 100644
--- a/src/locales/fr/docs/flat-paint.md
+++ b/src/locales/fr/docs/flat-paint.md
@@ -15,6 +15,10 @@ Utilisez un système multimatériau tel qu’un AMS, un CFS ou un changeur d’o
_Coupes de principe. Les couleurs identifient les matériaux ; les étiquettes indiquent la face à regarder._
+La progression compte les parties du maillage, pas les couches imprimées : plusieurs parties colorées peuvent partager une couche. Le badge Modèle indique le nombre de couches physiques, y compris le support transparent le cas échéant. L’aperçu des couches découpe la plaque couche par couche ; les deux poignées découpent aussi les zones fusionnées de fondation et de couleur. L’exportation contient toujours le modèle complet. Le bouton Générer le modèle 3D régénère toujours le modèle, même sans modification des réglages, et rétablit toutes les couches ainsi que la vue initiale de la caméra.
+
+Si la création échoue, Kromacut supprime le modèle incomplet et affiche l’erreur dans l’aperçu. Cliquez sur Générer le modèle 3D pour réessayer ; une fois la création réussie, les dimensions du modèle et l’aperçu des couches réapparaissent.
+
## Par défaut : face vers le bas avec support transparent
Activez **Peinture à plat** et laissez **Face vers le haut, sans couche transparente** désactivé.
@@ -47,7 +51,7 @@ Ces dispositions ont des géométries différentes. Cliquez de nouveau sur **Gé
| Peinture à plat, par défaut | Dessous, à travers le support transparent | Colonnes inversées et en miroir, fondation derrière. | Objets par filament, plus support. |
| Peinture à plat, face vers le haut | Dessus plat exposé | Ordre normal, fondation sous les colonnes plus courtes. | Objets par filament ; sans support. |
-La peinture à plat et le **Maillage lissé** ne fonctionnent pas ensemble. La plaque utilise des zones côte à côte couvrant toute l’emprise plutôt que des contours lissés. Activer le maillage lissé désactive la peinture à plat ; choisissez la disposition souhaitée et régénérez lors d’un changement.
+**Maillage lissé** fonctionne dans les deux orientations de la peinture à plat. Choisissez une intensité et reconstruisez. Le lissage adoucit le contour extérieur et les frontières communes entre couleurs, tout en conservant la plaque plane, les hauteurs de couche et les empilements de matériaux. De petites jonctions communes ferment les contacts diagonaux sans chevauchement. Les instructions d’impression et le 3MF indiquent l’intensité utilisée.
## Exporter et vérifier
diff --git a/src/locales/fr/docs/generating-exporting-output.md b/src/locales/fr/docs/generating-exporting-output.md
index 0d88c000..65b89876 100644
--- a/src/locales/fr/docs/generating-exporting-output.md
+++ b/src/locales/fr/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ Un aperçu de peinture automatique peut montrer des dizaines de mélanges proven
Le STL ne contient ni couleurs de filament ni affectations automatiques de bobines. Utilisez le plan de changements copié avec les commandes de changement de couleur du logiciel de découpe. Un 3MF reste un modèle, pas un G-code prêt à exécuter : choisissez votre imprimante, buse, profils de filament, températures et vitesses, puis découpez-le.
-Pour les modèles de **Peinture à plat**, le menu ne propose que le 3MF : le modèle contient un objet par filament physique et, dans la disposition par défaut face vers le bas, un objet de support transparent. La disposition facultative face vers le haut omet ce support. Un STL sans couleur à géométrie unique de l’une ou l’autre plaque serait inutile. La peinture à plat désactive le **Maillage lissé**, car sa disposition n’utilise pas de contours de frontière lissés.
+Pour les modèles de **Peinture à plat**, le menu ne propose que le 3MF : le modèle contient un objet par filament physique et, dans la disposition par défaut face vers le bas, un objet de support transparent. La disposition facultative face vers le haut omet ce support. Un STL sans couleur à géométrie unique de l’une ou l’autre plaque serait inutile. Les deux orientations prennent en charge toutes les intensités de **Maillage lissé**. Reconstruisez le modèle après avoir changé l’intensité pour l’appliquer à l’aperçu et à la géométrie exportée.
## Instructions d’impression
diff --git a/src/locales/fr/docs/settings-and-controls.md b/src/locales/fr/docs/settings-and-controls.md
index e92e1c9b..9d728ca4 100644
--- a/src/locales/fr/docs/settings-and-controls.md
+++ b/src/locales/fr/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ Le sélecteur **Langue** modifie l’interface, la documentation, les schémas e
Les pages publiques proposent aussi un sélecteur de langue. La documentation traduite utilise des liens partageables préfixés par la langue, comme `/ro/docs/overview`. Les noms de pages et les ancres de section restent stables d’une langue à l’autre. Les extensions de fichiers, données numériques des modèles, noms saisis par l’utilisateur et titres originaux des œuvres communautaires ne sont pas traduits.
-La boîte des paramètres contient des liens vers la documentation, Discord, Reddit, GitHub et Patreon, et affiche la version actuelle de Kromacut.
+La boîte des paramètres contient des liens vers la documentation, Discord, Reddit, GitHub et Patreon, et affiche la version actuelle de Kromacut. **Confidentialité et données locales** et **Conditions d’utilisation** sont également accessibles ici et s’ouvrent dans votre navigateur dans la langue sélectionnée.
## Modes de l’espace de travail
@@ -123,4 +123,16 @@ Une trace complète contient les informations de base d’exécution, l’instan
L’enregistrement sert aux investigations et peut créer de gros fichiers. Laissez-le désactivé pour les impressions ordinaires lorsque vous n’avez pas besoin d’une trace.
+## Ouvrir des fichiers depuis le bureau
+
+Les installations de bureau associent les fichiers `.kfil` et les anciens `.kapp` aux profils de filaments, et les fichiers `.kpal` aux palettes. Double-cliquez sur un fichier pour l’importer et le sélectionner dans Kromacut. Si l’application est déjà ouverte, le fichier est traité dans sa fenêtre existante. Les règles habituelles de validation, migration, doublons et préservation du calibrage s’appliquent.
+
+Les fichiers ouverts depuis le bureau attendent tant que l’éditeur de palettes, la boîte de dialogue d’étalonnage ou un formulaire pour renommer ou enregistrer un nouveau profil est ouvert. Terminez ou annulez cette session pour poursuivre les importations en attente ; les noms saisis et le profil en cours de modification restent inchangés jusque-là.
+
+Les importations attendent également pendant la modification du nom ou du champ HD d’un filament, ou tant que son sélecteur de couleur ou le panneau **Convertir depuis TD** est ouvert. Quittez le champ ou fermez le sélecteur ou le panneau pour reprendre les importations en attente ; les modifications de filaments non enregistrées nécessitent toujours votre choix avant d’être remplacées par un autre profil.
+
+Avant de remplacer des modifications de filaments non enregistrées, Kromacut propose **Conserver les modifications** ou **Ouvrir le profil**. Conservez-les pour les enregistrer d’abord ; ouvrir le profil les abandonne. Les fichiers ouverts ainsi doivent faire moins de 32 Mio. Les fichiers JSON génériques, images et modèles conservent leurs procédures d’importation habituelles.
+
+Sous Linux, les AppImages portables nécessitent une intégration au bureau pour les associations de fichiers. Si Kromacut n’est pas l’application par défaut, utilisez **Ouvrir avec** dans votre gestionnaire de fichiers.
+
Suite : [Dépannage](troubleshooting).
diff --git a/src/locales/fr/docs/troubleshooting.md b/src/locales/fr/docs/troubleshooting.md
index af062bfd..213e4881 100644
--- a/src/locales/fr/docs/troubleshooting.md
+++ b/src/locales/fr/docs/troubleshooting.md
@@ -55,7 +55,7 @@ Vérifiez séparément la ligne **Modèle d’apparence**. Un score global élev
## Une option en a désactivé une autre
-Deux paires sont volontairement exclusives : **Maillage lissé** et **Peinture à plat**, ainsi que **Préserver la séparation des couleurs** et **Tramage de hauteur**. La première paire choisit différentes géométries ; la seconde choisit différentes façons d’affecter les couleurs sources aux hauteurs imprimables. Consultez [Peinture à plat](flat-paint) et [Peinture automatique](auto-paint).
+**Préserver la séparation des couleurs** et **Tramage de hauteur** restent exclusifs, car ils affectent différemment les couleurs sources aux hauteurs imprimables. **Maillage lissé** et **Peinture à plat** peuvent être utilisés ensemble ; reconstruisez après toute modification. Consultez [Peinture à plat](flat-paint) et [Peinture automatique](auto-paint).
## La séparation des couleurs ne trouve aucun résultat
diff --git a/src/locales/fr/printing.json b/src/locales/fr/printing.json
index c6da7465..61e7c102 100644
--- a/src/locales/fr/printing.json
+++ b/src/locales/fr/printing.json
@@ -234,7 +234,9 @@
"colorCount_many": "{{count, number}} couleurs",
"tooManyColors_many": "Trop de couleurs ({{count, number}})"
},
- "threeDView": {
+ "threeDView": {
+ "buildFailed": "Impossible de créer le modèle 3D",
+ "buildFailedDetails": "Le modèle incomplet a été supprimé. Cliquez sur Construire le modèle 3D pour réessayer. Erreur : {{error}}",
"layerPreview": "Aperçu des couches",
"previewBottomLayerCutoff": "Limite inférieure des couches de l’aperçu",
"previewTopLayerCutoff": "Limite supérieure des couches de l’aperçu",
@@ -262,7 +264,11 @@
"firstLayerHeight": "Hauteur de première couche",
"effectiveLineWidth": "Largeur de ligne effective",
"smoothMeshing": "Maillage lissé",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "Lisse les frontières de couleurs connectées avec une topologie soudée rapide. Cette option désactive la peinture à plat.",
+ "smoothingNone": "Aucun",
+ "smoothingMinimal": "Minimal",
+ "smoothingMedium": "Moyen",
+ "smoothingAggressive": "Intensif",
+ "smoothingHelp": "Adoucit les contours du modèle dans l’aperçu et les exports. Moyen reproduit le lissage d’origine. Reconstruisez pour appliquer.",
"modelDimensions": "Modèle : {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -365,7 +371,7 @@
"readingImageLayers": "Lecture des couches de couleur de l’image",
"buildingLayer": "Génération de la couche de couleur {{layer}} sur {{total}}",
"layerProgress": "Couche {{layer}} sur {{total}} : {{label}}",
- "flatPaintPartProgress": "Pièce de peinture à plat {{part}} sur {{total}} : {{label}}",
+ "flatPaintPartProgress": "Partie du maillage de peinture à plat {{part}} sur {{total}} : {{label}}",
"partMeshComplete": "Maillage de la pièce terminé",
"selectingPixels": "Sélection des pixels actifs",
"preparingPreview": "Préparation de la géométrie de l’aperçu",
diff --git a/src/locales/fr/public.json b/src/locales/fr/public.json
index 8d03bafd..b2acfe00 100644
--- a/src/locales/fr/public.json
+++ b/src/locales/fr/public.json
@@ -1,6 +1,6 @@
{
"landing":{
- "importAnImage":"Importer une image","dropInArtworkAPhotoOrAPixelDesignAnd":"Importez une illustration, une photo ou un dessin en pixels tout en conservant l’original modifiable.","aColorful2DImageReadyToBeImportedIntoKromacut":"Une image 2D colorée prête à être importée dans Kromacut","reducePaintColors":"Réduire et peindre les couleurs","tuneACompactPaletteManuallyOrLetAutoPaintMatch":"Réglez une palette compacte manuellement ou laissez la peinture automatique exploiter les filaments que vous possédez.","aColorLayeredImagePreviewShowingSeparatePrintableColors":"Aperçu d’une image en couches de couleur montrant les couleurs imprimables distinctes","previewEveryLayer":"Prévisualiser chaque couche","inspectTheStackIn3DCheckTransitionsAndSeeExactly":"Examinez l’empilement en 3D, vérifiez les transitions et repérez précisément les changements de filament.","kromacut3DPreviewOfAStackedColorLayerPrint":"Aperçu 3D Kromacut d’une impression en couches de couleur empilées","exportAndPrint":"Exporter et imprimer","downloadASlicerReadySTLOr3MFWithThePrint":"Téléchargez un STL ou un 3MF prêt à trancher, avec le plan d’impression nécessaire pour terminer votre projet.","aFinishedColorfulLayeredPrintMadeFromAKromacutWorkflow":"Une impression colorée en couches terminée, réalisée avec Kromacut","aColorfulHobbitsAndDragonsLayered3DPrint":"Une impression 3D colorée en couches de Hobbits and Dragons","finishedPrint":"Impression terminée","aSecondViewOfTheHobbitsAndDragonsLayered3D":"Une seconde vue de l’impression 3D en couches de Hobbits and Dragons","slicerPreviewOfTheMulticolorKingOfHeartsPlayingCard":"Aperçu dans le logiciel de tranchage de la carte à jouer multicolore King of Hearts","slicerPreview":"Aperçu du tranchage","aMulticolorLayeredKingOfHeartsPlayingCardPrint":"Une impression multicolore en couches de la carte à jouer King of Hearts","kromacutAutoPaintPredictionForALayeredHopePosterPrint":"Prédiction de peinture automatique Kromacut pour une impression en couches de l’affiche Hope","kromacutPrediction":"Prédiction Kromacut","autoPaintPredictionUsingTheAvailableCalibratedFilamentProfile":"Prédiction de peinture automatique avec le profil de filaments étalonné disponible.","slicerPreviewOfTheLayeredHopePosterPrint":"Aperçu du tranchage de l’impression en couches de l’affiche Hope","thePhysicalLayerPlanPreparedForPrintingWithoutARed":"Le plan de couches physiques préparé pour une impression sans filament rouge.","finishedLayeredHopePosterPrintShowingOrangeAndPurpleBlue":"Impression terminée de l’affiche Hope en couches, présentant des décalages vers l’orange et le bleu-violet","paleYellowMatchedMostCloselyRedPrintedOrangeCyanLeaned":"Le jaune pâle est le plus fidèle ; le rouge est sorti orange, le cyan a viré au bleu-violet, et le noir a reproduit l’aperçu sans atteindre le bleu-vert sombre de la cible.","kromacutAutoPaintPredictionForTheGoldenWavesOfThe":"Prédiction de peinture automatique Kromacut pour les vagues dorées de l’affiche Titan","text10241527MmFootprint":"Emprise de 102,4 × 152,7 mm","autoPaintWithTheEightColorCalibratedFilamentProfile":"Peinture automatique avec le profil étalonné de huit filaments colorés.","crealityPrintSlicerPreviewOfTheTitanPosterWithIts":"Aperçu Creality Print de l’affiche Titan avec sa tour de changement de filament","crealityHi04MmNozzleAnd008Mm":"Creality Hi, buse de 0,4 mm et couches de 0,08 mm. Estimation du logiciel : 2 h 58 min, 28,90 g purges et tour comprises, six changements de filament.","finishedTitanLayeredPrintWithGoldenYellowAndOrangeWave":"Impression Titan terminée en couches, avec des reflets de vagues jaune doré et orange sur fond sombre","theCompletedPrintPhotographedByItsMakerLightingAndCamera":"L’impression terminée, photographiée par son auteur. L’éclairage et le traitement de l’appareil photo peuvent modifier les couleurs affichées.","kromacutAutoPaintPredictionOfTheBatmanJiroKuwataBatmanga":"Prédiction de peinture automatique Kromacut de la couverture Batman Jiro Kuwata Batmanga","text6581000MmFootprint":"Emprise de 65,8 × 100,0 mm","autoPaintWithDeepOptimizationEnhancedMatchingAndPreserveColor":"Peinture automatique avec optimisation très approfondie, correspondance améliorée et préservation de la séparation des couleurs activées.","crealityPrintSlicerPreviewOfTheBatmangaCoverAndFilament":"Aperçu Creality Print de la couverture Batmanga et de la tour de changement de filament","crealityHi04MmNozzleAnd008Mm2":"Creality Hi, buse de 0,4 mm et couches de 0,08 mm. Estimation du logiciel : 3 h 12 min, 21,30 g purges et tour comprises, huit changements de filament.","finishedBatmangaLayeredPrintWithAYellowJapaneseTitleTan":"Impression Batmanga terminée en couches, avec un titre japonais jaune, un fond beige et Batman en noir et gris","theCompletedPrintPhotographedByItsMakerTheLargeTitle":"L’impression terminée, photographiée par son auteur. Le grand titre et la silhouette restent distincts ; les petites lettres et les ombrages fins perdent des détails à cette taille.","kromacutAutoPaintPredictionOfNarutoLookingUpAtA":"Prédiction de peinture automatique Kromacut de Naruto regardant un ciel bleu","text11041902MmFootprint":"Emprise de 110,4 × 190,2 mm","autoPaintWithTheEightColorCalibratedFilamentProfile0":"Peinture automatique avec le profil étalonné de huit filaments colorés, des pixels de 0,15 mm et le maillage lissé activé.","crealityPrintSlicerPreviewOfTheNarutoPrintWithSeven":"Aperçu Creality Print de l’impression Naruto avec sept couleurs de filament et une tour de changement","crealityHi04MmNozzleAnd008Mm3":"Creality Hi, buse de 0,4 mm et couches de 0,08 mm. Estimation du logiciel : 5 h 55 min, 72,10 g purges et tour comprises, neuf changements de filament.","finishedNarutoLayeredPrintWithYellowHairOrangeClothingAnd":"Impression Naruto terminée en couches, avec des cheveux jaunes, des vêtements orange et un ciel bleu lavande","theCompletedOvernightPrintPhotographedByItsMakerLightingAnd":"L’impression terminée pendant la nuit, photographiée par son auteur. L’éclairage et le traitement de l’appareil photo peuvent modifier les couleurs affichées.","openAnyPhotoAtFullSize":"Ouvrez une photo en taille réelle.","viewRedditPost":"Voir la publication Reddit","communityLinks":"Liens de la communauté","kromacutOnGitHub":"Kromacut sur GitHub","joinKromacutOnDiscord":"Rejoindre Kromacut sur Discord","rKromacutOnReddit":"r/kromacut sur Reddit","supportKromacutOnPatreon":"Soutenir Kromacut sur Patreon","support":"Soutenir","system":"Système","dark":"Sombre","light":"Clair","changeTheme":"Changer de thème","theme":"Thème","skipToWorkflow":"Aller aux étapes de création","mainNavigation":"Navigation principale","howItWorks":"Comment ça marche","docs":"Documentation","releases":"Versions","openKromacut":"Ouvrir Kromacut","mobileNavigation":"Navigation mobile","openSourceBrowserFirst":"Code ouvert · pensé pour le navigateur","kromacutTransforms2DImagesIntoStackedColorLayered3DPrints":"Kromacut transforme les images 2D en impressions 3D faites de couches de couleur empilées. Préparez votre palette, associez vos vrais filaments, prévisualisez le résultat et exportez un modèle prêt à trancher.","startCreating":"Commencer à créer","readTheQuickStart":"Lire le guide de démarrage","freeToUse":"Utilisation gratuite","browserDesktop":"Navigateur et application de bureau","sTL3MFExport":"Export STL et 3MF","text3DLayerPreview":"Aperçu 3D des couches","readyToPrint":"prêt à imprimer","colorLayered3DPrintPreview":"Aperçu d’impression 3D en couches de couleur","paletteMapped":"Palette associée","text4PrintableColors":"4 couleurs imprimables","fromImageToObject":"De l’image à l’objet","aSimpleWorkflowWithSeriousControl":"Un parcours simple, un contrôle précis.","goFromAFlatImageToALayeredPrintWithout":"Passez d’une image plane à une impression en couches sans quitter votre navigateur. Gardez la main sur les choix créatifs ; Kromacut s’occupe de la géométrie et du plan d’impression.","original2DArtwork":"Illustration 2D d’origine","finishedKromacutPrint":"Impression Kromacut terminée","builtForRealPrints":"Conçu pour de vraies impressions","yourFilamentYourPaletteYourModel":"Vos filaments. Votre palette. Votre modèle.","useManualSlicingWhenYouWantPixelLevelControlOr":"Utilisez le découpage manuel pour un contrôle au pixel près, ou laissez la peinture automatique rechercher un empilement de couleurs à partir de votre profil de filaments étalonné. Dans les deux cas, vérifiez chaque couche avant l’export.","nonDestructiveImageAdjustments":"Réglages d’image non destructifs","threeJsLayerByLayer3DPreview":"Aperçu 3D couche par couche avec Three.js","calibratedAutoPaintWithDeterministicSearch":"Peinture automatique étalonnée avec recherche déterministe","slicerFriendlySTLAndMultiMaterial3MF":"STL prêts à trancher et 3MF multimatériaux","openTheTool":"Ouvrir l’outil","communityShowcase":"Galerie de la communauté","madeByTheKromacutCommunity":"Créé par la communauté Kromacut.","finishedPrintsAndBehindTheScenesPreviewsFromPeopleCreating":"Des impressions terminées et des aperçus des coulisses de celles et ceux qui créent avec Kromacut.","wantToShowcaseYourWorkOrContributeToKromacut":"Envie de présenter votre travail ou de contribuer à Kromacut ?","shareWhatYouMadeImproveTheProjectOrHelpThe":"Partagez vos créations, améliorez le projet ou aidez la communauté à découvrir les possibilités de Kromacut.","openAPullRequest":"Proposer une contribution","readyToMakeAFlatImagePhysical":"Prêt à donner du relief à une image ?","startWithAnImageFinishWithAPrint":"Commencez par une image. Terminez par une impression.","desktopReleases":"Versions de bureau","openSourceToolsForTurningImagesIntoColorLayered3D":"Des outils à code ouvert pour transformer les images en impressions 3D en couches de couleur.","footerNavigation":"Navigation du pied de page","supportKromacut":"Soutenir Kromacut","privacy":"Confidentialité","terms":"Conditions","heroTitle":"Transformez vos pixels en couches imprimables. ","creator":"Par {{creator}} ","photos_one":"{{count}} photo","photos_other":"{{count}} photos","photos_many":"{{count}} photos","openPhoto":"Ouvrir {{title}} — {{kind}} {{index}} en taille réelle","originalArtwork":"Œuvre originale : {{title}} ","themeLabel":"Thème : {{theme}}"
+ "importAnImage":"Importer une image","dropInArtworkAPhotoOrAPixelDesignAnd":"Importez une illustration, une photo ou un dessin en pixels tout en conservant l’original modifiable.","aColorful2DImageReadyToBeImportedIntoKromacut":"Une image 2D colorée prête à être importée dans Kromacut","reducePaintColors":"Réduire et peindre les couleurs","tuneACompactPaletteManuallyOrLetAutoPaintMatch":"Réglez une palette compacte manuellement ou laissez la peinture automatique exploiter les filaments que vous possédez.","aColorLayeredImagePreviewShowingSeparatePrintableColors":"Aperçu d’une image en couches de couleur montrant les couleurs imprimables distinctes","previewEveryLayer":"Prévisualiser chaque couche","inspectTheStackIn3DCheckTransitionsAndSeeExactly":"Examinez l’empilement en 3D, vérifiez les transitions et repérez précisément les changements de filament.","kromacut3DPreviewOfAStackedColorLayerPrint":"Aperçu 3D Kromacut d’une impression en couches de couleur empilées","exportAndPrint":"Exporter et imprimer","downloadASlicerReadySTLOr3MFWithThePrint":"Téléchargez un STL ou un 3MF prêt à trancher, avec le plan d’impression nécessaire pour terminer votre projet.","aFinishedColorfulLayeredPrintMadeFromAKromacutWorkflow":"Une impression colorée en couches terminée, réalisée avec Kromacut","aColorfulHobbitsAndDragonsLayered3DPrint":"Une impression 3D colorée en couches de Hobbits and Dragons","finishedPrint":"Impression terminée","aSecondViewOfTheHobbitsAndDragonsLayered3D":"Une seconde vue de l’impression 3D en couches de Hobbits and Dragons","slicerPreviewOfTheMulticolorKingOfHeartsPlayingCard":"Aperçu dans le logiciel de tranchage de la carte à jouer multicolore King of Hearts","slicerPreview":"Aperçu du tranchage","aMulticolorLayeredKingOfHeartsPlayingCardPrint":"Une impression multicolore en couches de la carte à jouer King of Hearts","kromacutAutoPaintPredictionForALayeredHopePosterPrint":"Prédiction de peinture automatique Kromacut pour une impression en couches de l’affiche Hope","kromacutPrediction":"Prédiction Kromacut","autoPaintPredictionUsingTheAvailableCalibratedFilamentProfile":"Prédiction de peinture automatique avec le profil de filaments étalonné disponible.","slicerPreviewOfTheLayeredHopePosterPrint":"Aperçu du tranchage de l’impression en couches de l’affiche Hope","thePhysicalLayerPlanPreparedForPrintingWithoutARed":"Le plan de couches physiques préparé pour une impression sans filament rouge.","finishedLayeredHopePosterPrintShowingOrangeAndPurpleBlue":"Impression terminée de l’affiche Hope en couches, présentant des décalages vers l’orange et le bleu-violet","paleYellowMatchedMostCloselyRedPrintedOrangeCyanLeaned":"Le jaune pâle est le plus fidèle ; le rouge est sorti orange, le cyan a viré au bleu-violet, et le noir a reproduit l’aperçu sans atteindre le bleu-vert sombre de la cible.","kromacutAutoPaintPredictionForTheGoldenWavesOfThe":"Prédiction de peinture automatique Kromacut pour les vagues dorées de l’affiche Titan","text10241527MmFootprint":"Emprise de 102,4 × 152,7 mm","autoPaintWithTheEightColorCalibratedFilamentProfile":"Peinture automatique avec le profil étalonné de huit filaments colorés.","crealityPrintSlicerPreviewOfTheTitanPosterWithIts":"Aperçu Creality Print de l’affiche Titan avec sa tour de changement de filament","crealityHi04MmNozzleAnd008Mm":"Creality Hi, buse de 0,4 mm et couches de 0,08 mm. Estimation du logiciel : 2 h 58 min, 28,90 g purges et tour comprises, six changements de filament.","finishedTitanLayeredPrintWithGoldenYellowAndOrangeWave":"Impression Titan terminée en couches, avec des reflets de vagues jaune doré et orange sur fond sombre","theCompletedPrintPhotographedByItsMakerLightingAndCamera":"L’impression terminée, photographiée par son auteur. L’éclairage et le traitement de l’appareil photo peuvent modifier les couleurs affichées.","kromacutAutoPaintPredictionOfTheBatmanJiroKuwataBatmanga":"Prédiction de peinture automatique Kromacut de la couverture Batman Jiro Kuwata Batmanga","text6581000MmFootprint":"Emprise de 65,8 × 100,0 mm","autoPaintWithDeepOptimizationEnhancedMatchingAndPreserveColor":"Peinture automatique avec optimisation très approfondie, correspondance améliorée et préservation de la séparation des couleurs activées.","crealityPrintSlicerPreviewOfTheBatmangaCoverAndFilament":"Aperçu Creality Print de la couverture Batmanga et de la tour de changement de filament","crealityHi04MmNozzleAnd008Mm2":"Creality Hi, buse de 0,4 mm et couches de 0,08 mm. Estimation du logiciel : 3 h 12 min, 21,30 g purges et tour comprises, huit changements de filament.","finishedBatmangaLayeredPrintWithAYellowJapaneseTitleTan":"Impression Batmanga terminée en couches, avec un titre japonais jaune, un fond beige et Batman en noir et gris","theCompletedPrintPhotographedByItsMakerTheLargeTitle":"L’impression terminée, photographiée par son auteur. Le grand titre et la silhouette restent distincts ; les petites lettres et les ombrages fins perdent des détails à cette taille.","kromacutAutoPaintPredictionOfNarutoLookingUpAtA":"Prédiction de peinture automatique Kromacut de Naruto regardant un ciel bleu","text11041902MmFootprint":"Emprise de 110,4 × 190,2 mm","autoPaintWithTheEightColorCalibratedFilamentProfile0":"Peinture automatique avec le profil étalonné de huit filaments colorés, des pixels de 0,15 mm et le maillage lissé activé.","crealityPrintSlicerPreviewOfTheNarutoPrintWithSeven":"Aperçu Creality Print de l’impression Naruto avec sept couleurs de filament et une tour de changement","crealityHi04MmNozzleAnd008Mm3":"Creality Hi, buse de 0,4 mm et couches de 0,08 mm. Estimation du logiciel : 5 h 55 min, 72,10 g purges et tour comprises, neuf changements de filament.","finishedNarutoLayeredPrintWithYellowHairOrangeClothingAnd":"Impression Naruto terminée en couches, avec des cheveux jaunes, des vêtements orange et un ciel bleu lavande","theCompletedOvernightPrintPhotographedByItsMakerLightingAnd":"L’impression terminée pendant la nuit, photographiée par son auteur. L’éclairage et le traitement de l’appareil photo peuvent modifier les couleurs affichées.","openAnyPhotoAtFullSize":"Ouvrez une photo en taille réelle.","viewRedditPost":"Voir la publication Reddit","communityLinks":"Liens de la communauté","kromacutOnGitHub":"Kromacut sur GitHub","joinKromacutOnDiscord":"Rejoindre Kromacut sur Discord","rKromacutOnReddit":"r/kromacut sur Reddit","supportKromacutOnPatreon":"Soutenir Kromacut sur Patreon","support":"Soutenir","system":"Système","dark":"Sombre","light":"Clair","changeTheme":"Changer de thème","theme":"Thème","skipToWorkflow":"Aller aux étapes de création","mainNavigation":"Navigation principale","howItWorks":"Comment ça marche","docs":"Documentation","releases":"Versions","openKromacut":"Ouvrir Kromacut","mobileNavigation":"Navigation mobile","openSourceBrowserFirst":"Code ouvert · pensé pour le navigateur","kromacutTransforms2DImagesIntoStackedColorLayered3DPrints":"Kromacut transforme les images 2D en impressions 3D faites de couches de couleur empilées. Préparez votre palette, associez vos vrais filaments, prévisualisez le résultat et exportez un modèle prêt à trancher.","startCreating":"Commencer à créer","readTheQuickStart":"Lire le guide de démarrage","freeToUse":"Utilisation gratuite","browserDesktop":"Navigateur et application de bureau","sTL3MFExport":"Export STL et 3MF","text3DLayerPreview":"Aperçu 3D des couches","readyToPrint":"prêt à imprimer","colorLayered3DPrintPreview":"Aperçu d’impression 3D en couches de couleur","paletteMapped":"Palette associée","text4PrintableColors":"4 couleurs imprimables","fromImageToObject":"De l’image à l’objet","aSimpleWorkflowWithSeriousControl":"Un parcours simple, un contrôle précis.","goFromAFlatImageToALayeredPrintWithout":"Passez d’une image plane à une impression en couches sans quitter votre navigateur. Gardez la main sur les choix créatifs ; Kromacut s’occupe de la géométrie et du plan d’impression.","original2DArtwork":"Illustration 2D d’origine","finishedKromacutPrint":"Impression Kromacut terminée","builtForRealPrints":"Conçu pour de vraies impressions","yourFilamentYourPaletteYourModel":"Vos filaments. Votre palette. Votre modèle.","useManualSlicingWhenYouWantPixelLevelControlOr":"Utilisez le découpage manuel pour un contrôle au pixel près, ou laissez la peinture automatique rechercher un empilement de couleurs à partir de votre profil de filaments étalonné. Dans les deux cas, vérifiez chaque couche avant l’export.","nonDestructiveImageAdjustments":"Réglages d’image non destructifs","threeJsLayerByLayer3DPreview":"Aperçu 3D couche par couche avec Three.js","calibratedAutoPaintWithDeterministicSearch":"Peinture automatique étalonnée avec recherche déterministe","slicerFriendlySTLAndMultiMaterial3MF":"STL prêts à trancher et 3MF multimatériaux","openTheTool":"Ouvrir l’outil","communityShowcase":"Galerie de la communauté","madeByTheKromacutCommunity":"Créé par la communauté Kromacut.","finishedPrintsAndBehindTheScenesPreviewsFromPeopleCreating":"Des impressions terminées et des aperçus des coulisses de celles et ceux qui créent avec Kromacut.","wantToShowcaseYourWorkOrContributeToKromacut":"Envie de présenter votre travail ou de contribuer à Kromacut ?","shareWhatYouMadeImproveTheProjectOrHelpThe":"Partagez vos créations, améliorez le projet ou aidez la communauté à découvrir les possibilités de Kromacut.","openAPullRequest":"Proposer une contribution","readyToMakeAFlatImagePhysical":"Prêt à donner du relief à une image ?","startWithAnImageFinishWithAPrint":"Commencez par une image. Terminez par une impression.","desktopReleases":"Versions de bureau","openSourceToolsForTurningImagesIntoColorLayered3D":"Des outils à code ouvert pour transformer les images en impressions 3D en couches de couleur.","footerNavigation":"Navigation du pied de page","supportKromacut":"Soutenir Kromacut","privacy":"Confidentialité","terms":"Conditions","heroTitle":"Transformez vos pixels en couches imprimables. ","creator":"Par {{creator}} ","photos_one":"{{count}} photo","photos_other":"{{count}} photos","photos_many":"{{count}} photos","openPhoto":"Ouvrir {{title}} — {{kind}} {{index}} en taille réelle","originalArtwork":"Œuvre originale : {{title}} ","themeLabel":"Thème : {{theme}}"
},
"navigation":{"homeLabel":"Accueil de Kromacut","openApp":"Ouvrir Kromacut","home":"Aller à l’accueil","docs":"Parcourir la documentation","privacy":"Confidentialité et données locales","terms":"Conditions d’utilisation"},
"legal":{"privacyEyebrow":"Le local d’abord. En toute clarté.","termsEyebrow":"Utiliser Kromacut.","updated":"Mis à jour le {{date}}","contents":"Sur cette page"},
diff --git a/src/locales/hi/common.json b/src/locales/hi/common.json
index 59ac3c49..0383bff1 100644
--- a/src/locales/hi/common.json
+++ b/src/locales/hi/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "अमान्य प्रोफ़ाइल फ़ाइल",
+ "failedTitle": "फ़ाइल नहीं खुल सकी",
+ "failedDescription": "“{{name}}” नहीं खुल सकी। जाँचें कि यह 32 MiB से छोटी, पढ़ी जा सकने वाली Kromacut फ़ाइल है और स्थानीय संग्रहण में जगह है।",
+ "unavailable": "फ़ाइल खोलना अस्थायी रूप से उपलब्ध नहीं है। Kromacut को पुनः शुरू करके फिर कोशिश करें।",
+ "unsavedTitle": "फ़िलामेंट के असहेजे बदलाव",
+ "unsavedDescription": "“{{name}}” खोलने से फ़िलामेंट के असहेजे बदलाव बदल जाएँगे। पहले सहेजने के लिए बदलाव रखें, या उन्हें छोड़ने के लिए प्रोफ़ाइल खोलें।",
+ "keepEdits": "बदलाव रखें",
+ "openProfile": "प्रोफ़ाइल खोलें",
+ "close": "बंद करें"
+ },
"colorPicker": {
"hue": "रंगत",
"color": "रंग",
@@ -27,6 +38,7 @@
"light": "लाइट",
"resources": "संसाधन और समुदाय",
"resourcesDescription": "गाइड, नए संस्करण और अपनी रचनाएँ साझा करने की जगहें।",
+ "openLinkFailed": "यह पेज नहीं खुल सका। दोबारा कोशिश करें।",
"docs": "दस्तावेज़",
"docsDescription": "काम करने का तरीका और प्रिंट सेटिंग्स जानें।",
"reddit": "Reddit पर r/kromacut",
diff --git a/src/locales/hi/docs/3d-mode.md b/src/locales/hi/docs/3d-mode.md
index 43532a3c..0843d6b4 100644
--- a/src/locales/hi/docs/3d-mode.md
+++ b/src/locales/hi/docs/3d-mode.md
@@ -76,13 +76,24 @@ _सांकेतिक अनुप्रस्थ काट। बाद क
स्मूदिंग बंद हो तो रूपरेखा चौकोर पिक्सेल ग्रिड का अनुसरण करती है। चालू हो तो जुड़ी सीमाएँ चिकनी वेल्ड की ज्यामिति बनती हैं। अंतर निर्यात होता है, पूर्वावलोकन फ़िल्टर नहीं है।
+पूर्वावलोकन और निर्यात में मॉडल की रूपरेखा को चिकना करता है। मध्यम पुराने चिकनेपन को बनाए रखता है। लागू करने के लिए फिर से बनाएँ।
+
+| तीव्रता | उपयोग |
+| --- | --- |
+| **कोई नहीं** | पिक्सेल कला, सटीक ग्रिड किनारे या सबसे तेज़ निर्माण। |
+| **न्यूनतम** | दाँतेदार कोनों की हल्की सफाई। |
+| **मध्यम** | पहले सक्षम सेटिंग का परिचित परिणाम। |
+| **तीव्र** | मध्यम की समान गति सीमा के भीतर रूपरेखा और बाकी सीढ़ीनुमा किनारों को अधिक चिकना करता है। |
+
+गति आधे स्रोत पिक्सेल से कम रहती है। मॉडल फिर से बनाएँ और स्लाइसर पूर्वावलोकन जाँचें। पुराने चालू/बंद विकल्प मध्यम/कोई नहीं बनते हैं।
+

_सांकेतिक रूपरेखा तुलना, स्लाइसर अनुकरण नहीं।_
घुमावदार या तिरछी रूपरेखा बहुत सीढ़ीनुमा लगे तो इस्तेमाल करें। जानबूझकर पिक्सेल कला या सटीक ग्रिड किनारों के लिए बंद रखें। कोई भी चुनाव न छपने लायक छोटे विवरण सुधारता या गायब स्रोत रिज़ॉल्यूशन बनाता नहीं।
-[समतल प्रिंट](flat-paint) के दौरान चिकनी मेशिंग निष्क्रिय है। चिकनी मेशिंग चालू करने से समतल प्रिंट बंद होता है; समतल प्रिंट अपना पूरे-क्षेत्र वाला पट्टी निर्माण इस्तेमाल करता है।
+चिकनी मेशिंग [समतल प्रिंट](flat-paint) के साथ भी काम करती है। दोनों दिशाओं में पट्टी समतल और रंग सीमाएँ साझा रहती हैं; तीव्रता चुनकर दोबारा बनाएँ।
## स्वचालित रंगाई
diff --git a/src/locales/hi/docs/auto-paint.md b/src/locales/hi/docs/auto-paint.md
index 74989112..d714aa85 100644
--- a/src/locales/hi/docs/auto-paint.md
+++ b/src/locales/hi/docs/auto-paint.md
@@ -135,7 +135,7 @@ _वैचारिक क्रम और आवंटन, सामग्र
_सांकेतिक तरीका, सुनिश्चित पहले-बाद का परिणाम नहीं। अलग ऊपरी ऊँचाइयों का मतलब अतिरिक्त स्पूल रंग नहीं।_
-कई वर्तमान स्वचालित रंगाई मैपिंग इस चरण से पहले ही अलग प्रिंट योग्य परत चुनती हैं। पहले से स्नैप क्षेत्रों में बाँटने के लिए आंशिक-ऊँचाई त्रुटि नहीं है और डिथरिंग चालू करने पर वे अपरिवर्तित रह सकते हैं। फिर बने पूर्वावलोकन और स्लाइसर से देखें कि इस चित्र में वास्तव में ऊँचाई विविधता बढ़ी है या नहीं; टॉगल अधिक टोन या दिखते बिंदु सुनिश्चित नहीं करता।
+डिथरिंग चालू होने पर, स्वचालित रंगाई चुनी गई परत और पास की प्रिंट योग्य परत के बीच की ऊँचाई खोजती है, यदि उनका मिश्रण अनुमानित रंग-मिलान को बेहतर बनाता है। सटीक रंग-मिलान और कैलिब्रेट किए गए लक्ष्य-मिलान अपनी चुनी हुई ऊँचाई पर रहते हैं; उपयोगी पड़ोसी मिश्रण के बिना क्षेत्र भी नहीं बदलते। सेटिंग बदलने के बाद मॉडल दोबारा बनाएँ और पूर्वावलोकन व स्लाइसर परिणाम की तुलना करें। डिथरिंग बंद करने पर सामान्य अलग-अलग परतों वाला मिलान लौट आता है।
बिंदु आकार **3D प्रिंट सेटिंग** की **प्रभावी रेखा चौड़ाई** और **पिक्सेल आकार** के अनुपात से पूरा-पिक्सेल ब्लॉक बनाकर मिलता है। यह अनुमानित है, सटीक न्यूनतम-चौड़ाई गारंटी नहीं। सीमा कलाकृतियाँ घटाने के लिए किनारे के क्षेत्र वही डिथरिंग नहीं लेते। स्लाइसर में छोटे द्वीप और अतिरिक्त यात्रा जाँचें।
diff --git a/src/locales/hi/docs/flat-paint.md b/src/locales/hi/docs/flat-paint.md
index e96a05e1..083d8aa7 100644
--- a/src/locales/hi/docs/flat-paint.md
+++ b/src/locales/hi/docs/flat-paint.md
@@ -15,6 +15,10 @@ description: सीढ़ीनुमा उभार, चित्र नीच
_वैचारिक अनुप्रस्थ काट। रंग सामग्री पहचानते हैं; लेबल देखने वाला चेहरा बताते हैं।_
+निर्माण प्रगति मेश भागों को गिनती है, प्रिंट की गई परतों को नहीं: कई रंगीन भाग एक ही परत साझा कर सकते हैं। मॉडल बैज भौतिक परतों की संख्या दिखाता है, जिसमें उपयोग होने पर पारदर्शी वाहक भी शामिल है। परत पूर्वावलोकन पट्टी को एक बार में एक भौतिक परत तक काटता है; दोनों हैंडल मिले हुए नींव और रंग क्षेत्रों को भी काटते हैं। निर्यात में हमेशा पूरा मॉडल शामिल होता है। 3D मॉडल बनाएँ सेटिंग्स न बदलने पर भी हमेशा मॉडल को फिर से बनाता है और पूरी परत सीमा तथा प्रारंभिक कैमरा दृश्य बहाल करता है।
+
+निर्माण विफल होने पर Kromacut अधूरे मॉडल को हटा देता है और पूर्वावलोकन में त्रुटि दिखाता है। दोबारा कोशिश करने के लिए 3D मॉडल बनाएँ दबाएँ; निर्माण सफल होने पर मॉडल के आयाम और परत पूर्वावलोकन फिर दिखाई देंगे।
+
## डिफ़ॉल्ट: पारदर्शी वाहक के साथ चित्र नीचे
**समतल प्रिंट** चालू करें और **चित्र ऊपर, पारदर्शी परत नहीं** बंद रखें।
@@ -47,7 +51,7 @@ _वैचारिक अनुप्रस्थ काट। रंग सा
| समतल प्रिंट, डिफ़ॉल्ट | पारदर्शी वाहक से नीचे का भाग | मिरर किए, उलटे स्तंभ और पीछे नींव। | हर फ़िलामेंट के ऑब्जेक्ट और वाहक। |
| समतल प्रिंट, चित्र ऊपर | खुला समतल ऊपरी भाग | सामान्य क्रम, छोटे स्तंभों के नीचे नींव। | हर फ़िलामेंट के ऑब्जेक्ट; वाहक नहीं। |
-समतल प्रिंट और **चिकनी मेशिंग** साथ काम नहीं करते। पट्टी चिकनी रूपरेखाओं के बजाय पूरे क्षेत्र के अगल-बगल हिस्से इस्तेमाल करती है। चिकनी मेशिंग चालू करने से समतल प्रिंट बंद होता है; बदलते समय इच्छित विन्यास चुनें और दोबारा बनाएँ।
+**चिकनी मेशिंग** समतल प्रिंट की दोनों दिशाओं में काम करती है। तीव्रता चुनकर मॉडल दोबारा बनाएँ। यह बाहरी रूपरेखा और रंगों की साझा सीमाओं को चिकना करती है, जबकि पट्टी समतल, परतों की ऊँचाई और सामग्री का क्रम बना रहता है। तिरछे कोनों के संपर्क छोटे साझा जोड़ से बंद होते हैं, बिना एक-दूसरे पर चढ़े। प्रिंट निर्देश और 3MF निर्माण में इस्तेमाल तीव्रता दर्ज करते हैं।
## निर्यात और जाँच
diff --git a/src/locales/hi/docs/generating-exporting-output.md b/src/locales/hi/docs/generating-exporting-output.md
index 51c147e9..5dd61593 100644
--- a/src/locales/hi/docs/generating-exporting-output.md
+++ b/src/locales/hi/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ Kromacut **प्रदर्शन चेतावनी** दिखाए त
STL फ़िलामेंट रंग या स्वचालित स्पूल आवंटन नहीं रखता। कॉपी किया बदलाव प्लान स्लाइसर के रंग-बदलाव नियंत्रणों के साथ इस्तेमाल करें। 3MF भी मॉडल है, सीधे चलने वाला G-code नहीं: अपना प्रिंटर, नोज़ल, फ़िलामेंट प्रोफ़ाइल, तापमान और गति चुनें, फिर स्लाइस करें।
-**समतल प्रिंट** मॉडल के डाउनलोड मेन्यू में केवल 3MF है: मॉडल में हर भौतिक फ़िलामेंट का एक ऑब्जेक्ट और डिफ़ॉल्ट चित्र-नीचे विन्यास में पारदर्शी वाहक ऑब्जेक्ट होता है। वैकल्पिक चित्र-ऊपर विन्यास वाहक छोड़ता है। किसी भी समतल पट्टी का बिना रंग वाला एकल-ज्यामिति STL उपयोगी नहीं होगा। समतल प्रिंट **चिकनी मेशिंग** बंद करता है क्योंकि समतल पट्टी में चिकनी सीमा रूपरेखाएँ नहीं होतीं।
+**समतल प्रिंट** मॉडल के डाउनलोड मेन्यू में केवल 3MF है: मॉडल में हर भौतिक फ़िलामेंट का एक ऑब्जेक्ट और डिफ़ॉल्ट चित्र-नीचे विन्यास में पारदर्शी वाहक ऑब्जेक्ट होता है। वैकल्पिक चित्र-ऊपर विन्यास वाहक छोड़ता है। किसी भी समतल पट्टी का बिना रंग वाला एकल-ज्यामिति STL उपयोगी नहीं होगा। दोनों दिशाओं में **चिकनी मेशिंग** की हर तीव्रता इस्तेमाल की जा सकती है। तीव्रता बदलने के बाद उसे पूर्वावलोकन और निर्यात की गई ज्यामिति पर लागू करने के लिए मॉडल दोबारा बनाएँ।
## प्रिंट निर्देश
diff --git a/src/locales/hi/docs/settings-and-controls.md b/src/locales/hi/docs/settings-and-controls.md
index 2c2edc1b..a7d4a328 100644
--- a/src/locales/hi/docs/settings-and-controls.md
+++ b/src/locales/hi/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ description: शीर्ष पट्टी क्रियाएँ, थीम
सार्वजनिक पृष्ठ भी भाषा चयन देते हैं। अनूदित दस्तावेज़ `/ro/docs/overview` जैसे साझा किए जा सकने वाले भाषा-उपसर्ग लिंक लेते हैं। पृष्ठ नाम और अनुभाग एंकर भाषाओं में स्थिर रहते हैं। फ़ाइल एक्सटेंशन, संख्यात्मक मॉडल डेटा, उपयोगकर्ता दिए नाम और समुदाय चित्रों के मूल शीर्षक अनूदित नहीं होते।
-सेटिंग संवाद में दस्तावेज़, Discord, Reddit, GitHub और Patreon लिंक हैं और वर्तमान Kromacut संस्करण दिखता है।
+सेटिंग संवाद में दस्तावेज़, Discord, Reddit, GitHub और Patreon लिंक हैं और वर्तमान Kromacut संस्करण दिखता है। **गोपनीयता और स्थानीय डेटा** और **नियम और शर्तें** भी यहाँ उपलब्ध हैं और चुनी गई भाषा में आपके ब्राउज़र में खुलते हैं।
## कार्यक्षेत्र मोड
@@ -123,4 +123,16 @@ Linux पर, जिन AppImage में अपडेट की जानक
रिकॉर्डिंग जाँच के लिए है और बड़ी फ़ाइलें बना सकती है। सामान्य प्रिंट में ट्रेस न चाहिए तो बंद रखें।
+## डेस्कटॉप से फ़ाइलें खोलना
+
+डेस्कटॉप स्थापना `.kfil` और पुराने `.kapp` फ़ाइलों को फ़िलामेंट प्रोफ़ाइल से तथा `.kpal` फ़ाइलों को पैलेट से जोड़ती है। फ़ाइल पर डबल-क्लिक करके उसे Kromacut में आयात और चयन करें। ऐप पहले से चल रहा हो तो उसकी मौजूदा विंडो का उपयोग होता है। सत्यापन, माइग्रेशन, डुप्लिकेट प्रबंधन और अंशांकन बनाए रखने के सामान्य नियम लागू होते हैं।
+
+पैलेट एडिटर, कैलिब्रेशन डायलॉग या प्रोफ़ाइल का नाम बदलने या नई प्रोफ़ाइल सहेजने का फ़ॉर्म खुला होने पर डेस्कटॉप से खोली गई फ़ाइलें प्रतीक्षा करती हैं। कतार में लगे आयात जारी रखने के लिए उस सत्र को पूरा करें या रद्द करें; तब तक लिखे गए नाम और संपादित की जा रही प्रोफ़ाइल अपरिवर्तित रहते हैं।
+
+फ़िलामेंट का नाम या HD फ़ील्ड संपादित करते समय, या उसका रंग चयनकर्ता या **TD से बदलें** पॉपओवर खुला होने पर भी आयात प्रतीक्षा करते हैं। कतार में लगे आयात फिर शुरू करने के लिए फ़ील्ड से बाहर जाएँ या चयनकर्ता अथवा पॉपओवर बंद करें; दूसरी प्रोफ़ाइल द्वारा असहेजे फ़िलामेंट बदलाव बदलने से पहले अब भी आपकी पसंद पूछी जाती है।
+
+फ़िलामेंट के असहेजे बदलाव बदलने से पहले Kromacut **बदलाव रखें** या **प्रोफ़ाइल खोलें** का विकल्प देता है। पहले सहेजने के लिए बदलाव रखें; प्रोफ़ाइल खोलने से वे बदलाव छोड़ दिए जाते हैं। इस तरह खोली गई फ़ाइलें 32 MiB से छोटी होनी चाहिए। सामान्य JSON, चित्र और मॉडल अपने सामान्य आयात तरीकों का उपयोग करते हैं।
+
+Linux पर पोर्टेबल AppImage के फ़ाइल संबंधों के लिए डेस्कटॉप एकीकरण आवश्यक है। यदि Kromacut डिफ़ॉल्ट ऐप नहीं है, तो फ़ाइल प्रबंधक में **इसके साथ खोलें** का उपयोग करें।
+
अगला: [समस्या निवारण](troubleshooting)।
diff --git a/src/locales/hi/docs/troubleshooting.md b/src/locales/hi/docs/troubleshooting.md
index 1a50e14f..7a4b082a 100644
--- a/src/locales/hi/docs/troubleshooting.md
+++ b/src/locales/hi/docs/troubleshooting.md
@@ -55,7 +55,7 @@ description: आम समस्याएँ और पहले आज़मा
## एक टॉगल ने दूसरा बंद कर दिया
-दो जोड़ियाँ जानबूझकर परस्पर अलग हैं: **चिकनी मेशिंग** और **समतल प्रिंट**, तथा **रंग पृथक्करण बनाए रखें** और **ऊँचाई डिथरिंग**। पहली जोड़ी अलग ज्यामिति विन्यास चुनती है; दूसरी स्रोत रंगों को प्रिंट योग्य ऊँचाइयों में बाँटने के अलग तरीके चुनती है। [समतल प्रिंट मोड](flat-paint) और [स्वचालित रंगाई](auto-paint) देखें।
+**रंग पृथक्करण बनाए रखें** और **ऊँचाई डिथरिंग** अब भी साथ चालू नहीं हो सकते, क्योंकि वे स्रोत रंगों को प्रिंट योग्य ऊँचाइयों पर अलग तरीकों से रखते हैं। **चिकनी मेशिंग** और **समतल प्रिंट** साथ इस्तेमाल किए जा सकते हैं; कोई भी सेटिंग बदलने के बाद दोबारा बनाएँ। [समतल प्रिंट मोड](flat-paint) और [स्वचालित रंगाई](auto-paint) देखें।
## रंग पृथक्करण परिणाम नहीं खोज पाता
diff --git a/src/locales/hi/printing.json b/src/locales/hi/printing.json
index e9ad003e..88f5163b 100644
--- a/src/locales/hi/printing.json
+++ b/src/locales/hi/printing.json
@@ -213,6 +213,8 @@
"tooManyColors_other": "बहुत अधिक रंग ({{count, number}})"
},
"threeDView": {
+ "buildFailed": "3D मॉडल नहीं बन सका",
+ "buildFailedDetails": "अधूरा मॉडल हटा दिया गया है। दोबारा कोशिश करने के लिए 3D मॉडल बनाएँ दबाएँ। त्रुटि: {{error}}",
"layerPreview": "परत पूर्वावलोकन",
"previewBottomLayerCutoff": "पूर्वावलोकन की निचली परत सीमा",
"previewTopLayerCutoff": "पूर्वावलोकन की ऊपरी परत सीमा",
@@ -240,7 +242,11 @@
"firstLayerHeight": "पहली परत की ऊँचाई",
"effectiveLineWidth": "प्रभावी लाइन चौड़ाई",
"smoothMeshing": "चिकनी मेशिंग",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "तेज़ वेल्डेड टोपोलॉजी से जुड़े रंग सीमांत किनारे चिकने करें। इसे चालू करने से समतल प्रिंट मोड बंद होता है।",
+ "smoothingNone": "कोई नहीं",
+ "smoothingMinimal": "न्यूनतम",
+ "smoothingMedium": "मध्यम",
+ "smoothingAggressive": "तीव्र",
+ "smoothingHelp": "पूर्वावलोकन और निर्यात में मॉडल की रूपरेखा को चिकना करता है। मध्यम पुराने चिकनेपन को बनाए रखता है। लागू करने के लिए फिर से बनाएँ।",
"modelDimensions": "मॉडल: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -335,7 +341,7 @@
"readingImageLayers": "चित्र की रंग परतें पढ़ी जा रही हैं",
"buildingLayer": "{{total}} में से रंग परत {{layer}} बन रही है",
"layerProgress": "परत {{layer}} / {{total}}: {{label}}",
- "flatPaintPartProgress": "समतल प्रिंट भाग {{part}} / {{total}}: {{label}}",
+ "flatPaintPartProgress": "समतल प्रिंट मेश भाग {{part}} / {{total}}: {{label}}",
"partMeshComplete": "भाग का मेश पूरा",
"selectingPixels": "सक्रिय पिक्सेल चुने जा रहे हैं",
"preparingPreview": "पूर्वावलोकन ज्यामिति तैयार हो रही है",
diff --git a/src/locales/hi/public.json b/src/locales/hi/public.json
index b9a4e5c0..e677f8a0 100644
--- a/src/locales/hi/public.json
+++ b/src/locales/hi/public.json
@@ -110,7 +110,7 @@
"photos_one": "{{count}} फ़ोटो",
"photos_other": "{{count}} फ़ोटो",
"openPhoto": "{{title}} — {{kind}} {{index}} पूरे आकार में खोलें",
- "originalArtwork": "मूल कलाकृति: {{title}} ",
+ "originalArtwork": "मूल कलाकृति: {{title}} ",
"themeLabel": "थीम: {{theme}}"
},
"navigation": {
diff --git a/src/locales/it/common.json b/src/locales/it/common.json
index d2c0bf79..8a12e039 100644
--- a/src/locales/it/common.json
+++ b/src/locales/it/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "File di profilo non valido",
+ "failedTitle": "Impossibile aprire il file",
+ "failedDescription": "Impossibile aprire «{{name}}». Verifica che sia un file Kromacut leggibile di meno di 32 MiB e che lo spazio di archiviazione locale sia sufficiente.",
+ "unavailable": "L’apertura dei file è temporaneamente non disponibile. Riavvia Kromacut e riprova.",
+ "unsavedTitle": "Modifiche ai filamenti non salvate",
+ "unsavedDescription": "Aprire «{{name}}» sostituirà le modifiche ai filamenti non salvate. Mantieni le modifiche per salvarle prima, oppure apri il profilo per scartarle.",
+ "keepEdits": "Mantieni le modifiche",
+ "openProfile": "Apri profilo",
+ "close": "Chiudi"
+ },
"colorPicker": {
"hue": "Tonalità",
"color": "Colore",
@@ -27,6 +38,7 @@
"light": "Chiaro",
"resources": "Risorse e community",
"resourcesDescription": "Guide, versioni e spazi in cui condividere le tue creazioni.",
+ "openLinkFailed": "Impossibile aprire questa pagina. Riprova.",
"docs": "Documentazione",
"docsDescription": "Scopri il flusso di lavoro e le impostazioni di stampa.",
"reddit": "r/kromacut su Reddit",
diff --git a/src/locales/it/docs/3d-mode.md b/src/locales/it/docs/3d-mode.md
index 8667f200..ed9ddc3e 100644
--- a/src/locales/it/docs/3d-mode.md
+++ b/src/locales/it/docs/3d-mode.md
@@ -76,13 +76,24 @@ I controlli manuali e le istruzioni di cambio supportano **64 colori**. Riduci l
Con la levigatura disattivata, i contorni seguono la griglia quadrata dei pixel. Attivandola, i bordi connessi vengono levigati in una geometria saldata. La differenza viene esportata: non è un filtro dell’anteprima.
+Ammorbidisce i contorni del modello nell’anteprima e nelle esportazioni. Media mantiene la levigatura originale. Ricostruisci per applicare.
+
+| Intensità | Utilizzo |
+| --- | --- |
+| **Nessuna** | Pixel art, bordi esatti della griglia o generazione più rapida. |
+| **Minima** | Pulizia leggera degli angoli frastagliati. |
+| **Media** | Il risultato familiare della precedente impostazione attiva. |
+| **Intensa** | Levigatura più intensa lungo i contorni, compresi i gradini residui, con lo stesso limite di spostamento di Media. |
+
+Lo spostamento rimane inferiore a mezzo pixel. Ricostruisci il modello e controlla l’anteprima nello slicer. Le impostazioni attiva/disattiva diventano Media/Nessuna.
+

_Confronto schematico dei contorni, non una simulazione dello slicer._
Usala per contorni curvi o diagonali che appaiono troppo scalettati. Lasciala disattivata per pixel art intenzionale o bordi esatti sulla griglia. Nessuna delle due scelte ripara dettagli troppo piccoli da stampare o inventa risoluzione assente nella sorgente.
-Mesh levigata è inattiva durante [Flat Paint](flat-paint). Attivare Mesh levigata disabilita Flat Paint; Flat Paint usa invece la propria costruzione a lastra sull’intera sagoma.
+Mesh levigata funziona anche con [Flat Paint](flat-paint). Entrambi gli orientamenti mantengono una lastra piatta e confini condivisi tra colori; scegli un’intensità e ricrea il modello.
## Auto-paint
diff --git a/src/locales/it/docs/auto-paint.md b/src/locales/it/docs/auto-paint.md
index 2453379f..f0277cc7 100644
--- a/src/locales/it/docs/auto-paint.md
+++ b/src/locales/it/docs/auto-paint.md
@@ -135,7 +135,7 @@ Il dithering dell’altezza può distribuire l’errore di arrotondamento su pic
_Meccanismo schematico, non un risultato prima/dopo garantito. Altezze superiori diverse non significano colori aggiuntivi delle bobine._
-Molte mappature attuali di Auto-paint selezionano già uno strato stampabile discreto prima di questa fase. Quelle regioni già allineate non hanno errori di altezza frazionaria da distribuire e possono restare invariate attivando il dithering. Usa l’anteprima rigenerata e lo slicer per verificare se questa specifica immagine acquisisce davvero variazioni di altezza; attivare l’interruttore non garantisce più toni o puntini visibili.
+Con il dithering attivo, Auto-paint cerca un’altezza intermedia tra lo strato selezionato e uno strato stampabile adiacente quando la loro miscela migliora la corrispondenza prevista. Le corrispondenze esatte e quelle dei target calibrati mantengono l’altezza selezionata; le regioni senza una miscela adiacente utile restano invariate. Rigenera il modello dopo aver cambiato l’opzione, quindi confronta anteprima e risultato nello slicer. Disattivando il dithering si ripristina la normale corrispondenza discreta.
La dimensione dei puntini segue **Larghezza effettiva della linea** nelle **Impostazioni di stampa 3D** in rapporto a **Dimensione pixel**, arrotondata a blocchi di pixel interi. È un’approssimazione, non una garanzia esatta della larghezza minima. Le regioni di bordo evitano lo stesso trattamento di dithering per ridurre gli artefatti sui confini. Controlla nello slicer la presenza di minuscole isole e spostamenti aggiuntivi.
diff --git a/src/locales/it/docs/flat-paint.md b/src/locales/it/docs/flat-paint.md
index 3c529e89..17fdc3cf 100644
--- a/src/locales/it/docs/flat-paint.md
+++ b/src/locales/it/docs/flat-paint.md
@@ -15,6 +15,10 @@ Usa un sistema multimateriale come AMS, CFS o cambio utensile con slicer adatto.
_Sezioni concettuali. I colori identificano i materiali; le etichette indicano la faccia da osservare._
+L’avanzamento conta le parti della mesh, non gli strati stampati: più parti colorate possono condividere uno strato. Il badge Modello mostra il numero di strati fisici, incluso il supporto trasparente se utilizzato. L’anteprima degli strati taglia la lastra uno strato alla volta; entrambe le maniglie tagliano anche le regioni unite di fondazione e colore. L’esportazione include sempre il modello completo. Il pulsante Crea modello 3D rigenera sempre il modello, anche senza modificare le impostazioni, e ripristina l’intero intervallo di strati e la vista iniziale della fotocamera.
+
+Se la creazione non riesce, Kromacut rimuove il modello incompleto e mostra l’errore nell’anteprima. Premi Crea modello 3D per riprovare; al termine di una creazione riuscita, ricompaiono le dimensioni del modello e l’anteprima degli strati.
+
## Predefinito: faccia in giù con supporto trasparente
Attiva **Flat Paint** e lascia **Faccia in su, senza strato trasparente** disattivato.
@@ -47,7 +51,7 @@ Le geometrie sono diverse. **Genera modello 3D** di nuovo dopo il cambio. Capovo
| Flat Paint predefinito | Fondo attraverso supporto | Colonne specchiate e invertite, fondazione dietro. | Oggetti per filamento più supporto. |
| Flat Paint a faccia in su | Superficie piana esposta | Ordine normale, fondazione sotto colonne corte. | Oggetti per filamento; niente supporto. |
-Flat Paint e **Mesh levigata** non operano insieme. La lastra usa regioni affiancate a impronta completa, non contorni levigati. Attivare Mesh levigata disattiva Flat Paint; scegli e rigenera quando cambi.
+**Mesh levigata** funziona con entrambi gli orientamenti di Flat Paint. Scegli un’intensità e ricrea il modello. La levigatura ammorbidisce la sagoma esterna e i confini condivisi tra colori, mantenendo la lastra piatta, le altezze dei livelli e le pile di materiali. Piccole giunzioni condivise chiudono i contatti diagonali senza sovrapposizioni. Le istruzioni di stampa e il 3MF registrano l’intensità usata.
## Esportare e verificare
diff --git a/src/locales/it/docs/generating-exporting-output.md b/src/locales/it/docs/generating-exporting-output.md
index 2428a00e..e94bb912 100644
--- a/src/locales/it/docs/generating-exporting-output.md
+++ b/src/locales/it/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ L’anteprima può mostrare decine di miscele con poche bobine. Il 3MF assegna i
STL non contiene colori né assegnazioni automatiche. Usa il piano copiato con i controlli cambio colore dello slicer. 3MF è comunque un modello, non G-code eseguibile: scegli stampante, ugello, profili, temperature e velocità, poi esegui slicing.
-Per **Flat Paint** è disponibile solo 3MF: un oggetto per filamento e, a faccia in giù predefinita, un supporto trasparente. L’opzione a faccia in su lo omette. STL senza colore e a geometria unica sarebbe inutile per entrambe le lastre. Flat Paint disattiva **Mesh levigata**, non utilizzando contorni levigati.
+Per **Flat Paint** è disponibile solo 3MF: un oggetto per filamento e, a faccia in giù predefinita, un supporto trasparente. L’opzione a faccia in su lo omette. STL senza colore e a geometria unica sarebbe inutile per entrambe le lastre. Entrambi gli orientamenti supportano tutte le intensità di **Mesh levigata**. Ricrea il modello dopo aver cambiato l’intensità per applicarla all’anteprima e alla geometria esportata.
## Istruzioni di stampa
diff --git a/src/locales/it/docs/settings-and-controls.md b/src/locales/it/docs/settings-and-controls.md
index cb64e22e..98372964 100644
--- a/src/locales/it/docs/settings-and-controls.md
+++ b/src/locales/it/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ Il tema offre **Sistema**, **Scuro** e **Chiaro**. Sistema segue le preferenze c
Anche le pagine pubbliche hanno il selettore. La documentazione tradotta usa collegamenti condivisibili con prefisso, come `/ro/docs/overview`. Nomi di pagina e ancore restano stabili tra lingue. Estensioni, numeri del modello, nomi inseriti dall’utente e titoli originali delle opere comunitarie non vengono tradotti.
-Le impostazioni includono documentazione, Discord, Reddit, GitHub, Patreon e versione corrente.
+Le impostazioni includono documentazione, Discord, Reddit, GitHub, Patreon e versione corrente. Anche **Privacy e dati locali** e **Termini e condizioni** sono disponibili qui e si aprono nel browser nella lingua selezionata.
## Modalità dell’area di lavoro
@@ -123,4 +123,16 @@ Una traccia completa include dati di esecuzione, istantanea di filamenti e calib
La registrazione è per indagini e può produrre file grandi. Lasciala spenta per stampa normale senza necessità diagnostiche.
+## Aprire file dal desktop
+
+Le installazioni desktop associano i file `.kfil` e i precedenti `.kapp` ai profili dei filamenti, e i file `.kpal` alle tavolozze. Fai doppio clic su un file per importarlo e selezionarlo in Kromacut. Se l’app è già aperta, viene usata la finestra esistente. Si applicano le normali regole di convalida, migrazione, gestione dei duplicati e conservazione della calibrazione.
+
+I file aperti dal desktop restano in attesa mentre è aperto l’editor delle tavolozze, la finestra di calibrazione o il modulo per rinominare o salvare un nuovo profilo. Completa o annulla la sessione per proseguire con le importazioni in attesa; i nomi digitati e il profilo in modifica restano invariati fino ad allora.
+
+Le importazioni attendono anche mentre modifichi il nome o il campo HD di un filamento, oppure mentre il suo selettore di colore o il pannello **Converti da TD** è aperto. Esci dal campo o chiudi il selettore o il pannello per riprendere le importazioni in attesa; le modifiche ai filamenti non salvate richiedono comunque la tua scelta prima che un altro profilo le sostituisca.
+
+Prima di sostituire modifiche ai filamenti non salvate, Kromacut offre **Mantieni le modifiche** o **Apri profilo**. Mantieni le modifiche per salvarle prima; aprire il profilo le scarta. I file aperti in questo modo devono essere inferiori a 32 MiB. File JSON generici, immagini e modelli mantengono i consueti flussi di importazione.
+
+Su Linux, le AppImage portabili richiedono l’integrazione desktop per le associazioni dei file. Se Kromacut non è l’app predefinita, usa **Apri con** nel gestore dei file.
+
Successivo: [Risoluzione dei problemi](troubleshooting).
diff --git a/src/locales/it/docs/troubleshooting.md b/src/locales/it/docs/troubleshooting.md
index 655ed3a8..63c105f8 100644
--- a/src/locales/it/docs/troubleshooting.md
+++ b/src/locales/it/docs/troubleshooting.md
@@ -55,7 +55,7 @@ Verifica separatamente **Modello di aspetto**. Un punteggio alto non garantisce
## Un interruttore ne ha spento un altro
-Due coppie sono esclusive: **Mesh levigata** e **Flat Paint**, e **Mantieni separazione colori** e **Dithering dell’altezza**. La prima sceglie geometrie diverse, la seconda assegnazioni diverse alle altezze. Vedi [Flat Paint](flat-paint) e [Auto-paint](auto-paint).
+**Mantieni separazione dei colori** e **Dithering dell’altezza** restano esclusivi perché assegnano i colori sorgente alle altezze stampabili in modi diversi. **Mesh levigata** e **Flat Paint** possono essere usati insieme; ricrea il modello dopo ogni modifica. Vedi [Flat Paint](flat-paint) e [Auto-paint](auto-paint).
## La separazione colori non trova risultati
diff --git a/src/locales/it/printing.json b/src/locales/it/printing.json
index e35a2743..3eca3d73 100644
--- a/src/locales/it/printing.json
+++ b/src/locales/it/printing.json
@@ -234,7 +234,9 @@
"colorCount_many": "{{count, number}} colori",
"tooManyColors_many": "Troppi colori ({{count, number}})"
},
- "threeDView": {
+ "threeDView": {
+ "buildFailed": "Impossibile creare il modello 3D",
+ "buildFailedDetails": "Il modello incompleto è stato rimosso. Premi Crea modello 3D per riprovare. Errore: {{error}}",
"layerPreview": "Anteprima degli strati",
"previewBottomLayerCutoff": "Limite inferiore degli strati in anteprima",
"previewTopLayerCutoff": "Limite superiore degli strati in anteprima",
@@ -262,7 +264,11 @@
"firstLayerHeight": "Altezza del primo strato",
"effectiveLineWidth": "Larghezza effettiva della linea",
"smoothMeshing": "Mesh levigata",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "Leviga i bordi connessi delle aree di colore con una topologia saldata veloce. L'attivazione disabilita Flat Paint.",
+ "smoothingNone": "Nessuna",
+ "smoothingMinimal": "Minima",
+ "smoothingMedium": "Media",
+ "smoothingAggressive": "Intensa",
+ "smoothingHelp": "Ammorbidisce i contorni del modello nell’anteprima e nelle esportazioni. Media mantiene la levigatura originale. Ricostruisci per applicare.",
"modelDimensions": "Modello: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -365,7 +371,7 @@
"readingImageLayers": "Lettura dei livelli colore dell’immagine",
"buildingLayer": "Generazione del livello colore {{layer}} di {{total}}",
"layerProgress": "Livello {{layer}} di {{total}}: {{label}}",
- "flatPaintPartProgress": "Parte Flat Paint {{part}} di {{total}}: {{label}}",
+ "flatPaintPartProgress": "Parte della mesh Flat Paint {{part}} di {{total}}: {{label}}",
"partMeshComplete": "Mesh della parte completata",
"selectingPixels": "Selezione dei pixel attivi",
"preparingPreview": "Preparazione della geometria di anteprima",
diff --git a/src/locales/it/public.json b/src/locales/it/public.json
index 9c62b370..8fe438c9 100644
--- a/src/locales/it/public.json
+++ b/src/locales/it/public.json
@@ -110,7 +110,7 @@
"photos_one": "{{count}} foto",
"photos_other": "{{count}} foto",
"openPhoto": "Apri {{title}} — {{kind}} {{index}} a dimensione intera",
- "originalArtwork": "Opera originale: {{title}} ",
+ "originalArtwork": "Opera originale: {{title}} ",
"themeLabel": "Tema: {{theme}}",
"photos_many": "{{count}} foto"
},
diff --git a/src/locales/ja/common.json b/src/locales/ja/common.json
index fab20f9e..7f06b4e1 100644
--- a/src/locales/ja/common.json
+++ b/src/locales/ja/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "無効なプロファイルファイル",
+ "failedTitle": "ファイルを開けませんでした",
+ "failedDescription": "「{{name}}」を開けませんでした。32 MiB 未満の読み取り可能な Kromacut ファイルであることと、ローカルストレージに空き容量があることを確認してください。",
+ "unavailable": "ファイルを開く機能は一時的に利用できません。Kromacut を再起動して、もう一度お試しください。",
+ "unsavedTitle": "未保存のフィラメント編集",
+ "unsavedDescription": "「{{name}}」を開くと、未保存のフィラメント編集が置き換えられます。先に保存する場合は編集を保持し、破棄する場合はプロファイルを開いてください。",
+ "keepEdits": "編集を保持",
+ "openProfile": "プロファイルを開く",
+ "close": "閉じる"
+ },
"colorPicker": {
"hue": "色相",
"color": "色",
@@ -27,6 +38,7 @@
"light": "ライト",
"resources": "資料とコミュニティ",
"resourcesDescription": "ガイド、リリース情報、作品を共有できる場所です。",
+ "openLinkFailed": "このページを開けませんでした。もう一度お試しください。",
"docs": "ドキュメント",
"docsDescription": "操作の流れや印刷設定を確認できます。",
"reddit": "Reddit の r/kromacut",
diff --git a/src/locales/ja/docs/3d-mode.md b/src/locales/ja/docs/3d-mode.md
index 2acd35ec..3a19b59d 100644
--- a/src/locales/ja/docs/3d-mode.md
+++ b/src/locales/ja/docs/3d-mode.md
@@ -76,13 +76,24 @@ _模式的な断面です。後の色の表面には、その下に前の区間
オフでは輪郭は四角いピクセル格子に沿います。オンではつながった境界を滑らかにし、接合した形状にします。違いは書き出しにも反映され、プレビューのフィルターではありません。
+プレビューと書き出しでモデルの輪郭を滑らかにします。「中」は従来の処理と同じです。適用するには再生成してください。
+
+| 強度 | 用途 |
+| --- | --- |
+| **なし** | ピクセルアート、正確な格子状の輪郭、または最速の生成。 |
+| **最小** | ギザギザした角を軽く整えます。 |
+| **中** | 従来の有効設定と同じ結果。 |
+| **強** | 「中」と同じ移動の上限を保ちながら、輪郭や残った段差をさらに滑らかにします。 |
+
+頂点の移動は元画像の半ピクセル未満です。モデルを再生成し、スライサーのプレビューを確認してください。従来のオン/オフ設定は中/なしになります。
+

_輪郭の模式比較であり、スライサーのシミュレーションではありません。_
曲線や斜めの輪郭が階段状に見えすぎる場合に使います。意図的なピクセルアートや正確な格子の辺にはオフを使います。どちらも印刷できないほど小さな細部を修復したり、不足する元画像の解像度を作ったりはしません。
-[フラット印刷](flat-paint)では滑らかなメッシュは無効です。滑らかなメッシュをオンにするとフラット印刷が無効になります。フラット印刷は代わりに占有領域全体の板を構築します。
+滑らかなメッシュは[フラット印刷](flat-paint)でも使用できます。どちらの向きでも平らな板と色の共有境界を保ちます。強度を選び、再作成して適用してください。
## 自動ペイント
diff --git a/src/locales/ja/docs/auto-paint.md b/src/locales/ja/docs/auto-paint.md
index fa234e96..d1dcff56 100644
--- a/src/locales/ja/docs/auto-paint.md
+++ b/src/locales/ja/docs/auto-paint.md
@@ -135,7 +135,7 @@ _概念的な順序と割り当てであり、材料の予測ではありませ
_仕組みの模式図であり、前後の結果を保証しません。上面の高さが違ってもスプール色が増えるわけではありません。_
-現在の自動ペイントの多くの割り当ては、この処理前に離散的な印刷可能層を選んでいます。すでにスナップした領域には分散すべき端数誤差がなく、ディザを有効にしても変わらない場合があります。この画像で本当に高さの変化が増えたか、再作成したプレビューとスライサーで確認してください。切り替えをオンにしても色調や見える点が増える保証はありません。
+ディザを有効にすると、自動ペイントは、選択した層と隣接する印刷可能層の混合で予測色の一致が改善する場合に、その間の高さを求めます。完全に一致する色とキャリブレーション済みのターゲットは選択された高さを維持し、有用な隣接混合がない領域も変わりません。設定を変更したらモデルを再作成し、プレビューとスライサー出力を比較してください。ディザを無効にすると、通常の離散的な層の割り当てに戻ります。
点のサイズは**3D印刷設定**の**実効線幅**と**ピクセルサイズ**の比から、整数ピクセルのブロックに丸めて決まります。概算であり、正確な最小幅の保証ではありません。境界の乱れを抑えるため、端の領域には同じディザ処理をしません。スライサーで小さな孤立領域と余分な移動を確認してください。
diff --git a/src/locales/ja/docs/flat-paint.md b/src/locales/ja/docs/flat-paint.md
index 273c9088..335ef6af 100644
--- a/src/locales/ja/docs/flat-paint.md
+++ b/src/locales/ja/docs/flat-paint.md
@@ -15,6 +15,10 @@ description: 段状のレリーフ、裏向きの透明キャリア、表向き
_概念的な断面図です。色は材料、ラベルは観察する面を表します。_
+作成の進捗はメッシュパーツ単位で数えられ、印刷レイヤー数ではありません。複数の色パーツが1つのレイヤーを共有することがあります。「モデル」バッジには、物理レイヤー数(使用する場合は透明キャリアを含む)が表示されます。レイヤープレビューは板を物理レイヤー1枚ずつ切り取り、両方のハンドルで結合された土台と色の領域も切り取ります。エクスポートには常に完全なモデルが含まれます。「3Dモデルを作成」は設定が変わっていなくても常にモデルを再生成し、レイヤー範囲全体と初期のカメラビューを復元します。
+
+作成に失敗すると、Kromacutは未完成のモデルを削除し、プレビューにエラーを表示します。「3Dモデルを作成」を押して再試行してください。作成が成功すると、モデルの寸法とレイヤープレビューが再び表示されます。
+
## 既定:透明キャリア付きの裏向き
**フラット印刷**を有効にし、**表向き、透明層なし**はオフのままにします。
@@ -47,7 +51,7 @@ _概念的な断面図です。色は材料、ラベルは観察する面を表
| フラット印刷、既定 | 透明キャリア越しの底面 | 左右反転・材料順反転した柱の背後に土台。 | フィラメントごとのオブジェクトとキャリア。 |
| フラット印刷、表向き | 露出した平らな上面 | 通常の順番で、低い柱の下に土台。 | フィラメントごとのオブジェクト。キャリアなし。 |
-フラット印刷と**滑らかなメッシュ**は併用できません。この板は滑らかな輪郭ではなく、占有領域全体に並ぶ領域を使います。滑らかなメッシュを有効にするとフラット印刷が無効になります。切り替えるときは必要な配置を選び、再作成してください。
+**滑らかなメッシュ**はフラット印刷の両方の向きで使用できます。強度を選び、再作成してください。板の平らな面、層高、材料の積層順を保ちながら、外側の輪郭と色の共有境界を滑らかにします。斜めの角の接触は小さな共有接合で閉じ、重なりを防ぎます。印刷手順と3MFには作成時の強度が記録されます。
## 書き出して確認する
diff --git a/src/locales/ja/docs/generating-exporting-output.md b/src/locales/ja/docs/generating-exporting-output.md
index a0331ad7..c125fe20 100644
--- a/src/locales/ja/docs/generating-exporting-output.md
+++ b/src/locales/ja/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ Kromacutに**パフォーマンス警告**が表示された場合、画像サ
STLにはフィラメント色や自動スプール割り当てが含まれません。コピーした交換計画を、スライサーの色変更操作とともに使います。3MFもモデルであり、そのまま実行できるGコードではありません。自分のプリンター、ノズル、フィラメントプロファイル、温度、速度を選んでスライスしてください。
-**フラット印刷**モデルでは3MFのみダウンロードできます。物理フィラメントごとに1つのオブジェクトがあり、既定の裏向き配置には透明キャリアのオブジェクトもあります。任意の表向き配置ではキャリアを省きます。どちらの平板も、無色の単一形状STLでは意味を失います。フラット印刷は滑らかな境界輪郭を使わないため、**滑らかなメッシュ**をオフにします。
+**フラット印刷**モデルでは3MFのみダウンロードできます。物理フィラメントごとに1つのオブジェクトがあり、既定の裏向き配置には透明キャリアのオブジェクトもあります。任意の表向き配置ではキャリアを省きます。どちらの平板も、無色の単一形状STLでは意味を失います。どちらの向きでも**滑らかなメッシュ**のすべての強度を使用できます。強度を変更したらモデルを再作成して、プレビューとエクスポートする形状に反映してください。
## 印刷手順
diff --git a/src/locales/ja/docs/settings-and-controls.md b/src/locales/ja/docs/settings-and-controls.md
index 4486622b..7ad51e60 100644
--- a/src/locales/ja/docs/settings-and-controls.md
+++ b/src/locales/ja/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ description: ヘッダー操作、テーマ、保存、パレット、プロフ
公開ページにも言語選択があります。翻訳ドキュメントは`/ro/docs/overview`のような言語接頭辞付きの共有リンクを使います。ページ名と節のアンカーは言語が変わっても安定しています。拡張子、モデルの数値データ、ユーザー入力名、コミュニティ作品の元の題名は翻訳されません。
-設定ダイアログにはドキュメント、Discord、Reddit、GitHub、Patreonへのリンクと、現在のKromacutのバージョンが表示されます。
+設定ダイアログにはドキュメント、Discord、Reddit、GitHub、Patreonへのリンクと、現在のKromacutのバージョンが表示されます。 **プライバシーとローカルデータ**と**利用規約**もここから利用でき、選択した言語でブラウザーに開きます。
## 作業領域のモード
@@ -123,4 +123,16 @@ Linuxでは、更新情報が埋め込まれたAppImageを、AppImageUpdateな
記録は調査用で、ファイルが大きくなる場合があります。通常の印刷で記録が不要なら無効のままにしてください。
+## デスクトップからファイルを開く
+
+デスクトップ版では、`.kfil` と旧形式の `.kapp` がフィラメントプロファイルに、`.kpal` がパレットに関連付けられます。ファイルをダブルクリックすると、Kromacut にインポートして選択できます。アプリが起動済みの場合は既存のウィンドウで処理されます。通常の検証、移行、重複処理、キャリブレーション保持のルールが適用されます。
+
+パレットエディター、キャリブレーションダイアログ、またはプロファイルの名前変更・新規保存フォームが開いている間、デスクトップから開いたファイルは待機します。編集中の作業を完了するかキャンセルすると、待機中のインポートが続行されます。それまでは、入力した名前と編集中のプロファイルは変更されません。
+
+フィラメントの名前やHD欄を編集中、またはそのカラーピッカーや**TDから変換**のポップオーバーが開いている間も、インポートは待機します。入力欄からフォーカスを外すか、ピッカーまたはポップオーバーを閉じると、待機中のインポートが再開します。未保存のフィラメント編集を別のプロファイルで置き換える前には、引き続き確認が表示されます。
+
+未保存のフィラメント編集を置き換える前に、Kromacut は **編集を保持** または **プロファイルを開く** を表示します。先に保存する場合は編集を保持してください。プロファイルを開くと未保存の編集は破棄されます。この方法で開くファイルは 32 MiB 未満である必要があります。一般的な JSON、画像、モデルは通常のインポート手順を使用します。
+
+Linux のポータブル AppImage でファイルを関連付けるには、デスクトップへの統合が必要です。Kromacut が既定のアプリでない場合は、ファイルマネージャーの **別のアプリで開く** を使用してください。
+
次へ:[トラブルシューティング](troubleshooting)。
diff --git a/src/locales/ja/docs/troubleshooting.md b/src/locales/ja/docs/troubleshooting.md
index e351d646..1a4238ad 100644
--- a/src/locales/ja/docs/troubleshooting.md
+++ b/src/locales/ja/docs/troubleshooting.md
@@ -55,7 +55,7 @@ description: よくある問題と、最初に試すこと。
## ある切り替えで別の切り替えがオフになった
-**滑らかなメッシュ**と**フラット印刷**、および**色の分離を保持**と**高さディザ**の2組は意図的に排他的です。前者は異なる形状配置を、後者は元の色を印刷可能な高さへ割り当てる異なる方式を選びます。[フラット印刷](flat-paint)と[自動ペイント](auto-paint)を参照してください。
+**色の分離を保持**と**高さディザ**は、元の色を印刷可能な高さに割り当てる方式が異なるため、引き続き併用できません。**滑らかなメッシュ**と**フラット印刷**は併用できます。設定を変更したら再作成してください。[フラット印刷](flat-paint)と[自動ペイント](auto-paint)を参照してください。
## 色の分離で結果が見つからない
diff --git a/src/locales/ja/printing.json b/src/locales/ja/printing.json
index f542c066..58f3b2e9 100644
--- a/src/locales/ja/printing.json
+++ b/src/locales/ja/printing.json
@@ -213,6 +213,8 @@
"tooManyColors_other": "色数が多すぎます({{count, number}}色)"
},
"threeDView": {
+ "buildFailed": "3Dモデルを作成できませんでした",
+ "buildFailedDetails": "未完成のモデルは削除されました。「3Dモデルを作成」を押して再試行してください。エラー: {{error}}",
"layerPreview": "レイヤープレビュー",
"previewBottomLayerCutoff": "表示する下端レイヤー",
"previewTopLayerCutoff": "表示する上端レイヤー",
@@ -240,7 +242,11 @@
"firstLayerHeight": "初層の高さ",
"effectiveLineWidth": "実効線幅",
"smoothMeshing": "滑らかなメッシュ",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "高速な頂点結合によって、つながった色境界を滑らかにします。有効にするとフラット印刷は無効になります。",
+ "smoothingNone": "なし",
+ "smoothingMinimal": "最小",
+ "smoothingMedium": "中",
+ "smoothingAggressive": "強",
+ "smoothingHelp": "プレビューと書き出しでモデルの輪郭を滑らかにします。「中」は従来の処理と同じです。適用するには再生成してください。",
"modelDimensions": "モデル:{{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -335,7 +341,7 @@
"readingImageLayers": "画像の色レイヤーを読み取り中",
"buildingLayer": "色レイヤー{{layer}}/{{total}}を作成中",
"layerProgress": "レイヤー{{layer}}/{{total}}:{{label}}",
- "flatPaintPartProgress": "フラット印刷のパーツ{{part}}/{{total}}:{{label}}",
+ "flatPaintPartProgress": "フラット印刷のメッシュパーツ{{part}}/{{total}}:{{label}}",
"partMeshComplete": "パーツのメッシュ完成",
"selectingPixels": "有効ピクセルを選択中",
"preparingPreview": "プレビューの形状を準備中",
diff --git a/src/locales/ja/public.json b/src/locales/ja/public.json
index b4171145..37729634 100644
--- a/src/locales/ja/public.json
+++ b/src/locales/ja/public.json
@@ -110,7 +110,7 @@
"photos_one": "写真{{count}}枚",
"photos_other": "写真{{count}}枚",
"openPhoto": "{{title}} — {{kind}} {{index}}を原寸で開く",
- "originalArtwork": "元作品:{{title}} ",
+ "originalArtwork": "元作品:{{title}} ",
"themeLabel": "テーマ:{{theme}}"
},
"navigation": {
diff --git a/src/locales/pt-PT/common.json b/src/locales/pt-PT/common.json
index 673d8a0c..fb879328 100644
--- a/src/locales/pt-PT/common.json
+++ b/src/locales/pt-PT/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "Ficheiro de perfil inválido",
+ "failedTitle": "Não foi possível abrir o ficheiro",
+ "failedDescription": "Não foi possível abrir «{{name}}». Verifique se é um ficheiro Kromacut legível com menos de 32 MiB e se existe espaço no armazenamento local.",
+ "unavailable": "A abertura de ficheiros está temporariamente indisponível. Reinicie o Kromacut e tente novamente.",
+ "unsavedTitle": "Alterações aos filamentos por guardar",
+ "unsavedDescription": "Abrir «{{name}}» substituirá as alterações aos filamentos por guardar. Mantenha as alterações para as guardar primeiro ou abra o perfil para as descartar.",
+ "keepEdits": "Manter alterações",
+ "openProfile": "Abrir perfil",
+ "close": "Fechar"
+ },
"colorPicker": {
"hue": "Matiz",
"color": "Cor",
@@ -27,6 +38,7 @@
"light": "Claro",
"resources": "Recursos e comunidade",
"resourcesDescription": "Guias, versões e espaços para partilhar as suas criações.",
+ "openLinkFailed": "Não foi possível abrir esta página. Tente novamente.",
"docs": "Documentação",
"docsDescription": "Conheça o fluxo de trabalho e as definições de impressão.",
"reddit": "r/kromacut no Reddit",
diff --git a/src/locales/pt-PT/docs/3d-mode.md b/src/locales/pt-PT/docs/3d-mode.md
index b6a11f67..f6a94a74 100644
--- a/src/locales/pt-PT/docs/3d-mode.md
+++ b/src/locales/pt-PT/docs/3d-mode.md
@@ -76,13 +76,24 @@ Controlos manuais e instruções suportam **64 cores**. Reduza paletas maiores e
Desligada, os contornos seguem a grelha quadrada. Ligada, fronteiras conectadas tornam-se geometria suavizada e soldada. A diferença é exportada, não um filtro visual.
+Suaviza os contornos do modelo na pré-visualização e nas exportações. Média mantém a suavização original. Reconstrua para aplicar.
+
+| Intensidade | Utilização |
+| --- | --- |
+| **Nenhuma** | Pixel art, contornos exatos da grelha ou geração mais rápida. |
+| **Mínima** | Limpeza ligeira dos cantos irregulares. |
+| **Média** | O resultado familiar da opção anteriormente ativada. |
+| **Intensa** | Suavização mais forte dos contornos e degraus restantes, com o mesmo limite de deslocação de Média. |
+
+A deslocação mantém-se abaixo de meio píxel. Reconstrua o modelo e verifique a pré-visualização no slicer. As definições antigas ligada/desligada passam a Média/Nenhuma.
+

_Comparação esquemática, não simulação do laminador._
Use para curvas ou diagonais demasiado escalonadas. Deixe desligada para arte de píxeis intencional ou bordos de grelha exatos. Nenhuma escolha repara detalhes demasiado pequenos nem inventa resolução ausente.
-A suavização está inativa na [Pintura plana](flat-paint). Ativá-la desliga Pintura plana, que usa construção de placa completa.
+Malha suavizada também funciona com [Pintura plana](flat-paint). Ambas as orientações mantêm uma placa plana e limites partilhados entre cores; escolha uma intensidade e gere novamente para a aplicar.
## Pintura automática
diff --git a/src/locales/pt-PT/docs/auto-paint.md b/src/locales/pt-PT/docs/auto-paint.md
index b0692b7e..bd4a2094 100644
--- a/src/locales/pt-PT/docs/auto-paint.md
+++ b/src/locales/pt-PT/docs/auto-paint.md
@@ -135,7 +135,7 @@ Pode distribuir erro de arredondamento por blocos pequenos de alturas vizinhas q
_Mecanismo esquemático, não antes/depois garantido. Topos diferentes não significam cores de bobina extra._
-Muitos mapeamentos atuais já escolhem uma camada discreta antes disto. Essas regiões não têm erro fracionário e podem manter-se iguais. Verifique vista regenerada e laminador para saber se esta imagem ganha variação; ativar não garante tons adicionais nem pontos visíveis.
+Com o dithering ativado, a pintura automática procura uma altura intermédia entre a camada selecionada e uma camada imprimível adjacente quando a mistura melhora a correspondência prevista. As correspondências exatas e as dos alvos calibrados mantêm a altura selecionada; as regiões sem uma mistura vizinha útil permanecem inalteradas. Regenere o modelo depois de alterar a opção e compare a pré-visualização com o resultado no laminador. Desativar o dithering repõe a correspondência discreta habitual.
O tamanho do ponto segue **Largura de linha efetiva** nas **Definições de impressão 3D** em relação ao **Tamanho do píxel**, arredondado a blocos inteiros. É aproximado, não garantia exata de largura mínima. Regiões de bordo evitam o mesmo tratamento para reduzir artefactos. Verifique ilhas pequenas e deslocações extra.
diff --git a/src/locales/pt-PT/docs/flat-paint.md b/src/locales/pt-PT/docs/flat-paint.md
index 2a31d00c..21c6414a 100644
--- a/src/locales/pt-PT/docs/flat-paint.md
+++ b/src/locales/pt-PT/docs/flat-paint.md
@@ -15,6 +15,10 @@ Use um fluxo multimaterial como AMS, CFS ou troca de ferramentas, com suporte ad
_Cortes conceptuais. As cores identificam materiais; as etiquetas identificam a face de observação._
+O progresso conta partes da malha, não camadas impressas: várias partes coloridas podem partilhar uma camada. O indicador Modelo mostra o número de camadas físicas, incluindo o suporte transparente quando utilizado. A pré-visualização de camadas corta a placa camada a camada; ambos os controlos cortam também as regiões unidas de fundação e cor. A exportação inclui sempre o modelo completo. O botão Gerar modelo 3D regenera sempre o modelo, mesmo sem alterar as definições, e repõe todas as camadas e a vista inicial da câmara.
+
+Se a criação falhar, o Kromacut remove o modelo incompleto e apresenta o erro na pré-visualização. Prima Gerar modelo 3D para tentar novamente; quando a criação terminar com êxito, as dimensões do modelo e a pré-visualização das camadas voltam a aparecer.
+
## Predefinição: face para baixo com suporte transparente
Ative **Pintura plana** e deixe **Face para cima, sem camada transparente** desligada.
@@ -47,7 +51,7 @@ Estas disposições têm geometria diferente. Volte a **Gerar modelo 3D** depois
| Pintura plana predefinida | Fundo através do suporte | Colunas espelhadas e invertidas com fundação atrás. | Objetos por filamento e suporte. |
| Pintura plana, face para cima | Topo plano exposto | Ordem normal, fundação sob colunas curtas. | Objetos por filamento; sem suporte. |
-Pintura plana e **Malha suavizada** não funcionam em conjunto. A placa usa regiões lado a lado cobrindo toda a área, não contornos suavizados. Ativar Malha suavizada desativa Pintura plana; escolha a disposição e regenere ao mudar.
+**Malha suavizada** funciona com ambas as orientações de Pintura plana. Escolha uma intensidade e gere novamente. A suavização amacia o contorno exterior e os limites partilhados entre cores, mantendo a placa plana, as alturas das camadas e as pilhas de materiais. Pequenas ligações partilhadas fecham os contactos diagonais sem sobreposição. As instruções de impressão e o 3MF registam a intensidade usada.
## Exportar e verificar
diff --git a/src/locales/pt-PT/docs/generating-exporting-output.md b/src/locales/pt-PT/docs/generating-exporting-output.md
index a544baa6..05cc82c2 100644
--- a/src/locales/pt-PT/docs/generating-exporting-output.md
+++ b/src/locales/pt-PT/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ Uma vista automática pode mostrar dezenas de misturas de poucas bobinas. O 3MF
STL não contém cores nem atribuições automáticas de bobinas. Use o plano copiado com os controlos de troca do laminador. Um 3MF é ainda um modelo, não G-code pronto: escolha impressora, bico, perfis, temperaturas e velocidades, e depois lamine.
-Para **Pintura plana** só se oferece 3MF: um objeto por filamento e, na disposição predefinida de face para baixo, um suporte transparente. A opção de face para cima omite-o. Um STL sem cores e de geometria única seria inútil para qualquer placa. A pintura plana desativa **Malha suavizada** porque não usa contornos suavizados.
+Para **Pintura plana** só se oferece 3MF: um objeto por filamento e, na disposição predefinida de face para baixo, um suporte transparente. A opção de face para cima omite-o. Um STL sem cores e de geometria única seria inútil para qualquer placa. Ambas as orientações suportam todas as intensidades de **Malha suavizada**. Gere novamente o modelo depois de alterar a intensidade para a aplicar à pré-visualização e à geometria exportada.
## Instruções de impressão
diff --git a/src/locales/pt-PT/docs/settings-and-controls.md b/src/locales/pt-PT/docs/settings-and-controls.md
index 72a17d3c..a8a5b018 100644
--- a/src/locales/pt-PT/docs/settings-and-controls.md
+++ b/src/locales/pt-PT/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ O seletor **Idioma** altera interface, documentação, diagramas e páginas púb
As páginas públicas também têm seletor. A documentação traduzida usa ligações partilháveis com prefixo, como `/ro/docs/overview`. Os nomes de páginas e âncoras mantêm-se entre idiomas. Extensões, dados numéricos, nomes introduzidos pelo utilizador e títulos originais de obras comunitárias não são traduzidos.
-O diálogo contém ligações para documentação, Discord, Reddit, GitHub e Patreon e mostra a versão atual.
+O diálogo contém ligações para documentação, Discord, Reddit, GitHub e Patreon e mostra a versão atual. **Privacidade e dados locais** e **Termos e condições** também estão disponíveis aqui e abrem no navegador no idioma selecionado.
## Modos da área de trabalho
@@ -123,4 +123,16 @@ Um registo completo inclui informação básica de execução, instantâneo de f
O registo destina-se a investigação e pode criar ficheiros grandes. Deixe desligado na impressão normal se não precisar dele.
+## Abrir ficheiros a partir do ambiente de trabalho
+
+As instalações de ambiente de trabalho associam os ficheiros `.kfil` e os antigos `.kapp` a perfis de filamentos, e os ficheiros `.kpal` a paletas. Faça duplo clique num ficheiro para o importar e selecionar no Kromacut. Se a aplicação já estiver aberta, é utilizada a janela existente. Aplicam-se as regras habituais de validação, migração, duplicados e preservação da calibração.
+
+Os ficheiros abertos a partir do ambiente de trabalho ficam em espera enquanto o editor de paletas, a caixa de diálogo de calibração ou o formulário para mudar o nome ou guardar um novo perfil estiver aberto. Conclua ou cancele essa sessão para continuar as importações pendentes; os nomes introduzidos e o perfil em edição mantêm-se inalterados até lá.
+
+As importações também ficam em espera enquanto edita o nome ou o campo HD de um filamento, ou enquanto o respetivo seletor de cor ou painel **Converter de TD** estiver aberto. Saia do campo ou feche o seletor ou painel para retomar as importações pendentes; as alterações aos filamentos por guardar continuam a exigir a sua decisão antes de serem substituídas por outro perfil.
+
+Antes de substituir alterações aos filamentos por guardar, o Kromacut apresenta **Manter alterações** ou **Abrir perfil**. Mantenha as alterações para as guardar primeiro; abrir o perfil descarta-as. Os ficheiros abertos desta forma devem ter menos de 32 MiB. Os ficheiros JSON genéricos, imagens e modelos mantêm os procedimentos habituais de importação.
+
+No Linux, as AppImages portáteis requerem integração no ambiente de trabalho para associar ficheiros. Se o Kromacut não for a aplicação predefinida, utilize **Abrir com** no gestor de ficheiros.
+
Seguinte: [Resolução de problemas](troubleshooting).
diff --git a/src/locales/pt-PT/docs/troubleshooting.md b/src/locales/pt-PT/docs/troubleshooting.md
index 8574a69e..91f99bfe 100644
--- a/src/locales/pt-PT/docs/troubleshooting.md
+++ b/src/locales/pt-PT/docs/troubleshooting.md
@@ -55,7 +55,7 @@ Verifique separadamente **Modelo de aparência**. Uma pontuação global alta n
## Uma opção desligou outra
-Dois pares são exclusivos: **Malha suavizada** e **Pintura plana**, e **Preservar separação de cores** e **Pontilhamento de altura**. O primeiro escolhe geometrias distintas; o segundo, formas distintas de atribuir cores a alturas. Consulte [Pintura plana](flat-paint) e [Pintura automática](auto-paint).
+**Preservar separação de cores** e **Pontilhamento de altura** continuam a ser exclusivos, pois atribuem as cores de origem às alturas imprimíveis de formas diferentes. **Malha suavizada** e **Pintura plana** podem ser usadas em conjunto; gere novamente depois de alterar qualquer definição. Consulte [Pintura plana](flat-paint) e [Pintura automática](auto-paint).
## A separação de cores não encontra resultado
diff --git a/src/locales/pt-PT/printing.json b/src/locales/pt-PT/printing.json
index 2611f44e..ddf4fcd1 100644
--- a/src/locales/pt-PT/printing.json
+++ b/src/locales/pt-PT/printing.json
@@ -234,7 +234,9 @@
"colorCount_many": "{{count, number}} cores",
"tooManyColors_many": "Demasiadas cores ({{count, number}})"
},
- "threeDView": {
+ "threeDView": {
+ "buildFailed": "Não foi possível criar o modelo 3D",
+ "buildFailedDetails": "O modelo incompleto foi removido. Prima Criar modelo 3D para tentar novamente. Erro: {{error}}",
"layerPreview": "Pré-visualização de camadas",
"previewBottomLayerCutoff": "Limite inferior de camadas da pré-visualização",
"previewTopLayerCutoff": "Limite superior de camadas da pré-visualização",
@@ -262,7 +264,11 @@
"firstLayerHeight": "Altura da primeira camada",
"effectiveLineWidth": "Largura de linha efetiva",
"smoothMeshing": "Malha suavizada",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "Suaviza os contornos ligados entre cores com uma topologia soldada rápida. Ativar esta opção desativa a pintura plana.",
+ "smoothingNone": "Nenhuma",
+ "smoothingMinimal": "Mínima",
+ "smoothingMedium": "Média",
+ "smoothingAggressive": "Intensa",
+ "smoothingHelp": "Suaviza os contornos do modelo na pré-visualização e nas exportações. Média mantém a suavização original. Reconstrua para aplicar.",
"modelDimensions": "Modelo: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -365,7 +371,7 @@
"readingImageLayers": "A ler as camadas de cor da imagem",
"buildingLayer": "A gerar a camada de cor {{layer}} de {{total}}",
"layerProgress": "Camada {{layer}} de {{total}}: {{label}}",
- "flatPaintPartProgress": "Peça de pintura plana {{part}} de {{total}}: {{label}}",
+ "flatPaintPartProgress": "Parte da malha de pintura plana {{part}} de {{total}}: {{label}}",
"partMeshComplete": "Malha da peça concluída",
"selectingPixels": "A selecionar os píxeis ativos",
"preparingPreview": "A preparar a geometria da pré-visualização",
diff --git a/src/locales/pt-PT/public.json b/src/locales/pt-PT/public.json
index d73a442a..7eddacf7 100644
--- a/src/locales/pt-PT/public.json
+++ b/src/locales/pt-PT/public.json
@@ -1,6 +1,6 @@
{
"landing":{
- "importAnImage":"Importe uma imagem","dropInArtworkAPhotoOrAPixelDesignAnd":"Adicione uma ilustração, uma fotografia ou um desenho em píxeis e mantenha o original editável em segurança.","aColorful2DImageReadyToBeImportedIntoKromacut":"Uma imagem 2D colorida pronta a importar para o Kromacut","reducePaintColors":"Reduza e pinte as cores","tuneACompactPaletteManuallyOrLetAutoPaintMatch":"Ajuste manualmente uma paleta compacta ou deixe a pintura automática encontrar correspondências nos filamentos que possui.","aColorLayeredImagePreviewShowingSeparatePrintableColors":"Pré-visualização de uma imagem em camadas de cor, com cores imprimíveis separadas","previewEveryLayer":"Pré-visualize cada camada","inspectTheStackIn3DCheckTransitionsAndSeeExactly":"Examine o empilhamento em 3D, verifique as transições e veja exatamente onde ocorrem as trocas de filamento.","kromacut3DPreviewOfAStackedColorLayerPrint":"Pré-visualização 3D do Kromacut de uma impressão com camadas de cor empilhadas","exportAndPrint":"Exporte e imprima","downloadASlicerReadySTLOr3MFWithThePrint":"Transfira um STL ou 3MF pronto para o laminador, com o plano de impressão necessário para concluir o projeto.","aFinishedColorfulLayeredPrintMadeFromAKromacutWorkflow":"Uma impressão colorida em camadas concluída com o Kromacut","aColorfulHobbitsAndDragonsLayered3DPrint":"Uma impressão 3D colorida em camadas de Hobbits and Dragons","finishedPrint":"Impressão concluída","aSecondViewOfTheHobbitsAndDragonsLayered3D":"Outra vista da impressão 3D em camadas de Hobbits and Dragons","slicerPreviewOfTheMulticolorKingOfHeartsPlayingCard":"Pré-visualização no laminador da impressão multicolorida da carta King of Hearts","slicerPreview":"Pré-visualização no laminador","aMulticolorLayeredKingOfHeartsPlayingCardPrint":"Uma impressão multicolorida em camadas da carta King of Hearts","kromacutAutoPaintPredictionForALayeredHopePosterPrint":"Previsão da pintura automática do Kromacut para uma impressão em camadas do cartaz Hope","kromacutPrediction":"Previsão do Kromacut","autoPaintPredictionUsingTheAvailableCalibratedFilamentProfile":"Previsão da pintura automática com o perfil de filamentos calibrado disponível.","slicerPreviewOfTheLayeredHopePosterPrint":"Pré-visualização no laminador da impressão em camadas do cartaz Hope","thePhysicalLayerPlanPreparedForPrintingWithoutARed":"O plano de camadas físicas preparado para imprimir sem filamento vermelho.","finishedLayeredHopePosterPrintShowingOrangeAndPurpleBlue":"Impressão concluída em camadas do cartaz Hope com desvios de cor para laranja e azul-arroxeado","paleYellowMatchedMostCloselyRedPrintedOrangeCyanLeaned":"O amarelo-pálido foi o mais fiel; o vermelho saiu laranja, o ciano tendeu para azul-arroxeado e o preto reproduziu a pré-visualização, mas não o verde-azulado escuro do alvo.","kromacutAutoPaintPredictionForTheGoldenWavesOfThe":"Previsão da pintura automática do Kromacut para as ondas douradas do cartaz Titan","text10241527MmFootprint":"Área de 102,4 × 152,7 mm","autoPaintWithTheEightColorCalibratedFilamentProfile":"Pintura automática com o perfil calibrado de oito cores de filamento.","crealityPrintSlicerPreviewOfTheTitanPosterWithIts":"Pré-visualização no Creality Print do cartaz Titan com a torre de troca de filamento","crealityHi04MmNozzleAnd008Mm":"Creality Hi, bico de 0,4 mm e camadas de 0,08 mm. Estimativa do laminador: 2 h 58 min, 28,90 g incluindo purgas e torre, seis trocas de filamento.","finishedTitanLayeredPrintWithGoldenYellowAndOrangeWave":"Impressão Titan concluída em camadas, com reflexos de ondas amarelo-dourados e laranja sobre um fundo escuro","theCompletedPrintPhotographedByItsMakerLightingAndCamera":"A impressão concluída, fotografada pelo seu criador. A iluminação e o processamento da câmara podem afetar as cores apresentadas.","kromacutAutoPaintPredictionOfTheBatmanJiroKuwataBatmanga":"Previsão da pintura automática do Kromacut da capa Batman Jiro Kuwata Batmanga","text6581000MmFootprint":"Área de 65,8 × 100,0 mm","autoPaintWithDeepOptimizationEnhancedMatchingAndPreserveColor":"Pintura automática com otimização profunda, correspondência melhorada e preservação da separação de cores ativadas.","crealityPrintSlicerPreviewOfTheBatmangaCoverAndFilament":"Pré-visualização no Creality Print da capa Batmanga e da torre de troca de filamento","crealityHi04MmNozzleAnd008Mm2":"Creality Hi, bico de 0,4 mm e camadas de 0,08 mm. Estimativa do laminador: 3 h 12 min, 21,30 g incluindo purgas e torre, oito trocas de filamento.","finishedBatmangaLayeredPrintWithAYellowJapaneseTitleTan":"Impressão Batmanga concluída em camadas, com título japonês amarelo, fundo bege e Batman em preto e cinzento","theCompletedPrintPhotographedByItsMakerTheLargeTitle":"A impressão concluída, fotografada pelo seu criador. O título grande e a silhueta mantêm-se distintos; as letras pequenas e o sombreado fino perdem detalhe a este tamanho.","kromacutAutoPaintPredictionOfNarutoLookingUpAtA":"Previsão da pintura automática do Kromacut de Naruto a olhar para um céu azul","text11041902MmFootprint":"Área de 110,4 × 190,2 mm","autoPaintWithTheEightColorCalibratedFilamentProfile0":"Pintura automática com o perfil calibrado de oito cores de filamento, píxeis de 0,15 mm e malha suavizada ativada.","crealityPrintSlicerPreviewOfTheNarutoPrintWithSeven":"Pré-visualização no Creality Print da impressão Naruto com sete cores de filamento e uma torre de troca","crealityHi04MmNozzleAnd008Mm3":"Creality Hi, bico de 0,4 mm e camadas de 0,08 mm. Estimativa do laminador: 5 h 55 min, 72,10 g incluindo purgas e torre, nove trocas de filamento.","finishedNarutoLayeredPrintWithYellowHairOrangeClothingAnd":"Impressão Naruto concluída em camadas, com cabelo amarelo, roupa laranja e um céu azul-lavanda","theCompletedOvernightPrintPhotographedByItsMakerLightingAnd":"A impressão concluída durante a noite, fotografada pelo seu criador. A iluminação e o processamento da câmara podem afetar as cores apresentadas.","openAnyPhotoAtFullSize":"Abra qualquer fotografia em tamanho completo.","viewRedditPost":"Ver publicação no Reddit","communityLinks":"Ligações da comunidade","kromacutOnGitHub":"Kromacut no GitHub","joinKromacutOnDiscord":"Junte-se ao Kromacut no Discord","rKromacutOnReddit":"r/kromacut no Reddit","supportKromacutOnPatreon":"Apoie o Kromacut no Patreon","support":"Apoiar","system":"Sistema","dark":"Escuro","light":"Claro","changeTheme":"Alterar tema","theme":"Tema","skipToWorkflow":"Saltar para o fluxo de trabalho","mainNavigation":"Navegação principal","howItWorks":"Como funciona","docs":"Documentação","releases":"Versões","openKromacut":"Abrir Kromacut","mobileNavigation":"Navegação móvel","openSourceBrowserFirst":"Código aberto · pensado para o navegador","kromacutTransforms2DImagesIntoStackedColorLayered3DPrints":"O Kromacut transforma imagens 2D em impressões 3D com camadas de cor empilhadas. Prepare a paleta, encontre correspondências com filamentos reais, pré-visualize o resultado e exporte um modelo pronto para o laminador.","startCreating":"Começar a criar","readTheQuickStart":"Ler o guia de início rápido","freeToUse":"Utilização gratuita","browserDesktop":"Navegador e aplicação de ambiente de trabalho","sTL3MFExport":"Exportação STL e 3MF","text3DLayerPreview":"Pré-visualização 3D de camadas","readyToPrint":"pronto a imprimir","colorLayered3DPrintPreview":"Pré-visualização de impressão 3D em camadas de cor","paletteMapped":"Paleta associada","text4PrintableColors":"4 cores imprimíveis","fromImageToObject":"Da imagem ao objeto","aSimpleWorkflowWithSeriousControl":"Um processo simples, com controlo preciso.","goFromAFlatImageToALayeredPrintWithout":"Passe de uma imagem plana a uma impressão em camadas sem sair do navegador. Mantenha o controlo das decisões criativas, enquanto o Kromacut trata da geometria e do plano de impressão.","original2DArtwork":"Ilustração 2D original","finishedKromacutPrint":"Impressão Kromacut concluída","builtForRealPrints":"Feito para impressões reais","yourFilamentYourPaletteYourModel":"Os seus filamentos. A sua paleta. O seu modelo.","useManualSlicingWhenYouWantPixelLevelControlOr":"Use o modo manual para controlar cada píxel ou deixe a pintura automática procurar um empilhamento de cores a partir do perfil de filamentos calibrado. Em qualquer dos casos, verifique cada camada antes de exportar.","nonDestructiveImageAdjustments":"Ajustes de imagem não destrutivos","threeJsLayerByLayer3DPreview":"Pré-visualização 3D camada a camada com Three.js","calibratedAutoPaintWithDeterministicSearch":"Pintura automática calibrada com pesquisa determinística","slicerFriendlySTLAndMultiMaterial3MF":"STL prontos para o laminador e 3MF multimaterial","openTheTool":"Abrir a ferramenta","communityShowcase":"Galeria da comunidade","madeByTheKromacutCommunity":"Criado pela comunidade Kromacut.","finishedPrintsAndBehindTheScenesPreviewsFromPeopleCreating":"Impressões concluídas e imagens dos bastidores de quem cria com o Kromacut.","wantToShowcaseYourWorkOrContributeToKromacut":"Quer mostrar o seu trabalho ou contribuir para o Kromacut?","shareWhatYouMadeImproveTheProjectOrHelpThe":"Partilhe as suas criações, melhore o projeto ou ajude a comunidade a descobrir o que o Kromacut pode fazer.","openAPullRequest":"Propor uma contribuição","readyToMakeAFlatImagePhysical":"Pronto para dar forma física a uma imagem plana?","startWithAnImageFinishWithAPrint":"Comece com uma imagem. Termine com uma impressão.","desktopReleases":"Versões para ambiente de trabalho","openSourceToolsForTurningImagesIntoColorLayered3D":"Ferramentas de código aberto para transformar imagens em impressões 3D em camadas de cor.","footerNavigation":"Navegação do rodapé","supportKromacut":"Apoiar o Kromacut","privacy":"Privacidade","terms":"Termos","heroTitle":"Transforme píxeis em camadas imprimíveis. ","creator":"Por {{creator}} ","photos_one":"{{count}} fotografia","photos_other":"{{count}} fotografias","photos_many":"{{count}} fotografias","openPhoto":"Abrir {{title}} — {{kind}} {{index}} em tamanho completo","originalArtwork":"Obra original: {{title}} ","themeLabel":"Tema: {{theme}}"
+ "importAnImage":"Importe uma imagem","dropInArtworkAPhotoOrAPixelDesignAnd":"Adicione uma ilustração, uma fotografia ou um desenho em píxeis e mantenha o original editável em segurança.","aColorful2DImageReadyToBeImportedIntoKromacut":"Uma imagem 2D colorida pronta a importar para o Kromacut","reducePaintColors":"Reduza e pinte as cores","tuneACompactPaletteManuallyOrLetAutoPaintMatch":"Ajuste manualmente uma paleta compacta ou deixe a pintura automática encontrar correspondências nos filamentos que possui.","aColorLayeredImagePreviewShowingSeparatePrintableColors":"Pré-visualização de uma imagem em camadas de cor, com cores imprimíveis separadas","previewEveryLayer":"Pré-visualize cada camada","inspectTheStackIn3DCheckTransitionsAndSeeExactly":"Examine o empilhamento em 3D, verifique as transições e veja exatamente onde ocorrem as trocas de filamento.","kromacut3DPreviewOfAStackedColorLayerPrint":"Pré-visualização 3D do Kromacut de uma impressão com camadas de cor empilhadas","exportAndPrint":"Exporte e imprima","downloadASlicerReadySTLOr3MFWithThePrint":"Transfira um STL ou 3MF pronto para o laminador, com o plano de impressão necessário para concluir o projeto.","aFinishedColorfulLayeredPrintMadeFromAKromacutWorkflow":"Uma impressão colorida em camadas concluída com o Kromacut","aColorfulHobbitsAndDragonsLayered3DPrint":"Uma impressão 3D colorida em camadas de Hobbits and Dragons","finishedPrint":"Impressão concluída","aSecondViewOfTheHobbitsAndDragonsLayered3D":"Outra vista da impressão 3D em camadas de Hobbits and Dragons","slicerPreviewOfTheMulticolorKingOfHeartsPlayingCard":"Pré-visualização no laminador da impressão multicolorida da carta King of Hearts","slicerPreview":"Pré-visualização no laminador","aMulticolorLayeredKingOfHeartsPlayingCardPrint":"Uma impressão multicolorida em camadas da carta King of Hearts","kromacutAutoPaintPredictionForALayeredHopePosterPrint":"Previsão da pintura automática do Kromacut para uma impressão em camadas do cartaz Hope","kromacutPrediction":"Previsão do Kromacut","autoPaintPredictionUsingTheAvailableCalibratedFilamentProfile":"Previsão da pintura automática com o perfil de filamentos calibrado disponível.","slicerPreviewOfTheLayeredHopePosterPrint":"Pré-visualização no laminador da impressão em camadas do cartaz Hope","thePhysicalLayerPlanPreparedForPrintingWithoutARed":"O plano de camadas físicas preparado para imprimir sem filamento vermelho.","finishedLayeredHopePosterPrintShowingOrangeAndPurpleBlue":"Impressão concluída em camadas do cartaz Hope com desvios de cor para laranja e azul-arroxeado","paleYellowMatchedMostCloselyRedPrintedOrangeCyanLeaned":"O amarelo-pálido foi o mais fiel; o vermelho saiu laranja, o ciano tendeu para azul-arroxeado e o preto reproduziu a pré-visualização, mas não o verde-azulado escuro do alvo.","kromacutAutoPaintPredictionForTheGoldenWavesOfThe":"Previsão da pintura automática do Kromacut para as ondas douradas do cartaz Titan","text10241527MmFootprint":"Área de 102,4 × 152,7 mm","autoPaintWithTheEightColorCalibratedFilamentProfile":"Pintura automática com o perfil calibrado de oito cores de filamento.","crealityPrintSlicerPreviewOfTheTitanPosterWithIts":"Pré-visualização no Creality Print do cartaz Titan com a torre de troca de filamento","crealityHi04MmNozzleAnd008Mm":"Creality Hi, bico de 0,4 mm e camadas de 0,08 mm. Estimativa do laminador: 2 h 58 min, 28,90 g incluindo purgas e torre, seis trocas de filamento.","finishedTitanLayeredPrintWithGoldenYellowAndOrangeWave":"Impressão Titan concluída em camadas, com reflexos de ondas amarelo-dourados e laranja sobre um fundo escuro","theCompletedPrintPhotographedByItsMakerLightingAndCamera":"A impressão concluída, fotografada pelo seu criador. A iluminação e o processamento da câmara podem afetar as cores apresentadas.","kromacutAutoPaintPredictionOfTheBatmanJiroKuwataBatmanga":"Previsão da pintura automática do Kromacut da capa Batman Jiro Kuwata Batmanga","text6581000MmFootprint":"Área de 65,8 × 100,0 mm","autoPaintWithDeepOptimizationEnhancedMatchingAndPreserveColor":"Pintura automática com otimização profunda, correspondência melhorada e preservação da separação de cores ativadas.","crealityPrintSlicerPreviewOfTheBatmangaCoverAndFilament":"Pré-visualização no Creality Print da capa Batmanga e da torre de troca de filamento","crealityHi04MmNozzleAnd008Mm2":"Creality Hi, bico de 0,4 mm e camadas de 0,08 mm. Estimativa do laminador: 3 h 12 min, 21,30 g incluindo purgas e torre, oito trocas de filamento.","finishedBatmangaLayeredPrintWithAYellowJapaneseTitleTan":"Impressão Batmanga concluída em camadas, com título japonês amarelo, fundo bege e Batman em preto e cinzento","theCompletedPrintPhotographedByItsMakerTheLargeTitle":"A impressão concluída, fotografada pelo seu criador. O título grande e a silhueta mantêm-se distintos; as letras pequenas e o sombreado fino perdem detalhe a este tamanho.","kromacutAutoPaintPredictionOfNarutoLookingUpAtA":"Previsão da pintura automática do Kromacut de Naruto a olhar para um céu azul","text11041902MmFootprint":"Área de 110,4 × 190,2 mm","autoPaintWithTheEightColorCalibratedFilamentProfile0":"Pintura automática com o perfil calibrado de oito cores de filamento, píxeis de 0,15 mm e malha suavizada ativada.","crealityPrintSlicerPreviewOfTheNarutoPrintWithSeven":"Pré-visualização no Creality Print da impressão Naruto com sete cores de filamento e uma torre de troca","crealityHi04MmNozzleAnd008Mm3":"Creality Hi, bico de 0,4 mm e camadas de 0,08 mm. Estimativa do laminador: 5 h 55 min, 72,10 g incluindo purgas e torre, nove trocas de filamento.","finishedNarutoLayeredPrintWithYellowHairOrangeClothingAnd":"Impressão Naruto concluída em camadas, com cabelo amarelo, roupa laranja e um céu azul-lavanda","theCompletedOvernightPrintPhotographedByItsMakerLightingAnd":"A impressão concluída durante a noite, fotografada pelo seu criador. A iluminação e o processamento da câmara podem afetar as cores apresentadas.","openAnyPhotoAtFullSize":"Abra qualquer fotografia em tamanho completo.","viewRedditPost":"Ver publicação no Reddit","communityLinks":"Ligações da comunidade","kromacutOnGitHub":"Kromacut no GitHub","joinKromacutOnDiscord":"Junte-se ao Kromacut no Discord","rKromacutOnReddit":"r/kromacut no Reddit","supportKromacutOnPatreon":"Apoie o Kromacut no Patreon","support":"Apoiar","system":"Sistema","dark":"Escuro","light":"Claro","changeTheme":"Alterar tema","theme":"Tema","skipToWorkflow":"Saltar para o fluxo de trabalho","mainNavigation":"Navegação principal","howItWorks":"Como funciona","docs":"Documentação","releases":"Versões","openKromacut":"Abrir Kromacut","mobileNavigation":"Navegação móvel","openSourceBrowserFirst":"Código aberto · pensado para o navegador","kromacutTransforms2DImagesIntoStackedColorLayered3DPrints":"O Kromacut transforma imagens 2D em impressões 3D com camadas de cor empilhadas. Prepare a paleta, encontre correspondências com filamentos reais, pré-visualize o resultado e exporte um modelo pronto para o laminador.","startCreating":"Começar a criar","readTheQuickStart":"Ler o guia de início rápido","freeToUse":"Utilização gratuita","browserDesktop":"Navegador e aplicação de ambiente de trabalho","sTL3MFExport":"Exportação STL e 3MF","text3DLayerPreview":"Pré-visualização 3D de camadas","readyToPrint":"pronto a imprimir","colorLayered3DPrintPreview":"Pré-visualização de impressão 3D em camadas de cor","paletteMapped":"Paleta associada","text4PrintableColors":"4 cores imprimíveis","fromImageToObject":"Da imagem ao objeto","aSimpleWorkflowWithSeriousControl":"Um processo simples, com controlo preciso.","goFromAFlatImageToALayeredPrintWithout":"Passe de uma imagem plana a uma impressão em camadas sem sair do navegador. Mantenha o controlo das decisões criativas, enquanto o Kromacut trata da geometria e do plano de impressão.","original2DArtwork":"Ilustração 2D original","finishedKromacutPrint":"Impressão Kromacut concluída","builtForRealPrints":"Feito para impressões reais","yourFilamentYourPaletteYourModel":"Os seus filamentos. A sua paleta. O seu modelo.","useManualSlicingWhenYouWantPixelLevelControlOr":"Use o modo manual para controlar cada píxel ou deixe a pintura automática procurar um empilhamento de cores a partir do perfil de filamentos calibrado. Em qualquer dos casos, verifique cada camada antes de exportar.","nonDestructiveImageAdjustments":"Ajustes de imagem não destrutivos","threeJsLayerByLayer3DPreview":"Pré-visualização 3D camada a camada com Three.js","calibratedAutoPaintWithDeterministicSearch":"Pintura automática calibrada com pesquisa determinística","slicerFriendlySTLAndMultiMaterial3MF":"STL prontos para o laminador e 3MF multimaterial","openTheTool":"Abrir a ferramenta","communityShowcase":"Galeria da comunidade","madeByTheKromacutCommunity":"Criado pela comunidade Kromacut.","finishedPrintsAndBehindTheScenesPreviewsFromPeopleCreating":"Impressões concluídas e imagens dos bastidores de quem cria com o Kromacut.","wantToShowcaseYourWorkOrContributeToKromacut":"Quer mostrar o seu trabalho ou contribuir para o Kromacut?","shareWhatYouMadeImproveTheProjectOrHelpThe":"Partilhe as suas criações, melhore o projeto ou ajude a comunidade a descobrir o que o Kromacut pode fazer.","openAPullRequest":"Propor uma contribuição","readyToMakeAFlatImagePhysical":"Pronto para dar forma física a uma imagem plana?","startWithAnImageFinishWithAPrint":"Comece com uma imagem. Termine com uma impressão.","desktopReleases":"Versões para ambiente de trabalho","openSourceToolsForTurningImagesIntoColorLayered3D":"Ferramentas de código aberto para transformar imagens em impressões 3D em camadas de cor.","footerNavigation":"Navegação do rodapé","supportKromacut":"Apoiar o Kromacut","privacy":"Privacidade","terms":"Termos","heroTitle":"Transforme píxeis em camadas imprimíveis. ","creator":"Por {{creator}} ","photos_one":"{{count}} fotografia","photos_other":"{{count}} fotografias","photos_many":"{{count}} fotografias","openPhoto":"Abrir {{title}} — {{kind}} {{index}} em tamanho completo","originalArtwork":"Obra original: {{title}} ","themeLabel":"Tema: {{theme}}"
},
"navigation":{"homeLabel":"Página inicial do Kromacut","openApp":"Abrir Kromacut","home":"Ir para a página inicial","docs":"Consultar a documentação","privacy":"Privacidade e dados locais","terms":"Termos e condições"},
"legal":{"privacyEyebrow":"Dados locais em primeiro lugar. Tudo explicado.","termsEyebrow":"Utilizar o Kromacut.","updated":"Atualizado em {{date}}","contents":"Nesta página"},
diff --git a/src/locales/ro/common.json b/src/locales/ro/common.json
index ad52736b..f7ac2360 100644
--- a/src/locales/ro/common.json
+++ b/src/locales/ro/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "Fișier de profil invalid",
+ "failedTitle": "Fișierul nu a putut fi deschis",
+ "failedDescription": "Fișierul „{{name}}” nu a putut fi deschis. Verifică dacă este un fișier Kromacut care poate fi citit, mai mic de 32 MiB, și dacă există spațiu în stocarea locală.",
+ "unavailable": "Deschiderea fișierelor este temporar indisponibilă. Repornește Kromacut și încearcă din nou.",
+ "unsavedTitle": "Modificări nesalvate ale filamentelor",
+ "unsavedDescription": "Deschiderea fișierului „{{name}}” va înlocui modificările nesalvate ale filamentelor. Păstrează modificările pentru a le salva mai întâi sau deschide profilul pentru a renunța la ele.",
+ "keepEdits": "Păstrează modificările",
+ "openProfile": "Deschide profilul",
+ "close": "Închide"
+ },
"colorPicker": {
"hue": "Nuanță",
"color": "Culoare",
@@ -27,6 +38,7 @@
"light": "Luminoasă",
"resources": "Resurse și comunitate",
"resourcesDescription": "Ghiduri, versiuni și locuri în care îți poți împărtăși creațiile.",
+ "openLinkFailed": "Pagina nu a putut fi deschisă. Încearcă din nou.",
"docs": "Documentație",
"docsDescription": "Descoperă fluxul de lucru și setările de imprimare.",
"reddit": "r/kromacut pe Reddit",
diff --git a/src/locales/ro/docs/3d-mode.md b/src/locales/ro/docs/3d-mode.md
index c95dccba..d4d4b432 100644
--- a/src/locales/ro/docs/3d-mode.md
+++ b/src/locales/ro/docs/3d-mode.md
@@ -76,13 +76,24 @@ Comenzile Manual și instrucțiunile de schimbare acceptă **64 de culori**. Red
Cu netezirea oprită, contururile urmează grila pătrată de pixeli. Cu ea pornită, limitele conectate sunt netezite într-o geometrie sudată. Diferența se exportă, nu este un filtru de previzualizare.
+Netezește contururile modelului în previzualizare și exporturi. Medie păstrează netezirea originală. Reconstruiește pentru aplicare.
+
+| Intensitate | Utilizare |
+| --- | --- |
+| **Fără** | Pixel art, margini exacte pe grilă sau generarea cea mai rapidă. |
+| **Minimă** | Netezire ușoară a colțurilor zimțate. |
+| **Medie** | Rezultatul familiar al setării activate anterior. |
+| **Intensă** | Netezire mai puternică de-a lungul contururilor, inclusiv a treptelor rămase, cu aceeași limită de deplasare ca Medie. |
+
+Deplasarea rămâne sub jumătate de pixel. Reconstruiește modelul și verifică previzualizarea în slicer. Setările vechi activat/dezactivat devin Medie/Fără.
+

_Comparație schematică de contururi, nu simulare de slicer._
Folosește-o pentru contururi curbe sau diagonale care par prea treptate. Las-o oprită pentru pixel art intenționat sau margini exacte de grilă. Niciuna dintre opțiuni nu repară detalii prea mici pentru imprimare și nu inventează rezoluție sursă lipsă.
-Geometria netezită este inactivă în [Flat Paint](flat-paint). Activarea ei dezactivează Flat Paint; Flat Paint folosește în schimb construcția sa de placă cu amprentă completă.
+Geometria netezită funcționează și cu [Flat Paint](flat-paint). Ambele orientări păstrează o placă plană și granițe comune între culori; alege o intensitate și reconstruiește pentru a o aplica.
## Auto-paint
diff --git a/src/locales/ro/docs/auto-paint.md b/src/locales/ro/docs/auto-paint.md
index 9f593ed3..dbffbb98 100644
--- a/src/locales/ro/docs/auto-paint.md
+++ b/src/locales/ro/docs/auto-paint.md
@@ -135,7 +135,7 @@ Ditheringul de înălțime poate distribui eroarea de rotunjire între blocuri m
_Mecanism schematic, nu un rezultat înainte/după garantat. Înălțimi superioare diferite nu înseamnă culori suplimentare de role._
-Multe mapări Auto-paint curente aleg deja un strat imprimabil discret înainte de acest pas. Aceste regiuni deja aliniate nu au eroare de înălțime fracționară de distribuit și pot rămâne neschimbate când ditheringul este activat. Folosește previzualizarea regenerată și slicerul pentru a verifica dacă această imagine câștigă efectiv variație de înălțime; activarea comutatorului nu garantează mai multe tonuri sau puncte vizibile.
+Cu ditheringul activat, Auto-paint caută o înălțime intermediară între stratul ales și un strat imprimabil vecin atunci când amestecul lor îmbunătățește potrivirea estimată. Potrivirile exacte de culoare și cele ale țintelor calibrate păstrează înălțimea aleasă; regiunile fără un amestec vecin util rămân neschimbate. Reconstruiește modelul după modificarea setării, apoi compară previzualizarea și rezultatul din slicer. Dezactivarea ditheringului restabilește potrivirea discretă obișnuită.
Dimensiunea punctelor urmează **Lățimea efectivă a liniei** din **Setări de imprimare 3D** în raport cu **Dimensiunea pixelului**, rotunjită la un bloc de pixeli întregi. Este o aproximație, nu o garanție exactă a lățimii minime. Regiunile de margine evită același tratament de dithering pentru a reduce artefactele de contur. Verifică în slicer insulele minuscule și deplasările suplimentare.
diff --git a/src/locales/ro/docs/flat-paint.md b/src/locales/ro/docs/flat-paint.md
index fe1cc344..2c4f1e56 100644
--- a/src/locales/ro/docs/flat-paint.md
+++ b/src/locales/ro/docs/flat-paint.md
@@ -15,6 +15,10 @@ Folosește un flux multimaterial, precum AMS, CFS sau un schimbător de scule, c
_Secțiuni conceptuale. Culorile identifică materialele; etichetele identifică fața de vizualizare._
+Progresul generării numără părțile plasei, nu straturile imprimate: mai multe părți colorate pot ocupa același strat. Indicatorul Model arată numărul straturilor fizice, inclusiv suportul transparent când este utilizat. Previzualizarea straturilor taie placa strat cu strat; ambele mânere taie și regiunile unite de fundație și culoare. Exportul include întotdeauna modelul complet. Butonul Generează modelul 3D regenerează întotdeauna modelul, chiar dacă setările nu s-au schimbat, și restabilește întregul interval de straturi și vederea inițială a camerei.
+
+Dacă o construire eșuează, Kromacut elimină modelul incomplet și afișează eroarea în previzualizare. Apasă Generează modelul 3D pentru a reîncerca; după o construire reușită, dimensiunile modelului și previzualizarea straturilor reapar.
+
## Implicit: fața în jos, cu suport transparent
Activează **Flat Paint** și lasă **Fața în sus, fără strat transparent** dezactivat.
@@ -47,7 +51,7 @@ Aceste aranjamente au geometrii diferite. Folosește din nou **Generează modelu
| Flat Paint, implicit | Partea de jos, prin suportul transparent | Coloane oglindite și inversate, cu fundația în spate. | Obiecte pentru fiecare filament, plus suport. |
| Flat Paint, fața în sus | Suprafață superioară plană expusă | Ordine normală, cu fundație sub coloanele mai scurte. | Obiecte pentru fiecare filament, fără suport. |
-Flat Paint și **Geometrie netezită** nu funcționează simultan. Placa folosește regiuni alăturate care umplu amprenta, nu contururi netezite. Activarea geometriei netezite dezactivează Flat Paint; alege aranjamentul dorit și regenerează la schimbare.
+**Geometrie netezită** funcționează în ambele orientări Flat Paint. Alege o intensitate și reconstruiește modelul. Netezirea atenuează conturul exterior și granițele comune dintre culori, păstrând placa plană, înălțimile straturilor și stivele de materiale. Contactele diagonale folosesc îmbinări comune mici pentru a închide regiunile fără suprapuneri. Instrucțiunile de imprimare și 3MF înregistrează intensitatea folosită.
## Exportă și verifică
diff --git a/src/locales/ro/docs/generating-exporting-output.md b/src/locales/ro/docs/generating-exporting-output.md
index 0e0f0d61..ec6d88cb 100644
--- a/src/locales/ro/docs/generating-exporting-output.md
+++ b/src/locales/ro/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ O previzualizare Auto-paint poate arăta zeci de culori amestecate produse din d
STL nu conține culori de filament sau atribuiri automate de role. Folosește planul de schimbări copiat cu comenzile de schimbare a culorii din slicer. Un 3MF este tot un model, nu G-code gata de rulare: alege imprimanta, duza, profilurile de filament, temperaturile și vitezele proprii, apoi feliază-l.
-Pentru modelele **Flat Paint**, meniul de descărcare oferă doar 3MF: modelul conține câte un obiect pentru fiecare filament fizic și, în aranjamentul implicit cu fața în jos, un obiect de suport transparent. Aranjamentul opțional cu fața în sus omite suportul. Un STL necolorat cu geometrie unică al oricăreia dintre aceste plăci ar fi inutil. Flat Paint dezactivează **Geometrie netezită**, deoarece aranjamentul de placă plană nu folosește contururi de limită netezite.
+Pentru modelele **Flat Paint**, meniul de descărcare oferă doar 3MF: modelul conține câte un obiect pentru fiecare filament fizic și, în aranjamentul implicit cu fața în jos, un obiect de suport transparent. Aranjamentul opțional cu fața în sus omite suportul. Un STL necolorat cu geometrie unică al oricăreia dintre aceste plăci ar fi inutil. Ambele orientări acceptă toate intensitățile pentru **Geometrie netezită**. Reconstruiește modelul după schimbarea intensității pentru a o aplica previzualizării și geometriei exportate.
## Instrucțiuni de imprimare
diff --git a/src/locales/ro/docs/settings-and-controls.md b/src/locales/ro/docs/settings-and-controls.md
index a24ef995..2aa63c0e 100644
--- a/src/locales/ro/docs/settings-and-controls.md
+++ b/src/locales/ro/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ Selectorul **Limbă** schimbă interfața, documentația, diagramele și paginil
Paginile publice oferă și ele un selector de limbă. Documentația tradusă folosește linkuri partajabile cu prefix de limbă, precum `/ro/docs/overview`. Numele paginilor și ancorele secțiunilor rămân stabile între limbi. Extensiile fișierelor, datele numerice ale modelului, numele introduse de utilizator și titlurile originale ale lucrărilor comunității nu sunt traduse.
-Dialogul de setări include legături către documentație, Discord, Reddit, GitHub și Patreon și afișează versiunea curentă Kromacut.
+Dialogul de setări include legături către documentație, Discord, Reddit, GitHub și Patreon și afișează versiunea curentă Kromacut. **Confidențialitate și date locale** și **Termeni și condiții** sunt disponibile tot aici și se deschid în browser în limba selectată.
## Modurile spațiului de lucru
@@ -123,4 +123,16 @@ O înregistrare completă include informații de bază despre rulare, instantane
Înregistrarea este destinată investigațiilor și poate crea fișiere mari. Las-o dezactivată pentru imprimarea obișnuită când nu ai nevoie de o înregistrare.
+## Deschiderea fișierelor de pe desktop
+
+Instalările desktop asociază fișierele `.kfil` și vechile `.kapp` cu profilurile de filamente, iar fișierele `.kpal` cu paletele. Fă dublu clic pe un fișier pentru a-l importa și selecta în Kromacut. Dacă aplicația rulează deja, fișierul este deschis în fereastra existentă. Se aplică regulile obișnuite de validare, migrare, gestionare a duplicatelor și păstrare a calibrării.
+
+Fișierele deschise de pe desktop așteaptă cât timp editorul de palete, dialogul de calibrare sau formularul de redenumire ori salvare a unui profil nou este deschis. Finalizează sau anulează sesiunea pentru a continua importurile din coadă; numele introduse și profilul editat rămân neschimbate până atunci.
+
+Importurile așteaptă și cât timp editezi numele sau câmpul HD al unui filament ori cât timp selectorul său de culoare sau panoul **Convertește din TD** este deschis. Părăsește câmpul sau închide selectorul ori panoul pentru a relua importurile din coadă; modificările nesalvate ale filamentelor necesită în continuare alegerea ta înainte ca alt profil să le înlocuiască.
+
+Înainte de a înlocui modificările nesalvate ale filamentelor, Kromacut oferă **Păstrează modificările** sau **Deschide profilul**. Păstrează modificările pentru a le salva mai întâi; deschiderea profilului renunță la ele. Fișierele deschise astfel trebuie să fie mai mici de 32 MiB. Fișierele JSON generice, imaginile și modelele păstrează fluxurile obișnuite de import.
+
+Pe Linux, fișierele AppImage portabile necesită integrare cu desktopul pentru asocierea fișierelor. Dacă sistemul nu a selectat Kromacut ca aplicație implicită, folosește **Deschide cu** în managerul de fișiere.
+
În continuare: [Depanare](troubleshooting).
diff --git a/src/locales/ro/docs/troubleshooting.md b/src/locales/ro/docs/troubleshooting.md
index 7be2323b..9a761fa0 100644
--- a/src/locales/ro/docs/troubleshooting.md
+++ b/src/locales/ro/docs/troubleshooting.md
@@ -55,7 +55,7 @@ Verifică separat rândul **Model de aspect**. Un scor general ridicat nu garant
## Un comutator l-a dezactivat pe altul
-Două perechi se exclud intenționat: **Geometrie netezită** și **Flat Paint**, respectiv **Păstrează separarea culorilor** și **Dithering de înălțime**. Prima pereche alege aranjamente geometrice diferite; a doua alege moduri diferite de atribuire a culorilor sursă înălțimilor imprimabile. Consultă [Flat Paint](flat-paint) și [Auto-paint](auto-paint).
+**Păstrează separarea culorilor** și **Dithering de înălțime** se exclud în continuare, deoarece atribuie diferit culorile sursă înălțimilor imprimabile. **Geometrie netezită** și **Flat Paint** pot fi folosite împreună; reconstruiește după schimbarea oricărei setări. Consultă [Flat Paint](flat-paint) și [Auto-paint](auto-paint).
## Separarea culorilor nu găsește un rezultat
diff --git a/src/locales/ro/printing.json b/src/locales/ro/printing.json
index 2706e196..c3ab3760 100644
--- a/src/locales/ro/printing.json
+++ b/src/locales/ro/printing.json
@@ -234,7 +234,9 @@
"colorCount_few": "{{count, number}} de culori",
"tooManyColors_few": "Prea multe culori ({{count, number}})"
},
- "threeDView": {
+ "threeDView": {
+ "buildFailed": "Modelul 3D nu a putut fi construit",
+ "buildFailedDetails": "Modelul incomplet a fost eliminat. Apasă Construiește modelul 3D pentru a reîncerca. Eroare: {{error}}",
"layerPreview": "Previzualizarea straturilor",
"previewBottomLayerCutoff": "Limita inferioară a straturilor previzualizate",
"previewTopLayerCutoff": "Limita superioară a straturilor previzualizate",
@@ -262,7 +264,11 @@
"firstLayerHeight": "Înălțimea primului strat",
"effectiveLineWidth": "Lățimea efectivă a liniei",
"smoothMeshing": "Geometrie netezită",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "Netezește muchiile conectate dintre culori folosind o topologie sudată rapidă. Activarea dezactivează Flat Paint.",
+ "smoothingNone": "Fără",
+ "smoothingMinimal": "Minimă",
+ "smoothingMedium": "Medie",
+ "smoothingAggressive": "Intensă",
+ "smoothingHelp": "Netezește contururile modelului în previzualizare și exporturi. Medie păstrează netezirea originală. Reconstruiește pentru aplicare.",
"modelDimensions": "Model: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -365,7 +371,7 @@
"readingImageLayers": "Se citesc straturile de culoare ale imaginii",
"buildingLayer": "Se generează stratul de culoare {{layer}} din {{total}}",
"layerProgress": "Stratul {{layer}} din {{total}}: {{label}}",
- "flatPaintPartProgress": "Piesa Flat Paint {{part}} din {{total}}: {{label}}",
+ "flatPaintPartProgress": "Parte de plasă Flat Paint {{part}} din {{total}}: {{label}}",
"partMeshComplete": "Geometria piesei finalizată",
"selectingPixels": "Se selectează pixelii activi",
"preparingPreview": "Se pregătește geometria previzualizării",
diff --git a/src/locales/ro/public.json b/src/locales/ro/public.json
index 9fd2246d..e4c36341 100644
--- a/src/locales/ro/public.json
+++ b/src/locales/ro/public.json
@@ -110,7 +110,7 @@
"photos_one": "{{count}} fotografie",
"photos_other": "{{count}} de fotografii",
"openPhoto": "Deschide {{title}} — {{kind}} {{index}} la dimensiune completă",
- "originalArtwork": "Ilustrație originală: {{title}} ",
+ "originalArtwork": "Ilustrație originală: {{title}} ",
"themeLabel": "Temă: {{theme}}",
"photos_few": "{{count}} fotografii"
},
diff --git a/src/locales/uk/common.json b/src/locales/uk/common.json
index e7495608..7f228a38 100644
--- a/src/locales/uk/common.json
+++ b/src/locales/uk/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "Недійсний файл профілю",
+ "failedTitle": "Не вдалося відкрити файл",
+ "failedDescription": "Не вдалося відкрити «{{name}}». Перевірте, що це доступний для читання файл Kromacut розміром менш як 32 MiB і що в локальному сховищі є місце.",
+ "unavailable": "Відкриття файлів тимчасово недоступне. Перезапустіть Kromacut і спробуйте ще раз.",
+ "unsavedTitle": "Незбережені зміни філаментів",
+ "unsavedDescription": "Відкриття «{{name}}» замінить незбережені зміни філаментів. Збережіть зміни, вибравши їх збереження для подальшого запису, або відкрийте профіль, щоб відкинути їх.",
+ "keepEdits": "Залишити зміни",
+ "openProfile": "Відкрити профіль",
+ "close": "Закрити"
+ },
"colorPicker": {
"hue": "Відтінок",
"color": "Колір",
@@ -27,6 +38,7 @@
"light": "Світла",
"resources": "Ресурси та спільнота",
"resourcesDescription": "Посібники, випуски та місця, де можна поділитися своїми роботами.",
+ "openLinkFailed": "Не вдалося відкрити цю сторінку. Спробуйте ще раз.",
"docs": "Документація",
"docsDescription": "Ознайомтеся з робочим процесом і налаштуваннями друку.",
"reddit": "r/kromacut на Reddit",
diff --git a/src/locales/uk/docs/3d-mode.md b/src/locales/uk/docs/3d-mode.md
index 66b189ad..b528e36c 100644
--- a/src/locales/uk/docs/3d-mode.md
+++ b/src/locales/uk/docs/3d-mode.md
@@ -76,13 +76,24 @@ _Схематичний переріз. Поверхня пізнішого ко
Без згладжування контури слідують квадратній піксельній сітці. З ним зв’язні межі згладжуються у зварену геометрію. Ця відмінність експортується, а не є фільтром перегляду.
+Згладжує контури моделі в перегляді та експорті. Середнє зберігає попереднє згладжування. Перебудуйте для застосування.
+
+| Інтенсивність | Застосування |
+| --- | --- |
+| **Без згладжування** | Піксельна графіка, точні краї сітки або найшвидша побудова. |
+| **Мінімальне** | Легке згладжування зубчастих кутів. |
+| **Середнє** | Звичний результат попереднього ввімкненого налаштування. |
+| **Інтенсивне** | Сильніше згладжування контурів і залишкових сходинок із тією самою межею зміщення, що й Середнє. |
+
+Зміщення залишається меншим за половину пікселя. Перебудуйте модель і перевірте перегляд у слайсері. Старі ввімкнено/вимкнено стають Середнє/Без згладжування.
+

_Схематичне порівняння контурів, не симуляція слайсера._
Використовуйте для кривих або діагональних контурів із надмірними сходинками. Залишайте вимкненим для навмисного піксель-арту чи точних країв сітки. Жоден варіант не виправляє надто дрібні для друку деталі й не вигадує відсутню вихідну роздільність.
-Згладжена сітка неактивна в [Flat Paint](flat-paint). Її ввімкнення вимикає Flat Paint; Flat Paint натомість використовує пластину на всю площу.
+Згладжена сітка також працює з [Flat Paint](flat-paint). Обидві орієнтації зберігають плоску пластину й спільні межі кольорів; виберіть інтенсивність і побудуйте модель знову.
## Auto-paint
diff --git a/src/locales/uk/docs/auto-paint.md b/src/locales/uk/docs/auto-paint.md
index 7915ddb8..e0ffdb77 100644
--- a/src/locales/uk/docs/auto-paint.md
+++ b/src/locales/uk/docs/auto-paint.md
@@ -135,7 +135,7 @@ _Концептуальні послідовності й призначення
_Схема механізму, не гарантований результат до/після. Різні верхні висоти не означають додаткові кольори котушок._
-Багато поточних зіставлень Auto-paint уже вибирають дискретний друкований шар перед цим кроком. Такі прив’язані ділянки не мають дробової похибки для розподілу й можуть лишитися без змін після ввімкнення дизерингу. Перевірте перебудований перегляд і слайсер, щоб побачити, чи саме це зображення отримало варіацію висоти; перемикач не гарантує більше тонів або видимих крапок.
+Коли дизеринг увімкнено, Auto-paint шукає проміжну висоту між вибраним і сусіднім друкованим шаром, якщо їхнє змішування покращує прогнозовану відповідність кольору. Точні збіги кольорів і зіставлення каліброваних цілей зберігають вибрану висоту; ділянки без корисного сусіднього змішування також не змінюються. Після зміни налаштування перебудуйте модель і порівняйте перегляд із результатом у слайсері. Вимкнення дизерингу повертає звичайне дискретне зіставлення.
Розмір крапок слідує **Ефективній ширині лінії** в **Параметрах друку 3D** відносно **Розміру пікселя**, округленій до цілопіксельних блоків. Це приблизно, не точна гарантія мінімальної ширини. Крайові ділянки уникають такого самого дизерингу, щоб зменшити артефакти меж. Перевірте у слайсері крихітні острівці й зайві переміщення.
diff --git a/src/locales/uk/docs/flat-paint.md b/src/locales/uk/docs/flat-paint.md
index abfa094a..424f7e21 100644
--- a/src/locales/uk/docs/flat-paint.md
+++ b/src/locales/uk/docs/flat-paint.md
@@ -15,6 +15,10 @@ description: Ступінчастий рельєф, прозорі основи
_Концептуальні перерізи. Кольори позначають матеріали; підписи — бік перегляду._
+Прогрес побудови рахує частини сітки, а не надруковані шари: кілька кольорових частин можуть займати один шар. Значок «Модель» показує кількість фізичних шарів, зокрема прозорий носій, якщо він використовується. Перегляд шарів розрізає пластину по одному фізичному шару; обидва повзунки також розрізають об’єднані ділянки фундаменту й кольору. Експорт завжди містить повну модель. «Побудувати модель 3D» завжди заново створює модель, навіть якщо налаштування не змінилися, і відновлює повний діапазон шарів та початковий вигляд камери.
+
+Якщо побудова завершується помилкою, Kromacut видаляє незавершену модель і показує помилку в області перегляду. Натисніть «Побудувати модель 3D», щоб повторити спробу; після успішної побудови знову з’являться розміри моделі та перегляд шарів.
+
## Типово: лицем униз із прозорим носієм
Увімкніть **Flat Paint** і залиште **Лицем угору, без прозорого шару** вимкненим.
@@ -47,7 +51,7 @@ _Концептуальні перерізи. Кольори позначают
| Flat Paint, типовий | Низ крізь прозорий носій | Дзеркальні зворотні стовпці з фундаментом позаду. | Об’єкти за філаментами й носій. |
| Flat Paint, лицем угору | Відкритий плаский верх | Звичайний порядок із фундаментом під коротшими стовпцями. | Об’єкти за філаментами, без носія. |
-Flat Paint і **Згладжена сітка** не працюють разом. Пластина використовує сусідні ділянки на всій площі замість згладжених контурів. Увімкнення згладженої сітки вимикає Flat Paint; виберіть потрібне компонування й перебудуйте після перемикання.
+**Згладжена сітка** працює з обома орієнтаціями Flat Paint. Виберіть інтенсивність і побудуйте модель знову. Згладжування пом’якшує зовнішній контур і спільні межі кольорів, зберігаючи плоску пластину, висоти шарів і стеки матеріалів. Невеликі спільні з’єднання закривають діагональні контакти без перекриття. Інструкції друку та 3MF записують використану інтенсивність.
## Експортуйте й перевірте
diff --git a/src/locales/uk/docs/generating-exporting-output.md b/src/locales/uk/docs/generating-exporting-output.md
index a90dfd3a..46a1f909 100644
--- a/src/locales/uk/docs/generating-exporting-output.md
+++ b/src/locales/uk/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ description: Побудуйте модель, перевірте її, експ
STL не переносить кольори філаментів або автоматичні призначення котушок. Використайте скопійований план замін із керуванням змінами кольору у слайсері. 3MF теж є моделлю, а не готовим G-code: виберіть свій принтер, сопло, профілі філаментів, температури й швидкості, потім виконайте нарізання.
-Для моделей **Flat Paint** меню пропонує лише 3MF: модель містить по одному об’єкту на фізичний філамент і, у типовому компонуванні лицем униз, об’єкт прозорого носія. Необов’язкове компонування лицем угору обходиться без нього. Некольоровий STL з єдиною геометрією будь-якої такої пластини був би марним. Flat Paint вимикає **Згладжену сітку**, бо пласка пластина не використовує згладжених контурів меж.
+Для моделей **Flat Paint** меню пропонує лише 3MF: модель містить по одному об’єкту на фізичний філамент і, у типовому компонуванні лицем униз, об’єкт прозорого носія. Необов’язкове компонування лицем угору обходиться без нього. Некольоровий STL з єдиною геометрією будь-якої такої пластини був би марним. Обидві орієнтації підтримують усі рівні інтенсивності **Згладженої сітки**. Після зміни інтенсивності побудуйте модель знову, щоб застосувати її до попереднього перегляду та експортованої геометрії.
## Інструкції друку
diff --git a/src/locales/uk/docs/settings-and-controls.md b/src/locales/uk/docs/settings-and-controls.md
index 673a207b..c1e5af98 100644
--- a/src/locales/uk/docs/settings-and-controls.md
+++ b/src/locales/uk/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ description: Дії заголовка, теми, збереження, палі
Публічні сторінки теж мають вибір мови. Перекладена документація використовує поширювані посилання з мовним префіксом, наприклад `/ro/docs/overview`. Назви сторінок і якорі розділів стабільні між мовами. Розширення файлів, числові дані моделі, введені користувачем назви й оригінальні назви робіт спільноти не перекладаються.
-Діалог налаштувань містить посилання на документацію, Discord, Reddit, GitHub і Patreon та показує поточну версію Kromacut.
+Діалог налаштувань містить посилання на документацію, Discord, Reddit, GitHub і Patreon та показує поточну версію Kromacut. **Конфіденційність і локальні дані** та **Умови користування** також доступні тут і відкриваються у браузері вибраною мовою.
## Режими робочої області
@@ -123,4 +123,16 @@ Kromacut запам’ятовує в браузері такі параметр
Запис призначений для досліджень і може створювати великі файли. Для звичайного друку залишайте його вимкненим, якщо журнал не потрібен.
+## Відкриття файлів із робочого столу
+
+Настільні інсталяції пов’язують файли `.kfil` і старі `.kapp` із профілями філаментів, а файли `.kpal` — з палітрами. Двічі клацніть файл, щоб імпортувати й вибрати його в Kromacut. Якщо застосунок уже працює, використовується його наявне вікно. Діють звичайні правила перевірки, міграції, обробки дублікатів і збереження калібрування.
+
+Файли, відкриті з робочого столу, очікують, поки відкрито редактор палітри, діалог калібрування або форму перейменування чи збереження нового профілю. Завершіть або скасуйте цей сеанс, щоб продовжити імпорт із черги; до того часу введені назви й профіль, який ви редагуєте, залишаються незмінними.
+
+Імпорт також очікує, поки ви редагуєте назву або поле HD філаменту чи поки відкрито його вибір кольору або спливну панель **Перетворити з TD**. Перейдіть з поля або закрийте вибір кольору чи панель, щоб продовжити імпорт із черги; перед заміною незбережених змін філаментів іншим профілем ви, як і раніше, маєте зробити вибір.
+
+Перед заміною незбережених змін філаментів Kromacut пропонує **Залишити зміни** або **Відкрити профіль**. Залиште зміни, щоб спочатку їх зберегти; відкриття профілю відкидає їх. Файли, відкриті таким способом, мають бути меншими за 32 MiB. Звичайні JSON, зображення й моделі зберігають свої звичні способи імпорту.
+
+У Linux портативні AppImage потребують інтеграції з робочим столом для асоціацій файлів. Якщо Kromacut не вибрано типовим застосунком, скористайтеся **Відкрити за допомогою** у файловому менеджері.
+
Далі: [Усунення несправностей](troubleshooting).
diff --git a/src/locales/uk/docs/troubleshooting.md b/src/locales/uk/docs/troubleshooting.md
index 71b97fec..47fdb4dd 100644
--- a/src/locales/uk/docs/troubleshooting.md
+++ b/src/locales/uk/docs/troubleshooting.md
@@ -55,7 +55,7 @@ description: Поширені проблеми й перші кроки для
## Один перемикач вимкнув інший
-Дві пари навмисно взаємовиключні: **Згладжена сітка** та **Flat Paint**, а також **Зберігати розділення кольорів** і **Дизеринг висоти**. Перша пара вибирає різні геометричні компонування; друга — різні способи призначення вихідних кольорів друкованим висотам. Див. [Flat Paint](flat-paint) та [Auto-paint](auto-paint).
+**Зберігати розділення кольорів** і **Дизеринг висоти** залишаються взаємовиключними, бо по-різному призначають вихідні кольори друкованим висотам. **Згладжена сітка** та **Flat Paint** можна використовувати разом; після зміни налаштувань побудуйте модель знову. Див. [Flat Paint](flat-paint) та [Auto-paint](auto-paint).
## Розділення кольорів не знаходить результату
diff --git a/src/locales/uk/printing.json b/src/locales/uk/printing.json
index 9a829a4f..48f005a2 100644
--- a/src/locales/uk/printing.json
+++ b/src/locales/uk/printing.json
@@ -256,7 +256,9 @@
"tooManyColors_few": "Забагато кольорів ({{count, number}})",
"tooManyColors_many": "Забагато кольорів ({{count, number}})"
},
- "threeDView": {
+ "threeDView": {
+ "buildFailed": "Не вдалося побудувати 3D-модель",
+ "buildFailedDetails": "Незавершену модель видалено. Натисніть «Побудувати 3D-модель», щоб повторити спробу. Помилка: {{error}}",
"layerPreview": "Перегляд шарів",
"previewBottomLayerCutoff": "Нижня межа шарів у перегляді",
"previewTopLayerCutoff": "Верхня межа шарів у перегляді",
@@ -284,7 +286,11 @@
"firstLayerHeight": "Висота першого шару",
"effectiveLineWidth": "Ефективна ширина лінії",
"smoothMeshing": "Згладжена сітка",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "Згладжує з’єднані межі кольорів за допомогою швидкого зварювання топології. Увімкнення вимикає Flat Paint.",
+ "smoothingNone": "Без згладжування",
+ "smoothingMinimal": "Мінімальне",
+ "smoothingMedium": "Середнє",
+ "smoothingAggressive": "Інтенсивне",
+ "smoothingHelp": "Згладжує контури моделі в перегляді та експорті. Середнє зберігає попереднє згладжування. Перебудуйте для застосування.",
"modelDimensions": "Модель: {{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -395,7 +401,7 @@
"readingImageLayers": "Читання колірних шарів зображення",
"buildingLayer": "Побудова колірного шару {{layer}} із {{total}}",
"layerProgress": "Шар {{layer}} із {{total}}: {{label}}",
- "flatPaintPartProgress": "Частина Flat Paint {{part}} із {{total}}: {{label}}",
+ "flatPaintPartProgress": "Частина сітки Flat Paint {{part}} із {{total}}: {{label}}",
"partMeshComplete": "Сітку частини завершено",
"selectingPixels": "Вибір активних пікселів",
"preparingPreview": "Підготовка геометрії перегляду",
diff --git a/src/locales/uk/public.json b/src/locales/uk/public.json
index 371daa04..accf27dc 100644
--- a/src/locales/uk/public.json
+++ b/src/locales/uk/public.json
@@ -110,7 +110,7 @@
"photos_one": "{{count}} фото",
"photos_other": "{{count}} фото",
"openPhoto": "Відкрити {{title}} — {{kind}} {{index}} у повному розмірі",
- "originalArtwork": "Оригінальна робота: {{title}} ",
+ "originalArtwork": "Оригінальна робота: {{title}} ",
"themeLabel": "Тема: {{theme}}",
"photos_few": "{{count}} фото",
"photos_many": "{{count}} фото"
diff --git a/src/locales/zh-CN/common.json b/src/locales/zh-CN/common.json
index 5a10f5c6..ee2cc0c7 100644
--- a/src/locales/zh-CN/common.json
+++ b/src/locales/zh-CN/common.json
@@ -1,4 +1,15 @@
{
+ "desktopFiles": {
+ "invalidProfile": "无效的配置文件",
+ "failedTitle": "无法打开文件",
+ "failedDescription": "无法打开“{{name}}”。请确认这是小于 32 MiB 且可读取的 Kromacut 文件,并且本地存储有可用空间。",
+ "unavailable": "文件打开功能暂时不可用。请重启 Kromacut 后重试。",
+ "unsavedTitle": "未保存的耗材修改",
+ "unsavedDescription": "打开“{{name}}”将替换未保存的耗材修改。请选择保留修改以便先保存,或打开配置以放弃修改。",
+ "keepEdits": "保留修改",
+ "openProfile": "打开配置",
+ "close": "关闭"
+ },
"colorPicker": {
"hue": "色相",
"color": "颜色",
@@ -27,6 +38,7 @@
"light": "浅色",
"resources": "资源与社区",
"resourcesDescription": "查看指南和版本发布,分享你的作品。",
+ "openLinkFailed": "无法打开此页面,请重试。",
"docs": "文档",
"docsDescription": "了解操作流程和打印设置。",
"reddit": "Reddit 上的 r/kromacut",
diff --git a/src/locales/zh-CN/docs/3d-mode.md b/src/locales/zh-CN/docs/3d-mode.md
index bb62d1ca..cec4721b 100644
--- a/src/locales/zh-CN/docs/3d-mode.md
+++ b/src/locales/zh-CN/docs/3d-mode.md
@@ -76,13 +76,24 @@ _截面示意。后续颜色表面下方包含前面的连续段。_
关闭平滑时,轮廓遵循方形像素网格。开启时,将连续边界平滑为焊接几何体。差异会导出,不是预览滤镜。
+平滑预览和导出模型的轮廓。中度保留原有的平滑效果。重新构建后应用。
+
+| 强度 | 用途 |
+| --- | --- |
+| **无** | 像素画、精确的方格边缘或最快生成。 |
+| **轻度** | 轻微平滑锯齿状角点。 |
+| **中度** | 与原来启用平滑时的效果相同。 |
+| **强度** | 进一步平滑轮廓和残留的阶梯状边缘,保持与中度相同的位移上限。 |
+
+顶点位移保持在源像素的一半以内。重新构建模型并检查切片预览。旧的开启/关闭设置对应中度/无。
+

_轮廓示意比较,不是切片模拟。_
曲线或斜线轮廓看起来过于阶梯化时使用。保留有意像素画或精确网格边缘时关闭。两者都无法修复小到不可打印的细节,也不会凭空增加源分辨率。
-[平面打印模式](flat-paint)下平滑网格不启用。开启平滑网格会禁用平面打印;平面打印改用覆盖完整占地的平板构造。
+平滑网格也可用于[平面打印模式](flat-paint)。两种朝向都保持平板表面和颜色间的共享边界;选择强度后重新构建即可应用。
## 自动配色
diff --git a/src/locales/zh-CN/docs/auto-paint.md b/src/locales/zh-CN/docs/auto-paint.md
index 5095a26d..82c0124a 100644
--- a/src/locales/zh-CN/docs/auto-paint.md
+++ b/src/locales/zh-CN/docs/auto-paint.md
@@ -135,7 +135,7 @@ _概念顺序及分配,并非材料预测。_
_机制示意,不保证前后结果。不同顶面高度不代表增加耗材卷颜色。_
-当前许多自动配色映射在此步骤之前已经选择离散可打印层。已吸附区域没有小数高度误差可分散,启用抖动后可能不变。请用重新构建预览和切片软件检查当前图像是否确实获得高度变化;开启开关不保证更多色调或可见点。
+启用抖动后,如果所选层与相邻可打印层的混合能改善预测的颜色匹配,自动配色就会在两者之间寻找中间高度。精确颜色匹配和已校准目标的匹配保持所选高度;没有合适相邻混合的区域也保持不变。更改设置后请重新构建模型,再比较预览与切片结果。关闭抖动会恢复普通的离散层匹配。
点尺寸由**3D打印设置**的**有效线宽**相对于**像素尺寸**决定,并舍入到整像素块尺寸。这是近似值,不是确切最小宽度保证。边缘区域避开相同抖动处理以减少边界伪影。请在切片软件中检查小孤岛及额外移动。
diff --git a/src/locales/zh-CN/docs/flat-paint.md b/src/locales/zh-CN/docs/flat-paint.md
index 0e452c06..af9f1319 100644
--- a/src/locales/zh-CN/docs/flat-paint.md
+++ b/src/locales/zh-CN/docs/flat-paint.md
@@ -15,6 +15,10 @@ description: 阶梯浮雕、正面朝下的透明承载层及正面朝上的裸
_概念截面图。颜色代表材料;标签标明观察面。_
+构建进度按网格部件计数,而不是按打印层计数:多个彩色部件可能共用同一层。“模型”标签显示物理层数,使用透明承载层时也包括该层。层预览每次按一个物理层剖切平板;两个滑块也会剖切合并后的基础材料和颜色区域。导出始终包含完整模型。即使设置未改变,“构建3D模型”也始终会重新生成模型,并恢复完整的层范围和初始相机视角。
+
+如果构建失败,Kromacut 会移除未完成的模型,并在预览中显示错误。点击“构建3D模型”重试;构建成功后,模型尺寸和层预览会重新显示。
+
## 默认:透明承载层、正面朝下
启用**平面打印模式**,让**正面朝上,无透明层**保持关闭。
@@ -47,7 +51,7 @@ _概念截面图。颜色代表材料;标签标明观察面。_
| 平面打印,默认 | 透过透明承载层的底面 | 镜像、反转材料顺序的列,基础层在后方。 | 每种耗材的对象加承载层。 |
| 平面打印,正面朝上 | 裸露平整顶面 | 通常顺序,基础层位于较矮列下方。 | 每种耗材的对象,无承载层。 |
-平面打印与**平滑网格**不能同时使用。平板采用覆盖完整占地的并排区域,而非平滑轮廓。启用平滑网格会禁用平面打印;切换时请选定布局并重新构建。
+**平滑网格**可用于平面打印的两种朝向。选择强度后重新构建。平滑处理会柔化外轮廓和颜色间的共享边界,同时保持平板表面、层高和材料堆叠顺序。对角接触使用小型共享连接,使区域闭合而不重叠。打印说明和3MF会记录构建时的强度。
## 导出与检查
diff --git a/src/locales/zh-CN/docs/generating-exporting-output.md b/src/locales/zh-CN/docs/generating-exporting-output.md
index 8f4a8fe9..7152350a 100644
--- a/src/locales/zh-CN/docs/generating-exporting-output.md
+++ b/src/locales/zh-CN/docs/generating-exporting-output.md
@@ -46,7 +46,7 @@ description: 构建并检查模型、导出文件以及复制打印说明。
STL不携带耗材颜色或自动耗材卷分配。请将复制的换料计划与切片软件的颜色切换控制搭配使用。3MF也仍是模型,不是可直接运行的G代码:请选择自己的打印机、喷嘴、耗材配置、温度和速度,然后切片。
-对于**平面打印模式**模型,下载菜单仅提供3MF:模型包含每种实体耗材各一个对象,默认正面朝下布局还有透明承载层对象。可选的正面朝上布局省略承载层。任一种平板的无色单几何体STL都没有实际用处。平面打印会关闭**平滑网格**,因为平板布局不使用平滑边界轮廓。
+对于**平面打印模式**模型,下载菜单仅提供3MF:模型包含每种实体耗材各一个对象,默认正面朝下布局还有透明承载层对象。可选的正面朝上布局省略承载层。任一种平板的无色单几何体STL都没有实际用处。两种朝向均支持**平滑网格**的所有强度。更改强度后请重新构建模型,以将设置应用于预览和导出的几何形状。
## 打印说明
diff --git a/src/locales/zh-CN/docs/settings-and-controls.md b/src/locales/zh-CN/docs/settings-and-controls.md
index 68cbb2e2..f330e327 100644
--- a/src/locales/zh-CN/docs/settings-and-controls.md
+++ b/src/locales/zh-CN/docs/settings-and-controls.md
@@ -22,7 +22,7 @@ description: 顶部操作、主题、持久化、调色板、配置及工作区
公开页面也有语言选择器。翻译文档使用可分享的语言前缀链接,例如`/ro/docs/overview`。页面名及章节锚点在不同语言间保持稳定。文件扩展名、模型数值数据、用户输入名称及社区作品原始标题不会翻译。
-设置对话框包含文档、Discord、Reddit、GitHub及Patreon链接,并显示当前Kromacut版本。
+设置对话框包含文档、Discord、Reddit、GitHub及Patreon链接,并显示当前Kromacut版本。 这里也提供**隐私与本地数据**和**使用条款**,会在浏览器中以所选语言打开。
## 工作区模式
@@ -123,4 +123,16 @@ HueForge耗材卷库导出(`.csv`或`.tsv`)可直接导入。在HueForge中
记录用于调查,可能生成大文件。普通打印不需要记录时,请保持禁用。
+## 从桌面打开文件
+
+桌面安装将 `.kfil` 和旧版 `.kapp` 文件关联到耗材配置,将 `.kpal` 文件关联到调色板。双击文件即可在 Kromacut 中导入并选中它。如果应用已经运行,会使用现有窗口处理文件。仍然遵循通常的验证、迁移、重复处理和校准保留规则。
+
+调色板编辑器、校准对话框或配置的重命名、新建保存表单打开时,从桌面打开的文件会等待处理。完成或取消当前操作后,排队的导入将继续;在此之前,已输入的名称和正在编辑的配置保持不变。
+
+编辑耗材名称或HD字段时,或者该耗材的颜色选择器或**从TD转换**弹出面板打开时,导入也会等待。移开输入框焦点或关闭选择器、弹出面板后,排队的导入会继续;在其他配置替换未保存的耗材修改前,仍会要求你作出选择。
+
+替换未保存的耗材修改之前,Kromacut 会提供 **保留修改** 或 **打开配置**。选择保留修改后可先保存;打开配置则会放弃这些修改。以此方式打开的文件必须小于 32 MiB。普通 JSON、图片和模型仍使用通常的导入流程。
+
+在 Linux 上,便携式 AppImage 需要桌面集成才能关联文件。如果系统没有将 Kromacut 设为默认应用,请在文件管理器中使用 **打开方式**。
+
下一步:[故障排查](troubleshooting)。
diff --git a/src/locales/zh-CN/docs/troubleshooting.md b/src/locales/zh-CN/docs/troubleshooting.md
index 57762236..384ed0fe 100644
--- a/src/locales/zh-CN/docs/troubleshooting.md
+++ b/src/locales/zh-CN/docs/troubleshooting.md
@@ -55,7 +55,7 @@ description: 常见问题及优先尝试的解决方法。
## 一个开关关闭了另一个开关
-两组开关故意互斥:**平滑网格**与**平面打印模式**,以及**保留颜色分离**与**高度抖动**。第一组选择不同几何布局,第二组选择将源颜色分配到可打印高度的不同方式。参阅[平面打印模式](flat-paint)及[自动配色](auto-paint)。
+**保留颜色分离**与**高度抖动**仍然互斥,因为它们将源颜色分配到可打印高度的方式不同。**平滑网格**与**平面打印模式**可以同时使用;修改任一设置后请重新构建。参阅[平面打印模式](flat-paint)及[自动配色](auto-paint)。
## 颜色分离找不到结果
diff --git a/src/locales/zh-CN/printing.json b/src/locales/zh-CN/printing.json
index d23122ed..63613ce3 100644
--- a/src/locales/zh-CN/printing.json
+++ b/src/locales/zh-CN/printing.json
@@ -213,6 +213,8 @@
"tooManyColors_other": "颜色过多({{count, number}}种)"
},
"threeDView": {
+ "buildFailed": "无法构建 3D 模型",
+ "buildFailedDetails": "已移除未完成的模型。请点击“构建 3D 模型”重试。错误:{{error}}",
"layerPreview": "层预览",
"previewBottomLayerCutoff": "预览底部层截断",
"previewTopLayerCutoff": "预览顶部层截断",
@@ -240,7 +242,11 @@
"firstLayerHeight": "首层高度",
"effectiveLineWidth": "有效线宽",
"smoothMeshing": "平滑网格",
- "smoothConnectedColorBoundaryEdgesWithFastWeldedTopology": "通过快速焊接拓扑平滑相连的颜色边界。启用此项会关闭平面打印模式。",
+ "smoothingNone": "无",
+ "smoothingMinimal": "轻度",
+ "smoothingMedium": "中度",
+ "smoothingAggressive": "强度",
+ "smoothingHelp": "平滑预览和导出模型的轮廓。中度保留原有的平滑效果。重新构建后应用。",
"modelDimensions": "模型:{{width}}×{{height}}×{{depth}} mm"
},
"printInstructions": {
@@ -335,7 +341,7 @@
"readingImageLayers": "正在读取图像颜色层",
"buildingLayer": "正在构建颜色层{{layer}}/{{total}}",
"layerProgress": "第{{layer}}/{{total}}层:{{label}}",
- "flatPaintPartProgress": "平面打印部件{{part}}/{{total}}:{{label}}",
+ "flatPaintPartProgress": "平面打印网格部件{{part}}/{{total}}:{{label}}",
"partMeshComplete": "部件网格已完成",
"selectingPixels": "正在选择有效像素",
"preparingPreview": "正在准备预览几何体",
diff --git a/src/locales/zh-CN/public.json b/src/locales/zh-CN/public.json
index 85653e96..85b93310 100644
--- a/src/locales/zh-CN/public.json
+++ b/src/locales/zh-CN/public.json
@@ -110,7 +110,7 @@
"photos_one": "{{count}}张照片",
"photos_other": "{{count}}张照片",
"openPhoto": "全尺寸打开{{title}} — {{kind}} {{index}}",
- "originalArtwork": "原作:{{title}} ",
+ "originalArtwork": "原作:{{title}} ",
"themeLabel": "主题:{{theme}}"
},
"navigation": {
diff --git a/src/types/index.ts b/src/types/index.ts
index 347cb34c..36e33c77 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -2,6 +2,8 @@ import type { AutoPaintResult } from '../lib/autoPaint';
import type { FrontlitCalibration } from '../lib/calibration';
import type { TargetSampleContext } from './appearance';
+export type SmoothMeshingStrength = 'none' | 'minimal' | 'medium' | 'aggressive';
+
export type {
AppearanceGeometryClass,
CanonicalSrgbColor,
@@ -91,7 +93,7 @@ export interface ThreeDControlsStateShape {
colorOrder: number[];
filteredSwatches: Swatch[];
pixelSize: number; // mm per pixel (XY)
- smoothMeshing?: boolean; // boundary-chain smoothed grid meshing
+ smoothMeshingStrength?: SmoothMeshingStrength;
filaments: Filament[];
paintMode: 'manual' | 'autopaint';
/** Optional user-selected cap for the generated Auto-paint stack height. */
diff --git a/tests/benchmark/README.md b/tests/benchmark/README.md
index f513df28..7a674870 100644
--- a/tests/benchmark/README.md
+++ b/tests/benchmark/README.md
@@ -36,3 +36,7 @@ npm run benchmark:calibrated -- desk-landscape baseline --seed 1263681357 --repo
The benchmark passes the fixture or override seed through to the production optimizer and reports weighted mean, p95, and coverage at Delta E 6. Use the same case, settings, and seed on both revisions; multiple fixed seeds are preferable for stochastic search tiers.
For Phase 3 and later, accept a change only when average realized error improves, no fixture regresses by more than 5%, and the 8-filament case stays within a 2-second budget on the comparison machine. Record the machine and the command when comparing reports.
+
+## Smooth meshing presets
+
+Run `npm run benchmark:meshing -- [baseline-ref]` for warmed, alternating-order median timings and mesh sizes on 256px/1024px logos and three 512px photo thresholds. Supply a Git ref before the strength presets (for example `cc577a7`) to compare the original smooth mesher in the same process and assert byte-for-byte Medium geometry compatibility. None, Minimal, Medium, and Aggressive all report vertex and triangle counts; use several runs to distinguish timing noise from regressions.
diff --git a/tests/benchmark/smoothMeshing.ts b/tests/benchmark/smoothMeshing.ts
new file mode 100644
index 00000000..5103b048
--- /dev/null
+++ b/tests/benchmark/smoothMeshing.ts
@@ -0,0 +1,108 @@
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { writeFileSync, unlinkSync } from 'node:fs';
+import { pathToFileURL, fileURLToPath } from 'node:url';
+import { generateSmoothMesh } from '../../src/lib/meshing.ts';
+import {
+ logoFixturePath,
+ largeIssueFixturePath,
+ maskFromPngAlpha,
+ maskFromJpegLuminance,
+} from '../imageFixtures.ts';
+
+// Optional positional Git ref compares the presets with the pre-change mesher in
+// the same process. Alternate order to reduce warmup/GC bias; timings are medians.
+const baselineRef = process.argv[2];
+const baselinePath = fileURLToPath(new URL('.smooth-meshing-baseline.ts', import.meta.url));
+const noYield = { yieldIntervalMs: Infinity, onYield: async () => undefined };
+const variants = ['none', 'minimal', 'medium', 'aggressive'] as const;
+const fixtures = [
+ { name: 'logo 256px', mask: maskFromPngAlpha(logoFixturePath, 256) },
+ { name: 'logo 1024px', mask: maskFromPngAlpha(logoFixturePath, 1024) },
+ ...[96, 144, 192].map((threshold) => ({
+ name: `photo 512px threshold ${threshold}`,
+ mask: maskFromJpegLuminance(largeIssueFixturePath, 512, threshold),
+ })),
+];
+let createdBaseline = false;
+try {
+ let baseline: typeof generateSmoothMesh | undefined;
+ if (baselineRef) {
+ writeFileSync(
+ baselinePath,
+ execFileSync('git', ['show', `${baselineRef}:src/lib/meshing.ts`], {
+ encoding: 'utf8',
+ }),
+ { flag: 'wx' }
+ );
+ createdBaseline = true;
+ baseline = (await import(pathToFileURL(baselinePath).href)).generateSmoothMesh;
+ }
+ const results = [];
+ for (const { name, mask } of fixtures) {
+ const run = (strength: (typeof variants)[number] | 'baseline') => {
+ const generate = strength === 'baseline' ? baseline! : generateSmoothMesh;
+ return generate(
+ mask.activePixels,
+ mask.width,
+ mask.height,
+ 0.08,
+ 0.2,
+ 0.1,
+ 1,
+ strength === 'baseline' ? noYield : { ...noYield, strength }
+ );
+ };
+ if (baseline) {
+ const old = await run('baseline');
+ const medium = await run('medium');
+ assert.deepEqual(
+ medium.positions,
+ old.positions,
+ `${name}: Medium changed legacy vertices`
+ );
+ assert.deepEqual(medium.indices, old.indices, `${name}: Medium changed legacy faces`);
+ }
+ const names = baseline ? ['baseline' as const, ...variants] : [...variants];
+ const reference = await run('medium');
+ const samples = new Map(names.map((variant) => [variant, [] as number[]]));
+ const sizes = new Map();
+ for (let round = 0; round < 11; round++) {
+ for (const variant of round % 2 ? [...names].reverse() : names) {
+ const start = performance.now();
+ const mesh = await run(variant);
+ const elapsed = performance.now() - start;
+ assert.equal(
+ mesh.positions.length,
+ reference.positions.length,
+ `${name}: ${variant} increased vertices`
+ );
+ assert.equal(
+ mesh.indices.length,
+ reference.indices.length,
+ `${name}: ${variant} changed triangle count`
+ );
+ if (round >= 2) samples.get(variant)!.push(elapsed);
+ sizes.set(variant, {
+ vertices: mesh.positions.length / 3,
+ triangles: mesh.indices.length / 3,
+ });
+ }
+ }
+ results.push({
+ fixture: name,
+ activePixels: mask.activeCount,
+ presets: Object.fromEntries(
+ names.map((variant) => {
+ const sorted = samples.get(variant)!.sort((a, b) => a - b);
+ return [variant, { medianMs: +sorted[4].toFixed(2), ...sizes.get(variant) }];
+ })
+ ),
+ });
+ }
+ process.stdout.write(
+ JSON.stringify({ baselineRef, samplesPerPreset: 9, results }, null, 2) + '\n'
+ );
+} finally {
+ if (createdBaseline) unlinkSync(baselinePath);
+}
diff --git a/tests/desktopFileAssociations.test.ts b/tests/desktopFileAssociations.test.ts
new file mode 100644
index 00000000..df9d8212
--- /dev/null
+++ b/tests/desktopFileAssociations.test.ts
@@ -0,0 +1,97 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { basename, dirname, join, resolve } from 'node:path';
+import { tmpdir } from 'node:os';
+import { execFileSync } from 'node:child_process';
+
+const nativeRoot = resolve('src-tauri');
+const read = (path: string) => readFileSync(resolve(nativeRoot, path), 'utf8');
+const config = JSON.parse(read('tauri.conf.json'));
+
+test('desktop packages claim only Kromacut formats and ship matching icons/MIME globs', () => {
+ const associations = config.bundle.fileAssociations;
+ assert.deepEqual(associations.flatMap((item: { ext: string[] }) => item.ext).sort(), [
+ 'kapp',
+ 'kfil',
+ 'kpal',
+ ]);
+ const linux = JSON.parse(read('tauri.linux.conf.json')).bundle.linux;
+ const mime = read('linux/kromacut-mime.xml');
+ const mac = JSON.parse(read('tauri.macos.conf.json')).bundle.macOS;
+ const macPlist = read(mac.infoPlist);
+ for (const association of associations) {
+ assert.ok(mime.includes(`type="${association.mimeType}"`));
+ for (const extension of association.ext)
+ assert.ok(mime.includes(`pattern="*.${extension}"`));
+ const type = macPlist
+ .split('')
+ .find((part) =>
+ part.includes(`${association.exportedType.identifier} `)
+ )!;
+ for (const extension of association.ext)
+ assert.ok(type.includes(`${extension} `));
+ const iconName = /CFBundleTypeIconFile<\/key>([^<]+)<\/string>/.exec(type)![1];
+ const icon = mac.files[`Resources/${iconName}`];
+ assert.ok(existsSync(resolve(nativeRoot, icon)));
+ for (const format of ['deb', 'rpm', 'appimage']) {
+ const files = linux[format].files;
+ assert.equal(files['/usr/share/mime/packages/kromacut.xml'], 'linux/kromacut-mime.xml');
+ const iconName = association.mimeType.replace('/', '-');
+ for (const size of [32, 128]) {
+ const source =
+ files[`/usr/share/icons/hicolor/${size}x${size}/mimetypes/${iconName}.png`];
+ const png = readFileSync(resolve(nativeRoot, source));
+ assert.equal(png.readUInt32BE(16), size);
+ assert.equal(png.readUInt32BE(20), size);
+ }
+ }
+ }
+ // appimage's staging uses the Debian desktop template before custom files are copied.
+ for (const format of ['deb', 'rpm']) {
+ assert.match(read(linux[format].desktopTemplate), /^Exec=\{\{exec\}\} %F$/m);
+ assert.equal(linux[format].postInstallScript, linux[format].postRemoveScript);
+ assert.match(
+ read(linux[format].postInstallScript),
+ /update-mime-database \/usr\/share\/mime/
+ );
+ }
+ const hooks = read(config.bundle.windows.nsis.installerHooks);
+ assert.ok(hooks.includes('"Software\\Classes"'));
+ assert.ok(hooks.includes('${KROMACUT_ASSOCIATIONS_ROOT}\\${FILECLASS}\\shell\\open\\command'));
+ assert.ok(hooks.includes('$\\"$INSTDIR\\${MAINBINARYNAME}.exe$\\" $\\"%1$\\"'));
+});
+
+test('Windows association macros preserve previous and later handlers through upgrades/removal', (t) => {
+ if (process.platform !== 'win32' || !process.env.LOCALAPPDATA) {
+ t.skip('Requires Windows and the NSIS compiler downloaded by Tauri');
+ return;
+ }
+ const compiler = join(process.env.LOCALAPPDATA, 'tauri', 'NSIS', 'makensis.exe');
+ if (!existsSync(compiler)) {
+ t.skip('Run a Windows Tauri bundle build to download the NSIS compiler first');
+ return;
+ }
+ const directory = mkdtempSync(join(tmpdir(), 'kromacut-associations-'));
+ const output = join(directory, 'association-test.exe');
+ try {
+ execFileSync(
+ compiler,
+ [
+ '/V2',
+ `/DTEST_OUTFILE=${output}`,
+ `/DTEST_ID=${basename(directory)}`,
+ `/DASSOCIATIONS_FILE=${resolve(nativeRoot, 'windows/file-associations.nsh')}`,
+ resolve('tests/native/file-associations.nsi'),
+ ],
+ { windowsHide: true, timeout: 30_000, stdio: 'pipe' }
+ );
+ // The harness only touches its unique HKCU Software\\Kromacut test key,
+ // not real file associations, and removes that key before it exits.
+ execFileSync(output, ['/S'], { windowsHide: true, timeout: 30_000, stdio: 'pipe' });
+ } finally {
+ assert.equal(dirname(directory), resolve(tmpdir()));
+ assert.ok(basename(directory).startsWith('kromacut-associations-'));
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
diff --git a/tests/desktopFileInbox.test.ts b/tests/desktopFileInbox.test.ts
new file mode 100644
index 00000000..f1f70ee0
--- /dev/null
+++ b/tests/desktopFileInbox.test.ts
@@ -0,0 +1,111 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createDesktopFileInbox, type DesktopOpenedFile } from '../src/lib/desktopFileInbox.ts';
+
+const file = (name: string): DesktopOpenedFile => ({
+ name,
+ kind: 'profile',
+ content: '{}',
+ error: null,
+});
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+
+test('desktop inbox retains startup files across unsubscribe/remount and serializes warm opens', async () => {
+ const pending = [file('startup.kfil')];
+ let signal = () => {};
+ let listeners = 0;
+ const inbox = createDesktopFileInbox({
+ listen: async (callback) => {
+ signal = callback;
+ listeners++;
+ },
+ take: async () => pending.shift() ?? null,
+ });
+ const unsubscribe = inbox.subscribe(() => {});
+ unsubscribe();
+ await flush();
+ assert.equal(inbox.getSnapshot()?.name, 'startup.kfil');
+ inbox.subscribe(() => {});
+ assert.equal(listeners, 1);
+ pending.push(file('next.kapp'), file('third.kfil'));
+ signal();
+ await flush();
+ assert.equal(inbox.getSnapshot()?.name, 'startup.kfil');
+ const id = inbox.getSnapshot()!.id;
+ inbox.finish(id);
+ await flush();
+ assert.equal(inbox.getSnapshot()?.name, 'next.kapp');
+ inbox.finish(id); // A late dialog callback must not dismiss the following document.
+ assert.equal(inbox.getSnapshot()?.name, 'next.kapp');
+ inbox.finish(inbox.getSnapshot()!.id);
+ await flush();
+ assert.equal(inbox.getSnapshot()?.name, 'third.kfil');
+ inbox.finish(inbox.getSnapshot()!.id);
+ await flush();
+ assert.equal(inbox.getSnapshot(), null);
+});
+
+test('desktop inbox does not lose an event arriving during an empty startup read', async () => {
+ let signal = () => {};
+ let complete: (value: DesktopOpenedFile | null) => void = () => {};
+ let reads = 0;
+ const inbox = createDesktopFileInbox({
+ listen: async (callback) => {
+ signal = callback;
+ },
+ take: async () => {
+ reads++;
+ if (reads === 1)
+ return new Promise((resolve) => {
+ complete = resolve;
+ });
+ return file('arrived-during-read.kfil');
+ },
+ });
+ inbox.subscribe(() => {});
+ await flush();
+ signal();
+ complete(null);
+ await flush();
+ assert.equal(reads, 2);
+ assert.equal(inbox.getSnapshot()?.name, 'arrived-during-read.kfil');
+});
+
+test('a bad file can be dismissed without losing later files; IPC failure does not loop', async () => {
+ const pending: DesktopOpenedFile[] = [
+ { ...file('unreadable.kfil'), content: null, error: 'denied' },
+ file('valid.kfil'),
+ ];
+ let signal = () => {};
+ let fail = false;
+ let reads = 0;
+ const inbox = createDesktopFileInbox({
+ listen: async (callback) => {
+ signal = callback;
+ },
+ take: async () => {
+ reads++;
+ if (fail) throw new Error('IPC unavailable');
+ return pending.shift() ?? null;
+ },
+ });
+ inbox.subscribe(() => {});
+ await flush();
+ assert.equal(inbox.getSnapshot()?.error, 'denied');
+ inbox.finish(inbox.getSnapshot()!.id);
+ await flush();
+ assert.equal(inbox.getSnapshot()?.name, 'valid.kfil');
+ fail = true;
+ inbox.finish(inbox.getSnapshot()!.id);
+ await flush();
+ assert.match(inbox.getSnapshot()!.error!, /IPC unavailable/);
+ const count = reads;
+ inbox.finish(inbox.getSnapshot()!.id);
+ await flush();
+ assert.equal(reads, count);
+ fail = false;
+ pending.push(file('retry.kfil'));
+ signal();
+ await flush();
+ assert.equal(inbox.getSnapshot()?.name, 'retry.kfil');
+});
diff --git a/tests/e2e/desktop-file-open.spec.ts b/tests/e2e/desktop-file-open.spec.ts
new file mode 100644
index 00000000..4a2a27f1
--- /dev/null
+++ b/tests/e2e/desktop-file-open.spec.ts
@@ -0,0 +1,1009 @@
+import { expect, test, type Page } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import type { DesktopOpenedFile } from '../../src/lib/desktopFileInbox';
+
+const legacyContent = readFileSync('tests/assets/filament-profiles/2_Colors.kapp', 'utf8');
+const legacy = JSON.parse(legacyContent);
+const legacyFile: DesktopOpenedFile = {
+ name: 'My spools 日本語.kapp',
+ kind: 'profile',
+ content: '\uFEFF' + legacyContent,
+ error: null,
+};
+const currentFile: DesktopOpenedFile = {
+ name: 'New spools.kfil',
+ kind: 'profile',
+ error: null,
+ content: JSON.stringify({
+ id: 'new-profile',
+ name: 'New spools',
+ version: 3,
+ filaments: [{ id: 'pink', color: '#ff80c0', td: 0.5 }],
+ createdAt: 1,
+ updatedAt: 1,
+ }),
+};
+const paletteFile = (id: string): DesktopOpenedFile => ({
+ name: `${id}.kpal`,
+ kind: 'palette',
+ error: null,
+ content: JSON.stringify({
+ id,
+ name: id,
+ version: 1,
+ colors: [['Second palette', 'Cannot save'].includes(id) ? '#00ff00' : '#ff0000', '#0000ff'],
+ colorNames: ['Red', 'Blue'],
+ disabledColors: [1],
+ createdAt: 1,
+ updatedAt: 1,
+ }),
+});
+
+type MockDesktop = Window & {
+ fileOpenTest: {
+ pending: DesktopOpenedFile[];
+ signal: () => void;
+ calls: string[];
+ failWrites?: boolean;
+ releaseRead?: () => void;
+ holdNextWrite?: boolean;
+ releaseWrite?: () => void;
+ };
+};
+
+async function mockDesktop(page: Page, initial: DesktopOpenedFile[], entryPath = '/app') {
+ await page.addInitScript((files) => {
+ localStorage.clear();
+ const callbacks = new Map void>();
+ let nextId = 0;
+ const desktop = window as unknown as MockDesktop & {
+ isTauri: boolean;
+ __TAURI_INTERNALS__: unknown;
+ };
+ desktop.isTauri = true;
+ desktop.fileOpenTest = { pending: files, signal: () => {}, calls: [] };
+ const setItem = Storage.prototype.setItem;
+ Storage.prototype.setItem = function (key, value) {
+ if (
+ desktop.fileOpenTest.failWrites &&
+ ['kromacut.autopaint.profiles', 'kromacut.palettes'].includes(key)
+ )
+ throw new DOMException('Test storage full', 'QuotaExceededError');
+ setItem.call(this, key, value);
+ };
+ desktop.__TAURI_INTERNALS__ = {
+ transformCallback(callback: (value: unknown) => void) {
+ const id = ++nextId;
+ callbacks.set(id, callback);
+ return id;
+ },
+ async invoke(
+ command: string,
+ args: { event?: string; handler?: number; data?: Uint8Array } = {}
+ ) {
+ desktop.fileOpenTest.calls.push(command);
+ if (command === 'plugin:event|listen') {
+ if (args.event === 'kromacut-opened-files') {
+ desktop.fileOpenTest.signal = () =>
+ callbacks.get(args.handler!)?.({
+ event: args.event,
+ id: args.handler,
+ payload: null,
+ });
+ }
+ return args.handler;
+ }
+ if (command === 'take_opened_file')
+ return desktop.fileOpenTest.pending.shift() ?? null;
+ if (command === 'get_app_version') return '4.1.0';
+ if (command === 'check_for_updates') return null;
+ if (command === 'plugin:dialog|save') return 'C:\\mock\\proof.3mf';
+ if (command === 'plugin:fs|open') return 91;
+ if (command === 'plugin:fs|write') {
+ const state = desktop.fileOpenTest;
+ if (state.holdNextWrite) {
+ state.holdNextWrite = false;
+ await new Promise((resolve) => {
+ state.releaseWrite = () => {
+ delete state.releaseWrite;
+ resolve();
+ };
+ });
+ }
+ return args.data!.byteLength;
+ }
+ if (command === 'plugin:resources|close') return;
+ if (command === 'plugin:dialog|message') return 'Ok';
+ throw new Error(`Unexpected desktop command: ${command}`);
+ },
+ };
+ }, initial);
+ await page.goto(entryPath);
+}
+
+async function openFiles(page: Page, files: DesktopOpenedFile[]) {
+ await page.evaluate((items) => {
+ const state = (window as unknown as MockDesktop).fileOpenTest;
+ state.pending.push(...items);
+ state.signal();
+ }, files);
+}
+
+const profiles = (page: Page) =>
+ page.evaluate(() => JSON.parse(localStorage.getItem('kromacut.autopaint.profiles') ?? '[]'));
+
+const savedPalettes = (page: Page) =>
+ page.evaluate(() => JSON.parse(localStorage.getItem('kromacut.palettes') ?? '[]'));
+
+const workingFilaments = (page: Page) =>
+ page.evaluate(() => JSON.parse(localStorage.getItem('kromacut.autopaint.v1')!).filaments);
+
+// Wait for the mocked native queue to deliver the first request to React.
+async function expectPendingFiles(page: Page, count: number) {
+ await expect
+ .poll(() =>
+ page.evaluate(() => (window as unknown as MockDesktop).fileOpenTest.pending.length)
+ )
+ .toBe(count);
+ await page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ })
+ );
+}
+
+for (const kind of ['profile', 'palette'] as const) {
+ test(`@smoke a pending manual ${kind} import preserves a desktop import completed during its read`, async ({
+ page,
+ }) => {
+ const initial = kind === 'profile' ? currentFile : paletteFile('Original palette');
+ const incoming: DesktopOpenedFile =
+ kind === 'profile'
+ ? {
+ ...currentFile,
+ content: JSON.stringify({
+ ...JSON.parse(currentFile.content!),
+ id: 'incoming-profile',
+ name: 'Incoming spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ }),
+ }
+ : paletteFile('Second palette');
+ const manual = {
+ name: `Pending manual.${kind === 'profile' ? 'kfil' : 'kpal'}`,
+ content: JSON.stringify({
+ id: 'manual-import',
+ name: 'Manual import',
+ version: kind === 'profile' ? 3 : 2,
+ createdAt: 1,
+ updatedAt: 1,
+ ...(kind === 'profile'
+ ? { filaments: [{ id: 'cyan', color: '#00ffff', td: 0.4 }] }
+ : { colors: ['#ffffff', '#000000'] }),
+ }),
+ };
+ const savedItems = () => (kind === 'profile' ? profiles(page) : savedPalettes(page));
+ await mockDesktop(page, [initial]);
+ await expect.poll(async () => (await savedItems()).length).toBe(1);
+
+ // Retain the real reader/callback, but release it only after the desktop import commits.
+ await page.evaluate((fileName) => {
+ const state = (window as unknown as MockDesktop).fileOpenTest;
+ const readAsText = FileReader.prototype.readAsText;
+ FileReader.prototype.readAsText = function (file, encoding) {
+ if ((file as File).name !== fileName) {
+ readAsText.call(this, file, encoding);
+ return;
+ }
+ FileReader.prototype.readAsText = readAsText;
+ state.releaseRead = () => {
+ delete state.releaseRead;
+ readAsText.call(this, file, encoding);
+ };
+ };
+ }, manual.name);
+ const input = page.getByTestId(
+ kind === 'profile' ? 'autopaint-profile-import-input' : 'palette-import-input'
+ );
+ await input.setInputFiles({
+ name: manual.name,
+ mimeType: 'application/json',
+ buffer: Buffer.from(manual.content),
+ });
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ Boolean((window as unknown as MockDesktop).fileOpenTest.releaseRead)
+ )
+ )
+ .toBe(true);
+
+ await openFiles(page, [incoming]);
+ await expect.poll(async () => (await savedItems()).length).toBe(2);
+ const beforeReadFinished = await savedItems();
+ await page.evaluate(() => (window as unknown as MockDesktop).fileOpenTest.releaseRead!());
+ await expect.poll(async () => (await savedItems()).length).toBe(3);
+ const after = await savedItems();
+ expect(after.slice(0, 2)).toEqual(beforeReadFinished);
+ expect(after[2].id).toBe('manual-import');
+ if (kind === 'profile') {
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ )
+ .toBe('manual-import');
+ expect((await workingFilaments(page))[0].color).toBe('#00ffff');
+ } else {
+ await expect(page.locator('#palette-select')).toContainText('Manual import');
+ }
+ });
+}
+
+for (const source of ['desktop', 'manual'] as const) {
+ test(`@smoke ${source} profile batches select the final saved version after duplicate resolution`, async ({
+ page,
+ }) => {
+ await mockDesktop(page, [currentFile]);
+ await expect.poll(async () => (await profiles(page)).length).toBe(1);
+ const original = (await profiles(page))[0];
+ const updated = {
+ ...original,
+ name: 'Updated spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ };
+ const batch = {
+ ...currentFile,
+ content: JSON.stringify([
+ { ...original, id: 'duplicate-alias', name: 'Same original spools' },
+ updated,
+ ]),
+ };
+ if (source === 'desktop') {
+ await openFiles(page, [batch]);
+ } else {
+ await page.getByTestId('autopaint-profile-import-input').setInputFiles({
+ name: batch.name,
+ mimeType: 'application/json',
+ buffer: Buffer.from(batch.content),
+ });
+ }
+ await expect.poll(async () => (await profiles(page))[0].name).toBe(updated.name);
+ await expect.poll(() => workingFilaments(page)).toEqual(updated.filaments);
+ await expect(page.getByText('Unsaved changes', { exact: true })).not.toBeVisible();
+ expect((await profiles(page)).length).toBe(1);
+ expect(
+ await page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ ).toBe(original.id);
+ });
+}
+
+for (const ownerChange of ['unchanged', 'renamed', 'replaced', 'deleted'] as const) {
+ test(`@smoke a late proof save preserves desktop imports when its original owner is ${ownerChange}`, async ({
+ page,
+ }) => {
+ const firstFile = {
+ ...legacyFile,
+ content: readFileSync('tests/assets/filament-profiles/4_Colors.kapp', 'utf8'),
+ };
+ await mockDesktop(page, [firstFile]);
+ await expect.poll(async () => (await profiles(page)).length).toBe(1);
+ const owner = (await profiles(page))[0];
+ await page.getByRole('button', { name: '2D', exact: true }).click();
+ await page.getByTestId('image-file-input').setInputFiles('tests/assets/1024x1024p.png');
+ await expect(page.locator('body')).toContainText('Image: 1024', { timeout: 60_000 });
+ await page.getByRole('button', { name: '3D', exact: true }).click();
+ await page.getByRole('tab', { name: 'Auto-paint', exact: true }).click();
+ await expect(page.getByTestId('build-3d-model')).toBeEnabled({ timeout: 90_000 });
+ await page.getByRole('button', { name: 'Calibrate', exact: true }).click();
+ const dialog = page.getByRole('alertdialog');
+ await dialog.getByRole('tab', { name: 'Palette Proof', exact: true }).click();
+ await expect(page.getByTestId('download-palette-proof')).toBeEnabled();
+ await page.evaluate(() => {
+ (window as unknown as MockDesktop).fileOpenTest.holdNextWrite = true;
+ });
+ await page.getByTestId('download-palette-proof').click();
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ Boolean((window as unknown as MockDesktop).fileOpenTest.releaseWrite)
+ )
+ )
+ .toBe(true);
+
+ const incoming = {
+ ...currentFile,
+ content: JSON.stringify({
+ ...JSON.parse(currentFile.content!),
+ id: ownerChange === 'replaced' ? owner.id : 'incoming-profile',
+ name: 'Incoming spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ }),
+ };
+ // Cover an already queued open, plus edits/deletion after the exporting dialog closes.
+ if (ownerChange === 'unchanged' || ownerChange === 'replaced') {
+ await openFiles(page, [incoming]);
+ await expectPendingFiles(page, 0);
+ expect(await profiles(page)).toEqual([owner]);
+ }
+ await dialog.getByRole('button', { name: 'Close calibration dialog', exact: true }).click();
+ if (ownerChange === 'renamed') {
+ await page
+ .getByRole('button', { name: 'Rename selected profile', exact: true })
+ .click();
+ const name = page.getByPlaceholder('Profile name...', { exact: true });
+ await name.fill('Renamed proof owner');
+ await name.press('Enter');
+ await expect(name).not.toBeVisible();
+ await openFiles(page, [incoming]);
+ } else if (ownerChange === 'deleted') {
+ await page
+ .getByRole('button', { name: 'Delete selected profile', exact: true })
+ .click();
+ await expect.poll(() => profiles(page)).toEqual([]);
+ await openFiles(page, [incoming]);
+ await page
+ .getByRole('alertdialog')
+ .getByRole('button', { name: 'Open profile', exact: true })
+ .click();
+ }
+ const incomingId = ownerChange === 'replaced' ? owner.id : 'incoming-profile';
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ )
+ .toBe(incomingId);
+ const beforeSaveFinished = await profiles(page);
+ const incomingProfile = beforeSaveFinished.find(
+ (profile: { id: string }) => profile.id === incomingId
+ );
+ await page.evaluate(() => (window as unknown as MockDesktop).fileOpenTest.releaseWrite!());
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ (window as unknown as MockDesktop).fileOpenTest.calls.includes(
+ 'plugin:dialog|message'
+ )
+ )
+ )
+ .toBe(true);
+ await expectPendingFiles(page, 0);
+ const after = await profiles(page);
+ expect(after.find((profile: { id: string }) => profile.id === incomingId)).toEqual(
+ incomingProfile
+ );
+ expect(after.length).toBe(beforeSaveFinished.length);
+ if (ownerChange === 'unchanged' || ownerChange === 'renamed') {
+ const finalOwner = after.find((profile: { id: string }) => profile.id === owner.id);
+ expect(finalOwner.filaments).toEqual(owner.filaments);
+ expect(finalOwner.name).toBe(
+ ownerChange === 'renamed' ? 'Renamed proof owner' : owner.name
+ );
+ expect(finalOwner.appearance.proofs).toHaveLength(1);
+ } else {
+ expect(after).toEqual(beforeSaveFinished);
+ }
+ expect(
+ await page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ ).toBe(incomingId);
+ expect((await workingFilaments(page))[0].color).toBe('#ffffff');
+ });
+}
+
+for (const field of ['name', 'hiding distance'] as const) {
+ test(`@smoke desktop imports wait for a focused filament ${field} draft before protecting its edits`, async ({
+ page,
+ }) => {
+ await mockDesktop(page, [currentFile]);
+ await expect.poll(async () => (await profiles(page)).length).toBe(1);
+ const originalProfiles = await profiles(page);
+ const originalFilaments = await workingFilaments(page);
+ const replacement = {
+ ...currentFile,
+ content: JSON.stringify({
+ ...originalProfiles[0],
+ id: 'incoming-profile',
+ name: 'Incoming spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ }),
+ };
+ const row = page.getByPlaceholder('Name', { exact: true }).first().locator('..');
+ const draft =
+ field === 'name'
+ ? row.getByPlaceholder('Name', { exact: true })
+ : row.getByRole('spinbutton');
+ const draftValue = field === 'name' ? 'My pink spool' : '0.73';
+ await draft.fill(draftValue);
+
+ const queuedPalette = paletteFile('After filament draft');
+ // A palette arriving first must not hide an uncommitted row draft either.
+ await openFiles(
+ page,
+ field === 'name' ? [queuedPalette, replacement] : [replacement, queuedPalette]
+ );
+ await expectPendingFiles(page, 1);
+ await expect(draft).toBeFocused();
+ await expect(draft).toHaveValue(draftValue);
+ await expect(page.getByRole('alertdialog')).not.toBeVisible();
+ expect(await profiles(page)).toEqual(originalProfiles);
+ expect(await savedPalettes(page)).toEqual([]);
+ expect(await workingFilaments(page)).toEqual(originalFilaments);
+
+ // Commit by keyboard or by switching paint tabs; both must precede the dirty-state check.
+ if (field === 'name') await draft.press('Enter');
+ else await page.getByRole('tab', { name: 'Manual', exact: true }).click();
+ const dialog = page.getByRole('alertdialog');
+ await expect(dialog).toContainText('Unsaved filament edits');
+ const committedFilaments = await workingFilaments(page);
+ expect(committedFilaments[0][field === 'name' ? 'name' : 'td']).toBe(
+ field === 'name' ? draftValue : 0.73
+ );
+ expect(await profiles(page)).toEqual(originalProfiles);
+ await dialog.getByRole('button', { name: 'Keep edits', exact: true }).click();
+ await expect
+ .poll(async () =>
+ (await savedPalettes(page)).map((palette: { name: string }) => palette.name)
+ )
+ .toEqual(['After filament draft']);
+ expect(await workingFilaments(page)).toEqual(committedFilaments);
+ expect(await profiles(page)).toEqual(originalProfiles);
+
+ await openFiles(page, [replacement]);
+ await expect(dialog).toContainText('Unsaved filament edits');
+ await dialog.getByRole('button', { name: 'Open profile', exact: true }).click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ )
+ .toBe('incoming-profile');
+ expect((await workingFilaments(page))[0]).toEqual({
+ id: 'white',
+ color: '#ffffff',
+ td: 0.8,
+ });
+ });
+}
+
+for (const outcome of ['unchanged focus', 'row removal'] as const) {
+ test(`@smoke desktop imports resume when filament editing ends by ${outcome}`, async ({
+ page,
+ }) => {
+ await mockDesktop(page, [currentFile]);
+ await expect.poll(async () => (await profiles(page)).length).toBe(1);
+ const original = (await profiles(page))[0];
+ const replacement = {
+ ...currentFile,
+ content: JSON.stringify({
+ ...original,
+ id: 'incoming-profile',
+ name: 'Incoming spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ }),
+ };
+ const name = page.getByPlaceholder('Name', { exact: true }).first();
+ const row = name.locator('..');
+ if (outcome === 'row removal') await name.fill('Draft spool');
+ else await name.click();
+ await openFiles(page, [replacement]);
+ await expectPendingFiles(page, 0);
+ await expect(name).toBeFocused();
+ await expect(page.getByRole('alertdialog')).not.toBeVisible();
+ expect(await profiles(page)).toEqual([original]);
+
+ if (outcome === 'row removal') {
+ // Invoke the handler without an earlier pointer-down blur, isolating row cleanup.
+ await row
+ .getByTitle('Remove filament', { exact: true })
+ .evaluate((button: HTMLButtonElement) => button.click());
+ const dialog = page.getByRole('alertdialog');
+ await expect(dialog).toContainText('Unsaved filament edits');
+ expect(await workingFilaments(page)).toEqual([]);
+ await dialog.getByRole('button', { name: 'Keep edits', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ expect(await profiles(page)).toEqual([original]);
+ expect(await workingFilaments(page)).toEqual([]);
+ } else {
+ await name.press('Enter');
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ )
+ .toBe('incoming-profile');
+ await expect(page.getByRole('alertdialog')).not.toBeVisible();
+ expect((await workingFilaments(page))[0].color).toBe('#ffffff');
+ }
+ });
+}
+
+for (const outcome of ['convert', 'dismiss'] as const) {
+ test(`@smoke desktop imports wait for a filament TD conversion draft to ${outcome}`, async ({
+ page,
+ }) => {
+ await mockDesktop(page, [currentFile]);
+ await expect.poll(async () => (await profiles(page)).length).toBe(1);
+ const original = (await profiles(page))[0];
+ const replacement = {
+ ...currentFile,
+ content: JSON.stringify({
+ ...original,
+ id: 'incoming-profile',
+ name: 'Incoming spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ }),
+ };
+ await page
+ .getByTitle('Convert from TD (lithophane/backlit value)', { exact: true })
+ .first()
+ .click();
+ const draft = page.getByPlaceholder('e.g. 4.0', { exact: true });
+ await draft.fill('7.3');
+ await openFiles(page, [replacement]);
+ await expectPendingFiles(page, 0);
+ await expect(draft).toHaveValue('7.3');
+ await expect(page.getByRole('alertdialog')).not.toBeVisible();
+ expect(await profiles(page)).toEqual([original]);
+ expect((await workingFilaments(page))[0].td).toBe(0.5);
+
+ if (outcome === 'convert') {
+ await draft.press('Enter');
+ const dialog = page.getByRole('alertdialog');
+ await expect(dialog).toContainText('Unsaved filament edits');
+ expect((await workingFilaments(page))[0].td).toBe(0.73);
+ await dialog.getByRole('button', { name: 'Keep edits', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ expect(await profiles(page)).toEqual([original]);
+ expect((await workingFilaments(page))[0].td).toBe(0.73);
+ } else {
+ // A discarded local conversion has no committed edit to protect and must not block.
+ await draft.press('Escape');
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ )
+ .toBe('incoming-profile');
+ await expect(page.getByRole('alertdialog')).not.toBeVisible();
+ expect((await profiles(page)).length).toBe(2);
+ expect((await workingFilaments(page))[0].color).toBe('#ffffff');
+ }
+ });
+}
+
+test('@smoke desktop imports wait for an open filament color picker before protecting its edits', async ({
+ page,
+}) => {
+ await mockDesktop(page, [currentFile]);
+ await expect.poll(async () => (await profiles(page)).length).toBe(1);
+ const original = (await profiles(page))[0];
+ const replacement = {
+ ...currentFile,
+ content: JSON.stringify({
+ ...original,
+ id: 'incoming-profile',
+ name: 'Incoming spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ }),
+ };
+ await page.getByTitle('Change filament color', { exact: true }).first().click();
+ const picker = page.getByRole('dialog');
+ const hex = picker.getByRole('textbox');
+ await hex.fill('#123456');
+ await expect.poll(async () => (await workingFilaments(page))[0].color).toBe('#123456');
+ await openFiles(page, [replacement]);
+ await expectPendingFiles(page, 0);
+ await expect(picker).toContainText('Pick Color');
+ await expect(hex).toHaveValue('#123456');
+ await expect(page.getByRole('alertdialog')).not.toBeVisible();
+ expect(await profiles(page)).toEqual([original]);
+
+ await hex.press('Escape');
+ const dialog = page.getByRole('alertdialog');
+ await expect(dialog).toContainText('Unsaved filament edits');
+ await dialog.getByRole('button', { name: 'Keep edits', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ expect(await profiles(page)).toEqual([original]);
+ expect((await workingFilaments(page))[0].color).toBe('#123456');
+});
+
+test('@smoke desktop imports wait for a closed filament color picker debounce to commit', async ({
+ page,
+}) => {
+ await page.clock.install({ time: new Date('2026-09-30T12:00:00Z') });
+ await mockDesktop(page, [currentFile]);
+ await expect.poll(async () => (await profiles(page)).length).toBe(1);
+ const original = (await profiles(page))[0];
+ const replacement = {
+ ...currentFile,
+ content: JSON.stringify({
+ ...original,
+ id: 'incoming-profile',
+ name: 'Incoming spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ }),
+ };
+ await page.clock.pauseAt(new Date('2026-09-30T13:00:00Z'));
+ await page.getByTitle('Change filament color', { exact: true }).first().click();
+ const hex = page.getByRole('dialog').getByRole('textbox');
+ await hex.fill('#123456');
+ await hex.press('Escape');
+ await openFiles(page, [replacement]);
+ await page.clock.runFor(199);
+ await expect(page.getByRole('dialog')).not.toBeVisible();
+ await expect(page.getByRole('alertdialog')).not.toBeVisible();
+ expect(await profiles(page)).toEqual([original]);
+ expect((await workingFilaments(page))[0].color).toBe(original.filaments[0].color);
+ expect(
+ await page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ ).toBe(original.id);
+
+ await page.clock.runFor(1);
+ const dialog = page.getByRole('alertdialog');
+ await expect(dialog).toContainText('Unsaved filament edits');
+ expect((await workingFilaments(page))[0].color).toBe('#123456');
+ await page.clock.resume();
+ await dialog.getByRole('button', { name: 'Open profile', exact: true }).click();
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId')))
+ .toBe('incoming-profile');
+ expect((await workingFilaments(page))[0].color).toBe('#ffffff');
+});
+
+test('@smoke desktop imports wait for calibration and protect the resulting unsaved measurements', async ({
+ page,
+}) => {
+ const original = {
+ id: 'calibration-owner',
+ name: 'Calibration owner',
+ version: 3,
+ createdAt: 1,
+ updatedAt: 1,
+ filaments: [
+ { id: 'white', name: 'White spool', color: '#ffffff', td: 0.6 },
+ { id: 'black', name: 'Black spool', color: '#000000', td: 0.02 },
+ ],
+ };
+ const firstFile = { ...currentFile, content: JSON.stringify(original) };
+ const replacement = {
+ ...currentFile,
+ content: JSON.stringify({
+ ...original,
+ id: 'calibration-replacement',
+ name: 'Different spools',
+ filaments: [
+ { id: 'white', name: 'Magenta spool', color: '#ff00ff', td: 0.8 },
+ original.filaments[1],
+ ],
+ }),
+ };
+ await mockDesktop(page, [firstFile]);
+ await page.getByRole('button', { name: 'Calibrate', exact: true }).click();
+ const dialog = page.getByRole('alertdialog');
+ await dialog.getByRole('button', { name: /White spool/ }).click();
+ await dialog.getByRole('button', { name: 'Next: Base' }).click();
+ await dialog.getByRole('button', { name: 'Next: Print' }).click();
+ await dialog.getByRole('button', { name: 'Next: Enter Results' }).click();
+ await dialog.getByPlaceholder('match', { exact: true }).fill('5');
+ await expect(
+ dialog.getByRole('button', { name: 'Save Calibration', exact: true })
+ ).toBeEnabled();
+
+ await openFiles(page, [replacement, paletteFile('After calibration')]);
+ await expectPendingFiles(page, 1);
+ expect((await profiles(page)).map((profile: { id: string }) => profile.id)).toEqual([
+ original.id,
+ ]);
+ expect(await savedPalettes(page)).toEqual([]);
+ await expect(dialog.getByPlaceholder('match', { exact: true })).toHaveValue('5');
+ expect((await workingFilaments(page))[0].td).toBe(0.6);
+
+ await dialog.getByRole('button', { name: 'Save Calibration', exact: true }).click();
+ await expect(dialog).toContainText('Unsaved filament edits');
+ expect(
+ await page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ ).toBe(original.id);
+ const calibratedWhite = (await workingFilaments(page))[0];
+ expect(calibratedWhite.color).toBe('#ffffff');
+ expect(calibratedWhite.calibration).toBeDefined();
+ expect(calibratedWhite.td).not.toBe(0.6);
+
+ await dialog.getByRole('button', { name: 'Keep edits', exact: true }).click();
+ await expect(page.locator('#palette-select')).toContainText('After calibration');
+ expect((await profiles(page)).length).toBe(1);
+ expect((await workingFilaments(page))[0]).toEqual(calibratedWhite);
+
+ await openFiles(page, [replacement]);
+ await expect(dialog).toContainText('Unsaved filament edits');
+ await dialog.getByRole('button', { name: 'Open profile', exact: true }).click();
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId')))
+ .toBe('calibration-replacement');
+ const magenta = (await workingFilaments(page))[0];
+ expect(magenta.color).toBe('#ff00ff');
+ expect(magenta.td).toBe(0.8);
+ expect(magenta.calibration).toBeUndefined();
+});
+
+test('@smoke a queued same-ID palette import runs after the editor saves its draft', async ({
+ page,
+}) => {
+ const original = {
+ id: 'edited-palette',
+ name: 'Original palette',
+ version: 2,
+ colors: ['#ff0000', '#000000'],
+ createdAt: 1,
+ updatedAt: 1,
+ };
+ await mockDesktop(page, [{ ...paletteFile(original.id), content: JSON.stringify(original) }]);
+ await page.getByRole('button', { name: 'Edit selected palette' }).click();
+ await page.getByLabel('Palette Name', { exact: true }).fill('Saved draft');
+ const before = await savedPalettes(page);
+ const incoming = { ...original, name: 'Imported palette', colors: ['#00ff00', '#0000ff'] };
+ await openFiles(page, [{ ...paletteFile(original.id), content: JSON.stringify(incoming) }]);
+ await expectPendingFiles(page, 0);
+ expect(await savedPalettes(page)).toEqual(before);
+ await expect(page.getByLabel('Palette Name', { exact: true })).toHaveValue('Saved draft');
+
+ await page.getByRole('button', { name: 'Save Changes', exact: true }).click();
+ await expect(page.getByRole('alertdialog')).not.toBeVisible();
+ await expect(page.locator('#palette-select')).toContainText('Imported palette');
+ const after = await savedPalettes(page);
+ expect(after.length).toBe(1);
+ expect(after[0].colors).toEqual(incoming.colors.map((color) => color.toUpperCase()));
+});
+
+test('@smoke cancelling a palette editor resumes a mixed profile/palette queue without losing either file', async ({
+ page,
+}) => {
+ await mockDesktop(page, [paletteFile('Original palette')]);
+ await page.getByRole('button', { name: 'Edit selected palette' }).click();
+ await page.getByLabel('Palette Name', { exact: true }).fill('Discarded draft');
+ const before = await savedPalettes(page);
+ await openFiles(page, [currentFile, paletteFile('Second palette')]);
+ await expectPendingFiles(page, 1);
+ expect(await profiles(page)).toEqual([]);
+ expect(await savedPalettes(page)).toEqual(before);
+ await expect(page.getByLabel('Palette Name', { exact: true })).toHaveValue('Discarded draft');
+
+ await page
+ .getByRole('alertdialog')
+ .getByRole('button', { name: 'Cancel', exact: true })
+ .click();
+ await expect(page.locator('#palette-select')).toContainText('Second palette');
+ expect((await profiles(page)).map((profile: { id: string }) => profile.id)).toEqual([
+ 'new-profile',
+ ]);
+ const after = await savedPalettes(page);
+ expect(after.length).toBe(2);
+ expect(after[0]).toEqual(before[0]);
+});
+
+for (const editor of ['Rename', 'Save New'] as const) {
+ for (const outcome of ['save', 'cancel'] as const) {
+ test(`@smoke desktop imports wait for profile ${editor} to ${outcome} its draft`, async ({
+ page,
+ }) => {
+ await mockDesktop(page, [currentFile]);
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ )
+ .toBe('new-profile');
+ await page
+ .getByRole('button', {
+ name: editor === 'Rename' ? 'Rename selected profile' : 'Save as new profile',
+ exact: true,
+ })
+ .click();
+ const draft = page.getByPlaceholder('Profile name...', { exact: true });
+ const draftName = `${editor} original spools`;
+ await draft.fill(draftName);
+ const originalProfiles = await profiles(page);
+ const originalFilaments = await workingFilaments(page);
+ const replacement = {
+ ...currentFile,
+ content: JSON.stringify({
+ ...originalProfiles[0],
+ id: 'incoming-profile',
+ name: 'Incoming spools',
+ filaments: [{ id: 'white', color: '#ffffff', td: 0.8 }],
+ }),
+ };
+ // Cover a profile replacement and a palette workspace switch arriving first.
+ const queuedPalette = paletteFile('After profile editor');
+ await openFiles(
+ page,
+ editor === 'Rename' ? [replacement, queuedPalette] : [queuedPalette, replacement]
+ );
+ await expectPendingFiles(page, 1);
+ await expect(draft).toHaveValue(draftName);
+ expect(await profiles(page)).toEqual(originalProfiles);
+ expect(await savedPalettes(page)).toEqual([]);
+ expect(await workingFilaments(page)).toEqual(originalFilaments);
+ expect(
+ await page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ ).toBe('new-profile');
+
+ if (editor === 'Save New' && outcome === 'save') {
+ await page.evaluate(() => {
+ (window as unknown as MockDesktop).fileOpenTest.failWrites = true;
+ });
+ await draft.press('Enter');
+ await expect(page.getByText(/Profile changes could not be saved/)).toBeVisible();
+ await expect(draft).toHaveValue(draftName);
+ expect(await profiles(page)).toEqual(originalProfiles);
+ expect(await savedPalettes(page)).toEqual([]);
+ await page.evaluate(() => {
+ (window as unknown as MockDesktop).fileOpenTest.failWrites = false;
+ });
+ }
+
+ await draft.press(outcome === 'save' ? 'Enter' : 'Escape');
+ await expect(draft).not.toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId'))
+ )
+ .toBe('incoming-profile');
+ const after = await profiles(page);
+ const original = after.find((profile: { id: string }) => profile.id === 'new-profile');
+ expect(original.filaments).toEqual(originalProfiles[0].filaments);
+ expect(original.name).toBe(
+ editor === 'Rename' && outcome === 'save' ? draftName : 'New spools'
+ );
+ if (editor === 'Save New' && outcome === 'save') {
+ const copy = after.find((profile: { name: string }) => profile.name === draftName);
+ expect(copy.filaments).toEqual(originalProfiles[0].filaments);
+ expect(copy.id).not.toBe('new-profile');
+ expect(after).toHaveLength(3);
+ } else {
+ expect(after).toHaveLength(2);
+ }
+ expect(
+ (await savedPalettes(page)).map((palette: { name: string }) => palette.name)
+ ).toEqual(['After profile editor']);
+ expect((await workingFilaments(page))[0].id).toBe('white');
+ await expectPendingFiles(page, 0);
+ });
+ }
+}
+
+test('@smoke desktop startup imports legacy profiles; warm batches import/select profiles and palettes', async ({
+ page,
+}) => {
+ const errors: string[] = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ await mockDesktop(page, [legacyFile], '/docs/overview');
+ await expect(page).toHaveURL(/:\d+\/$/);
+ await expect(page.getByRole('tab', { name: 'Auto-paint', exact: true })).toHaveAttribute(
+ 'data-state',
+ 'active'
+ );
+ await expect.poll(async () => (await profiles(page)).length).toBe(1);
+ const saved = (await profiles(page))[0];
+ expect(saved.id).toBe(legacy.id);
+ expect(saved.version).toBe(3);
+ expect(saved.filaments.map((item: { td: number }) => item.td)).toEqual([0.6, 0.016]);
+ const calls = await page.evaluate(() => (window as unknown as MockDesktop).fileOpenTest.calls);
+ expect(calls.indexOf('plugin:event|listen')).toBeLessThan(calls.indexOf('take_opened_file'));
+
+ await openFiles(page, [
+ currentFile,
+ paletteFile('First palette'),
+ paletteFile('Second palette'),
+ ]);
+ await expect(page.locator('#palette-select')).toContainText('Second palette');
+ await expect(page.locator('#final-colors')).toHaveValue('1');
+ expect((await profiles(page)).length).toBe(2);
+ const savedPalettes = await page.evaluate(() =>
+ JSON.parse(localStorage.getItem('kromacut.palettes') ?? '[]')
+ );
+ expect(savedPalettes.length).toBe(2);
+ expect(savedPalettes[1].disabledColors).toEqual([1]);
+ expect(savedPalettes[1].colorNames).toEqual(['Red', 'Blue']);
+ const duplicatePalette = {
+ ...paletteFile('Second palette'),
+ content: JSON.stringify({
+ ...savedPalettes[1],
+ id: 'duplicate-palette',
+ name: 'Duplicate palette',
+ }),
+ };
+ await openFiles(page, [duplicatePalette]);
+ await expect
+ .poll(() =>
+ page.evaluate(() => (window as unknown as MockDesktop).fileOpenTest.pending.length)
+ )
+ .toBe(0);
+ await expect(page.locator('#palette-select')).toContainText('Second palette');
+ expect(
+ await page.evaluate(() => JSON.parse(localStorage.getItem('kromacut.palettes')!).length)
+ ).toBe(2);
+ expect(errors).toEqual([]);
+});
+
+test('@smoke desktop profile opens protect unsaved edits and resolve existing content duplicates', async ({
+ page,
+}) => {
+ await mockDesktop(page, [legacyFile]);
+ await expect(page.getByRole('tab', { name: 'Auto-paint', exact: true })).toHaveAttribute(
+ 'data-state',
+ 'active'
+ );
+ await page.getByRole('button', { name: /add filament/i }).click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () => JSON.parse(localStorage.getItem('kromacut.autopaint.v1')!).filaments.length
+ )
+ )
+ .toBe(3);
+ await openFiles(page, [currentFile]);
+ const dialog = page.getByRole('alertdialog');
+ await expect(dialog).toContainText('Unsaved filament edits');
+ await dialog.getByRole('button', { name: 'Keep edits', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ expect((await profiles(page)).length).toBe(1);
+ expect(
+ await page.evaluate(
+ () => JSON.parse(localStorage.getItem('kromacut.autopaint.v1')!).filaments.length
+ )
+ ).toBe(3);
+ await openFiles(page, [currentFile]);
+ await dialog.getByRole('button', { name: 'Open profile', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId')))
+ .toBe('new-profile');
+ expect((await profiles(page)).length).toBe(2);
+ const duplicate = {
+ ...legacyFile,
+ content: JSON.stringify({ ...legacy, id: 'content-duplicate', name: 'Same spools' }),
+ };
+ await openFiles(page, [duplicate]);
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId')))
+ .toBe(legacy.id);
+ expect((await profiles(page)).length).toBe(2);
+});
+
+test('@smoke invalid and unreadable desktop files leave saved data intact and allow following imports', async ({
+ page,
+}) => {
+ await mockDesktop(page, []);
+ await expect(page.locator('#palette-select')).toBeVisible();
+ await openFiles(page, [
+ { ...currentFile, content: '{broken json' },
+ { ...legacyFile, content: null, error: 'File not found' },
+ paletteFile('After errors'),
+ ]);
+ const dialog = page.getByRole('alertdialog');
+ await expect(dialog).toContainText('New spools.kfil');
+ expect(await profiles(page)).toEqual([]);
+ await dialog.getByRole('button', { name: 'Close', exact: true }).click();
+ await expect(dialog).toContainText('My spools 日本語.kapp');
+ expect(await profiles(page)).toEqual([]);
+ await dialog.getByRole('button', { name: 'Close', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ await expect(page.locator('#palette-select')).toContainText('After errors');
+ const palettesBefore = await page.evaluate(() => localStorage.getItem('kromacut.palettes'));
+ await page.evaluate(() => {
+ (window as unknown as MockDesktop).fileOpenTest.failWrites = true;
+ });
+ await openFiles(page, [currentFile]);
+ await expect(dialog).toContainText('New spools.kfil');
+ expect(await profiles(page)).toEqual([]);
+ await dialog.getByRole('button', { name: 'Close', exact: true }).click();
+ await openFiles(page, [paletteFile('Cannot save')]);
+ await expect(dialog).toContainText('Cannot save.kpal');
+ expect(await page.evaluate(() => localStorage.getItem('kromacut.palettes'))).toBe(
+ palettesBefore
+ );
+ await dialog.getByRole('button', { name: 'Close', exact: true }).click();
+ await page.evaluate(() => {
+ (window as unknown as MockDesktop).fileOpenTest.failWrites = false;
+ });
+ await openFiles(page, [currentFile]);
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('kromacut.autopaint.lastProfileId')))
+ .toBe('new-profile');
+});
diff --git a/tests/e2e/flat-paint-preview.spec.ts b/tests/e2e/flat-paint-preview.spec.ts
new file mode 100644
index 00000000..ee5a716f
--- /dev/null
+++ b/tests/e2e/flat-paint-preview.spec.ts
@@ -0,0 +1,535 @@
+import { expect, test, type Page } from '@playwright/test';
+import type * as THREE from 'three';
+import JSZip from 'jszip';
+import { readFile } from 'node:fs/promises';
+import { flatPaintPrecisionFixture } from '../flatPaintPrecisionFixture';
+import { createFlatPaintMesher } from '../../src/lib/flatPaintMeshing';
+import { inspectMeshIntegrity } from '../meshDiagnostics';
+
+type BuildTestWindow = Window & {
+ __KROMACUT_E2E?: { lastBuild?: { status?: string } };
+};
+
+declare global {
+ interface Window {
+ __FLAT_PAINT_TEST_EXPORT?: string;
+ }
+}
+
+async function geometryState(page: Page) {
+ return page.evaluate(() => {
+ const root = (window as unknown as { __KROMACUT_LAST_MESH: THREE.Group })
+ .__KROMACUT_LAST_MESH;
+ const meshes = root.children as THREE.Mesh[];
+ const visible = meshes.filter((mesh) => mesh.visible);
+ const zs = visible.flatMap((mesh) => {
+ const pos = mesh.geometry.getAttribute('position');
+ return Array.from({ length: pos.count }, (_, i) => pos.getZ(i));
+ });
+ return {
+ min: zs.length ? Math.min(...zs) : null,
+ max: zs.length ? Math.max(...zs) : null,
+ visible: visible.length,
+ exportUnchanged:
+ JSON.stringify(
+ meshes.map((mesh) =>
+ Array.from(mesh.geometry.userData.kromacutExportGeometry.positions)
+ )
+ ) === window.__FLAT_PAINT_TEST_EXPORT,
+ };
+ });
+}
+
+async function exportMeshes(page: Page) {
+ if (!(await page.getByTestId('download-3mf').isVisible())) {
+ await page.getByTestId('download-3d-model').click();
+ }
+ const pending = page.waitForEvent('download');
+ await page.getByTestId('download-3mf').click();
+ const zip = await JSZip.loadAsync(await readFile((await (await pending).path())!));
+ const xml = await zip.file('3D/3dmodel.model')!.async('string');
+ return xml.match(/[\s\S]*?<\/mesh>/g);
+}
+
+test('@smoke Flat Paint cuts physical layers, retains face colors, and exports the full slab', async ({
+ page,
+}) => {
+ const errors: string[] = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ await page.addInitScript(() => {
+ Object.assign(window, { __KROMACUT_E2E: { buildHistory: [] } });
+ localStorage.setItem(
+ 'kromacut:3d-print-settings',
+ JSON.stringify({
+ pixelSize: 0.4,
+ layerHeight: 0.08,
+ slicerFirstLayerHeight: 0.16,
+ smoothMeshingStrength: 'none',
+ })
+ );
+ });
+ await page.goto('/app');
+ const png = await page.evaluate(() => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 48;
+ canvas.height = 32;
+ const ctx = canvas.getContext('2d')!;
+ // Dense diagonal contacts used to expand independently for each color.
+ // Bands also exercise long merged backing slabs and intermediate colors.
+ for (let y = 0; y < 32; y++)
+ for (let x = 0; x < 48; x++) {
+ const v = y < 16 ? ((x + y) % 2 ? 255 : 0) : Math.round((x * 255) / 47);
+ ctx.fillStyle = `rgb(${v},${v},${v})`;
+ ctx.fillRect(x, y, 1, 1);
+ }
+ return canvas.toDataURL().split(',')[1];
+ });
+ await page.getByTestId('image-file-input').setInputFiles({
+ name: 'flat-preview.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from(png, 'base64'),
+ });
+ await page.getByRole('button', { name: '3D', exact: true }).click();
+ await page.getByRole('tab', { name: 'Auto-paint', exact: true }).click();
+ await page
+ .getByTestId('autopaint-profile-import-input')
+ .setInputFiles('tests/assets/filament-profiles/2_Colors.kapp');
+ await expect(page.getByText(/1 imported|1 overwritten/)).toBeVisible();
+ await page.getByTestId('autopaint-flat-paint').click();
+ for (const strength of ['None', 'Medium']) {
+ await page.getByTestId('print-smooth-meshing').click();
+ await page.getByRole('option', { name: strength, exact: true }).click();
+ for (const faceUp of [false, true]) {
+ const orientation = page.getByTestId('autopaint-flat-paint-face-up');
+ if (((await orientation.getAttribute('data-state')) === 'checked') !== faceUp)
+ await orientation.click();
+ await page.waitForTimeout(600);
+ const before = await page.evaluate(
+ () =>
+ (window as unknown as { __KROMACUT_E2E: { buildHistory: unknown[] } })
+ .__KROMACUT_E2E.buildHistory.length
+ );
+ await page.getByTestId('build-3d-model').click();
+ await page.waitForFunction(
+ (n) =>
+ (window as unknown as { __KROMACUT_E2E: { buildHistory: unknown[] } })
+ .__KROMACUT_E2E.buildHistory.length > n,
+ before
+ );
+ const info = await page.evaluate(() => {
+ const root = (window as unknown as { __KROMACUT_LAST_MESH: THREE.Group })
+ .__KROMACUT_LAST_MESH;
+ const meshes = root.children as THREE.Mesh[];
+ window.__FLAT_PAINT_TEST_EXPORT = JSON.stringify(
+ meshes.map((mesh) =>
+ Array.from(mesh.geometry.userData.kromacutExportGeometry.positions)
+ )
+ );
+ return {
+ top: Math.max(...meshes.map((mesh) => Number(mesh.userData.topZ))),
+ parts: meshes.length,
+ };
+ });
+ const range = page.getByTestId('layer-preview-range');
+ const low = range.getByRole('slider').nth(0);
+ const high = range.getByRole('slider').nth(1);
+ const layers = Number(await high.getAttribute('aria-valuemax'));
+ expect(layers).toBe(Math.round((info.top - 0.16) / 0.08) + 1);
+ expect(info.parts).toBeGreaterThan(layers);
+ await expect(
+ page.getByText(new RegExp(`^Model:.*\\(${layers} physical layers\\)$`))
+ ).toBeVisible();
+ // Inspect the actual flat surface: total top-cap area must equal
+ // the image footprint. Overlapping black/white masks exceeded it.
+ const face = await page.evaluate(
+ ({ faceUp, top }) => {
+ const root = (window as unknown as { __KROMACUT_LAST_MESH: THREE.Group })
+ .__KROMACUT_LAST_MESH;
+ const plane = faceUp ? top : 0.16;
+ let area = 0;
+ const colors = new Set();
+ for (const mesh of root.children as THREE.Mesh[]) {
+ if (mesh.userData.kromacutExportGroup === 'flat-paint:carrier') continue;
+ const p = mesh.geometry.getAttribute('position');
+ const ids = mesh.geometry.index!;
+ for (let i = 0; i < ids.count; i += 3) {
+ const a = ids.getX(i),
+ b = ids.getX(i + 1),
+ c = ids.getX(i + 2);
+ if (![a, b, c].every((id) => Math.abs(p.getZ(id) - plane) < 1e-6))
+ continue;
+ const signedArea =
+ ((p.getX(b) - p.getX(a)) * (p.getY(c) - p.getY(a)) -
+ (p.getY(b) - p.getY(a)) * (p.getX(c) - p.getX(a))) /
+ 2;
+ if ((faceUp && signedArea > 0) || (!faceUp && signedArea < 0)) {
+ area += Math.abs(signedArea);
+ colors.add(
+ (
+ mesh.material as THREE.MeshStandardMaterial
+ ).color.getHexString()
+ );
+ if (!mesh.visible)
+ throw new Error('Artwork face is hidden after building');
+ }
+ }
+ }
+ return { area, colors: colors.size };
+ },
+ { faceUp, top: info.top }
+ );
+ if (strength === 'None') expect(face.area).toBeCloseTo(48 * 32 * 0.4 * 0.4, 4);
+ expect(face.colors).toBeGreaterThan(2);
+ const fullExport = await exportMeshes(page);
+ await high.focus();
+ for (let layer = layers - 1; layer >= 1; layer--) {
+ await high.press('ArrowLeft');
+ await expect(high).toHaveAttribute('aria-valuenow', String(layer));
+ await expect
+ .poll(async () => (await geometryState(page)).max)
+ .toBeCloseTo(0.16 + (layer - 1) * 0.08, 6);
+ expect((await geometryState(page)).exportUnchanged).toBe(true);
+ }
+ await high.press('ArrowLeft');
+ await expect.poll(async () => (await geometryState(page)).visible).toBe(0);
+ expect(await exportMeshes(page)).toEqual(fullExport);
+ await high.focus();
+ await high.press('End');
+ await low.focus();
+ await low.press('ArrowRight');
+ await expect.poll(async () => (await geometryState(page)).min).toBeCloseTo(0.16, 6);
+ await page.getByTestId('preview-render-mode-trigger').click();
+ await page.getByTestId('preview-render-mode-wireframe').locator('..').click();
+ await low.press('ArrowRight');
+ await expect.poll(async () => (await geometryState(page)).min).toBeCloseTo(0.24, 6);
+ await low.press('Home');
+ await page.getByTestId('preview-render-mode-trigger').click();
+ await page.getByTestId('preview-render-mode-shaded').locator('..').click();
+ await expect.poll(async () => (await geometryState(page)).min).toBeCloseTo(0, 6);
+ if (faceUp && strength === 'None') {
+ // Face-up colors occupy the last physical layer. Cutting it off
+ // can expose dark backing. Build must restore the artwork even
+ // when all input settings are unchanged, rather than keep that cut.
+ await high.press('ArrowLeft');
+ const beforeRebuild = await page.evaluate(() => {
+ const state = window as unknown as {
+ __KROMACUT_E2E: { buildHistory: unknown[] };
+ __KROMACUT_LAST_MESH: THREE.Group;
+ };
+ return {
+ count: state.__KROMACUT_E2E.buildHistory.length,
+ meshId: state.__KROMACUT_LAST_MESH.children[0].uuid,
+ };
+ });
+ await page.getByTestId('build-3d-model').click();
+ await page.waitForFunction(
+ (count) =>
+ (window as unknown as { __KROMACUT_E2E: { buildHistory: unknown[] } })
+ .__KROMACUT_E2E.buildHistory.length > count,
+ beforeRebuild.count
+ );
+ await expect(high).toHaveAttribute('aria-valuenow', String(layers));
+ await expect
+ .poll(async () => (await geometryState(page)).max)
+ .toBeCloseTo(info.top, 6);
+ expect(
+ await page.evaluate(
+ () =>
+ (window as unknown as { __KROMACUT_LAST_MESH: THREE.Group })
+ .__KROMACUT_LAST_MESH.children[0].uuid
+ )
+ ).not.toBe(beforeRebuild.meshId);
+ }
+ await page.screenshot({
+ path: test.info().outputPath(`flat-${strength}-${faceUp ? 'up' : 'down'}.png`),
+ });
+ }
+ }
+ expect(errors).toEqual([]);
+});
+
+test('@smoke face-up finishes precision-sensitive caps and recovers cleanly from a failed build', async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ Object.assign(window, { __KROMACUT_E2E: { buildHistory: [] } });
+ localStorage.setItem(
+ 'kromacut:3d-print-settings',
+ JSON.stringify({
+ pixelSize: 0.04,
+ layerHeight: 0.08,
+ slicerFirstLayerHeight: 0.16,
+ smoothMeshingStrength: 'medium',
+ })
+ );
+ localStorage.setItem(
+ 'kromacut.autopaint.v1',
+ JSON.stringify({
+ filaments: [
+ { id: 'white', color: '#ffffff', td: 0.6 },
+ { id: 'black', color: '#000000', td: 0.016 },
+ ],
+ schemaVersion: 2,
+ paintMode: 'autopaint',
+ flatPaint: true,
+ flatPaintFaceUp: true,
+ omitAtRiskPixels: false,
+ enhancedColorMatch: false,
+ optimizerSeed: 27,
+ })
+ );
+ });
+ await page.goto('/app');
+ const fixture = flatPaintPrecisionFixture();
+ const png = await page.evaluate(
+ ({ width, height, counts }) => {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d')!;
+ const pixels = ctx.createImageData(width, height);
+ counts.forEach((label, index) => {
+ // Face-up maps image rows into Cartesian Y; retain the failing
+ // boundary's absolute print coordinates after that transform.
+ const offset =
+ ((height - 1 - Math.floor(index / width)) * width + (index % width)) * 4;
+ pixels.data.fill(label === 1 ? 0 : 255, offset, offset + 3);
+ pixels.data[offset + 3] = 255;
+ });
+ ctx.putImageData(pixels, 0, 0);
+ return canvas.toDataURL().split(',')[1];
+ },
+ { ...fixture, counts: Array.from(fixture.counts) }
+ );
+ await page.getByTestId('image-file-input').setInputFiles({
+ name: 'flat-paint-precision.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from(png, 'base64'),
+ });
+ await page.getByRole('button', { name: '3D', exact: true }).click();
+ await page.getByRole('tab', { name: 'Auto-paint', exact: true }).click();
+ const build = async () => {
+ await page.getByTestId('build-3d-model').click();
+ await expect(async () => {
+ const warning = page.getByRole('button', { name: 'Build Anyway', exact: true });
+ if (await warning.isVisible()) await warning.click();
+ expect(
+ await page.evaluate(
+ () => (window as BuildTestWindow).__KROMACUT_E2E?.lastBuild?.status
+ )
+ ).toBeDefined();
+ }).toPass();
+ };
+ await build();
+ await page.waitForFunction(() =>
+ ['complete', 'failed'].includes(
+ (window as BuildTestWindow).__KROMACUT_E2E?.lastBuild?.status ?? ''
+ )
+ );
+ expect(
+ await page.evaluate(() => (window as BuildTestWindow).__KROMACUT_E2E?.lastBuild)
+ ).toMatchObject({
+ status: 'complete',
+ cropWidth: fixture.width,
+ cropHeight: fixture.height,
+ settings: { flatPaintFaceUp: true, smoothMeshingStrength: 'medium' },
+ });
+ await expect(page.getByTestId('layer-preview-range')).toBeVisible();
+ const surface = await page.evaluate(() => {
+ const root = (window as unknown as { __KROMACUT_LAST_MESH: THREE.Group })
+ .__KROMACUT_LAST_MESH;
+ const meshes = root.children as THREE.Mesh[];
+ const maxZ = Math.max(...meshes.map((mesh) => mesh.userData.topZ as number));
+ const face = meshes.filter((mesh) => Math.abs(mesh.userData.topZ - maxZ) < 1e-6);
+ let area = 0;
+ for (const mesh of face) {
+ const pos = mesh.geometry.getAttribute('position');
+ const index = mesh.geometry.getIndex()!;
+ for (let i = 0; i < index.count; i += 3) {
+ const [a, b, c] = [index.getX(i), index.getX(i + 1), index.getX(i + 2)];
+ if ([a, b, c].every((v) => Math.abs(pos.getZ(v) - maxZ) < 1e-6)) {
+ area +=
+ ((pos.getX(b) - pos.getX(a)) * (pos.getY(c) - pos.getY(a)) -
+ (pos.getY(b) - pos.getY(a)) * (pos.getX(c) - pos.getX(a))) /
+ 2;
+ }
+ }
+ }
+ return {
+ area,
+ colors: face.map((mesh) =>
+ (mesh.material as THREE.MeshStandardMaterial).color.getHexString()
+ ),
+ carrier: meshes.some(
+ (mesh) => mesh.userData.kromacutExportGroup === 'flat-paint:carrier'
+ ),
+ };
+ });
+ expect(new Set(surface.colors).size).toBe(2);
+ expect(surface.carrier).toBe(false);
+ const options = { yieldIntervalMs: Infinity, onYield: async () => undefined };
+ const meshMask = await createFlatPaintMesher(
+ fixture.counts,
+ fixture.width,
+ fixture.height,
+ fixture.pixelSize,
+ 'medium',
+ options
+ );
+ const footprint = await meshMask(new Uint8Array(fixture.counts.length).fill(1), options);
+ expect(surface.area).toBeCloseTo(inspectMeshIntegrity(footprint).signedVolume, 6);
+
+ // Fail after one part has already been added, then retry unchanged settings.
+ await page.evaluate(() => {
+ const root = (window as unknown as { __KROMACUT_LAST_MESH: THREE.Group })
+ .__KROMACUT_LAST_MESH;
+ const prototype = Object.getPrototypeOf(
+ (root.children[0] as THREE.Mesh).geometry
+ ) as THREE.BufferGeometry;
+ const original = prototype.setAttribute;
+ let parts = 0;
+ prototype.setAttribute = function (name, attribute) {
+ if (name === 'position' && ++parts === 2) {
+ prototype.setAttribute = original;
+ throw new Error('Injected failure after the first Flat Paint part');
+ }
+ return original.call(this, name, attribute);
+ };
+ Object.assign(window, { __FLAT_PAINT_FAILED_ROOT: root });
+ });
+ await build();
+ await expect(page.getByTestId('model-build-error')).toContainText('Injected failure');
+ await expect(page.getByTestId('layer-preview-range')).toBeHidden();
+ expect(
+ await page.evaluate(() => ({
+ status: (window as BuildTestWindow).__KROMACUT_E2E?.lastBuild?.status,
+ parts: (window as unknown as { __FLAT_PAINT_FAILED_ROOT: THREE.Group })
+ .__FLAT_PAINT_FAILED_ROOT.children.length,
+ exportAvailable: !!(window as unknown as { __KROMACUT_LAST_MESH?: THREE.Group })
+ .__KROMACUT_LAST_MESH,
+ }))
+ ).toEqual({ status: 'failed', parts: 0, exportAvailable: false });
+ await build();
+ await expect(page.getByTestId('model-build-error')).toBeHidden();
+ await expect(page.getByTestId('layer-preview-range')).toBeVisible();
+ expect(
+ await page.evaluate(() => (window as BuildTestWindow).__KROMACUT_E2E?.lastBuild?.status)
+ ).toBe('complete');
+});
+
+test('@smoke enhanced matching preserves the artwork across both Flat Paint orientations', async ({
+ page,
+}) => {
+ const { filaments } = JSON.parse(
+ await readFile('tests/assets/filament-profiles/8_Colors_Calibrated_Frontlit.kfil', 'utf8')
+ ) as { filaments: unknown[] };
+ await page.addInitScript((filaments) => {
+ Object.assign(window, { __KROMACUT_E2E: { buildHistory: [] } });
+ localStorage.setItem(
+ 'kromacut:3d-print-settings',
+ JSON.stringify({
+ pixelSize: 0.4,
+ layerHeight: 0.08,
+ slicerFirstLayerHeight: 0.2,
+ smoothMeshingStrength: 'none',
+ })
+ );
+ localStorage.setItem(
+ 'kromacut.autopaint.v1',
+ JSON.stringify({
+ filaments,
+ schemaVersion: 2,
+ paintMode: 'autopaint',
+ autoPaintMaxHeight: 1.6,
+ enhancedColorMatch: true,
+ preserveSeparation: true,
+ separationMaxDeltaE: 15,
+ failOnSeparationError: false,
+ transitionOpacity: 0.8,
+ optimizerSeed: 27,
+ flatPaint: true,
+ flatPaintFaceUp: false,
+ })
+ );
+ }, filaments);
+ await page.goto('/app');
+ const png = await page.evaluate(() => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 128;
+ canvas.height = 128;
+ const ctx = canvas.getContext('2d')!;
+ [
+ '#ffffff',
+ '#000000',
+ '#ff7eb4',
+ '#f7d000',
+ '#d83400',
+ '#6300c5',
+ '#00b8c4',
+ '#04bb00',
+ ].forEach((color, i) => {
+ ctx.fillStyle = color;
+ ctx.fillRect(i * 16, 0, 16, 128);
+ });
+ return canvas.toDataURL().split(',')[1];
+ });
+ await page.getByTestId('image-file-input').setInputFiles({
+ name: 'eight-colors.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from(png, 'base64'),
+ });
+ await page.getByRole('button', { name: '3D', exact: true }).click();
+ await page.getByRole('tab', { name: 'Auto-paint', exact: true }).click();
+ await expect(page.getByTestId('autopaint-enhanced-color-match')).toHaveAttribute(
+ 'data-state',
+ 'checked'
+ );
+ await expect(page.getByTestId('autopaint-preserve-separation')).toHaveAttribute(
+ 'data-state',
+ 'checked'
+ );
+ let previousColors: string[] | undefined;
+ for (const faceUp of [false, true]) {
+ if (faceUp) await page.getByTestId('autopaint-flat-paint-face-up').click();
+ const before = await page.evaluate(
+ () =>
+ (window as unknown as { __KROMACUT_E2E: { buildHistory: unknown[] } })
+ .__KROMACUT_E2E.buildHistory.length
+ );
+ await page.getByTestId('build-3d-model').click();
+ await page.waitForFunction(
+ (count) =>
+ (window as unknown as { __KROMACUT_E2E: { buildHistory: unknown[] } })
+ .__KROMACUT_E2E.buildHistory.length > count,
+ before
+ );
+ const surface = await page.evaluate((faceUp) => {
+ const meshes = (window as unknown as { __KROMACUT_LAST_MESH: THREE.Group })
+ .__KROMACUT_LAST_MESH.children as THREE.Mesh[];
+ const top = Math.max(...meshes.map((mesh) => Number(mesh.userData.topZ)));
+ const face = meshes.filter((mesh) =>
+ faceUp
+ ? Math.abs(mesh.userData.topZ - top) < 1e-6
+ : Math.abs(mesh.userData.baseZ - 0.2) < 1e-6 &&
+ Math.abs(mesh.userData.topZ - 0.28) < 1e-6
+ );
+ return {
+ colors: face
+ .map((mesh) =>
+ (mesh.material as THREE.MeshStandardMaterial).color.getHexString()
+ )
+ .sort(),
+ visible: face.every((mesh) => mesh.visible),
+ carrier: meshes.some(
+ (mesh) => mesh.userData.kromacutExportGroup === 'flat-paint:carrier'
+ ),
+ };
+ }, faceUp);
+ expect(surface.visible).toBe(true);
+ expect(surface.carrier).toBe(!faceUp);
+ expect(new Set(surface.colors).size).toBe(8);
+ if (previousColors) expect(surface.colors).toEqual(previousColors);
+ previousColors = surface.colors;
+ }
+ await page.screenshot({ path: test.info().outputPath('enhanced-face-up.png') });
+});
diff --git a/tests/e2e/height-dithering.spec.ts b/tests/e2e/height-dithering.spec.ts
new file mode 100644
index 00000000..7b296529
--- /dev/null
+++ b/tests/e2e/height-dithering.spec.ts
@@ -0,0 +1,154 @@
+import { expect, test, type Page } from '@playwright/test';
+import { readFile } from 'node:fs/promises';
+import JSZip from 'jszip';
+
+type Build = {
+ status: string;
+ triangleCount: number;
+ dimensions: { width: number; height: number; depth: number };
+ settings: { heightDithering: boolean; enhancedColorMatch: boolean };
+ layerMetrics: Array<{ activePixelCount: number }>;
+};
+declare global {
+ interface Window {
+ __HEIGHT_DITHER_TEST?: { buildHistory: Build[]; lastBuild?: Build };
+ }
+}
+
+async function setSwitch(page: Page, id: string, enabled: boolean) {
+ const control = page.getByTestId(id);
+ await expect(control).toBeEnabled();
+ if (((await control.getAttribute('data-state')) === 'checked') !== enabled)
+ await control.click();
+ await expect(control).toHaveAttribute('data-state', enabled ? 'checked' : 'unchecked');
+}
+
+async function rebuild(page: Page, enabled: boolean) {
+ await setSwitch(page, 'autopaint-height-dithering', enabled);
+ const count = await page.evaluate(() => window.__HEIGHT_DITHER_TEST!.buildHistory.length);
+ await expect(page.getByTestId('build-3d-model')).toBeEnabled();
+ await page.getByTestId('build-3d-model').click();
+ await page.waitForFunction((n) => window.__HEIGHT_DITHER_TEST!.buildHistory.length > n, count);
+ const build = await page.evaluate(() => window.__HEIGHT_DITHER_TEST!.lastBuild!);
+ expect(build.status).toBe('complete');
+ expect(build.settings).toMatchObject({ heightDithering: enabled, enhancedColorMatch: true });
+ return build;
+}
+
+async function download(page: Page, format: 'stl' | '3mf') {
+ const item = page.getByTestId(`download-${format}`);
+ if (!(await item.isVisible())) await page.getByTestId('download-3d-model').click();
+ await expect(item).toBeVisible();
+ const pending = page.waitForEvent('download');
+ await item.click();
+ const file = await pending;
+ return readFile((await file.path())!);
+}
+
+async function geometry(page: Page) {
+ const stl = await download(page, 'stl');
+ expect(stl.length).toBe(84 + stl.readUInt32LE(80) * 50);
+ const zip = await JSZip.loadAsync(await download(page, '3mf'));
+ const model = await zip.file('3D/3dmodel.model')!.async('string');
+ return {
+ stl: stl.subarray(80),
+ meshes: model.match(/[\s\S]*?<\/mesh>/g),
+ materials: model.match(//g),
+ };
+}
+
+for (const smooth of [false, true]) {
+ test(`@smoke height dithering changes rebuilt preview and STL/3MF with ${smooth ? 'smooth' : 'greedy'} meshing`, async ({
+ page,
+ }) => {
+ const errors: string[] = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.addInitScript((smoothMeshing) => {
+ localStorage.clear();
+ localStorage.setItem(
+ 'kromacut.autopaint.v1',
+ JSON.stringify({
+ schemaVersion: 2,
+ filaments: [],
+ paintMode: 'manual',
+ enhancedColorMatch: true,
+ heightDithering: false,
+ preserveSeparation: false,
+ ditherLineWidth: 0.2,
+ optimizerAlgorithm: 'fast',
+ optimizerSeed: 42,
+ })
+ );
+ localStorage.setItem(
+ 'kromacut:3d-print-settings',
+ JSON.stringify({
+ pixelSize: 0.2,
+ layerHeight: 0.08,
+ slicerFirstLayerHeight: 0.16,
+ smoothMeshing,
+ })
+ );
+ const hook = { buildHistory: [] };
+ Object.assign(window, { __KROMACUT_E2E: hook, __HEIGHT_DITHER_TEST: hook });
+ }, smooth);
+ await page.goto('/app');
+ await expect(page.getByTestId('image-file-input')).toBeAttached();
+ const png = await page.evaluate(() => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 128;
+ canvas.height = 96;
+ const ctx = canvas.getContext('2d')!;
+ [0, 32, 64, 96, 128, 160, 192, 224].forEach((v, i) => {
+ ctx.fillStyle = `rgb(${v},${v},${v})`;
+ ctx.fillRect(i * 16, 0, 16, 96);
+ });
+ return canvas.toDataURL('image/png').split(',')[1];
+ });
+ await page.getByTestId('image-file-input').setInputFiles({
+ name: 'height-dithering.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from(png, 'base64'),
+ });
+ await page.getByRole('button', { name: '3D', exact: true }).click();
+ await page.getByRole('tab', { name: 'Auto-paint', exact: true }).click();
+ await page
+ .getByTestId('autopaint-profile-import-input')
+ .setInputFiles('tests/assets/filament-profiles/2_Colors.kapp');
+ await expect(page.getByText(/1 imported|1 overwritten/)).toBeVisible();
+ await setSwitch(page, 'autopaint-enhanced-color-match', true);
+ // Let the debounced profile calculation begin before waiting for its idle state.
+ await page.waitForTimeout(500);
+ await expect(page.getByTestId('build-3d-model')).toBeEnabled();
+ const plain = await rebuild(page, false);
+ const plainGeometry = await geometry(page);
+ expect(plainGeometry.meshes?.length).toBeGreaterThan(0);
+ expect(plainGeometry.materials?.length).toBeGreaterThan(0);
+ const dithered = await rebuild(page, true);
+ const ditheredGeometry = await geometry(page);
+ expect(dithered.triangleCount).toBeGreaterThan(plain.triangleCount);
+ expect(dithered.layerMetrics.map((l) => l.activePixelCount)).not.toEqual(
+ plain.layerMetrics.map((l) => l.activePixelCount)
+ );
+ expect(ditheredGeometry.stl.equals(plainGeometry.stl)).toBe(false);
+ expect(ditheredGeometry.meshes).not.toEqual(plainGeometry.meshes);
+ expect(ditheredGeometry.materials).toEqual(plainGeometry.materials);
+ expect(dithered.dimensions).toEqual(plain.dimensions);
+ await rebuild(page, false);
+ await rebuild(page, true);
+ expect((await geometry(page)).stl).toEqual(ditheredGeometry.stl);
+ const width = page.getByTestId('print-effective-line-width');
+ await width.fill('0.4');
+ await width.blur();
+ const wider = await rebuild(page, true);
+ expect(wider.triangleCount).toBeLessThan(dithered.triangleCount);
+ expect(wider.triangleCount).toBeGreaterThan(plain.triangleCount);
+ const widerGeometry = await geometry(page);
+ expect(widerGeometry.stl.equals(ditheredGeometry.stl)).toBe(false);
+ expect(widerGeometry.meshes).not.toEqual(ditheredGeometry.meshes);
+ await rebuild(page, false);
+ const restored = await geometry(page);
+ expect(restored.stl).toEqual(plainGeometry.stl);
+ expect(restored.meshes).toEqual(plainGeometry.meshes);
+ expect(errors).toEqual([]);
+ });
+}
diff --git a/tests/e2e/kromacut-flow.spec.ts b/tests/e2e/kromacut-flow.spec.ts
index 6a9f1c1c..4c972d92 100644
--- a/tests/e2e/kromacut-flow.spec.ts
+++ b/tests/e2e/kromacut-flow.spec.ts
@@ -268,7 +268,8 @@ async function runFlow(
await setNumberInput(page.getByTestId('print-pixel-size'), '0.1');
await setNumberInput(page.getByTestId('print-layer-height'), '0.08');
await setNumberInput(page.getByTestId('print-first-layer-height'), '0.16');
- await setSwitch(page, 'print-smooth-meshing', mesher.smooth);
+ await page.getByTestId('print-smooth-meshing').click();
+ await page.getByRole('option', { name: mesher.smooth ? 'Medium' : 'None', exact: true }).click();
await page.getByRole('tab', { name: 'Auto-paint' }).click();
await page
diff --git a/tests/e2e/landing.spec.ts b/tests/e2e/landing.spec.ts
index 2d712d16..5b4b99bb 100644
--- a/tests/e2e/landing.spec.ts
+++ b/tests/e2e/landing.spec.ts
@@ -60,7 +60,7 @@ test.describe('landing page smoke @smoke', () => {
await batmanga.screenshot({ path: testInfo.outputPath(`batmanga-${width}.png`) });
const naruto = cards.filter({ has: page.getByRole('heading', { name: 'Naruto', exact: true }) });
await expect(naruto).toHaveCount(1);
- await expect(naruto.locator('footer').getByRole('link', { name: 'Naruto artwork — source on Pinterest' })).toHaveAttribute('href', 'https://in.pinterest.com/pin/169870217190172931/');
+ await expect(naruto.locator('footer').getByRole('link', { name: 'Naruto', exact: true })).toHaveAttribute('href', 'https://in.pinterest.com/pin/169870217190172931/');
await expect(naruto.locator('header').getByText('110.4 × 190.2 mm footprint')).toHaveCount(1);
expect(await naruto.locator('figcaption > p:first-child').allTextContents()).toEqual(['Kromacut prediction', 'Slicer preview', 'Finished print']);
expect(await naruto.locator('img').evaluateAll(images => images.map(image => image.getAttribute('alt')))).toEqual([
diff --git a/tests/e2e/settings-legal-links.spec.ts b/tests/e2e/settings-legal-links.spec.ts
new file mode 100644
index 00000000..9605f050
--- /dev/null
+++ b/tests/e2e/settings-legal-links.spec.ts
@@ -0,0 +1,126 @@
+import { expect, test, type Page } from '@playwright/test';
+import commonRo from '../../src/locales/ro/common.json' with { type: 'json' };
+import publicRo from '../../src/locales/ro/public.json' with { type: 'json' };
+
+type LegalLinkTestWindow = Window & {
+ isTauri?: boolean;
+ legalLinkTest: { calls: string[]; fail: boolean };
+ __TAURI_INTERNALS__?: {
+ transformCallback: () => number;
+ invoke: (command: string, args?: { path?: string }) => Promise;
+ };
+};
+
+async function prepare(page: Page, desktop = false) {
+ await page.addInitScript((native) => {
+ localStorage.clear();
+ localStorage.setItem('kromacut.language.v1', 'en');
+ localStorage.setItem('legal-link-workspace-sentinel', 'preserved');
+ if (!native) return;
+ const host = window as unknown as LegalLinkTestWindow;
+ host.isTauri = true;
+ host.legalLinkTest = { calls: [], fail: false };
+ host.__TAURI_INTERNALS__ = {
+ transformCallback: () => 1,
+ async invoke(command, args) {
+ if (command === 'get_app_version') return '4.1.0';
+ if (command === 'check_for_updates' || command === 'take_opened_file') return null;
+ if (command === 'plugin:event|listen' || command === 'plugin:event|unlisten')
+ return 1;
+ if (command === 'open_legal_page') {
+ if (host.legalLinkTest.fail) throw new Error('Browser unavailable');
+ host.legalLinkTest.calls.push(args!.path!);
+ return;
+ }
+ throw new Error(`Unexpected native command: ${command}`);
+ },
+ };
+ }, desktop);
+ await page.goto('/app');
+ await page.getByRole('button', { name: 'Open settings' }).click();
+}
+
+test('Settings legal links open localized web pages in a new tab', async ({ page }) => {
+ await prepare(page);
+ for (const [path, label] of [
+ ['/privacy', 'Privacy & local data'],
+ ['/terms', 'Terms & conditions'],
+ ]) {
+ const link = page.getByRole('dialog').getByRole('link', { name: label, exact: true });
+ await expect(link).toHaveAttribute('href', path);
+ const popupEvent = page.waitForEvent('popup');
+ await link.click();
+ const popup = await popupEvent;
+ await expect(popup.getByTestId(`${path.slice(1)}-page`)).toBeVisible();
+ await popup.close();
+ await expect(page).toHaveURL(/\/app$/);
+ await page.getByRole('button', { name: 'Open settings' }).click();
+ }
+ await page.getByTestId('app-language').click();
+ await page.getByRole('option', { name: 'Română', exact: true }).click();
+ const privacy = page.getByRole('link', { name: publicRo.navigation.privacy, exact: true });
+ await expect(privacy).toHaveAttribute('href', '/ro/privacy');
+ await expect(
+ page.getByRole('link', { name: publicRo.navigation.terms, exact: true })
+ ).toHaveAttribute('href', '/ro/terms');
+ const popupEvent = page.waitForEvent('popup');
+ await privacy.click();
+ const popup = await popupEvent;
+ await expect(popup).toHaveURL(/\/ro\/privacy$/);
+ await expect(popup.locator('html')).toHaveAttribute('lang', 'ro');
+ await popup.close();
+});
+
+test('desktop Settings legal links use the native browser command and keep the workspace', async ({
+ page,
+}, testInfo) => {
+ await prepare(page, true);
+ await expect(
+ page.getByRole('link', { name: 'Privacy & local data', exact: true })
+ ).toHaveAttribute('href', 'https://kromacut.com/privacy');
+ await page.getByRole('link', { name: 'Privacy & local data', exact: true }).click();
+ await expect(page.getByRole('dialog')).toBeHidden();
+ await page.getByRole('button', { name: 'Open settings' }).click();
+ await page.getByTestId('app-language').click();
+ await page.getByRole('option', { name: 'Română', exact: true }).click();
+ const terms = page.getByRole('link', { name: publicRo.navigation.terms, exact: true });
+ await expect(terms).toHaveAttribute('href', 'https://kromacut.com/ro/terms');
+ await page.setViewportSize({ width: 360, height: 800 });
+ await terms.scrollIntoViewIfNeeded();
+ const dialog = page.getByRole('dialog');
+ expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
+ true
+ );
+ await page.screenshot({ path: testInfo.outputPath('settings-legal-links-mobile.png') });
+ await terms.click();
+ await expect(dialog).toBeHidden();
+ expect(
+ await page.evaluate(() => (window as unknown as LegalLinkTestWindow).legalLinkTest.calls)
+ ).toEqual(['/privacy', '/ro/terms']);
+ expect(await page.evaluate(() => localStorage.getItem('legal-link-workspace-sentinel'))).toBe(
+ 'preserved'
+ );
+ await expect(page).toHaveURL(/\/app$/);
+ expect(page.context().pages()).toHaveLength(1);
+ await expect(page.getByRole('button', { name: commonRo.settings.open })).toBeVisible();
+});
+
+test('desktop browser-open failure leaves Settings available for retry', async ({ page }) => {
+ await prepare(page, true);
+ await page.evaluate(() => {
+ (window as unknown as LegalLinkTestWindow).legalLinkTest.fail = true;
+ });
+ const terms = page.getByRole('link', { name: 'Terms & conditions', exact: true });
+ await terms.click();
+ await expect(page.getByRole('alert')).toHaveText('Could not open this page. Try again.');
+ await expect(page.getByRole('dialog')).toBeVisible();
+ await page.evaluate(() => {
+ (window as unknown as LegalLinkTestWindow).legalLinkTest.fail = false;
+ });
+ await terms.click();
+ await expect(page.getByRole('dialog')).toBeHidden();
+ expect(
+ await page.evaluate(() => (window as unknown as LegalLinkTestWindow).legalLinkTest.calls)
+ ).toEqual(['/terms']);
+ await expect(page).toHaveURL(/\/app$/);
+});
diff --git a/tests/e2e/smooth-meshing-strength.spec.ts b/tests/e2e/smooth-meshing-strength.spec.ts
new file mode 100644
index 00000000..3708a87a
--- /dev/null
+++ b/tests/e2e/smooth-meshing-strength.spec.ts
@@ -0,0 +1,245 @@
+import { expect, test, type Page } from '@playwright/test';
+import { readFile } from 'node:fs/promises';
+import JSZip from 'jszip';
+import { inspectMeshIntegrity } from '../meshDiagnostics.ts';
+
+async function selectStrength(page: Page, label: string) {
+ await page.getByTestId('print-smooth-meshing').click();
+ await page.getByRole('option', { name: label, exact: true }).click();
+}
+
+async function download(page: Page, format: 'stl' | '3mf') {
+ const item = page.getByTestId(`download-${format}`);
+ if (!(await item.isVisible())) await page.getByTestId('download-3d-model').click();
+ const pending = page.waitForEvent('download');
+ await item.click();
+ return readFile((await (await pending).path())!);
+}
+
+test('@smoke smoothing strengths apply on Build, persist, and export healthy geometry', async ({
+ page,
+}) => {
+ const errors: string[] = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ await page.addInitScript(() => {
+ // Seed the old enabled toggle to exercise the real UI migration.
+ if (!localStorage.getItem('kromacut:3d-print-settings')) {
+ localStorage.setItem(
+ 'kromacut:3d-print-settings',
+ JSON.stringify({
+ pixelSize: 0.4,
+ layerHeight: 0.08,
+ slicerFirstLayerHeight: 0.16,
+ smoothMeshing: true,
+ })
+ );
+ }
+ Object.assign(window, { __KROMACUT_E2E: { buildHistory: [] } });
+ });
+ await page.goto('/app');
+ const png = await page.evaluate(() => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 32;
+ canvas.height = 24;
+ const ctx = canvas.getContext('2d')!;
+ for (let y = 0; y < 24; y++) {
+ ctx.fillStyle = '#000000';
+ ctx.fillRect(0, y, 32, 1);
+ ctx.fillStyle = '#ff0000';
+ ctx.fillRect(4 + Math.floor(y / 3), y, 8, 1);
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(16 + Math.floor(y / 4), y, 6, 1);
+ }
+ return canvas.toDataURL('image/png').split(',')[1];
+ });
+ await page.getByTestId('image-file-input').setInputFiles({
+ name: 'strengths.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from(png, 'base64'),
+ });
+ await page.getByRole('button', { name: '3D', exact: true }).click();
+ await expect(page.getByTestId('print-smooth-meshing')).toHaveText('Medium');
+ const historyCount = () =>
+ page.evaluate(
+ () =>
+ (window as unknown as { __KROMACUT_E2E: { buildHistory: unknown[] } })
+ .__KROMACUT_E2E.buildHistory.length
+ );
+ const meshesByStrength = new Map();
+ for (const label of ['None', 'Minimal', 'Medium', 'Aggressive']) {
+ const before = await historyCount();
+ await selectStrength(page, label);
+ await page.waitForTimeout(350);
+ expect(await historyCount()).toBe(before);
+ if (meshesByStrength.size) {
+ const previous = [...meshesByStrength.keys()].at(-1)!;
+ await expect(page.getByTestId('print-instructions-smoothing')).toContainText(previous);
+ }
+ await page.getByTestId('build-3d-model').click();
+ await page.waitForFunction(
+ (n) =>
+ (window as unknown as { __KROMACUT_E2E: { buildHistory: unknown[] } })
+ .__KROMACUT_E2E.buildHistory.length > n,
+ before
+ );
+ const built = await page.evaluate(
+ () =>
+ (
+ window as unknown as {
+ __KROMACUT_E2E: {
+ lastBuild: {
+ status: string;
+ settings: { smoothMeshingStrength: string };
+ };
+ };
+ }
+ ).__KROMACUT_E2E.lastBuild
+ );
+ expect(built.status).toBe('complete');
+ expect(built.settings.smoothMeshingStrength).toBe(label.toLowerCase());
+ await expect(page.getByTestId('print-instructions-smoothing')).toContainText(label);
+ const stl = await download(page, 'stl');
+ const triangles = stl.readUInt32LE(80);
+ expect(triangles).toBeGreaterThan(0);
+ expect(stl.length).toBe(84 + triangles * 50);
+ // Every STL triangle must retain finite vertices, a nonzero area, and
+ // outward winding relative to its stored normal.
+ for (let i = 0; i < triangles; i++) {
+ const offset = 84 + i * 50;
+ const values = Array.from({ length: 12 }, (_, j) => stl.readFloatLE(offset + j * 4));
+ expect(values.every(Number.isFinite)).toBe(true);
+ const [nx, ny, nz, ax, ay, az, bx, by, bz, cx, cy, cz] = values;
+ const cross = [
+ (by - ay) * (cz - az) - (bz - az) * (cy - ay),
+ (bz - az) * (cx - ax) - (bx - ax) * (cz - az),
+ (bx - ax) * (cy - ay) - (by - ay) * (cx - ax),
+ ];
+ expect(Math.hypot(...cross)).toBeGreaterThan(0);
+ expect(nx * cross[0] + ny * cross[1] + nz * cross[2]).toBeGreaterThan(0);
+ }
+ const zip = await JSZip.loadAsync(await download(page, '3mf'));
+ const xml = await zip.file('3D/3dmodel.model')!.async('string');
+ expect(xml).toContain(
+ `${label.toLowerCase()} `
+ );
+ const meshes = xml.match(/[\s\S]*?<\/mesh>/g)!;
+ expect(meshes.length).toBe(3);
+ for (const mesh of meshes) {
+ const positions = Float32Array.from(
+ [...mesh.matchAll(/ /g)].flatMap(
+ (v) => v.slice(1).map(Number)
+ )
+ );
+ const indices = [
+ ...mesh.matchAll(/]*\/>/g),
+ ].flatMap((v) => v.slice(1).map(Number));
+ const report = inspectMeshIntegrity({ positions, indices });
+ expect(report.isValid, JSON.stringify(report)).toBe(true);
+ }
+ meshesByStrength.set(label, meshes);
+ }
+ expect(meshesByStrength.get('Minimal')).not.toEqual(meshesByStrength.get('None'));
+ expect(meshesByStrength.get('Medium')).not.toEqual(meshesByStrength.get('Minimal'));
+ expect(meshesByStrength.get('Aggressive')).not.toEqual(meshesByStrength.get('Medium'));
+ // Flat Paint retains the selected strength in both viewing orientations.
+ await page.getByRole('button', { name: '2D', exact: true }).click();
+ await page.getByRole('button', { name: '3D', exact: true }).click();
+ await expect(page.getByTestId('print-smooth-meshing')).toHaveText('Aggressive');
+ await page.getByRole('tab', { name: 'Auto-paint', exact: true }).click();
+ await page
+ .getByTestId('autopaint-profile-import-input')
+ .setInputFiles('tests/assets/filament-profiles/2_Colors.kapp');
+ await expect(page.getByText(/1 imported|1 overwritten/)).toBeVisible();
+ const flatPaint = page.getByTestId('autopaint-flat-paint');
+ await flatPaint.click();
+ await expect(page.getByTestId('print-smooth-meshing')).toHaveText('Aggressive');
+ await expect(page.getByTestId('print-instructions-smoothing')).toContainText('Aggressive');
+ await flatPaint.click();
+ await expect(page.getByTestId('print-smooth-meshing')).toHaveText('Aggressive');
+ await flatPaint.click();
+ await selectStrength(page, 'Minimal');
+ await expect(flatPaint).toHaveAttribute('data-state', 'checked');
+ await page.waitForTimeout(500);
+ await expect(page.getByTestId('build-3d-model')).toBeEnabled();
+ for (const faceUp of [false, true]) {
+ if (faceUp) await page.getByTestId('autopaint-flat-paint-face-up').click();
+ for (const label of ['None', 'Minimal', 'Medium', 'Aggressive']) {
+ const before = await historyCount();
+ await selectStrength(page, label);
+ await expect(flatPaint).toHaveAttribute('data-state', 'checked');
+ await page.getByTestId('build-3d-model').click();
+ await page.waitForFunction(
+ (n) =>
+ (window as unknown as { __KROMACUT_E2E: { buildHistory: unknown[] } })
+ .__KROMACUT_E2E.buildHistory.length > n,
+ before
+ );
+ const built = await page.evaluate(
+ () =>
+ (
+ window as unknown as {
+ __KROMACUT_E2E: {
+ lastBuild: {
+ status: string;
+ settings: {
+ flatPaint: boolean;
+ flatPaintFaceUp: boolean;
+ smoothMeshingStrength: string;
+ };
+ };
+ };
+ }
+ ).__KROMACUT_E2E.lastBuild
+ );
+ expect(built.status).toBe('complete');
+ expect(built.settings.flatPaint).toBe(true);
+ expect(built.settings.flatPaintFaceUp).toBe(faceUp);
+ expect(built.settings.smoothMeshingStrength).toBe(label.toLowerCase());
+ await expect(page.getByTestId('print-instructions-smoothing')).toContainText(label);
+ const zip = await JSZip.loadAsync(await download(page, '3mf'));
+ const xml = await zip.file('3D/3dmodel.model')!.async('string');
+ expect(xml).toContain(
+ `${label.toLowerCase()} `
+ );
+ expect(xml.includes('transparent carrier')).toBe(!faceUp);
+ const meshes = xml.match(/[\s\S]*?<\/mesh>/g)!;
+ expect(meshes.length).toBeGreaterThan(0);
+ for (const mesh of meshes) {
+ // Filament objects contain independent closed member shells;
+ // preserve vertex identities at contacts between those shells.
+ const edges = new Map();
+ const triangles = [
+ ...mesh.matchAll(/]*\/>/g),
+ ];
+ expect(triangles.length).toBeGreaterThan(0);
+ for (const triangle of triangles) {
+ const [a, b, c] = triangle.slice(1).map(Number);
+ for (const [start, end] of [
+ [a, b],
+ [b, c],
+ [c, a],
+ ]) {
+ const key = start < end ? `${start}/${end}` : `${end}/${start}`;
+ edges.set(key, (edges.get(key) ?? 0) + 1);
+ }
+ }
+ expect([...edges.values()].every((count) => count === 2)).toBe(true);
+ }
+ }
+ }
+ await expect(page.getByText('Exporting 3MF', { exact: true })).toBeHidden();
+ await page.screenshot({ path: test.info().outputPath('smooth-flat-paint.png') });
+ await selectStrength(page, 'Minimal');
+ await page.reload();
+ await page.getByRole('button', { name: '3D', exact: true }).click();
+ await expect(page.getByTestId('print-smooth-meshing')).toHaveText('Minimal');
+ await page.getByRole('tab', { name: 'Auto-paint', exact: true }).click();
+ await expect(flatPaint).toHaveAttribute('data-state', 'checked');
+ await expect(page.getByTestId('autopaint-flat-paint-face-up')).toHaveAttribute(
+ 'data-state',
+ 'checked'
+ );
+ await page.getByRole('button', { name: 'Reset print settings', exact: true }).click();
+ await expect(page.getByTestId('print-smooth-meshing')).toHaveText('None');
+ expect(errors).toEqual([]);
+});
diff --git a/tests/e2e/sticky-mobile-cta.spec.ts b/tests/e2e/sticky-mobile-cta.spec.ts
index 4e376a63..362ab2bb 100644
--- a/tests/e2e/sticky-mobile-cta.spec.ts
+++ b/tests/e2e/sticky-mobile-cta.spec.ts
@@ -89,8 +89,11 @@ test.describe('sticky mobile landing CTA @smoke', () => {
await scrollToFooter(page);
await expect(sticky).toBeVisible();
- await page.getByRole('navigation', { name: 'Footer navigation' })
- .getByRole('link', { name: 'Terms', exact: true }).focus();
+ const footerNavigation = page.getByRole('navigation', { name: 'Footer navigation' });
+ await footerNavigation.getByRole('link', { name: 'Terms', exact: true }).focus();
+ // The footer language selector is the last control before the sticky action.
+ await page.keyboard.press('Tab');
+ await expect(footerNavigation.getByTestId('app-language')).toBeFocused();
await page.keyboard.press('Tab');
const stickyAction = sticky.getByRole('link', { name: 'Open Kromacut', exact: true });
await expect(stickyAction).toBeFocused();
diff --git a/tests/export3mf.test.ts b/tests/export3mf.test.ts
index 73188662..83cb4a37 100644
--- a/tests/export3mf.test.ts
+++ b/tests/export3mf.test.ts
@@ -160,6 +160,23 @@ const filamentProfileFixtures = [bwProfile, gh27Profile, current8Profile];
const exportTopologyMeshers: Array<{ name: string; generate: MeshGenerator }> = [
{ name: 'greedy', generate: generateGreedyMesh },
{ name: 'smooth', generate: generateSmoothMesh },
+ ...(['minimal', 'aggressive'] as const).map((strength) => ({
+ name: `smooth ${strength}`,
+ generate: (...args: Parameters) => {
+ const [pixels, width, height, thickness, zOffset, pixelSize, heightScale, options] =
+ args;
+ return generateSmoothMesh(
+ pixels,
+ width,
+ height,
+ thickness,
+ zOffset,
+ pixelSize,
+ heightScale,
+ { ...options, strength }
+ );
+ },
+ })),
];
function profileColors(profile: FilamentProfileFixture) {
diff --git a/tests/flatPaint.test.ts b/tests/flatPaint.test.ts
index 403602f3..dced2a17 100644
--- a/tests/flatPaint.test.ts
+++ b/tests/flatPaint.test.ts
@@ -13,6 +13,8 @@ import {
type FlatPaintPart,
} from '../src/lib/flatPaint.ts';
import { generateGreedyMesh, type MeshData } from '../src/lib/meshing.ts';
+import { createFlatPaintMesher } from '../src/lib/flatPaintMeshing.ts';
+import type { SmoothMeshingStrength } from '../src/types/index.ts';
import { exportObjectToStlBlob } from '../src/lib/exportStl.ts';
import { loadViteModule } from './helpers/viteModule.ts';
import { inspectMeshIntegrity, type MeshIntegrityReport } from './meshDiagnostics.ts';
@@ -192,10 +194,12 @@ test('Flat Paint layout reverses columns: visible blend at the plate, foundation
const expectColumn = (
pixel: number,
- expected: Array & {
- baseZ: number;
- topZ: number;
- }>
+ expected: Array<
+ Pick & {
+ baseZ: number;
+ topZ: number;
+ }
+ >
) => {
const covering = partsCoveringPixel(layout, pixel).map((part) => ({
kind: part.kind,
@@ -433,21 +437,29 @@ test('Flat Paint part masks produce manifold greedy meshes', async () => {
}
});
-async function buildFixturePartMeshes(layout = buildFixtureLayout()) {
+async function buildFixturePartMeshes(
+ layout = buildFixtureLayout(),
+ strength: SmoothMeshingStrength = 'none',
+ layerCounts = FIXTURE.layerCounts,
+ legacyCompactStl = false
+) {
const pixelSize = 0.1;
const root = new THREE.Group();
+ const meshMask = await createFlatPaintMesher(
+ layerCounts,
+ FIXTURE.width,
+ FIXTURE.height,
+ pixelSize,
+ strength,
+ noYieldOptions
+ );
for (const part of layout.parts) {
- const meshData = await generateGreedyMesh(
- part.mask,
- FIXTURE.width,
- FIXTURE.height,
- part.topZ - part.baseZ,
- part.baseZ,
- pixelSize,
- 1,
- noYieldOptions
- );
+ const meshData = await meshMask(part.mask, noYieldOptions);
+ for (let i = 2; i < meshData.positions.length; i += 3) {
+ meshData.positions[i] =
+ part.baseZ + meshData.positions[i] * (part.topZ - part.baseZ);
+ }
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3));
geometry.setIndex(meshData.indices);
@@ -459,7 +471,7 @@ async function buildFixturePartMeshes(layout = buildFixtureLayout()) {
height: FIXTURE.height,
pixelSize,
topZ: part.topZ,
- compactHeightfield: true,
+ compactHeightfield: legacyCompactStl,
};
const mesh = new THREE.Mesh(
@@ -477,7 +489,7 @@ async function buildFixturePartMeshes(layout = buildFixtureLayout()) {
}
test('Flat Paint parts compact into a manifold uniform-height STL slab', async () => {
- const { layout, root } = await buildFixturePartMeshes();
+ const { layout, root } = await buildFixturePartMeshes(undefined, 'none', undefined, true);
const blob = await exportObjectToStlBlob(root);
const buffer = await blob.arrayBuffer();
@@ -607,10 +619,7 @@ test('3MF export merges Flat Paint parts into one object per filament', async ()
// Base materials hold physical filament colors. The clear carrier has its
// own material slot so slicers do not merge it with a real white filament.
- const baseMaterials = Array.from(
- modelXml.matchAll(/ m[1]
- );
+ const baseMaterials = Array.from(modelXml.matchAll(/ m[1]);
assert.deepEqual(baseMaterials, ['D8FFF8', '000000', 'FFFFFF']);
assert.deepEqual(
objects.map((object) => object.materialIndex),
@@ -680,3 +689,72 @@ test('face-up Flat Paint 3MF omits the transparent carrier object and material',
assert.equal(object.badEdgeCount, 0, `${object.name} should contain only closed shells`);
}
});
+
+test('smooth Flat Paint exports closed filament shells at every strength and orientation', async (t) => {
+ installFileReaderPolyfill();
+ const { exportObjectTo3MFBlob } = await loadExport3mfModule();
+ for (const orientation of ['face-up', 'face-down'] as const) {
+ for (const strength of ['none', 'minimal', 'medium', 'aggressive'] as const) {
+ await t.test(`${orientation}, ${strength}`, async () => {
+ // Repeated diagonal classes exercise the shared corner tessellation.
+ const layerCounts = Uint16Array.from([1, 2, 3, 2, 1, 2]);
+ const layout = buildFlatPaintLayout({
+ ...FIXTURE,
+ layerCounts,
+ orientation,
+ firstLayerThickness: 0.2,
+ });
+ const { root } = await buildFixturePartMeshes(layout, strength, layerCounts);
+ const blob = await exportObjectTo3MFBlob(root, { smoothMeshingStrength: strength });
+ const zip = await JSZip.loadAsync(await blob.arrayBuffer());
+ const xml = await zip.file('3D/3dmodel.model')!.async('string');
+ const objects = parse3mfMeshObjects(xml);
+ assert.equal(objects.length, new Set(layout.parts.map((p) => p.exportGroup)).size);
+ assert.equal(xml.includes('transparent carrier'), orientation === 'face-down');
+ assert.ok(
+ xml.includes(
+ `${strength} `
+ )
+ );
+ for (const object of objects) assert.equal(object.badEdgeCount, 0, object.name);
+ const expectedTriangles = root.children.reduce(
+ (sum, child) => sum + (child as THREE.Mesh).geometry.getIndex()!.count / 3,
+ 0
+ );
+ assert.equal(
+ objects.reduce((sum, object) => sum + object.triangleCount, 0),
+ expectedTriangles,
+ '3MF rounding must not discard cap or wall triangles'
+ );
+ // Even direct STL export must keep the smooth source
+ // geometry rather than rebuilding a pixel-aligned slab.
+ const stl = new DataView(await (await exportObjectToStlBlob(root)).arrayBuffer());
+ assert.equal(stl.getUint32(80, true), expectedTriangles);
+ let offset = 84;
+ for (const child of root.children) {
+ const geometry = (child as THREE.Mesh).geometry;
+ const positions = geometry.getAttribute('position').array;
+ for (const vertexIndex of geometry.getIndex()!.array) {
+ if ((offset - 84) % 50 === 0) offset += 12;
+ for (let axis = 0; axis < 3; axis++) {
+ assert.equal(
+ stl.getFloat32(offset, true),
+ positions[vertexIndex * 3 + axis]
+ );
+ offset += 4;
+ }
+ if ((offset - 84) % 50 === 48) offset += 2;
+ }
+ }
+ assert.equal(offset, stl.byteLength);
+ for (const child of root.children) {
+ const mesh = child as THREE.Mesh;
+ assertHealthyMesh('smooth slab part', {
+ positions: mesh.geometry.getAttribute('position').array as Float32Array,
+ indices: [...mesh.geometry.getIndex()!.array],
+ });
+ }
+ });
+ }
+ }
+});
diff --git a/tests/flatPaintMeshing.test.ts b/tests/flatPaintMeshing.test.ts
new file mode 100644
index 00000000..3645c0f5
--- /dev/null
+++ b/tests/flatPaintMeshing.test.ts
@@ -0,0 +1,370 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import test from 'node:test';
+import { createFlatPaintMesher } from '../src/lib/flatPaintMeshing.ts';
+import type { MeshData } from '../src/lib/meshing.ts';
+import { inspectMeshIntegrity } from './meshDiagnostics.ts';
+import { logoFixturePath, maskFromPngAlpha } from './imageFixtures.ts';
+import { flatPaintPrecisionFixture } from './flatPaintPrecisionFixture.ts';
+
+const noYield = { yieldIntervalMs: Infinity, onYield: async () => undefined };
+const strengths = ['minimal', 'medium', 'aggressive'] as const;
+const fixtures = [
+ ['112222', '111222', '111122', '111112'],
+ ['000110', '001120', '011230', '112330', '123330', '000000'],
+ ['1212', '2121', '1212', '2121'],
+ ['1020', '0102', '3020', '0302'],
+ ['11111', '12221', '12021', '13331', '11111'],
+ ['1122', '1322', '3334', '3344'],
+];
+
+function masksFor(counts: Uint16Array) {
+ return [...new Set(counts)]
+ .filter((n) => n > 0)
+ .map((n) => Uint8Array.from(counts, (label) => (label === n ? 1 : 0)));
+}
+
+function wallEdges(mesh: MeshData) {
+ const edges = new Map();
+ for (let i = 0; i < mesh.indices.length; i += 3) {
+ const ids = mesh.indices.slice(i, i + 3);
+ const top = ids.filter((id) => mesh.positions[id * 3 + 2] === 1);
+ const bottom = ids.filter((id) => mesh.positions[id * 3 + 2] === 0);
+ if (top.length !== 2 || bottom.length !== 1) continue;
+ const keys = top
+ .map((id) => `${mesh.positions[id * 3]},${mesh.positions[id * 3 + 1]}`)
+ .sort();
+ const key = keys.join('|');
+ edges.set(key, (edges.get(key) ?? 0) + 1);
+ }
+ return edges;
+}
+
+function assertHealthyAtBothPrecisions(mesh: MeshData) {
+ for (const positions of [
+ mesh.positions,
+ Float32Array.from(mesh.positions, (n) => Math.round(n * 100_000) / 100_000),
+ ]) {
+ const report = inspectMeshIntegrity({ positions, indices: mesh.indices });
+ assert.equal(report.isValid, true, JSON.stringify(report));
+ }
+}
+
+async function checkTiling(
+ counts: Uint16Array,
+ width: number,
+ height: number,
+ strength: 'none' | (typeof strengths)[number],
+ pixelSize = 0.1
+) {
+ const meshMask = await createFlatPaintMesher(
+ counts,
+ width,
+ height,
+ pixelSize,
+ strength,
+ noYield
+ );
+ const opaque = await meshMask(
+ Uint8Array.from(counts, (n) => (n ? 1 : 0)),
+ noYield
+ );
+ const opaqueReport = inspectMeshIntegrity(opaque);
+ assertHealthyAtBothPrecisions(opaque);
+ const edges = new Map();
+ let volume = 0;
+ for (const mask of masksFor(counts)) {
+ const mesh = await meshMask(mask, noYield);
+ const report = inspectMeshIntegrity(mesh);
+ assertHealthyAtBothPrecisions(mesh);
+ assert.equal(
+ report.isValid,
+ true,
+ JSON.stringify({ strength, counts: [...counts], report })
+ );
+ volume += report.signedVolume;
+ for (const [key, count] of wallEdges(mesh)) edges.set(key, (edges.get(key) ?? 0) + count);
+ const again = await meshMask(mask, noYield);
+ assert.deepEqual(again.positions, mesh.positions, 'deterministic shared coordinates');
+ assert.deepEqual(again.indices, mesh.indices);
+ }
+ assert.ok(
+ Math.abs(volume - opaqueReport.signedVolume) < 1e-7,
+ `class areas must tile the carrier: ${volume} != ${opaqueReport.signedVolume}`
+ );
+ const outsideEdges = wallEdges(opaque);
+ for (const [key, count] of edges) {
+ assert.equal(
+ count,
+ outsideEdges.has(key) ? 1 : 2,
+ `unmatched or overlapping interface ${key}`
+ );
+ }
+ for (const key of outsideEdges.keys()) assert.equal(edges.get(key), 1, `carrier edge ${key}`);
+ return opaque;
+}
+
+test('None tiles diagonal colors without expanding binary masks into neighboring colors', async () => {
+ const counts = Uint16Array.from([1, 2, 2, 1]);
+ const opaque = await checkTiling(counts, 2, 2, 'none', 1);
+ assert.ok(Math.abs(inspectMeshIntegrity(opaque).signedVolume - 4) < 1e-7);
+});
+
+test('nearly collinear corner repairs retain closed, disjoint caps at print coordinates', async () => {
+ const { counts, width, height, pixelSize } = flatPaintPrecisionFixture();
+ // Medium previously threw halfway through this image, leaving just backing
+ // parts in the preview. Also cover the unsmoothed path and both other presets.
+ for (const strength of ['none', ...strengths] as const) {
+ await checkTiling(counts, width, height, strength, pixelSize);
+ }
+});
+
+test('None keeps staircase grid vertices fixed away from diagonal contacts', async () => {
+ const rows = fixtures[0];
+ const counts = Uint16Array.from(rows.join(''), Number);
+ const mesher = await createFlatPaintMesher(
+ counts,
+ rows[0].length,
+ rows.length,
+ 1,
+ 'none',
+ noYield
+ );
+ for (const mask of masksFor(counts)) {
+ const mesh = await mesher(mask, noYield);
+ assert.ok([...mesh.positions].every(Number.isInteger));
+ assert.equal(mesh.metrics?.mesher, 'greedy');
+ }
+});
+
+test('chunked Flat Paint relaxation preserves the geometry from before the responsiveness fix', async () => {
+ const baselines = [
+ [
+ 0,
+ [
+ '689a241b8f132b5d9c9e14092045173cd8821478a2a5b0e0d630219c8aaddad2',
+ '2c253722907fa9d32d620901067d975f83a3fc48b89e6d719e4edba00eaea1bc',
+ 'b76943a4d2f850acc02fda4e860ca48c03831f185988e842d868ff14ad869a44',
+ ],
+ ],
+ [
+ 2,
+ [
+ 'f26bfdb2370ed3bc9923d89580939610f0c8f5aa427f48141459ee2b892d766a',
+ '6021c4e3a2145687ae0629509b10f4f55e37bc4a22a33e80dd6b989beab854b2',
+ '6021c4e3a2145687ae0629509b10f4f55e37bc4a22a33e80dd6b989beab854b2',
+ ],
+ ],
+ [
+ 4,
+ [
+ '13fabb36db3c62dae666a2cfd4150fa0a20a8d47dbbf2a1a73b944e50082b83e',
+ '275fe3f9dab7905bd4d81e14e76308cc503a7e7f02e77fb754ee592ede1ecd7f',
+ 'ccb314855ae336a88cff5edb29f181aa91f3d587fc0ace3e4380377e25c18003',
+ ],
+ ],
+ [
+ 5,
+ [
+ '8ae53ff815a2b2d579ad756a362c53dd4d904c7380efc6fea577805b203ac978',
+ 'e43de56afbde8504ec10a6d2bb8a91c1fd81934cf80a76eb0e5c979971e511b4',
+ '011eea81e28c94c476320ea8b7a9e08d299e3972fd5300c0293421f366f50ff7',
+ ],
+ ],
+ ] as const;
+ for (const [fixture, hashes] of baselines) {
+ const rows = fixtures[fixture];
+ const counts = Uint16Array.from(rows.join(''), Number);
+ for (const [index, strength] of strengths.entries()) {
+ const meshMask = await createFlatPaintMesher(
+ counts,
+ rows[0].length,
+ rows.length,
+ 0.1,
+ strength,
+ noYield
+ );
+ const hash = createHash('sha256');
+ for (const mask of masksFor(counts)) {
+ const mesh = await meshMask(mask, noYield);
+ hash.update(Buffer.from(mesh.positions.buffer)).update(
+ JSON.stringify(mesh.indices)
+ );
+ }
+ assert.equal(hash.digest('hex'), hashes[index], `fixture ${fixture}, ${strength}`);
+ }
+ }
+});
+
+test('all Flat Paint strengths keep colors and the carrier joined with closed topology', async (t) => {
+ for (const [fixture, rows] of fixtures.entries()) {
+ const counts = Uint16Array.from(rows.join(''), Number);
+ for (const strength of ['none', ...strengths] as const) {
+ for (const pixelSize of [0.01, 0.4]) {
+ await t.test(`${fixture}: ${strength}, ${pixelSize} mm`, async () => {
+ await checkTiling(counts, rows[0].length, rows.length, strength, pixelSize);
+ });
+ }
+ }
+ }
+});
+
+test('seeded color grids preserve shared topology through corner contacts and junctions', async () => {
+ let seed = 8703;
+ for (let sample = 0; sample < 30; sample++) {
+ const counts = Uint16Array.from({ length: 36 }, () => {
+ seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
+ return (seed >>> 16) % 5;
+ });
+ await checkTiling(counts, 6, 6, strengths[sample % strengths.length]);
+ }
+});
+
+test('Flat Paint strength presets change internal stair steps while preserving flat Z faces', async () => {
+ const rows = fixtures[0];
+ const counts = Uint16Array.from(rows.join(''), Number);
+ const outputs: Float32Array[] = [];
+ for (const strength of ['none', ...strengths] as const) {
+ const meshMask = await createFlatPaintMesher(counts, 6, 4, 0.4, strength, noYield);
+ const mesh = await meshMask(masksFor(counts)[0], noYield);
+ for (let i = 2; i < mesh.positions.length; i += 3) {
+ assert.ok(mesh.positions[i] === 0 || mesh.positions[i] === 1, 'slab faces remain flat');
+ }
+ outputs.push(mesh.positions);
+ }
+ for (let i = 1; i < outputs.length; i++) assert.notDeepEqual(outputs[i], outputs[i - 1]);
+});
+
+test('shared Flat Paint preparation and mesh generation yield to the UI', async () => {
+ let yields = 0;
+ const options = {
+ yieldIntervalMs: 0,
+ onYield: async () => {
+ yields++;
+ },
+ };
+ const meshMask = await createFlatPaintMesher(
+ Uint16Array.from([1, 2, 2, 1]),
+ 2,
+ 2,
+ 0.1,
+ 'medium',
+ options
+ );
+ assert.ok(yields > 0);
+ yields = 0;
+ await meshMask(Uint8Array.from([1, 0, 0, 1]), options);
+ assert.ok(yields > 0);
+});
+
+test('dense Flat Paint boundaries yield during initialization and within relaxation passes', async (t) => {
+ const size = 128;
+ for (const pattern of ['checkerboard', 'stair steps'] as const) {
+ await t.test(pattern, async () => {
+ const counts = Uint16Array.from({ length: size * size }, (_, index) => {
+ const diagonal = (index % size) + Math.floor(index / size);
+ return 1 + ((pattern === 'checkerboard' ? diagonal : Math.floor(diagonal / 3)) % 2);
+ });
+ let smoothingProgress: number | undefined;
+ let initializationYields = 0;
+ let firstPassYields = 0;
+ let relaxationYields = 0;
+ await createFlatPaintMesher(counts, size, size, 0.1, 'medium', {
+ yieldIntervalMs: 0,
+ onProgress: (progress) => {
+ if (progress.phase === 'smoothing') smoothingProgress = progress.progress;
+ },
+ onYield: () =>
+ new Promise((resolve) => {
+ // An actual event-loop turn must run while this stage is
+ // in flight, rather than just before or after smoothing.
+ setImmediate(() => {
+ if (smoothingProgress !== undefined && smoothingProgress < 0.1)
+ initializationYields++;
+ if (
+ smoothingProgress !== undefined &&
+ smoothingProgress > 0.1 &&
+ smoothingProgress < 1
+ )
+ relaxationYields++;
+ if (
+ smoothingProgress !== undefined &&
+ smoothingProgress > 0.1 &&
+ smoothingProgress < 0.19
+ )
+ firstPassYields++;
+ resolve();
+ });
+ }),
+ });
+ assert.ok(initializationYields > 10, 'dense graph initialization must be chunked');
+ assert.ok(relaxationYields >= 9, 'every relaxation pass must allow the UI to run');
+ if (pattern === 'stair steps') {
+ assert.ok(
+ firstPassYields > 10,
+ 'a large pass must yield before all its vertices are relaxed'
+ );
+ }
+ });
+ }
+});
+
+test('large Flat Paint caps yield before a single source row finishes triangulating', async () => {
+ const width = 16_384;
+ const counts = new Uint16Array(width).fill(1);
+ const meshMask = await createFlatPaintMesher(counts, width, 1, 0.01, 'medium', noYield);
+ let capProgress = 0;
+ let intermediateYields = 0;
+ const mesh = await meshMask(new Uint8Array(width).fill(1), {
+ yieldIntervalMs: 0,
+ onProgress: (progress) => {
+ if (progress.phase === 'caps') capProgress = progress.progress;
+ },
+ onYield: () =>
+ new Promise((resolve) => {
+ setImmediate(() => {
+ // A yield after the only source row would already report 0.9.
+ if (capProgress > 0 && capProgress < 0.9) intermediateYields++;
+ resolve();
+ });
+ }),
+ });
+ assert.ok(
+ intermediateYields > 1,
+ 'large caps must yield repeatedly while triangulation is in flight'
+ );
+ assertHealthyAtBothPrecisions(mesh);
+});
+
+test('subdivided large caps preserve shared class interfaces and the carrier footprint', async () => {
+ const width = 1024;
+ const height = 16;
+ const counts = Uint16Array.from({ length: width * height }, (_, index) => {
+ const x = index % width;
+ const y = Math.floor(index / width);
+ if (x >= 700 && x < 800 && y >= 4 && y < 12) return 0;
+ return x < 500 + Math.floor(y / 2) ? 1 : 2;
+ });
+ for (const strength of strengths) await checkTiling(counts, width, height, strength, 0.01);
+});
+
+test('long stair-step color boundaries retain every cap vertex at mesh and slicer precision', async () => {
+ const counts = new Uint16Array(32 * 24).fill(1);
+ for (let y = 0; y < 24; y++) {
+ for (let x = 4 + Math.floor(y / 3); x < 12 + Math.floor(y / 3); x++) counts[y * 32 + x] = 3;
+ for (let x = 16 + Math.floor(y / 4); x < 22 + Math.floor(y / 4); x++)
+ counts[y * 32 + x] = 8;
+ }
+ for (const strength of strengths) await checkTiling(counts, 32, 24, strength, 0.4);
+});
+
+test('image-derived silhouettes tile smoothly across multiple Flat Paint classes', async () => {
+ const raster = maskFromPngAlpha(logoFixturePath, 128);
+ const counts = Uint16Array.from(raster.activePixels, (active, index) =>
+ active
+ ? 1 + (Math.floor(((index % raster.width) + Math.floor(index / raster.width)) / 13) % 3)
+ : 0
+ );
+ for (const strength of strengths)
+ await checkTiling(counts, raster.width, raster.height, strength);
+});
diff --git a/tests/flatPaintPrecisionFixture.ts b/tests/flatPaintPrecisionFixture.ts
new file mode 100644
index 00000000..cabf1511
--- /dev/null
+++ b/tests/flatPaintPrecisionFixture.ts
@@ -0,0 +1,48 @@
+// Reduced from a seeded image that failed at pixel (514, 777). Keep its offset:
+// Float32 and five-decimal export rounding depend on the absolute coordinates.
+const rows = [
+ '11122222211221122221211222211121',
+ '12122122211221112122211222121221',
+ '11111121111121222222212211121111',
+ '11221212111222112112211222111111',
+ '11121111221121211121122222122221',
+ '21221211111212122212112222221121',
+ '21222122222111222212112121211122',
+ '11221211122221122121122212222111',
+ '22211122212122211211111122112221',
+ '21121121221111112111221212222111',
+ '12122122221122122211121211111112',
+ '11112111222222122221122221122112',
+ '21112122112121222222111121122122',
+ '21211211112212112111221222122211',
+ '21122212112211112211221122122221',
+ '21222221122212212221212212111221',
+ '12111212111212111211122222121121',
+ '22122122121212212111221212122112',
+ '21122222211111122122211211112221',
+ '21122122121122212211211211122122',
+ '12111112111221122221221111111221',
+ '12111212222212111121111211221112',
+ '21122211112222212121112211212212',
+ '12222112221212222112212211211121',
+ '22122212211112111122112211112221',
+ '21221211222121212222111221122122',
+ '12222222121211121122221121212112',
+ '12112111112112211211211111121111',
+ '12221122212211222111121211121111',
+ '11122121222112222212212221212211',
+ '22222211112212111112211222211221',
+ '21111221121122212212211111112121',
+];
+
+export function flatPaintPrecisionFixture() {
+ const width = 532;
+ const height = 795;
+ const counts = new Uint16Array(width * height).fill(1);
+ rows.forEach((row, y) =>
+ [...row].forEach((label, x) => {
+ counts[(y + 761) * width + x + 498] = Number(label);
+ })
+ );
+ return { width, height, counts, pixelSize: 0.04 };
+}
diff --git a/tests/heightDithering.test.ts b/tests/heightDithering.test.ts
new file mode 100644
index 00000000..005c15ac
--- /dev/null
+++ b/tests/heightDithering.test.ts
@@ -0,0 +1,248 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { quantizeHeightMap } from '../src/lib/heightDithering.ts';
+import { loadViteModule } from './helpers/viteModule.ts';
+import { generateGreedyMesh, generateSmoothMesh } from '../src/lib/meshing.ts';
+import { inspectMeshIntegrity } from './meshDiagnostics.ts';
+
+type AutoPaint = typeof import('../src/lib/autoPaint.ts');
+let autoPaint: Promise | undefined;
+const loadAutoPaint = () => (autoPaint ??= loadViteModule('/src/lib/autoPaint.ts'));
+const options = {
+ layerHeight: 0.08,
+ slicerFirstLayerHeight: 0.16,
+ minModelH: 0.16,
+ maxModelH: 0.4,
+ heightDithering: true,
+ ditherLineWidth: 0.2,
+ pixelSize: 0.2,
+};
+const palette = [20, 60, 90].map((L, index) => ({
+ height: 0.16 + index * 0.08,
+ predictedLab: [L, 0, 0] as const,
+}));
+const mapping = {
+ targetColor: { rgb: [70, 70, 70] as const },
+ targetLab: [30, 0, 0] as readonly [number, number, number],
+ paletteIndex: 0,
+ projectedHeight: 0.16,
+};
+const key = 0x464646;
+
+test('dithering recovers fractional heights without changing ordinary final-stack mappings', async () => {
+ const { createFinalStackTargetHeightCache } = await loadAutoPaint();
+ const before = JSON.stringify({ palette, mapping });
+ assert.equal(createFinalStackTargetHeightCache([mapping], 0.16, 0.32).get(key), 0.16);
+ const dithered = createFinalStackTargetHeightCache([mapping], 0.16, 0.32, {
+ palette,
+ layerHeight: 0.08,
+ });
+ assert.ok(Math.abs(dithered.get(key)! - 0.18) < 1e-9);
+ const lowerNeighbor = createFinalStackTargetHeightCache(
+ [{ ...mapping, targetLab: [40, 0, 0], paletteIndex: 1, projectedHeight: 0.24 }],
+ 0.16,
+ 0.32,
+ { palette, layerHeight: 0.08 }
+ );
+ assert.ok(Math.abs(lowerNeighbor.get(key)! - 0.2) < 1e-9);
+ assert.equal(JSON.stringify({ palette, mapping }), before, 'never mutate the final stack');
+});
+
+test('dither targets preserve exact matches, calibration anchors and legacy snapshots', async () => {
+ const { createFinalStackTargetHeightCache } = await loadAutoPaint();
+ const cache = (target: typeof mapping, entries = palette) =>
+ createFinalStackTargetHeightCache([target], 0.16, 0.32, {
+ palette: entries,
+ layerHeight: 0.08,
+ }).get(key);
+ assert.equal(cache({ ...mapping, targetLab: [20, 0, 0] }), 0.16);
+ const anchored = palette.map((entry, index) => ({
+ ...entry,
+ ...(index === 0 ? { exactAnchorTargetLab: mapping.targetLab } : {}),
+ }));
+ assert.equal(cache(mapping, anchored), 0.16);
+ assert.equal(
+ createFinalStackTargetHeightCache(
+ [{ targetColor: mapping.targetColor, projectedHeight: 0.24 }],
+ 0.16,
+ 0.32,
+ { palette, layerHeight: 0.08 }
+ ).get(key),
+ 0.24
+ );
+});
+
+test('dither interpolation cannot jump gaps, expose foundation prefixes or leave the selected neighborhood', async () => {
+ const { createFinalStackTargetHeightCache } = await loadAutoPaint();
+ const cache = (entries: typeof palette, maximum = 0.4) =>
+ createFinalStackTargetHeightCache([mapping], 0.16, maximum, {
+ palette: entries,
+ layerHeight: 0.08,
+ }).get(key);
+ assert.equal(cache(palette.map((p, i) => ({ ...p, surfaceEligible: i !== 1 }))), 0.16);
+ assert.equal(cache(palette.map((p, i) => ({ ...p, height: p.height + (i ? 0.08 : 0) }))), 0.16);
+ assert.equal(cache(palette, 0.16), 0.16);
+ // A remote perfect color must not replace the layer chosen by hybrid reranking.
+ const remote = [20, 10, 30].map((L, i) => ({
+ ...palette[i],
+ predictedLab: [L, 0, 0] as const,
+ }));
+ assert.equal(cache(remote), 0.16);
+ assert.equal(cache(palette.map((p) => ({ ...p, predictedLab: [20, 0, 0] as const }))), 0.16);
+});
+
+test('fractional targets generate deterministic neighboring-layer patterns with the requested block size', () => {
+ const width = 64,
+ height = 48;
+ for (const blockSize of [1, 2, 3]) {
+ const original = new Float32Array(width * height).fill(0.18);
+ const plain = original.slice();
+ quantizeHeightMap(plain, width, height, { ...options, heightDithering: false });
+ assert.deepEqual([...new Set(plain)], [Math.fround(0.16)]);
+ const dithered = original.slice();
+ const settings = { ...options, ditherLineWidth: blockSize * options.pixelSize };
+ quantizeHeightMap(dithered, width, height, settings);
+ const repeat = original.slice();
+ quantizeHeightMap(repeat, width, height, settings);
+ assert.deepEqual(dithered, repeat);
+ assert.deepEqual([...new Set(dithered)].sort(), [Math.fround(0.16), Math.fround(0.24)]);
+ const average = dithered.reduce((sum, h) => sum + h, 0) / dithered.length;
+ assert.ok(
+ Math.abs(average - 0.18) < 0.003,
+ `average ${average} should preserve the fractional tone`
+ );
+ for (let y = 0; y < height; y++)
+ for (let x = 0; x < width; x++) {
+ const blockStart =
+ Math.floor(y / blockSize) * blockSize * width +
+ Math.floor(x / blockSize) * blockSize;
+ assert.equal(dithered[y * width + x], dithered[blockStart]);
+ }
+ }
+});
+
+test('dithering preserves transparency, protected boundaries and exact heights inside mixed blocks', () => {
+ const width = 64,
+ height = 48;
+ const input = new Float32Array(width * height).fill(0.18);
+ for (let y = 0; y < height; y++) {
+ input[y * width + 5] = 0;
+ input[y * width + 20] = 0.16; // Same rounded height as its fractional neighbor.
+ for (let x = 40; x < width; x++) input[y * width + x] = 0.29;
+ }
+ for (const blockSize of [1, 2]) {
+ const output = input.slice();
+ quantizeHeightMap(output, width, height, {
+ ...options,
+ ditherLineWidth: blockSize * options.pixelSize,
+ });
+ for (let y = 0; y < height; y++) {
+ assert.equal(output[y * width + 5], 0);
+ assert.equal(output[y * width + 20], Math.fround(0.16));
+ assert.equal(output[y * width + 39], Math.fround(0.16));
+ assert.equal(output[y * width + 40], Math.fround(0.32));
+ }
+ assert.ok(
+ output.every((h, i) =>
+ input[i] === 0 ? h === 0 : h >= Math.fround(0.16) && h <= Math.fround(0.32)
+ )
+ );
+ }
+});
+
+test('real Auto-paint final-stack mappings retain fractional input through the height dither pipeline', async () => {
+ const { generateAutoLayers, createFinalStackTargetHeightCache } = await loadAutoPaint();
+ const result = generateAutoLayers(
+ [
+ { id: 'black', color: '#000000', td: 0.016 },
+ { id: 'white', color: '#ffffff', td: 0.6 },
+ ],
+ [0, 32, 64, 96, 128, 160, 192, 224].map((v) => ({
+ hex: '#' + v.toString(16).padStart(2, '0').repeat(3),
+ count: 1,
+ })),
+ 0.08,
+ 0.16,
+ 1,
+ true,
+ false,
+ { algorithm: 'fast', seed: 42 }
+ );
+ const stack = result.finalStack;
+ const minimum = stack.palette.find((p) => p.surfaceEligible !== false)!.height;
+ const original = createFinalStackTargetHeightCache(
+ stack.targetMappings,
+ minimum,
+ stack.totalHeight
+ );
+ const fractional = createFinalStackTargetHeightCache(
+ stack.targetMappings,
+ minimum,
+ stack.totalHeight,
+ {
+ palette: stack.palette,
+ layerHeight: 0.08,
+ }
+ );
+ assert.ok([...fractional].some(([k, h]) => Math.abs(h - original.get(k)!) > 1e-6));
+ const h = [...fractional.values()].find(
+ (h) => Math.abs((h - 0.16) / 0.08 - Math.round((h - 0.16) / 0.08)) > 0.01
+ )!;
+ assert.ok(h > 0);
+ const heightMap = new Float32Array(64 * 48).fill(h);
+ quantizeHeightMap(heightMap, 64, 48, {
+ ...options,
+ minModelH: minimum,
+ maxModelH: stack.totalHeight,
+ });
+ assert.equal(
+ new Set(heightMap).size,
+ 2,
+ 'the shared cache must not feed only snapped heights to diffusion'
+ );
+});
+
+test('dithered layer masks remain manifold with greedy and smooth meshing', async () => {
+ const width = 32,
+ height = 24;
+ for (const lineWidth of [0.2, 0.4]) {
+ const heights = new Float32Array(width * height).fill(0.18);
+ quantizeHeightMap(heights, width, height, { ...options, ditherLineWidth: lineWidth });
+ for (const level of [0.16, 0.24]) {
+ const mask = Uint8Array.from(heights, (h) => (h >= level - 1e-6 ? 1 : 0));
+ for (const generate of [generateGreedyMesh, generateSmoothMesh]) {
+ const mesh = await generate(mask, width, height, 0.08, level - 0.08, 0.2, 1, {
+ yieldIntervalMs: Infinity,
+ onYield: async () => undefined,
+ });
+ const integrity = inspectMeshIntegrity(mesh);
+ assert.equal(integrity.isValid, true, JSON.stringify(integrity));
+ }
+ }
+ }
+});
+
+test('non-default first layers and fine layer heights stay on grid, bounded, and exact where anchored', () => {
+ const settings = {
+ ...options,
+ layerHeight: 0.04,
+ slicerFirstLayerHeight: 0.1,
+ minModelH: 0.62,
+ maxModelH: 3.7,
+ };
+ const width = 32,
+ height = 24;
+ const input = new Float32Array(width * height).fill(3.675);
+ for (let y = 0; y < height; y++) input[y * width + 9] = 3.66;
+ for (const lineWidth of [0.2, 0.4]) {
+ const result = input.slice();
+ quantizeHeightMap(result, width, height, { ...settings, ditherLineWidth: lineWidth });
+ assert.equal(new Set(result).size, 2);
+ for (const h of result) {
+ const layer = (h - settings.slicerFirstLayerHeight) / settings.layerHeight;
+ assert.ok(Math.abs(layer - Math.round(layer)) < 1e-5);
+ assert.ok(h >= Math.fround(settings.minModelH) && h <= Math.fround(settings.maxModelH));
+ }
+ for (let y = 0; y < height; y++) assert.equal(result[y * width + 9], Math.fround(3.66));
+ }
+});
diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts
index 7e18cbb8..5266e34f 100644
--- a/tests/i18n.test.ts
+++ b/tests/i18n.test.ts
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
+import { readdirSync, readFileSync } from 'node:fs';
import {
matchLanguage,
resolveLanguage,
@@ -237,3 +237,17 @@ test('the current release feed provides real notes for every supported language'
if (code !== 'en') assert.notEqual(localized?.text, feed.release_notes);
}
});
+
+test('translation component tags avoid void HTML element names', () => {
+ // Trans parses , , etc. as void elements, so the wrapped text
+ // renders after the component and a translated link becomes empty.
+ const pairedVoidTag =
+ /<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)>[^"]*?<\/\1>/;
+ for (const { code } of SUPPORTED_LANGUAGES) {
+ for (const file of readdirSync(`src/locales/${code}`)) {
+ if (!file.endsWith('.json')) continue;
+ const resource = readFileSync(`src/locales/${code}/${file}`, 'utf8');
+ assert.doesNotMatch(resource, pairedVoidTag, `${code}/${file}`);
+ }
+ }
+});
diff --git a/tests/meshing.test.ts b/tests/meshing.test.ts
index 25a2a4c0..6b7aeef0 100644
--- a/tests/meshing.test.ts
+++ b/tests/meshing.test.ts
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
import test, { type TestContext } from 'node:test';
import { generateGreedyMesh, generateSmoothMesh, type MeshData } from '../src/lib/meshing.ts';
import {
@@ -26,8 +27,171 @@ const noYieldOptions = {
const meshers: Array<{ name: string; generate: MeshGenerator }> = [
{ name: 'greedy', generate: generateGreedyMesh },
{ name: 'smooth', generate: generateSmoothMesh },
+ ...(['minimal', 'aggressive'] as const).map((strength) => ({
+ name: `smooth ${strength}`,
+ generate: (...args: Parameters) => {
+ const [pixels, width, height, thickness, zOffset, pixelSize, heightScale, options] =
+ args;
+ return generateSmoothMesh(
+ pixels,
+ width,
+ height,
+ thickness,
+ zOffset,
+ pixelSize,
+ heightScale,
+ { ...options, strength }
+ );
+ },
+ })),
];
+test('None matches greedy geometry and Medium preserves the original smooth default', async () => {
+ const mask = maskFromRows(['##..', '###.', '####', '..##']);
+ for (const [strength, generate] of [
+ ['none', generateGreedyMesh],
+ ['medium', generateSmoothMesh],
+ ] as const) {
+ const expected = await generate(mask.activePixels, 4, 4, 0.08, 0.2, 0.1, 1, noYieldOptions);
+ const actual = await generateSmoothMesh(mask.activePixels, 4, 4, 0.08, 0.2, 0.1, 1, {
+ ...noYieldOptions,
+ strength,
+ });
+ assert.deepEqual(actual.positions, expected.positions);
+ assert.deepEqual(actual.indices, expected.indices);
+ assert.equal(actual.metrics?.mesher, expected.metrics?.mesher);
+ }
+});
+
+test('ordinary smooth presets retain their geometry from develop before Flat Paint support', async () => {
+ const mask = Uint8Array.from([1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1]);
+ const hashes = {
+ minimal: '278cd589d74af4ecde59021ceaa71ef3505caad9b248ac9816e4a4a44580b35f',
+ medium: '8f65c3b012e34ea7dcf9ef7362cead7fc943e49ebd48f141aedc4cf17ed8676f',
+ aggressive: '40879acb71f0ebb53df900b51b0475d6b6819aa4168968138174fdaf0740832c',
+ };
+ for (const strength of ['minimal', 'medium', 'aggressive'] as const) {
+ const mesh = await generateSmoothMesh(mask, 4, 4, 0.08, 0.2, 0.1, 1, {
+ ...noYieldOptions,
+ strength,
+ });
+ const hash = createHash('sha256')
+ .update(Buffer.from(mesh.positions.buffer))
+ .update(JSON.stringify(mesh.indices))
+ .digest('hex');
+ assert.equal(hash, hashes[strength]);
+ }
+});
+
+test('Aggressive removes diagonal oscillation beyond Medium in every orientation', async (t: TestContext) => {
+ const size = 64;
+ for (const pixelSize of [0.01, 0.4]) {
+ for (let orientation = 0; orientation < 8; orientation++) {
+ await t.test(`orientation ${orientation}, pixel size ${pixelSize}`, async () => {
+ const rotations = orientation % 4;
+ const reflected = orientation >= 4;
+ const pixels = new Uint8Array(size * size);
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x <= y; x++) {
+ let targetX = reflected ? size - 1 - x : x;
+ let targetY = y;
+ for (let rotation = 0; rotation < rotations; rotation++) {
+ [targetX, targetY] = [size - 1 - targetY, targetX];
+ }
+ pixels[targetY * size + targetX] = 1;
+ }
+ }
+ const deviations: number[] = [];
+ for (const strength of ['minimal', 'medium', 'aggressive'] as const) {
+ const mesh = await generateSmoothMesh(
+ pixels,
+ size,
+ size,
+ 0.08,
+ 0.2,
+ pixelSize,
+ 1,
+ { ...noYieldOptions, strength }
+ );
+ assertHealthyMesh(`${strength} diagonal orientation ${orientation}`, mesh);
+ assert.deepEqual(inspectRoundedExportTopology(mesh), {
+ boundaryEdgeCount: 0,
+ overusedEdgeCount: 0,
+ skippedTriangleCount: 0,
+ });
+ const distances: number[] = [];
+ for (let i = 0; i < mesh.positions.length; i += 3) {
+ let x = mesh.positions[i] / pixelSize;
+ let y = mesh.positions[i + 1] / pixelSize;
+ // Undo rotations/reflection on grid corners, then inspect
+ // the central diagonal away from the triangle's endpoints.
+ for (let rotation = 0; rotation < rotations; rotation++) {
+ [x, y] = [y, size - x];
+ }
+ if (reflected) x = size - x;
+ const originX = Math.round(x),
+ originY = Math.round(y);
+ if (
+ originX >= 20 &&
+ originX <= 40 &&
+ originY >= 20 &&
+ originY <= 40 &&
+ (originX - originY === 0 || originX - originY === 1)
+ ) {
+ distances.push(Math.abs(x - y - 0.5) / Math.SQRT2);
+ }
+ }
+ assert.ok(
+ distances.length > 20,
+ 'expected vertices on both sides of the staircase'
+ );
+ deviations.push(Math.max(...distances));
+ }
+ const [minimal, medium, aggressive] = deviations;
+ assert.ok(medium < minimal, 'Medium should soften the staircase more than Minimal');
+ assert.ok(
+ aggressive < medium * 0.1,
+ `Aggressive should remove residual diagonal steps: ${JSON.stringify({ minimal, medium, aggressive })}`
+ );
+ });
+ }
+ }
+});
+
+test('all smoothing strengths keep every 3x3 footprint manifold after export rounding', async () => {
+ for (let bits = 1; bits < 512; bits++) {
+ const pixels = Uint8Array.from({ length: 9 }, (_, i) => (bits >>> i) & 1);
+ for (const strength of ['minimal', 'medium', 'aggressive'] as const) {
+ const mesh = await generateSmoothMesh(
+ pixels,
+ 3,
+ 3,
+ 0.08,
+ 0.2,
+ bits % 2 ? 0.01 : 0.4,
+ 1,
+ { ...noYieldOptions, strength }
+ );
+ assertHealthyMesh(`${strength} footprint ${bits}`, mesh);
+ const pixelSize = bits % 2 ? 0.01 : 0.4;
+ const maxMove = strength === 'minimal' ? 0.2 : 0.49;
+ for (let i = 0; i < mesh.positions.length; i += 3) {
+ const x = mesh.positions[i] / pixelSize;
+ const y = mesh.positions[i + 1] / pixelSize;
+ assert.ok(
+ Math.hypot(x - Math.round(x), y - Math.round(y)) <= maxMove + 1e-6,
+ `${strength} exceeded its movement envelope`
+ );
+ }
+ assert.deepEqual(inspectRoundedExportTopology(mesh), {
+ boundaryEdgeCount: 0,
+ overusedEdgeCount: 0,
+ skippedTriangleCount: 0,
+ });
+ }
+ }
+});
+
function maskFromRows(rows: string[]): RasterMask {
const width = rows[0].length;
const height = rows.length;
@@ -475,156 +639,190 @@ test('large issue JPG fixture masks stay slicer-safe across meshers', async (t:
}
});
-test('smooth 1024px logo stays slicer-safe after 3MF export rounding', async () => {
- const logoMask = maskFromPngAlpha(logoFixturePath, 128);
- const mesh = await generateSmoothMesh(
- logoMask.activePixels,
- logoMask.width,
- logoMask.height,
- 0.08,
- 0,
- 0.4,
- 1,
- noYieldOptions
- );
-
- assertHealthyMesh('smooth export-rounded logo source mesh', mesh);
- assert.equal(hasFractionalXY(mesh, 0.4), true, 'smooth logo should contain smoothed vertices');
-
- const report = inspectRoundedExportTopology(mesh);
- assert.deepEqual(report, {
- boundaryEdgeCount: 0,
- overusedEdgeCount: 0,
- skippedTriangleCount: 0,
- });
+test('smooth 1024px logo stays slicer-safe after 3MF export rounding', async (t: TestContext) => {
+ for (const strength of ['minimal', 'medium', 'aggressive'] as const) {
+ await t.test(strength, async () => {
+ const logoMask = maskFromPngAlpha(logoFixturePath, 128);
+ const mesh = await generateSmoothMesh(
+ logoMask.activePixels,
+ logoMask.width,
+ logoMask.height,
+ 0.08,
+ 0,
+ 0.4,
+ 1,
+ { ...noYieldOptions, strength }
+ );
+
+ assertHealthyMesh('smooth export-rounded logo source mesh', mesh);
+ assert.equal(
+ hasFractionalXY(mesh, 0.4),
+ true,
+ 'smooth logo should contain smoothed vertices'
+ );
+
+ const report = inspectRoundedExportTopology(mesh);
+ assert.deepEqual(report, {
+ boundaryEdgeCount: 0,
+ overusedEdgeCount: 0,
+ skippedTriangleCount: 0,
+ });
+ });
+ }
});
-test('smooth caps stay inside the bounded smoothing envelope', async () => {
- const mask = maskFromRows(['######', '#....#', '#.##.#', '#.##.#', '#....#', '######']);
- const mesh = await generateSmoothMesh(
- mask.activePixels,
- mask.width,
- mask.height,
- 0.08,
- 0,
- 0.4,
- 1,
- noYieldOptions
- );
-
- assertHealthyMesh('smooth concave footprint mesh', mesh);
- assert.equal(hasFractionalXY(mesh, 0.4), true, 'concave footprint should still be smoothed');
- assert.ok(
- countCapCentroidsOutsideMask(mesh, mask, 0.4) > 0,
- 'smooth boundaries should be able to fill source-pixel stair notches'
- );
- assert.equal(countCapCentroidsOutsideExpandedMask(mesh, mask, 0.4, 0.5), 0);
+test('smooth caps stay inside the bounded smoothing envelope', async (t: TestContext) => {
+ for (const strength of ['minimal', 'medium', 'aggressive'] as const) {
+ await t.test(strength, async () => {
+ const mask = maskFromRows(['######', '#....#', '#.##.#', '#.##.#', '#....#', '######']);
+ const mesh = await generateSmoothMesh(
+ mask.activePixels,
+ mask.width,
+ mask.height,
+ 0.08,
+ 0,
+ 0.4,
+ 1,
+ { ...noYieldOptions, strength }
+ );
+
+ assertHealthyMesh('smooth concave footprint mesh', mesh);
+ assert.equal(
+ hasFractionalXY(mesh, 0.4),
+ true,
+ 'concave footprint should still be smoothed'
+ );
+ assert.ok(
+ countCapCentroidsOutsideMask(mesh, mask, 0.4) > 0,
+ 'smooth boundaries should be able to fill source-pixel stair notches'
+ );
+ assert.equal(countCapCentroidsOutsideExpandedMask(mesh, mask, 0.4, 0.5), 0);
+ });
+ }
});
-test('smooth caps avoid reversed center-fan triangles after boundary movement', async () => {
- const mask = maskFromRows(['#...', '##.#', '#.#.', '.#..', '#...', '#...', '#...', '#...']);
- const mesh = await generateSmoothMesh(
- mask.activePixels,
- mask.width,
- mask.height,
- 0.2,
- 0,
- 1,
- 1,
- noYieldOptions
- );
-
- assertHealthyMesh('smooth moved-boundary cap mesh', mesh);
+test('smooth caps avoid reversed center-fan triangles after boundary movement', async (t: TestContext) => {
+ for (const strength of ['minimal', 'medium', 'aggressive'] as const) {
+ await t.test(strength, async () => {
+ const mask = maskFromRows([
+ '#...',
+ '##.#',
+ '#.#.',
+ '.#..',
+ '#...',
+ '#...',
+ '#...',
+ '#...',
+ ]);
+ const mesh = await generateSmoothMesh(
+ mask.activePixels,
+ mask.width,
+ mask.height,
+ 0.2,
+ 0,
+ 1,
+ 1,
+ { ...noYieldOptions, strength }
+ );
+
+ assertHealthyMesh('smooth moved-boundary cap mesh', mesh);
+ });
+ }
});
-test('smooth caps avoid export-degenerate final ears after boundary movement', async () => {
- const mask = maskFromRows([
- '...#..',
- '..#.#.',
- '.#.#.#',
- '.#.#..',
- '..#.#.',
- '.###..',
- '#...#.',
- '#..#..',
- '####..',
- ]);
- const mesh = await generateSmoothMesh(
- mask.activePixels,
- mask.width,
- mask.height,
- 0.2,
- 0,
- 0.4,
- 1,
- noYieldOptions
- );
-
- assertHealthyMesh('smooth rounded-boundary cap mesh', mesh);
- assert.deepEqual(inspectRoundedExportTopology(mesh), {
- boundaryEdgeCount: 0,
- overusedEdgeCount: 0,
- skippedTriangleCount: 0,
- });
+test('smooth caps avoid export-degenerate final ears after boundary movement', async (t: TestContext) => {
+ for (const strength of ['minimal', 'medium', 'aggressive'] as const) {
+ await t.test(strength, async () => {
+ const mask = maskFromRows([
+ '...#..',
+ '..#.#.',
+ '.#.#.#',
+ '.#.#..',
+ '..#.#.',
+ '.###..',
+ '#...#.',
+ '#..#..',
+ '####..',
+ ]);
+ const mesh = await generateSmoothMesh(
+ mask.activePixels,
+ mask.width,
+ mask.height,
+ 0.2,
+ 0,
+ 0.4,
+ 1,
+ { ...noYieldOptions, strength }
+ );
+
+ assertHealthyMesh('smooth rounded-boundary cap mesh', mesh);
+ assert.deepEqual(inspectRoundedExportTopology(mesh), {
+ boundaryEdgeCount: 0,
+ overusedEdgeCount: 0,
+ skippedTriangleCount: 0,
+ });
+ });
+ }
});
-test('smooth caps survive 3MF integer-precision export without dropped faces', async () => {
- // Regression: cap degeneracy used to be checked on quantized-back floats
- // against Number.EPSILON, while the 3MF exporter tests exact integer
- // coordinate units. Faces with integer area exactly 0 but float area
- // ~1e-12 passed the mesher and were then dropped by the exporter, leaving
- // pinholes that slicers report as non-manifold/open edges. This noisy
- // blob mask (mulberry32 seed 33 at 96px, pixel size 0.1) produced two
- // such faces before the mesher switched to the exporter's integer grid.
- const seed = 33;
- const size = 96;
- let state = seed >>> 0;
- const rand = () => {
- state |= 0;
- state = (state + 0x6d2b79f5) | 0;
- let t = Math.imul(state ^ (state >>> 15), 1 | state);
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
-
- const activePixels = new Uint8Array(size * size);
- const blobs = 3 + Math.floor(rand() * 5);
- const centers = Array.from({ length: blobs }, () => ({
- x: rand() * size,
- y: rand() * size,
- r: size * (0.1 + rand() * 0.25),
- }));
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- for (const center of centers) {
- if (Math.hypot(x - center.x, y - center.y) < center.r + (rand() - 0.5) * 3) {
- activePixels[y * size + x] = 1;
- break;
+test('smooth caps survive 3MF integer-precision export without dropped faces', async (t: TestContext) => {
+ for (const strength of ['minimal', 'medium', 'aggressive'] as const) {
+ await t.test(strength, async () => {
+ // Regression: cap degeneracy used to be checked on quantized-back floats
+ // against Number.EPSILON, while the 3MF exporter tests exact integer
+ // coordinate units. Faces with integer area exactly 0 but float area
+ // ~1e-12 passed the mesher and were then dropped by the exporter, leaving
+ // pinholes that slicers report as non-manifold/open edges. This noisy
+ // blob mask (mulberry32 seed 33 at 96px, pixel size 0.1) produced two
+ // such faces before the mesher switched to the exporter's integer grid.
+ const seed = 33;
+ const size = 96;
+ let state = seed >>> 0;
+ const rand = () => {
+ state |= 0;
+ state = (state + 0x6d2b79f5) | 0;
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+
+ const activePixels = new Uint8Array(size * size);
+ const blobs = 3 + Math.floor(rand() * 5);
+ const centers = Array.from({ length: blobs }, () => ({
+ x: rand() * size,
+ y: rand() * size,
+ r: size * (0.1 + rand() * 0.25),
+ }));
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ for (const center of centers) {
+ if (
+ Math.hypot(x - center.x, y - center.y) <
+ center.r + (rand() - 0.5) * 3
+ ) {
+ activePixels[y * size + x] = 1;
+ break;
+ }
+ }
}
}
- }
- }
- for (let i = 0; i < activePixels.length; i++) {
- if (rand() < 0.02) activePixels[i] = activePixels[i] ? 0 : 1;
- }
+ for (let i = 0; i < activePixels.length; i++) {
+ if (rand() < 0.02) activePixels[i] = activePixels[i] ? 0 : 1;
+ }
- const mesh = await generateSmoothMesh(
- activePixels,
- size,
- size,
- 0.12,
- 0.2,
- 0.1,
- 1,
- noYieldOptions
- );
+ const mesh = await generateSmoothMesh(activePixels, size, size, 0.12, 0.2, 0.1, 1, {
+ ...noYieldOptions,
+ strength,
+ });
- assertHealthyMesh('smooth noisy-blob mesh', mesh);
- assert.deepEqual(inspectRoundedExportTopology(mesh), {
- boundaryEdgeCount: 0,
- overusedEdgeCount: 0,
- skippedTriangleCount: 0,
- });
+ assertHealthyMesh('smooth noisy-blob mesh', mesh);
+ assert.deepEqual(inspectRoundedExportTopology(mesh), {
+ boundaryEdgeCount: 0,
+ overusedEdgeCount: 0,
+ skippedTriangleCount: 0,
+ });
+ });
+ }
});
test('smooth metrics stay on the smooth algorithm without mesher substitution state', async () => {
diff --git a/tests/native/file-associations.nsi b/tests/native/file-associations.nsi
new file mode 100644
index 00000000..0804c2a6
--- /dev/null
+++ b/tests/native/file-associations.nsi
@@ -0,0 +1,64 @@
+; Exercise the production macros under an isolated HKCU test key, never Classes.
+Unicode true
+SilentInstall silent
+RequestExecutionLevel user
+OutFile "${TEST_OUTFILE}"
+!define MAINBINARYNAME "kromacut"
+!define KROMACUT_ASSOCIATIONS_ROOT "Software\Kromacut\FileAssociationTests\${TEST_ID}"
+!include LogicLib.nsh
+; The hook expects the two upstream macros to be defined before it replaces them.
+!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
+!macroend
+!macro APP_UNASSOCIATE EXT FILECLASS
+!macroend
+!include "${ASSOCIATIONS_FILE}"
+
+!macro AssertEqual ACTUAL EXPECTED
+ ${If} "${ACTUAL}" != "${EXPECTED}"
+ SetErrorLevel 1
+ Goto cleanup
+ ${EndIf}
+!macroend
+
+Section
+ SetShellVarContext current
+ StrCpy $INSTDIR "$TEMP\Kromacut test path with spaces"
+
+ ; Fresh install and upgrade must retain the original handler.
+ WriteRegStr HKCU "${KROMACUT_ASSOCIATIONS_ROOT}\.kfil" "" "Previous.App"
+ !insertmacro APP_ASSOCIATE "kfil" "Kromacut.FilamentProfile" "Profile" "icon" "Open" "unused"
+ !insertmacro APP_ASSOCIATE "kfil" "Kromacut.FilamentProfile" "Profile" "icon" "Open" "unused"
+ ReadRegStr $R1 HKCU "${KROMACUT_ASSOCIATIONS_ROOT}\.kfil" "Kromacut.FilamentProfile_backup"
+ !insertmacro AssertEqual "$R1" "Previous.App"
+ ReadRegStr $R1 HKCU "${KROMACUT_ASSOCIATIONS_ROOT}\Kromacut.FilamentProfile\shell\open\command" ""
+ !insertmacro AssertEqual "$R1" '$\"$INSTDIR\kromacut.exe$\" $\"%1$\"'
+ !insertmacro APP_UNASSOCIATE "kfil" "Kromacut.FilamentProfile"
+ ReadRegStr $R1 HKCU "${KROMACUT_ASSOCIATIONS_ROOT}\.kfil" ""
+ !insertmacro AssertEqual "$R1" "Previous.App"
+
+ ; Another handler selected after installation must not be replaced on removal.
+ !insertmacro APP_ASSOCIATE "kapp" "Kromacut.FilamentProfile" "Profile" "icon" "Open" "unused"
+ WriteRegStr HKCU "${KROMACUT_ASSOCIATIONS_ROOT}\.kapp" "" "Chosen.Later"
+ !insertmacro APP_UNASSOCIATE "kapp" "Kromacut.FilamentProfile"
+ ReadRegStr $R1 HKCU "${KROMACUT_ASSOCIATIONS_ROOT}\.kapp" ""
+ !insertmacro AssertEqual "$R1" "Chosen.Later"
+
+ ; A fresh extension must not retain an empty key or a dangling ProgID.
+ !insertmacro APP_ASSOCIATE "kpal" "Kromacut.Palette" "Palette" "icon" "Open" "unused"
+ !insertmacro APP_UNASSOCIATE "kpal" "Kromacut.Palette"
+ ClearErrors
+ ReadRegStr $R1 HKCU "${KROMACUT_ASSOCIATIONS_ROOT}\.kpal" ""
+ ${IfNot} ${Errors}
+ SetErrorLevel 1
+ Goto cleanup
+ ${EndIf}
+ ClearErrors
+ ReadRegStr $R1 HKCU "${KROMACUT_ASSOCIATIONS_ROOT}\Kromacut.Palette" ""
+ ${IfNot} ${Errors}
+ SetErrorLevel 1
+ Goto cleanup
+ ${EndIf}
+ SetErrorLevel 0
+cleanup:
+ DeleteRegKey HKCU "${KROMACUT_ASSOCIATIONS_ROOT}"
+SectionEnd
diff --git a/tests/paletteManager.test.ts b/tests/paletteManager.test.ts
index b9bbb250..89da6b37 100644
--- a/tests/paletteManager.test.ts
+++ b/tests/paletteManager.test.ts
@@ -17,6 +17,26 @@ import { toHex6 } from '../src/lib/colorUtils.ts';
import { deduplicateName } from '../src/lib/nameUtils.ts';
import type { CustomPalette } from '../src/types/index.ts';
+test('OS-opened palette duplicates resolve to the saved palette with names and disabled colors intact', () => {
+ const saved = createCustomPalette(
+ 'Saved palette',
+ ['#ff0000', '#0000ff'],
+ [1],
+ ['Red', 'Blue']
+ );
+ const result = importCustomPalettes(
+ [saved],
+ [{ ...saved, id: 'duplicate-id', name: 'File palette' }]
+ );
+ assert.equal(result.imported.length, 0);
+ assert.equal(result.resolved[0], saved);
+ assert.equal(result.palettes.length, 1);
+ const overwritten = importCustomPalettes([saved], [{ ...saved, name: 'Updated palette' }]);
+ assert.equal(overwritten.resolved[0], overwritten.imported[0]);
+ assert.deepEqual(overwritten.resolved[0].disabledColors, [1]);
+ assert.deepEqual(overwritten.resolved[0].colorNames, ['Red', 'Blue']);
+});
+
function makePalette(overrides: Partial = {}): CustomPalette {
return {
id: 'palette-1',
@@ -198,9 +218,7 @@ test('updateCustomPalette clears the disabled field when all colors would be dis
test('import treats identical colors with different disabled sets as distinct content', () => {
const existing = [makePalette()];
- const incoming = [
- makePalette({ id: 'palette-2', name: 'Partly Off', disabledColors: [1] }),
- ];
+ const incoming = [makePalette({ id: 'palette-2', name: 'Partly Off', disabledColors: [1] })];
const result = importCustomPalettes(existing, incoming);
assert.equal(result.imported.length, 1);
assert.equal(result.skipped.length, 0);
@@ -209,9 +227,7 @@ test('import treats identical colors with different disabled sets as distinct co
test('import skips palettes matching both colors and disabled set', () => {
const existing = [makePalette({ disabledColors: [1] })];
- const incoming = [
- makePalette({ id: 'palette-2', name: 'Duplicate', disabledColors: [1] }),
- ];
+ const incoming = [makePalette({ id: 'palette-2', name: 'Duplicate', disabledColors: [1] })];
const result = importCustomPalettes(existing, incoming);
assert.equal(result.imported.length, 0);
assert.equal(result.skipped.length, 1);
diff --git a/tests/previewLayerRange.test.ts b/tests/previewLayerRange.test.ts
new file mode 100644
index 00000000..2fa7f36c
--- /dev/null
+++ b/tests/previewLayerRange.test.ts
@@ -0,0 +1,97 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import * as THREE from 'three';
+import JSZip from 'jszip';
+import { applyPreviewLayerRange, previewLayerHeights } from '../src/lib/previewLayerRange.ts';
+import {
+ rebuildPreviewWireframeOverlay,
+ syncPreviewWireframeOverlayVisibility,
+} from '../src/lib/previewWireframe.ts';
+import { loadViteModule } from './helpers/viteModule.ts';
+
+function slab(base = 0, top = 1.6) {
+ const geometry = new THREE.BoxGeometry(1, 1, top - base).translate(0, 0, (top + base) / 2);
+ const original = geometry.getAttribute('position').array.slice();
+ geometry.userData.kromacutExportGeometry = {
+ positions: geometry.getAttribute('position').array,
+ indices: geometry.index!.array,
+ };
+ const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial());
+ Object.assign(mesh.userData, { baseZ: base, topZ: top, kromacutFlatPaintHeightScale: 1 });
+ return { mesh, original };
+}
+
+function zBounds(geometry: THREE.BufferGeometry) {
+ geometry.computeBoundingBox();
+ return [geometry.boundingBox!.min.z, geometry.boundingBox!.max.z];
+}
+
+test('physical slider boundaries include a thick first layer, exact top, and no float sliver', () => {
+ assert.equal(previewLayerHeights(1.6, 0.08, 0.16).length - 1, 19);
+ assert.equal(previewLayerHeights(1.68, 0.08, 0.16).length - 1, 20);
+ assert.deepEqual(
+ previewLayerHeights(0.310000002, 0.08, 0.15).map((h) => Number(h.toFixed(8))),
+ [0, 0.15, 0.23, 0.31]
+ );
+});
+
+test('each layer cutoff trims merged Flat Paint slabs while preserving complete export positions', () => {
+ const { mesh, original } = slab();
+ for (const height of previewLayerHeights(1.6, 0.08, 0.16).slice(1).reverse()) {
+ applyPreviewLayerRange(mesh, 0, height);
+ assert.equal(mesh.visible, true);
+ assert.ok(Math.abs(zBounds(mesh.geometry)[1] - height) < 1e-6);
+ assert.deepEqual(mesh.geometry.userData.kromacutExportGeometry.positions, original);
+ }
+ applyPreviewLayerRange(mesh, 0.32, 0.48);
+ assert.ok(zBounds(mesh.geometry).every((z, i) => Math.abs(z - [0.32, 0.48][i]) < 1e-6));
+ applyPreviewLayerRange(mesh, 0.32, 0.32);
+ assert.equal(mesh.visible, false);
+ applyPreviewLayerRange(mesh, 0, 1.6);
+ assert.equal(mesh.visible, true);
+ assert.deepEqual(mesh.geometry.getAttribute('position').array, original);
+});
+
+test('a cutoff hides face slabs exactly at the boundary and leaves ordinary mesh geometry alone', () => {
+ const { mesh, original } = slab(0.72, 0.8);
+ applyPreviewLayerRange(mesh, 0, 0.7200000000000001);
+ assert.equal(mesh.visible, false);
+ delete mesh.userData.kromacutFlatPaintHeightScale;
+ const ordinary = slab().mesh;
+ delete ordinary.userData.kromacutFlatPaintHeightScale;
+ const positions = ordinary.geometry.getAttribute('position');
+ applyPreviewLayerRange(ordinary, 0, 0.4);
+ assert.equal(ordinary.geometry.getAttribute('position'), positions);
+ assert.deepEqual(mesh.geometry.userData.kromacutExportGeometry.positions, original);
+});
+
+test('wireframe built during a cut follows both slider handles and restores the full slab', async () => {
+ const { mesh } = slab();
+ const overlay = new THREE.Group();
+ applyPreviewLayerRange(mesh, 0.32, 0.48);
+ await rebuildPreviewWireframeOverlay(mesh, overlay);
+ const geometry = (overlay.children[0] as THREE.LineSegments).geometry;
+ assert.ok(zBounds(geometry).every((z, i) => Math.abs(z - [0.32, 0.48][i]) < 1e-6));
+ applyPreviewLayerRange(mesh, 0, 1.6);
+ syncPreviewWireframeOverlayVisibility(overlay);
+ assert.deepEqual(zBounds(geometry), zBounds(mesh.geometry));
+ applyPreviewLayerRange(mesh, 0, 0);
+ syncPreviewWireframeOverlayVisibility(overlay);
+ assert.equal(overlay.children[0].visible, false);
+});
+
+test('3MF includes the complete slab even when the slider cuts or hides it', async () => {
+ const { exportObjectTo3MFBlob } =
+ await loadViteModule('/src/lib/export3mf.ts');
+ const { mesh } = slab();
+ const readMesh = async () => {
+ const zip = await JSZip.loadAsync(await (await exportObjectTo3MFBlob(mesh)).arrayBuffer());
+ const xml = await zip.file('3D/3dmodel.model')!.async('string');
+ return xml.match(/[\s\S]*?<\/mesh>/g);
+ };
+ const complete = await readMesh();
+ applyPreviewLayerRange(mesh, 0.32, 0.48);
+ assert.deepEqual(await readMesh(), complete);
+ applyPreviewLayerRange(mesh, 0, 0);
+ assert.deepEqual(await readMesh(), complete);
+});
diff --git a/tests/printSettingsStorage.test.ts b/tests/printSettingsStorage.test.ts
index fa55139e..07c06e71 100644
--- a/tests/printSettingsStorage.test.ts
+++ b/tests/printSettingsStorage.test.ts
@@ -4,6 +4,9 @@ import test from 'node:test';
import {
arePrintSettingsDefault,
DEFAULT_PRINT_SETTINGS,
+ loadPrintSettingsFromStorage,
+ savePrintSettingsToStorage,
+ PRINT_SETTINGS_STORAGE_KEY,
} from '../src/lib/printSettingsStorage.ts';
test('print settings are default only when Smooth Meshing is also at its default', () => {
@@ -11,7 +14,7 @@ test('print settings are default only when Smooth Meshing is also at its default
assert.equal(
arePrintSettingsDefault({
...DEFAULT_PRINT_SETTINGS,
- smoothMeshing: !DEFAULT_PRINT_SETTINGS.smoothMeshing,
+ smoothMeshingStrength: 'medium',
}),
false
);
@@ -25,3 +28,52 @@ test('each numeric print setting participates in the default-state check', () =>
);
assert.equal(arePrintSettingsDefault({ ...DEFAULT_PRINT_SETTINGS, pixelSize: 0.4 }), false);
});
+
+test('saved smoothing strengths round-trip and the old toggle migrates without changing print dimensions', () => {
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
+ const data = new Map();
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ localStorage: {
+ getItem: (key: string) => data.get(key) ?? null,
+ setItem: (key: string, value: string) => data.set(key, value),
+ },
+ },
+ });
+ try {
+ for (const smoothMeshingStrength of ['none', 'minimal', 'medium', 'aggressive'] as const) {
+ const settings = { ...DEFAULT_PRINT_SETTINGS, smoothMeshingStrength };
+ savePrintSettingsToStorage(settings);
+ assert.deepEqual(loadPrintSettingsFromStorage(), settings);
+ assert.equal(arePrintSettingsDefault(settings), smoothMeshingStrength === 'none');
+ }
+ for (const smoothMeshing of [true, false]) {
+ data.set(
+ PRINT_SETTINGS_STORAGE_KEY,
+ JSON.stringify({ layerHeight: 0.08, pixelSize: 0.4, smoothMeshing })
+ );
+ assert.deepEqual(loadPrintSettingsFromStorage(), {
+ ...DEFAULT_PRINT_SETTINGS,
+ layerHeight: 0.08,
+ pixelSize: 0.4,
+ smoothMeshingStrength: smoothMeshing ? 'medium' : 'none',
+ });
+ }
+ for (const invalid of [null, 2, 'unknown']) {
+ data.set(
+ PRINT_SETTINGS_STORAGE_KEY,
+ JSON.stringify({ smoothMeshingStrength: invalid })
+ );
+ assert.equal(loadPrintSettingsFromStorage()?.smoothMeshingStrength, 'none');
+ }
+ data.set(
+ PRINT_SETTINGS_STORAGE_KEY,
+ JSON.stringify({ smoothMeshing: true, smoothMeshingStrength: 'none' })
+ );
+ assert.equal(loadPrintSettingsFromStorage()?.smoothMeshingStrength, 'none');
+ } finally {
+ if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
+ else Reflect.deleteProperty(globalThis, 'window');
+ }
+});
diff --git a/tests/profileManager.test.ts b/tests/profileManager.test.ts
index d5cc4318..b3e7b488 100644
--- a/tests/profileManager.test.ts
+++ b/tests/profileManager.test.ts
@@ -45,6 +45,20 @@ test('auto-paint profile exports use kfil filenames', async () => {
assert.equal(profileFileName('PLA Basic White'), 'PLA_Basic_White.kfil');
});
+test('OS-opened profile duplicates resolve to the saved item while preserving import safeguards', async () => {
+ const { createProfile, importProfiles } = await loadProfileManager();
+ const saved = createProfile('Saved profile', [{ id: 'white', color: '#ffffff', td: 0.6 }]);
+ const duplicate = { ...saved, id: 'different-id', name: 'File profile' };
+ const result = importProfiles([saved], [duplicate]);
+ assert.equal(result.imported.length, 0);
+ assert.equal(result.resolved[0], saved);
+ assert.equal(result.profiles.length, 1);
+ const overwritten = importProfiles([saved], [{ ...saved, name: 'Updated profile' }]);
+ assert.equal(overwritten.resolved[0], overwritten.imported[0]);
+ assert.equal(overwritten.resolved[0].name, 'Updated profile');
+ assert.deepEqual(importProfiles([saved], [null as never]).resolved, []);
+});
+
test('dirty profile exports use a fresh id and omit incompatible appearance evidence', async () => {
const { buildProfileExportSnapshot } = await loadProfileManager();
const active: AutoPaintProfile = {
diff --git a/vite.config.ts b/vite.config.ts
index 7461aae2..d6413fc2 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -57,12 +57,20 @@ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(packageJson.version),
},
+ server: {
+ watch: {
+ // Tauri watches native sources itself. Cargo outputs can be locked while linking.
+ ignored: ['**/src-tauri/**'],
+ },
+ },
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
+ // Start from the app, not generated HTML in native build output or test reports.
+ entries: ['index.html'],
include: ['three'],
// Treat three example controls as source to avoid stale optimized deps
exclude: [