From b0eeefd777a0bde540cade973e3810e562ea7789 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 27 Sep 2026 19:15:42 +0300 Subject: [PATCH 01/31] Fix height dithering for finalized Auto-paint stacks --- CHANGELOG.md | 4 + src/components/ThreeDView.tsx | 209 +++------------------- src/docs/auto-paint.md | 2 +- src/lib/autoPaint.ts | 107 +++++++++++- src/lib/heightDithering.ts | 207 ++++++++++++++++++++++ src/locales/bn/docs/auto-paint.md | 2 +- src/locales/de/docs/auto-paint.md | 2 +- src/locales/es/docs/auto-paint.md | 2 +- src/locales/fr/docs/auto-paint.md | 2 +- src/locales/hi/docs/auto-paint.md | 2 +- src/locales/it/docs/auto-paint.md | 2 +- src/locales/ja/docs/auto-paint.md | 2 +- src/locales/pt-PT/docs/auto-paint.md | 2 +- src/locales/ro/docs/auto-paint.md | 2 +- src/locales/uk/docs/auto-paint.md | 2 +- src/locales/zh-CN/docs/auto-paint.md | 2 +- tests/e2e/height-dithering.spec.ts | 154 +++++++++++++++++ tests/heightDithering.test.ts | 248 +++++++++++++++++++++++++++ 18 files changed, 748 insertions(+), 205 deletions(-) create mode 100644 src/lib/heightDithering.ts create mode 100644 tests/e2e/height-dithering.spec.ts create mode 100644 tests/heightDithering.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbce21df..78b74a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Kromacut are documented in this file. ## Unreleased +### Fixed + +- **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. + ## v4.1.0 - 2026-09-26 ### Upgrade notes diff --git a/src/components/ThreeDView.tsx b/src/components/ThreeDView.tsx index 6b5a8c47..a20748ae 100644 --- a/src/components/ThreeDView.tsx +++ b/src/components/ThreeDView.tsx @@ -13,6 +13,7 @@ import { type MeshProgress, } from '../lib/meshing'; import { LAYER_ACTIVATION_EPSILON } from '../lib/layerActivation'; +import { quantizeHeightMap } from '../lib/heightDithering'; import { normalizeHexColor as normalizeHexColorValue } from '../lib/colorUtils'; import { buildFlatPaintLayout, @@ -1160,18 +1161,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 +1272,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 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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)); + } +}); From 496b0bce59d5ed6d684bfe588128a5d353f56211 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 28 Sep 2026 20:28:30 +0300 Subject: [PATCH 02/31] chore: ignore local temporary files --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 2dc843ab2b8d69e26cfd44a812f4f1aeb607d62c Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 30 Sep 2026 19:12:56 +0300 Subject: [PATCH 03/31] feat: associate Kromacut files with desktop app --- .gitattributes | 1 + CHANGELOG.md | 4 + src-tauri/Cargo.lock | 439 +++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/linux/kromacut-mime.xml | 14 + src-tauri/linux/kromacut.desktop | 14 + src-tauri/linux/refresh-file-associations.sh | 9 + src-tauri/macos/Info.plist | 25 + src-tauri/src/lib.rs | 45 +- src-tauri/src/opened_files.rs | 197 ++++++++ src-tauri/tauri.conf.json | 29 +- src-tauri/tauri.linux.conf.json | 40 ++ src-tauri/tauri.macos.conf.json | 11 + src-tauri/windows/file-associations.nsh | 37 ++ src/App.tsx | 55 +++ src/components/AutoPaintTab.tsx | 15 +- src/components/ControlsPanel.tsx | 6 + src/components/DesktopFileDialog.tsx | 63 +++ src/components/DesktopProfileImport.tsx | 67 +++ src/components/PaletteManager.tsx | 11 +- src/components/ThreeDControls.tsx | 26 ++ src/docs/settings-and-controls.md | 10 + src/hooks/useDesktopFileOpen.ts | 25 + src/hooks/usePaletteManager.ts | 69 +-- src/hooks/useProfileManager.ts | 88 ++-- src/lib/desktopFileInbox.ts | 83 ++++ src/lib/paletteManager.ts | 6 + src/lib/profileManager.ts | 6 + src/locales/bn/common.json | 11 + src/locales/bn/docs/settings-and-controls.md | 10 + src/locales/de/common.json | 11 + src/locales/de/docs/settings-and-controls.md | 10 + src/locales/en/common.json | 23 +- src/locales/es/common.json | 11 + src/locales/es/docs/settings-and-controls.md | 10 + src/locales/fr/common.json | 11 + src/locales/fr/docs/settings-and-controls.md | 10 + src/locales/hi/common.json | 11 + src/locales/hi/docs/settings-and-controls.md | 10 + src/locales/it/common.json | 11 + src/locales/it/docs/settings-and-controls.md | 10 + src/locales/ja/common.json | 11 + src/locales/ja/docs/settings-and-controls.md | 10 + src/locales/pt-PT/common.json | 11 + .../pt-PT/docs/settings-and-controls.md | 10 + src/locales/ro/common.json | 11 + src/locales/ro/docs/settings-and-controls.md | 10 + src/locales/uk/common.json | 11 + src/locales/uk/docs/settings-and-controls.md | 10 + src/locales/zh-CN/common.json | 11 + .../zh-CN/docs/settings-and-controls.md | 10 + tests/desktopFileAssociations.test.ts | 97 ++++ tests/desktopFileInbox.test.ts | 111 +++++ tests/e2e/desktop-file-open.spec.ts | 402 ++++++++++++++++ tests/native/file-associations.nsi | 64 +++ tests/paletteManager.test.ts | 28 +- tests/profileManager.test.ts | 14 + 57 files changed, 2265 insertions(+), 101 deletions(-) create mode 100644 src-tauri/linux/kromacut-mime.xml create mode 100644 src-tauri/linux/kromacut.desktop create mode 100644 src-tauri/linux/refresh-file-associations.sh create mode 100644 src-tauri/macos/Info.plist create mode 100644 src-tauri/src/opened_files.rs create mode 100644 src-tauri/tauri.linux.conf.json create mode 100644 src-tauri/tauri.macos.conf.json create mode 100644 src-tauri/windows/file-associations.nsh create mode 100644 src/components/DesktopFileDialog.tsx create mode 100644 src/components/DesktopProfileImport.tsx create mode 100644 src/hooks/useDesktopFileOpen.ts create mode 100644 src/lib/desktopFileInbox.ts create mode 100644 tests/desktopFileAssociations.test.ts create mode 100644 tests/desktopFileInbox.test.ts create mode 100644 tests/e2e/desktop-file-open.spec.ts create mode 100644 tests/native/file-associations.nsi 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/CHANGELOG.md b/CHANGELOG.md index 78b74a6c..4ae74957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Kromacut are documented in this file. ## Unreleased +### Added + +- **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 or calibration dialog is finished or cancelled. 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 imports queued during editing. + ### Fixed - **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. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f836110d..6217cf00 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" @@ -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..a3fb7f07 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 + + + CFBundleTypeNameKromacut filament profile + CFBundleTypeExtensionskfilkapp + CFBundleTypeRoleEditor + CFBundleTypeIconFilekromacut-document.icns + LSHandlerRankOwner + LSItemContentTypescom.kromacut.filament-profile + + + CFBundleTypeNameKromacut color palette + CFBundleTypeExtensionskpal + CFBundleTypeRoleEditor + CFBundleTypeIconFilekromacut-document.icns + LSHandlerRankOwner + LSItemContentTypescom.kromacut.palette + + + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da5a578d..35d052f8 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"; @@ -230,18 +232,32 @@ fn open_external_url(url: &str) -> Result<(), String> { 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, + 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 +267,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)] @@ -278,6 +303,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..34a66738 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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..f27c18f9 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,17 @@ function App(): React.ReactElement | null { handleDeletePalette, handleExportPalette, handleImportFile: handleImportPaletteFile, + importText: importPaletteText, } = usePaletteManager(); + const [paletteEditorOpen, setPaletteEditorOpen] = useState(false); + const [calibrationDialogOpen, setCalibrationDialogOpen] = useState(false); + const { file: queuedDesktopFile, finish: finishDesktopFile } = useDesktopFileOpen(); + // OS file opens can arrive through an active modal. Keep the request queued + // until its draft is saved or dismissed, before changing either workspace. + const desktopFile = paletteEditorOpen || calibrationDialogOpen ? 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 +402,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); @@ -798,6 +832,8 @@ function App(): React.ReactElement | null { onDeletePalette={handleDeletePalette} onExportPalette={handleExportPalette} onImportPaletteFile={handleImportPaletteFile} + paletteEditorOpen={paletteEditorOpen} + onPaletteEditorOpenChange={setPaletteEditorOpen} /> )} @@ -1072,6 +1117,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..faf87d5f 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -334,6 +334,8 @@ interface AutoPaintSliceData { } interface AutoPaintTabProps { + calibrationDialogOpen: boolean; + onCalibrationDialogOpenChange: (open: boolean) => void; // Filament state filaments: Filament[]; addFilament: () => void; @@ -431,6 +433,8 @@ interface AutoPaintTabProps { } export default function AutoPaintTab({ + calibrationDialogOpen, + onCalibrationDialogOpenChange, filaments, addFilament, addFilamentWithProps, @@ -537,19 +541,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[]) => { 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/PaletteManager.tsx b/src/components/PaletteManager.tsx index 07498288..f2e8b165 100644 --- a/src/components/PaletteManager.tsx +++ b/src/components/PaletteManager.tsx @@ -44,6 +44,8 @@ interface PaletteManagerProps { onDeletePalette: (id: string) => 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/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index 6bd630a3..1ac86f82 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -36,6 +36,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 +65,10 @@ 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; } export default function ThreeDControls({ @@ -76,6 +82,10 @@ export default function ThreeDControls({ onSettingsChange, persisted, hasAutoPaintWorkingState = false, + desktopProfileFile = null, + onDesktopFileFinished, + calibrationDialogOpen, + onCalibrationDialogOpenChange, }: ThreeDControlsProps) { const { t } = useTranslation('printing'); // --- Filaments --- @@ -92,6 +102,7 @@ export default function ThreeDControls({ // --- Profiles --- const profileManager = useProfileManager({ filaments, setFilaments }); + const [profileReady, setProfileReady] = useState(false); // Apply initial filaments from profile if available (one-time) const [appliedProfileInit] = useState(() => { @@ -110,6 +121,7 @@ export default function ThreeDControls({ if (appliedProfileInit) { setFilaments(appliedProfileInit); } + setProfileReady(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -567,6 +579,18 @@ export default function ThreeDControls({ return (
+ {profileReady && onDesktopFileFinished && ( + 0) + } + importText={profileManager.importText} + onImported={() => setPaintMode('autopaint')} + onFinished={onDesktopFileFinished} + /> + )} {/* Apply button */}
)} diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index 1ac86f82..84219e11 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -69,6 +69,10 @@ interface ThreeDControlsProps { onDesktopFileFinished?: (id: number) => void; calibrationDialogOpen: boolean; onCalibrationDialogOpenChange: (open: boolean) => void; + profileSaveNewOpen: boolean; + onProfileSaveNewOpenChange: (open: boolean) => void; + profileRenameOpen: boolean; + onProfileRenameOpenChange: (open: boolean) => void; } export default function ThreeDControls({ @@ -86,6 +90,10 @@ export default function ThreeDControls({ onDesktopFileFinished, calibrationDialogOpen, onCalibrationDialogOpenChange, + profileSaveNewOpen, + onProfileSaveNewOpenChange, + profileRenameOpen, + onProfileRenameOpenChange, }: ThreeDControlsProps) { const { t } = useTranslation('printing'); // --- Filaments --- @@ -101,7 +109,14 @@ 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); // Apply initial filaments from profile if available (one-time) diff --git a/src/docs/settings-and-controls.md b/src/docs/settings-and-controls.md index 48cc608d..da175e4b 100644 --- a/src/docs/settings-and-controls.md +++ b/src/docs/settings-and-controls.md @@ -127,7 +127,7 @@ Recording is intended for investigations and may create large files. Leave it di 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 or calibration dialog is open. Finish or cancel that session to continue the queued imports. +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. 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. diff --git a/src/hooks/useProfileManager.ts b/src/hooks/useProfileManager.ts index 7399e88d..22676df2 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(); @@ -72,9 +84,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager 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 +124,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE); } }, - [filaments, profiles] + [filaments, profiles, setShowSaveNewPopover] ); // Save (overwrite): updates existing profile in-place (templates are read-only) @@ -149,7 +159,7 @@ export function useProfileManager({ filaments, setFilaments }: UseProfileManager setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE); } }, - [activeProfileId, profiles] + [activeProfileId, profiles, setShowRenamePopover] ); const handleLoadProfile = useCallback( diff --git a/src/locales/bn/docs/settings-and-controls.md b/src/locales/bn/docs/settings-and-controls.md index 8d560142..6da00332 100644 --- a/src/locales/bn/docs/settings-and-controls.md +++ b/src/locales/bn/docs/settings-and-controls.md @@ -127,7 +127,7 @@ Linux-এ, যেসব AppImage-এ আপডেটের তথ্য সং ডেস্কটপ ইনস্টলেশন `.kfil` ও পুরোনো `.kapp` ফাইলকে ফিলামেন্ট প্রোফাইলের সঙ্গে এবং `.kpal` ফাইলকে প্যালেটের সঙ্গে যুক্ত করে। Kromacut-এ আমদানি ও নির্বাচন করতে ফাইলে ডাবল-ক্লিক করুন। অ্যাপ আগে থেকেই চললে তার বর্তমান উইন্ডো ব্যবহার হয়। যাচাই, মাইগ্রেশন, সদৃশ ফাইল পরিচালনা এবং ক্যালিব্রেশন ধরে রাখার স্বাভাবিক নিয়ম প্রযোজ্য। -প্যালেট এডিটর বা ক্যালিব্রেশন ডায়ালগ খোলা থাকলে ডেস্কটপ থেকে খোলা ফাইলগুলো অপেক্ষা করে। সারিতে থাকা আমদানিগুলো চালিয়ে যেতে সেই সেশনটি শেষ করুন বা বাতিল করুন। +প্যালেট এডিটর, ক্যালিব্রেশন ডায়ালগ বা প্রোফাইলের নাম পরিবর্তন বা নতুন প্রোফাইল সংরক্ষণের ফর্ম খোলা থাকলে ডেস্কটপ থেকে খোলা ফাইলগুলো অপেক্ষা করে। সারিতে থাকা আমদানিগুলো চালিয়ে যেতে সেই সেশনটি শেষ করুন বা বাতিল করুন; ততক্ষণ লেখা নাম ও সম্পাদনা করা প্রোফাইল অপরিবর্তিত থাকে। ফিলামেন্টের অসংরক্ষিত পরিবর্তন প্রতিস্থাপনের আগে Kromacut **পরিবর্তন রাখুন** বা **প্রোফাইল খুলুন** বিকল্প দেয়। আগে সংরক্ষণ করতে পরিবর্তন রাখুন; প্রোফাইল খুললে সেগুলি বাতিল হয়। এভাবে খোলা ফাইল 32 MiB-এর ছোট হতে হবে। সাধারণ JSON, ছবি ও মডেলের স্বাভাবিক আমদানি পদ্ধতি বজায় থাকে। diff --git a/src/locales/de/docs/settings-and-controls.md b/src/locales/de/docs/settings-and-controls.md index f2bd9938..7da0a029 100644 --- a/src/locales/de/docs/settings-and-controls.md +++ b/src/locales/de/docs/settings-and-controls.md @@ -127,7 +127,7 @@ Die Aufzeichnung dient Untersuchungen und kann große Dateien erzeugen. Lass sie 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 oder der Kalibrierungsdialog geöffnet ist. Schließe die Bearbeitung ab oder brich sie ab, um die wartenden Importe fortzusetzen. +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. 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. diff --git a/src/locales/es/docs/settings-and-controls.md b/src/locales/es/docs/settings-and-controls.md index c9185b2f..452a7bd2 100644 --- a/src/locales/es/docs/settings-and-controls.md +++ b/src/locales/es/docs/settings-and-controls.md @@ -127,7 +127,7 @@ El registro está pensado para investigar y puede crear archivos grandes. Déjal 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 o el cuadro de calibración. Termina o cancela esa sesión para continuar con las importaciones pendientes. +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. 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. diff --git a/src/locales/fr/docs/settings-and-controls.md b/src/locales/fr/docs/settings-and-controls.md index 683bc6c8..bfb3a346 100644 --- a/src/locales/fr/docs/settings-and-controls.md +++ b/src/locales/fr/docs/settings-and-controls.md @@ -127,7 +127,7 @@ L’enregistrement sert aux investigations et peut créer de gros fichiers. Lais 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 ou la boîte de dialogue d’étalonnage est ouvert. Terminez ou annulez cette session pour poursuivre les importations en attente. +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à. 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. diff --git a/src/locales/hi/docs/settings-and-controls.md b/src/locales/hi/docs/settings-and-controls.md index afe1a068..85e2e45b 100644 --- a/src/locales/hi/docs/settings-and-controls.md +++ b/src/locales/hi/docs/settings-and-controls.md @@ -127,7 +127,7 @@ Linux पर, जिन AppImage में अपडेट की जानक डेस्कटॉप स्थापना `.kfil` और पुराने `.kapp` फ़ाइलों को फ़िलामेंट प्रोफ़ाइल से तथा `.kpal` फ़ाइलों को पैलेट से जोड़ती है। फ़ाइल पर डबल-क्लिक करके उसे Kromacut में आयात और चयन करें। ऐप पहले से चल रहा हो तो उसकी मौजूदा विंडो का उपयोग होता है। सत्यापन, माइग्रेशन, डुप्लिकेट प्रबंधन और अंशांकन बनाए रखने के सामान्य नियम लागू होते हैं। -पैलेट एडिटर या कैलिब्रेशन डायलॉग खुला होने पर डेस्कटॉप से खोली गई फ़ाइलें प्रतीक्षा करती हैं। कतार में लगे आयात जारी रखने के लिए उस सत्र को पूरा करें या रद्द करें। +पैलेट एडिटर, कैलिब्रेशन डायलॉग या प्रोफ़ाइल का नाम बदलने या नई प्रोफ़ाइल सहेजने का फ़ॉर्म खुला होने पर डेस्कटॉप से खोली गई फ़ाइलें प्रतीक्षा करती हैं। कतार में लगे आयात जारी रखने के लिए उस सत्र को पूरा करें या रद्द करें; तब तक लिखे गए नाम और संपादित की जा रही प्रोफ़ाइल अपरिवर्तित रहते हैं। फ़िलामेंट के असहेजे बदलाव बदलने से पहले Kromacut **बदलाव रखें** या **प्रोफ़ाइल खोलें** का विकल्प देता है। पहले सहेजने के लिए बदलाव रखें; प्रोफ़ाइल खोलने से वे बदलाव छोड़ दिए जाते हैं। इस तरह खोली गई फ़ाइलें 32 MiB से छोटी होनी चाहिए। सामान्य JSON, चित्र और मॉडल अपने सामान्य आयात तरीकों का उपयोग करते हैं। diff --git a/src/locales/it/docs/settings-and-controls.md b/src/locales/it/docs/settings-and-controls.md index e632bab0..4de9e98f 100644 --- a/src/locales/it/docs/settings-and-controls.md +++ b/src/locales/it/docs/settings-and-controls.md @@ -127,7 +127,7 @@ La registrazione è per indagini e può produrre file grandi. Lasciala spenta pe 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 o la finestra di calibrazione. Completa o annulla la sessione per proseguire con le importazioni in attesa. +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. 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. diff --git a/src/locales/ja/docs/settings-and-controls.md b/src/locales/ja/docs/settings-and-controls.md index 87437708..35c1b5ff 100644 --- a/src/locales/ja/docs/settings-and-controls.md +++ b/src/locales/ja/docs/settings-and-controls.md @@ -127,7 +127,7 @@ Linuxでは、更新情報が埋め込まれたAppImageを、AppImageUpdateな デスクトップ版では、`.kfil` と旧形式の `.kapp` がフィラメントプロファイルに、`.kpal` がパレットに関連付けられます。ファイルをダブルクリックすると、Kromacut にインポートして選択できます。アプリが起動済みの場合は既存のウィンドウで処理されます。通常の検証、移行、重複処理、キャリブレーション保持のルールが適用されます。 -パレットエディターまたはキャリブレーションダイアログが開いている間、デスクトップから開いたファイルは待機します。編集中の作業を完了するかキャンセルすると、待機中のインポートが続行されます。 +パレットエディター、キャリブレーションダイアログ、またはプロファイルの名前変更・新規保存フォームが開いている間、デスクトップから開いたファイルは待機します。編集中の作業を完了するかキャンセルすると、待機中のインポートが続行されます。それまでは、入力した名前と編集中のプロファイルは変更されません。 未保存のフィラメント編集を置き換える前に、Kromacut は **編集を保持** または **プロファイルを開く** を表示します。先に保存する場合は編集を保持してください。プロファイルを開くと未保存の編集は破棄されます。この方法で開くファイルは 32 MiB 未満である必要があります。一般的な JSON、画像、モデルは通常のインポート手順を使用します。 diff --git a/src/locales/pt-PT/docs/settings-and-controls.md b/src/locales/pt-PT/docs/settings-and-controls.md index d2cf83e1..98a3e68d 100644 --- a/src/locales/pt-PT/docs/settings-and-controls.md +++ b/src/locales/pt-PT/docs/settings-and-controls.md @@ -127,7 +127,7 @@ O registo destina-se a investigação e pode criar ficheiros grandes. Deixe desl 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 ou a caixa de diálogo de calibração estiver aberto. Conclua ou cancele essa sessão para continuar as importações pendentes. +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á. 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. diff --git a/src/locales/ro/docs/settings-and-controls.md b/src/locales/ro/docs/settings-and-controls.md index 8fe8bee8..15c2d736 100644 --- a/src/locales/ro/docs/settings-and-controls.md +++ b/src/locales/ro/docs/settings-and-controls.md @@ -127,7 +127,7 @@ O înregistrare completă include informații de bază despre rulare, instantane 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 sau dialogul de calibrare este deschis. Finalizează sau anulează sesiunea pentru a continua importurile din coadă. +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. Î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. diff --git a/src/locales/uk/docs/settings-and-controls.md b/src/locales/uk/docs/settings-and-controls.md index 4f49335b..ea93c000 100644 --- a/src/locales/uk/docs/settings-and-controls.md +++ b/src/locales/uk/docs/settings-and-controls.md @@ -127,7 +127,7 @@ Kromacut запам’ятовує в браузері такі параметр Настільні інсталяції пов’язують файли `.kfil` і старі `.kapp` із профілями філаментів, а файли `.kpal` — з палітрами. Двічі клацніть файл, щоб імпортувати й вибрати його в Kromacut. Якщо застосунок уже працює, використовується його наявне вікно. Діють звичайні правила перевірки, міграції, обробки дублікатів і збереження калібрування. -Файли, відкриті з робочого столу, очікують, поки відкрито редактор палітри або діалог калібрування. Завершіть або скасуйте цей сеанс, щоб продовжити імпорт із черги. +Файли, відкриті з робочого столу, очікують, поки відкрито редактор палітри, діалог калібрування або форму перейменування чи збереження нового профілю. Завершіть або скасуйте цей сеанс, щоб продовжити імпорт із черги; до того часу введені назви й профіль, який ви редагуєте, залишаються незмінними. Перед заміною незбережених змін філаментів Kromacut пропонує **Залишити зміни** або **Відкрити профіль**. Залиште зміни, щоб спочатку їх зберегти; відкриття профілю відкидає їх. Файли, відкриті таким способом, мають бути меншими за 32 MiB. Звичайні JSON, зображення й моделі зберігають свої звичні способи імпорту. diff --git a/src/locales/zh-CN/docs/settings-and-controls.md b/src/locales/zh-CN/docs/settings-and-controls.md index ef628f2f..9d883ded 100644 --- a/src/locales/zh-CN/docs/settings-and-controls.md +++ b/src/locales/zh-CN/docs/settings-and-controls.md @@ -127,7 +127,7 @@ HueForge耗材卷库导出(`.csv`或`.tsv`)可直接导入。在HueForge中 桌面安装将 `.kfil` 和旧版 `.kapp` 文件关联到耗材配置,将 `.kpal` 文件关联到调色板。双击文件即可在 Kromacut 中导入并选中它。如果应用已经运行,会使用现有窗口处理文件。仍然遵循通常的验证、迁移、重复处理和校准保留规则。 -调色板编辑器或校准对话框打开时,从桌面打开的文件会等待处理。完成或取消当前操作后,排队的导入将继续。 +调色板编辑器、校准对话框或配置的重命名、新建保存表单打开时,从桌面打开的文件会等待处理。完成或取消当前操作后,排队的导入将继续;在此之前,已输入的名称和正在编辑的配置保持不变。 替换未保存的耗材修改之前,Kromacut 会提供 **保留修改** 或 **打开配置**。选择保留修改后可先保存;打开配置则会放弃这些修改。以此方式打开的文件必须小于 32 MiB。普通 JSON、图片和模型仍使用通常的导入流程。 diff --git a/tests/e2e/desktop-file-open.spec.ts b/tests/e2e/desktop-file-open.spec.ts index b2b0dd4b..88182fed 100644 --- a/tests/e2e/desktop-file-open.spec.ts +++ b/tests/e2e/desktop-file-open.spec.ts @@ -260,6 +260,96 @@ test('@smoke cancelling a palette editor resumes a mixed profile/palette queue w 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, }) => { From 0244e5a962ade35bd7f73003057c5bf2fa975c0a Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 30 Sep 2026 21:55:05 +0300 Subject: [PATCH 06/31] fix: preserve overlapping manual and desktop imports --- CHANGELOG.md | 2 +- src/hooks/usePaletteManager.ts | 29 ++++++---- src/hooks/useProfileManager.ts | 35 +++++++----- tests/e2e/desktop-file-open.spec.ts | 88 +++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 678703bf..3cfc26b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Added -- **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, or profile Rename/Save New form is finished or cancelled, preserving draft names and the profile being edited. 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 imports queued during editing, saving/cancelling profile forms, and retrying after a profile save failure. +- **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, or profile Rename/Save New form is finished or cancelled, preserving draft names and the profile being edited. Overlapping manual and desktop imports merge with the latest saved list, preserving files imported while a manual read is pending. 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, imports queued during editing, saving/cancelling profile forms, and retrying after a profile save failure. ### Fixed diff --git a/src/hooks/usePaletteManager.ts b/src/hooks/usePaletteManager.ts index 3e3dffa0..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 ---- @@ -197,7 +202,11 @@ export function usePaletteManager() { setImportFeedback('Invalid palette file'); return false; } - const result = importCustomPalettes(customPalettes, incoming, RESERVED_PALETTE_IDS); + const result = importCustomPalettes( + customPalettesRef.current, + incoming, + RESERVED_PALETTE_IDS + ); if (result.imported.length > 0) { if (!saveCustomPalettes(result.palettes)) { setImportFeedback(paletteStorageFailure('The palette import')); @@ -222,7 +231,7 @@ export function usePaletteManager() { } return result.resolved.length > 0; }, - [customPalettes, setSelectedPalette] + [setCustomPalettes, setSelectedPalette] ); const handleImportFile = useCallback( diff --git a/src/hooks/useProfileManager.ts b/src/hooks/useProfileManager.ts index 22676df2..95326f24 100644 --- a/src/hooks/useProfileManager.ts +++ b/src/hooks/useProfileManager.ts @@ -80,7 +80,14 @@ export function 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 ); @@ -124,7 +131,7 @@ export function useProfileManager({ setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE); } }, - [filaments, profiles, setShowSaveNewPopover] + [filaments, profiles, setProfiles, setShowSaveNewPopover] ); // Save (overwrite): updates existing profile in-place (templates are read-only) @@ -139,7 +146,7 @@ export function useProfileManager({ ) { setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE); } - }, [activeProfileId, filaments, profiles]); + }, [activeProfileId, filaments, profiles, setProfiles]); const handleRenameProfile = useCallback( (name: string) => { @@ -159,7 +166,7 @@ export function useProfileManager({ setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE); } }, - [activeProfileId, profiles, setShowRenamePopover] + [activeProfileId, profiles, setProfiles, setShowRenamePopover] ); const handleLoadProfile = useCallback( @@ -199,7 +206,7 @@ export function useProfileManager({ setImportFeedback(PROFILE_STORAGE_FAILURE_MESSAGE); } }, - [profiles, activeProfileId] + [profiles, activeProfileId, setProfiles] ); const handleExportProfile = useCallback(async () => { @@ -251,7 +258,7 @@ export function useProfileManager({ setProfiles(updated); return record; }, - [activeProfile, activeProfileId, isDirty, profiles] + [activeProfile, activeProfileId, isDirty, profiles, setProfiles] ); const handleSetPaletteTargetResponse = useCallback( @@ -278,7 +285,7 @@ export function useProfileManager({ } setProfiles(updated); }, - [activeProfile, activeProfileId, isDirty, profiles] + [activeProfile, activeProfileId, isDirty, profiles, setProfiles] ); const handleCompletePaletteProofEvaluation = useCallback( @@ -300,7 +307,7 @@ export function useProfileManager({ } setProfiles(updated); }, - [activeProfile, activeProfileId, isDirty, profiles] + [activeProfile, activeProfileId, isDirty, profiles, setProfiles] ); const handleReopenPaletteProofEvaluation = useCallback( @@ -322,7 +329,7 @@ export function useProfileManager({ } setProfiles(updated); }, - [activeProfile, activeProfileId, isDirty, profiles] + [activeProfile, activeProfileId, isDirty, profiles, setProfiles] ); const handleDeletePaletteProof = useCallback( @@ -344,7 +351,7 @@ export function useProfileManager({ } setProfiles(updated); }, - [activeProfile, activeProfileId, isDirty, profiles] + [activeProfile, activeProfileId, isDirty, profiles, setProfiles] ); const handleUpsertStackMatrixCalibration = useCallback( @@ -368,7 +375,7 @@ export function useProfileManager({ } setProfiles(updated); }, - [activeProfile, activeProfileId, isDirty, profiles] + [activeProfile, activeProfileId, isDirty, profiles, setProfiles] ); const handleDeleteStackMatrixCalibration = useCallback( @@ -390,7 +397,7 @@ export function useProfileManager({ } setProfiles(updated); }, - [activeProfile, activeProfileId, isDirty, profiles] + [activeProfile, activeProfileId, isDirty, profiles, setProfiles] ); const importText = useCallback( @@ -403,7 +410,7 @@ export function useProfileManager({ setImportFeedback('Invalid profile file'); return false; } - const result = importProfiles(profiles, incoming, RESERVED_PROFILE_IDS); + const result = importProfiles(profilesRef.current, incoming, RESERVED_PROFILE_IDS); if (result.imported.length > 0) { if (!saveProfilesToStorage(result.profiles)) { setImportFeedback( @@ -433,7 +440,7 @@ export function useProfileManager({ } return result.resolved.length > 0; }, - [profiles, setFilaments] + [setProfiles, setFilaments] ); const handleImportFile = useCallback( diff --git a/tests/e2e/desktop-file-open.spec.ts b/tests/e2e/desktop-file-open.spec.ts index 88182fed..f20c734e 100644 --- a/tests/e2e/desktop-file-open.spec.ts +++ b/tests/e2e/desktop-file-open.spec.ts @@ -45,6 +45,7 @@ type MockDesktop = Window & { signal: () => void; calls: string[]; failWrites?: boolean; + releaseRead?: () => void; }; }; @@ -130,6 +131,93 @@ async function expectPendingFiles(page: Page, count: number) { ); } +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'); + } + }); +} + test('@smoke desktop imports wait for calibration and protect the resulting unsaved measurements', async ({ page, }) => { From 5ba24fbc4af6d84650c5c0c7d4ad2be2fca39221 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 30 Sep 2026 23:18:46 +0300 Subject: [PATCH 07/31] docs: remove obsolete TD test-image README example --- CHANGELOG.md | 2 +- README.md | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8314736..2daff1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to Kromacut are documented in this file. ### Changed -- **README organization** - Added section navigation, consolidated the getting-started workflows, and linked the illustrated guides for detailed controls and calibration. Separated desktop installation from development instructions and clarified the calibration entry point, profile backups, Node.js prerequisites, and when to rebuild before export. +- **README organization** - Added section navigation, consolidated the getting-started workflows, and linked the illustrated guides for detailed controls and calibration. Separated desktop installation from development instructions and clarified the calibration entry point, profile backups, Node.js prerequisites, and when to rebuild before export. Removed the obsolete TD test-image example. ### Fixed diff --git a/README.md b/README.md index 7315d66e..6f64ba49 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,6 @@ From source image to finished print: | **Sliced by color** | **Real print** | | Fuji model sliced by color | Finished Fuji print | -
-Try the included TD test image - -Download [tdTest.png](src/assets/tdTest.png), then upload it to experiment with Manual color ordering and per-color heights. It is a source image for experimentation, not a measurement of your filament's hiding distance. - -Transmission Distance test image for manual layer experiments - -
- ## Features - **Image preparation:** Drag-and-drop upload, non-destructive adjustments, resizing, color reduction, dedithering, and pixel touch-up tools. From 8783b4ee6cee6267f6687ea51dcbc4333200b127 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 30 Sep 2026 23:23:35 +0300 Subject: [PATCH 08/31] docs: add clickable YouTube previews to README --- CHANGELOG.md | 2 +- README.md | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2daff1ad..4f824389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Added -- **README video guides** - Linked the Kromacut tutorial and original introduction video in a dedicated section near the top of the README. +- **README video guides** - Linked the Kromacut tutorial and original introduction video with clickable YouTube thumbnails and separate labeled sections near the top of the README. ### Changed diff --git a/README.md b/README.md index 6f64ba49..7aa18269 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,19 @@ Turn a 2D image into a stacked, color-layered 3D print. Kromacut is a free, open ## Video guides -- **Tutorial:** [Turn Any Image into a Multicolor 3D Print | Kromacut Tutorial](https://youtu.be/NnaTVuJ2Sps) — Follow the image-to-print workflow. -- **Original introduction:** [I Recreated the Industry’s Most Advanced 3D Printing App… For Free](https://youtu.be/nTcZJ2gG7zo) — Meet the project and the idea behind Kromacut. +Click a thumbnail to watch on YouTube. + +### Tutorial + +[Turn Any Image into a Multicolor 3D Print | Kromacut Tutorial](https://youtu.be/NnaTVuJ2Sps) ? Follow the image-to-print workflow. + +[Watch the Kromacut tutorial on YouTube](https://youtu.be/NnaTVuJ2Sps) + +### Original introduction + +[I Recreated the Industry?s Most Advanced 3D Printing App? For Free](https://youtu.be/nTcZJ2gG7zo) ? Meet the project and the idea behind Kromacut. + +[Watch the original Kromacut introduction on YouTube](https://youtu.be/nTcZJ2gG7zo) For current controls and settings, use the [written quick start](https://kromacut.com/docs/quick-start) alongside the videos. From d652a7b8853eb0f02e8551e186be5ee0a52fe086 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 30 Sep 2026 23:31:20 +0300 Subject: [PATCH 09/31] docs: reuse landing-page showcase images in README --- CHANGELOG.md | 2 +- README.md | 30 +++++++++++++++++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f824389..a2361c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to Kromacut are documented in this file. ### Changed -- **README organization** - Added section navigation, consolidated the getting-started workflows, and linked the illustrated guides for detailed controls and calibration. Separated desktop installation from development instructions and clarified the calibration entry point, profile backups, Node.js prerequisites, and when to rebuild before export. Removed the obsolete TD test-image example. +- **README organization** - Added section navigation, consolidated the getting-started workflows, and linked the illustrated guides for detailed controls and calibration. Separated desktop installation from development instructions and clarified the calibration entry point, profile backups, Node.js prerequisites, and when to rebuild before export. Removed the obsolete TD test-image example. Replaced the old Fuji examples with landing-page showcase images, including creator and artwork credits. ### Fixed diff --git a/README.md b/README.md index 7aa18269..878be7f7 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,13 @@ Click a thumbnail to watch on YouTube. ### Tutorial -[Turn Any Image into a Multicolor 3D Print | Kromacut Tutorial](https://youtu.be/NnaTVuJ2Sps) ? Follow the image-to-print workflow. +[Turn Any Image into a Multicolor 3D Print | Kromacut Tutorial](https://youtu.be/NnaTVuJ2Sps) — Follow the image-to-print workflow. [Watch the Kromacut tutorial on YouTube](https://youtu.be/NnaTVuJ2Sps) ### Original introduction -[I Recreated the Industry?s Most Advanced 3D Printing App? For Free](https://youtu.be/nTcZJ2gG7zo) ? Meet the project and the idea behind Kromacut. +[I Recreated the Industry’s Most Advanced 3D Printing App… For Free](https://youtu.be/nTcZJ2gG7zo) — Meet the project and the idea behind Kromacut. [Watch the original Kromacut introduction on YouTube](https://youtu.be/nTcZJ2gG7zo) @@ -30,13 +30,29 @@ For current controls and settings, use the [written quick start](https://kromacu ## Examples -From source image to finished print: +Projects from the [landing-page community showcase](https://kromacut.com/). Click an image to open the full-size view. -| 2D input | 3D preview | +### Titan: from preview to finished print + +| Kromacut prediction | Slicer preview | Finished print | +| --- | --- | --- | +| [Kromacut Auto-paint prediction for the golden waves of the Titan poster](content/community/titan-kromacut-preview.png) | [Creality Print slicer preview of the Titan poster with its filament change tower](content/community/titan-slicer-preview.png) | [Finished Titan layered print with golden yellow and orange wave reflections on a dark background](content/community/titan-finished.jpg) | + +Print and previews by **vycdev**. Original artwork: [NASA/JPL - Titan, Visions of the Future](https://www.jpl.nasa.gov/images/titan-jpl-travel-poster/). + +Preview colors are predictions; filament, calibration, lighting, and camera processing affect the finished result. + +### More finished prints + +| Hobbits and Dragons | King of Hearts | Hope poster | +| --- | --- | --- | +| [A colorful Hobbits and Dragons layered 3D print](content/community/hobbits-and-dragons-1.jpg) | [A multicolor layered King of Hearts playing-card print](content/community/king-of-hearts.jpg) | [Finished layered Hope poster print showing orange and purple-blue color shifts](content/community/hope-finished.jpg) | +| By [u/ominaex25](https://www.reddit.com/r/kromacut/comments/1vum7om/hobbits_and_dragons/) | By **vycdev** | By **vycdev** | + +| Batman: The Jiro Kuwata Batmanga | Naruto | | --- | --- | -| Fuji source image | Fuji stacked 3D preview | -| **Sliced by color** | **Real print** | -| Fuji model sliced by color | Finished Fuji print | +| [Finished Batmanga layered print with a yellow Japanese title, tan background, and black-and-gray Batman figure](content/community/batmanga-finished.jpg) | [Finished Naruto layered print with yellow hair, orange clothing, and a lavender-blue sky](content/community/naruto-finished.jpg) | +| By **vycdev**. Artwork: [Jiro Kuwata / DC - Batmanga, Book 1](https://m.media-amazon.com/images/I/81rOZq5ZgqL._AC_UF1000,1000_QL80_.jpg). | By **vycdev**. Artwork: [Naruto](https://in.pinterest.com/pin/169870217190172931/). | ## Features From 4101150b776162d7306746a96bd49967dc418e6b Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 30 Sep 2026 23:39:26 +0300 Subject: [PATCH 10/31] docs: replace README app walkthroughs with feature overview --- CHANGELOG.md | 2 +- README.md | 83 ++++++++++------------------------------------------ 2 files changed, 16 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2361c25..ce97bff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to Kromacut are documented in this file. ### Changed -- **README organization** - Added section navigation, consolidated the getting-started workflows, and linked the illustrated guides for detailed controls and calibration. Separated desktop installation from development instructions and clarified the calibration entry point, profile backups, Node.js prerequisites, and when to rebuild before export. Removed the obsolete TD test-image example. Replaced the old Fuji examples with landing-page showcase images, including creator and artwork credits. +- **README organization** - Added section navigation and concise feature descriptions, with detailed usage, calibration, and print instructions linked through documentation. Separated desktop downloads from development setup, removed the obsolete TD test-image example, and refreshed the examples with landing-page showcase images and creator/artwork credits. ### Fixed diff --git a/README.md b/README.md index 878be7f7..6e787828 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Turn a 2D image into a stacked, color-layered 3D print. Kromacut is a free, open **[Open the app](https://kromacut.com/app)** · [Website](https://kromacut.com/) · [User documentation](https://kromacut.com/docs/overview) · [Desktop downloads](https://github.com/vycdev/Kromacut/releases) -[Video guides](#video-guides) · [Examples](#examples) · [Features](#features) · [How to use](#how-to-use) · [Printing and export](#printing-and-export) · [Documentation](#documentation) · [Desktop app](#native-desktop-app-tauri) · [Development](#development) · [Contributing](#contributing) +[Video guides](#video-guides) · [Examples](#examples) · [Features](#features) · [Documentation](#documentation) · [Desktop app](#native-desktop-app-tauri) · [Development](#development) · [Contributing](#contributing) ## Video guides @@ -56,80 +56,35 @@ Preview colors are predictions; filament, calibration, lighting, and camera proc ## Features -- **Image preparation:** Drag-and-drop upload, non-destructive adjustments, resizing, color reduction, dedithering, and pixel touch-up tools. -- **Manual layer control:** Edit palette colors, reorder them, and set per-color heights. -- **Auto-paint:** Plan physical filament stacks from your real filament colors and hiding distances, with five deterministic optimizer effort tiers and region weighting. -- **Filament calibration and profiles:** Measure hiding distance with printed wedges, compare Palette Proofs, photograph Stack Matrices, and save or share filament profiles. -- **2D and 3D previews:** Inspect the stack layer by layer and compare simulated blends with physical filament colors. -- **Printable exports:** Download STL or color-aware 3MF files and copy a print plan with filament swap layers. -- **Flat Paint (experimental):** Make a flat face-up or face-down slab for multi-material printing via 3MF. +### Image preparation -## How to use - -1. **Load an image** with the upload button or drag it into the 2D preview. -2. **Prepare it in 2D.** Apply any image adjustments, resize if needed, and reduce colors in Quantization Settings. Start with K-means and an Auto palette; use Dedither if isolated speckles remain. -3. **Switch to 3D.** Set Pixel Size (XY), Layer Height, and First Layer Height to match your intended print and slicer settings. -4. **Choose Auto-paint or Manual** using the workflows below. -5. **Click Build 3D Model** and inspect the result with the Layer Preview slider. -6. **Export** from the download menu, copy the Print Instructions, and check the sliced model before printing. - -After changing the image or 3D settings, click **Build 3D Model** again before exporting. Auto-paint recalculates its stack automatically, but the displayed geometry and export stay tied to the last build. +Non-destructive image adjustments, cropping, resizing, color reduction, and dedithering turn artwork into a compact palette. Brush, Eraser, Fill, Text, and color-picking tools support pixel touch-ups, while custom and supplier palettes provide reusable color sets. ### Auto-paint -Use Auto-paint when you want Kromacut to plan the filament stack: - -1. Add the filaments you actually own, or load a saved profile. -2. Set each filament's color and **Hiding Distance (HD)**. Open **Calibrate → Hiding Distance** to measure HD with a printed wedge, or start with an estimate. **Convert from TD** accepts conventional backlit TD values. -3. Leave **Max Height** on **Auto** initially. Enable **Enhanced color matching** to search for a better filament order; **Balanced** is a practical starting tier. -4. Wait for calculation, inspect the transition zones and confidence details, then build the model. - -Profiles are saved locally and can be exported as `.kfil` files. Imports also accept legacy `.kapp`, `.json`, and HueForge spool `.csv`/`.tsv` files. Save changes to your named profile before exporting a backup so its Palette Proof and Stack Matrix evidence is included. - -See the [Auto-paint guide](https://kromacut.com/docs/auto-paint) for optimizer tiers, repeated swaps, color separation, height dithering, region weighting, and filament suggestions. The [calibration workflows guide](https://kromacut.com/docs/calibration-workflows) covers wedges, Palette Proofs, and Stack Matrices. +Auto-paint plans physical layer stacks from the filaments you own, using their colors and Hiding Distance (HD) to predict thin-layer blends. Five deterministic search effort options, repeated filament swaps, region weighting, color separation, and height dithering offer control over the result. ### Manual mode -Use Manual when you want direct control over the image colors: - -1. Reduce the image to the palette you want to work with. -2. Edit swatches in **Image colors** as needed. -3. In **3D → Manual**, drag colors into print order and adjust each color's slice height. -4. Build the model, inspect the layers, and export with the copied swap plan. - -See the [3D mode guide](https://kromacut.com/docs/3d-mode) for dimensions, layer snapping, manual heights, and preview controls. - -## Printing and export +Direct control over palette colors, stack order, and per-color heights supports simple layered designs and carefully tuned color transitions. -- Default regular layer height: **0.12 mm**. Default first-layer height: **0.20 mm**. -- Match both layer heights in Kromacut and your slicer so the color-swap layer numbers agree. -- Set the intended physical size with **Pixel Size (XY)** before building. -- Use the **Layer Preview** slider to inspect transitions. Trimming the preview does not trim the exported model. -- Keep the export at **100% Z scale** with constant layer height. To change layer height, update it in Kromacut and rebuild. -- Preview colors are predictions; filament, calibration, printing conditions, and lighting affect the finished result. +### Filament calibration and profiles -| Format | Use it for | -| --- | --- | -| **STL** | A widely supported geometry file. Add filament swaps in your slicer using the Print Instructions. | -| **3MF** | Color-aware output with physical filament assignments for compatible slicers. Review those assignments and your printer settings. | - -Flat Paint exports use **3MF only**. See the [Flat Paint guide](https://kromacut.com/docs/flat-paint) for face-up and face-down layouts. +Printed wedges measure frontlit hiding distance. Palette Proofs compare candidate stacks for artwork colors, while photographed Stack Matrices record measured recipe colors. Reusable `.kfil` profiles keep filament settings and calibration evidence together, with profile sharing and HueForge spool-library import. -### 3MF export (preview) +### 3D preview -3MF preserves physical filament colors rather than assigning a new material to every simulated blend. It is a model file, not ready-to-run G-code: choose your printer and filament profiles, review the slicer settings, and slice it before printing. +Layer-by-layer inspection reveals the stack from foundation to top. Simulated and Physical color modes compare predicted blends with real filament assignments; **Color accurate**, **Shaded**, **Transparent**, and **Wireframe** views offer different ways to inspect the model. -Kromacut 3MF export opened in a slicer +### STL and 3MF export -Read [Generating and exporting output](https://kromacut.com/docs/generating-exporting-output) for build behavior, swap-layer interpretation, slicer setup, and saving files. Report export problems in [GitHub Issues](https://github.com/vycdev/Kromacut/issues). +Binary STL and color-aware 3MF exports connect the model to compatible slicers. Auto-paint 3MFs preserve physical filament colors rather than assigning a material to every predicted shade. Standard layered prints include plain-text instructions with starting colors and filament swap layers. -## Hiding Distance (HD) and Transmission Distance (TD) +### Flat Paint (experimental) -Thin filament layers can blend into intermediate shades, as popularized by [HueForge](https://shop.thehueforge.com/blogs/news/what-is-hueforge). Auto-paint estimates these blends from your filament colors and **Hiding Distance (HD)**: the depth at which a filament hides the material beneath it under front lighting. +Face-up and face-down layouts turn layered artwork into a uniform-thickness slab for bookmarks, coasters, and other flat pieces. Flat Paint supports suitable multi-material workflows and exports as 3MF. -Conventional backlit/lithophane **Transmission Distance (TD)** is a different input, roughly **10× HD**. Use **Convert from TD** on the filament row to convert it; do not convert an existing HD value again. - -Printed wedge calibration measures frontlit HD. Palette Proofs and Stack Matrices provide additional evidence about actual printed stacks. See [Calibration theory](https://kromacut.com/docs/calibration-theory) for the optical model and [Calibration workflows](https://kromacut.com/docs/calibration-workflows) for practical steps. +The [documentation](#documentation) covers settings, calibration, and printing workflows in detail. ## Documentation @@ -160,15 +115,7 @@ Download a pre-built app from [GitHub Releases](https://github.com/vycdev/Kromac The desktop app can check for new versions and open the release download page. See [Update checker documentation](docs/UPDATE_CHECKER.md) for details. -**macOS “Kromacut is damaged” error:** Unsigned builds may need their quarantine attribute removed after installation: - -```bash -sudo xattr -d com.apple.quarantine /Applications/Kromacut.app -``` - -**Windows SmartScreen warning:** For the unsigned installer, choose **More info → Run anyway**. Windows builds use Microsoft Edge WebView2; the standard installer includes its bootstrapper, while the offline installer includes the runtime installer. - -See the [desktop distribution notes](docs/TAURI.md#distribution-notes) for platform details. +For installation help and platform requirements, see [Troubleshooting](https://kromacut.com/docs/troubleshooting) and the [desktop distribution notes](docs/TAURI.md#distribution-notes). ## Development From 9942cd93590c4730e9a9a7f156cc8715682d982e Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Wed, 30 Sep 2026 23:39:52 +0300 Subject: [PATCH 11/31] fix: preserve calibration and pending filament edits --- CHANGELOG.md | 2 +- src/App.tsx | 8 +- src/components/AutoPaintTab.tsx | 3 + src/components/FilamentRow.tsx | 60 ++- src/components/ThreeDControls.tsx | 16 +- src/docs/settings-and-controls.md | 2 + src/hooks/useProfileManager.ts | 21 +- src/locales/bn/docs/settings-and-controls.md | 2 + src/locales/de/docs/settings-and-controls.md | 2 + src/locales/es/docs/settings-and-controls.md | 2 + src/locales/fr/docs/settings-and-controls.md | 2 + src/locales/hi/docs/settings-and-controls.md | 2 + src/locales/it/docs/settings-and-controls.md | 2 + src/locales/ja/docs/settings-and-controls.md | 2 + .../pt-PT/docs/settings-and-controls.md | 2 + src/locales/ro/docs/settings-and-controls.md | 2 + src/locales/uk/docs/settings-and-controls.md | 2 + .../zh-CN/docs/settings-and-controls.md | 2 + tests/e2e/desktop-file-open.spec.ts | 431 +++++++++++++++++- 19 files changed, 551 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cfc26b6..d25c63b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Added -- **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, or profile Rename/Save New form is finished or cancelled, preserving draft names and the profile being edited. Overlapping manual and desktop imports merge with the latest saved list, preserving files imported while a manual read is pending. 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, imports queued during editing, saving/cancelling profile forms, and retrying after a profile save failure. +- **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. ### Fixed diff --git a/src/App.tsx b/src/App.tsx index 1aa945b5..32106f29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -271,11 +271,16 @@ function App(): React.ReactElement | null { 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 + paletteEditorOpen || + calibrationDialogOpen || + profileSaveNewOpen || + profileRenameOpen || + filamentEditing ? null : queuedDesktopFile; const handledPaletteFile = useRef(null); @@ -886,6 +891,7 @@ function App(): React.ReactElement | null { onProfileSaveNewOpenChange={setProfileSaveNewOpen} profileRenameOpen={profileRenameOpen} onProfileRenameOpenChange={setProfileRenameOpen} + onFilamentEditingChange={setFilamentEditing} />
)} diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index faf87d5f..b6b6ae72 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -342,6 +342,7 @@ interface AutoPaintTabProps { 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[]; @@ -440,6 +441,7 @@ export default function AutoPaintTab({ addFilamentWithProps, removeFilament, updateFilament, + onFilamentEditingChange, profiles, activeProfileId, isDirty, @@ -864,6 +866,7 @@ export default function AutoPaintTab({ filament={f} onUpdate={updateFilament} onRemove={removeFilament} + onEditingChange={onFilamentEditingChange} /> ))}
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); + }} + >