diff --git a/CHANGELOG.md b/CHANGELOG.md index e5cde45f..4a632858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Fixed +- **Relief layer keyboard controls** - Arrow keys now move each Layer Preview handle by one physical print layer, including the first-layer boundary, without adding a rounding sliver at the top of taller models. Home/End and larger keyboard steps respect the selected range. Pointer positioning remains proportional to height. Added physical-boundary regression coverage and browser coverage for both handles, exact ten-layer moves, and rebuild recovery. - **Reloading image files** - Choosing the same source image again reloads its original pixels after crop, resize, or other edits, instead of silently retaining the edited image. Added a browser regression for same-path reloads after resizing. - **Desktop palette exports** - Save `.kpal` palettes through the native Save As dialog instead of WebView browser downloads, preserving browser downloads and surfacing write failures without changing saved palettes. Retry partial writes and treat stalled writes as failures with localized feedback. Added cancellation, error, partial-write, and export-content regressions; recovery checks exclude automatic feedback dismissal. diff --git a/src/components/ThreeDView.tsx b/src/components/ThreeDView.tsx index 5dab988f..21e201a7 100644 --- a/src/components/ThreeDView.tsx +++ b/src/components/ThreeDView.tsx @@ -2,7 +2,7 @@ import type { SmoothMeshingStrength } from '../types'; import { useTranslation } from 'react-i18next'; import { translateRuntimeMessage } from '../lib/runtimeMessages'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { PointerEvent } from 'react'; +import type { KeyboardEvent, PointerEvent } from 'react'; import * as THREE from 'three'; import * as SliderPrimitive from '@radix-ui/react-slider'; import useThreeScene from '../hooks/useThreeScene'; @@ -711,6 +711,29 @@ export default function ThreeDView({ setPreviewHeight(Math.max(low, high)); }; + const handleReliefPreviewKeyDown = (event: KeyboardEvent, upper: boolean) => { + // Pointer positions remain proportional to height. Keyboard movement must + // use printable boundaries: a 0.01 mm increment otherwise snaps back to + // the same layer. Flat Paint already uses integer boundary indices. + if (flatPaint) return; + const decreasing = ['ArrowLeft', 'ArrowDown', 'PageDown'].includes(event.key); + const increasing = ['ArrowRight', 'ArrowUp', 'PageUp'].includes(event.key); + if (!decreasing && !increasing && event.key !== 'Home' && event.key !== 'End') return; + event.preventDefault(); + const current = upper ? (previewHeight ?? maxModelHeight) : previewMinHeight; + const page = event.shiftKey || event.key.startsWith('Page') ? 10 : 1; + const index = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? flatPreviewHeights.length - 1 + : flatPreviewIndex(current) + (decreasing ? -page : page); + const next = + flatPreviewHeights[Math.max(0, Math.min(flatPreviewHeights.length - 1, index))]; + if (upper) setPreviewHeight(Math.max(previewMinHeight, next)); + else setPreviewMinHeight(Math.min(previewHeight ?? maxModelHeight, next)); + }; + // 2. Rebuild mesh geometry only when the parent sends an explicit build signal. const buildTokenRef = useRef(0); const debounceTimerRef = useRef(null); @@ -2330,6 +2353,9 @@ export default function ThreeDView({ + handleReliefPreviewKeyDown(event, false) + } aria-valuetext={t('threeDView.height', { height: previewMinHeight.toFixed(2), })} @@ -2337,6 +2363,9 @@ export default function ThreeDView({ /> + handleReliefPreviewKeyDown(event, true) + } aria-valuetext={t('threeDView.height', { height: previewHeight.toFixed(2), })} diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index 7fc9d6b4..a923b7d0 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -162,6 +162,8 @@ View mode and simulated/physical choices are remembered. **Preview colors** appe Drag the bottom bar's lower and upper handles to isolate a height range. Positions snap to the layer grid. Hover over material segments for start or swap information. +Focus either handle and use the arrow keys to move one printed layer at a time. Shift+arrow or Page Up/Down moves ten layers; Home/End moves to a range limit. The handles cannot cross. + ![Cutoff handles hide layers for inspection but the export still contains the complete model.](19_preview_only.svg) _Schematic. Hiding a layer on screen never deletes it from the export._ diff --git a/src/lib/previewLayerRange.ts b/src/lib/previewLayerRange.ts index f85ffaef..6d9a6321 100644 --- a/src/lib/previewLayerRange.ts +++ b/src/lib/previewLayerRange.ts @@ -14,7 +14,10 @@ export function previewLayerHeights(total: number, layerHeight: number, firstLay if (total <= 0 || layerHeight <= 0) return [0]; const heights = [0]; const first = Math.max(layerHeight, firstLayerHeight); - for (let i = 0; first + i * layerHeight < total - HEIGHT_EPSILON; i++) { + // Relief bounds use Float32 vertices. Their rounding error grows with height; + // don't count that error as a separate layer at the top of taller models. + const topEpsilon = Math.max(HEIGHT_EPSILON, total * 2 ** -24); + for (let i = 0; first + i * layerHeight < total - topEpsilon; i++) { heights.push(first + i * layerHeight); } heights.push(total); diff --git a/src/locales/bn/docs/3d-mode.md b/src/locales/bn/docs/3d-mode.md index f3d184d3..763fd139 100644 --- a/src/locales/bn/docs/3d-mode.md +++ b/src/locales/bn/docs/3d-mode.md @@ -164,6 +164,8 @@ _উদাহরণমূলক কনট্যুরের তুলনা, স উচ্চতার একটি পরিসর আলাদা করে দেখতে নিচের বারের নিচু ও উঁচু হ্যান্ডল টানুন। অবস্থান স্তরের গ্রিডে আটকায়। শুরু বা বদলের তথ্য দেখতে উপাদানের অংশের ওপর মাউস রাখুন। +যেকোনো হ্যান্ডলে ফোকাস করে তীর কী দিয়ে একবারে একটি মুদ্রিত স্তর সরান। Shift+তীর বা Page Up/Down দশটি স্তর সরায়; Home/End পরিসরের সীমায় নিয়ে যায়। হ্যান্ডল দুটি একে অপরকে অতিক্রম করতে পারে না। + ![কাটঅফ হ্যান্ডল পরীক্ষার জন্য স্তর লুকায়, কিন্তু এক্সপোর্টে পুরো মডেল থাকে।](19_preview_only.svg) _উদাহরণচিত্র। পর্দায় স্তর লুকালে তা এক্সপোর্ট থেকে কখনো মুছে যায় না।_ diff --git a/src/locales/de/docs/3d-mode.md b/src/locales/de/docs/3d-mode.md index 7fbf31e3..474142d1 100644 --- a/src/locales/de/docs/3d-mode.md +++ b/src/locales/de/docs/3d-mode.md @@ -164,6 +164,8 @@ Ansichtsmodus und Auswahl simuliert/physisch werden gespeichert. **Vorschaufarbe Ziehe die unteren und oberen Griffe der unteren Leiste, um einen Höhenbereich einzeln zu betrachten. Positionen rasten am Schichtraster ein. Fahre über Materialsegmente, um Start- oder Wechselinformationen zu sehen. +Fokussiere einen Griff und bewege ihn mit den Pfeiltasten um jeweils eine gedruckte Schicht. Umschalt+Pfeil oder Bild auf/ab bewegt ihn um zehn Schichten; Pos1/Ende springt zur Bereichsgrenze. Die Griffe können sich nicht kreuzen. + ![Begrenzungsgriffe blenden Schichten zur Prüfung aus; der Export enthält weiterhin das vollständige Modell.](19_preview_only.svg) _Schematische Darstellung. Das Ausblenden einer Schicht am Bildschirm löscht sie niemals aus dem Export._ diff --git a/src/locales/es/docs/3d-mode.md b/src/locales/es/docs/3d-mode.md index 915e88cd..dfe011ec 100644 --- a/src/locales/es/docs/3d-mode.md +++ b/src/locales/es/docs/3d-mode.md @@ -164,6 +164,8 @@ Se recuerdan el modo de vista y la elección simulada/física. **Colores de vist Arrastra los controles inferior y superior de la barra inferior para aislar un intervalo de altura. Se ajustan a la cuadrícula de capas. Pasa el puntero por segmentos de material para información de inicio o cambio. +Enfoca un control y usa las flechas para moverlo una capa impresa cada vez. Mayús+flecha o Re Pág/Av Pág mueve diez capas; Inicio/Fin lleva a un límite del intervalo. Los controles no pueden cruzarse. + ![Los límites ocultan capas para inspección, pero la exportación sigue incluyendo el modelo completo.](19_preview_only.svg) _Esquema. Ocultar una capa en pantalla nunca la elimina de la exportación._ diff --git a/src/locales/fr/docs/3d-mode.md b/src/locales/fr/docs/3d-mode.md index 50123d6b..c0c3c517 100644 --- a/src/locales/fr/docs/3d-mode.md +++ b/src/locales/fr/docs/3d-mode.md @@ -164,6 +164,8 @@ Le mode de vue et le choix simulé/physique sont mémorisés. **Couleurs de l’ Déplacez les poignées inférieure et supérieure de la barre du bas pour isoler une plage de hauteurs. Les positions s’alignent sur la grille de couches. Survolez les segments de matériau pour les informations de départ ou de changement. +Placez le focus sur une poignée et utilisez les flèches pour avancer d’une couche imprimée. Maj+flèche ou Page précédente/suivante déplace de dix couches ; Début/Fin rejoint une limite. Les poignées ne peuvent pas se croiser. + ![Les poignées de limite masquent des couches pour l’examen, mais l’export contient toujours le modèle complet.](19_preview_only.svg) _Schéma de principe. Masquer une couche à l’écran ne la supprime jamais de l’export._ diff --git a/src/locales/hi/docs/3d-mode.md b/src/locales/hi/docs/3d-mode.md index 0843d6b4..73486cdb 100644 --- a/src/locales/hi/docs/3d-mode.md +++ b/src/locales/hi/docs/3d-mode.md @@ -164,6 +164,8 @@ _सांकेतिक रूपरेखा तुलना, स्लाइ नीचे की पट्टी के निचले और ऊपरी हैंडल खींचकर ऊँचाई सीमा अलग करें। स्थितियाँ परत ग्रिड पर स्नैप होती हैं। सामग्री खंड पर होवर से शुरुआत या बदलाव जानकारी मिलती है। +किसी हैंडल पर फ़ोकस करके तीर कुंजियों से एक बार में एक मुद्रित परत आगे बढ़ें। Shift+तीर या Page Up/Down से दस परतें बदलती हैं; Home/End सीमा तक ले जाते हैं। हैंडल एक-दूसरे को पार नहीं कर सकते। + ![सीमा हैंडल जाँच के लिए परतें छिपाते हैं, लेकिन निर्यात में पूरा मॉडल रहता है।](19_preview_only.svg) _सांकेतिक। स्क्रीन पर परत छिपाने से वह निर्यात से कभी नहीं मिटती।_ diff --git a/src/locales/it/docs/3d-mode.md b/src/locales/it/docs/3d-mode.md index ed9ddc3e..1590cc8c 100644 --- a/src/locales/it/docs/3d-mode.md +++ b/src/locales/it/docs/3d-mode.md @@ -164,6 +164,8 @@ La modalità di visualizzazione e la scelta tra colori simulati e fisici vengono Trascina le maniglie inferiore e superiore della barra in basso per isolare un intervallo di altezze. Le posizioni si agganciano alla griglia degli strati. Passa sopra i segmenti dei materiali per informazioni sull’inizio o sui cambi. +Porta il focus su una maniglia e usa le frecce per spostarti di uno strato stampato alla volta. Maiusc+freccia o Pag Su/Giù sposta di dieci strati; Home/Fine raggiunge un limite dell’intervallo. Le maniglie non possono incrociarsi. + ![Le maniglie di taglio nascondono strati per esaminarli, ma l’esportazione contiene comunque il modello completo.](19_preview_only.svg) _Schema. Nascondere uno strato sullo schermo non lo elimina mai dall’esportazione._ diff --git a/src/locales/ja/docs/3d-mode.md b/src/locales/ja/docs/3d-mode.md index 3a19b59d..c9d7a494 100644 --- a/src/locales/ja/docs/3d-mode.md +++ b/src/locales/ja/docs/3d-mode.md @@ -164,6 +164,8 @@ _輪郭の模式比較であり、スライサーのシミュレーションで 下部バーの下限・上限ハンドルをドラッグして、高さ範囲を絞ります。位置は層の格子にスナップします。材料の区間にポインターを重ねると開始や交換情報を見られます。 +いずれかのハンドルにフォーカスして矢印キーを押すと、印刷レイヤー単位で移動します。Shift+矢印または Page Up/Down は10層ずつ、Home/End は範囲の端まで移動します。ハンドル同士は交差しません。 + ![範囲ハンドルは確認のため層を隠しますが、書き出しにはモデル全体が残ります。](19_preview_only.svg) _模式図です。画面上で層を隠しても、書き出しから削除されることはありません。_ diff --git a/src/locales/pt-PT/docs/3d-mode.md b/src/locales/pt-PT/docs/3d-mode.md index f6a94a74..3d1feb27 100644 --- a/src/locales/pt-PT/docs/3d-mode.md +++ b/src/locales/pt-PT/docs/3d-mode.md @@ -164,6 +164,8 @@ Modo de vista e escolha simulada/física são recordados. **Cores da pré-visual Arraste as pegas inferior e superior da barra para isolar alturas. As posições encaixam na grelha de camadas. Passe sobre segmentos para informação de início ou troca. +Foca uma pega e usa as setas para avançar uma camada impressa de cada vez. Shift+seta ou Page Up/Down avança dez camadas; Home/End vai até um limite do intervalo. As pegas não podem cruzar-se. + ![As pegas ocultam camadas para inspeção, mas a exportação mantém o modelo completo.](19_preview_only.svg) _Esquema. Ocultar no ecrã nunca elimina da exportação._ diff --git a/src/locales/ro/docs/3d-mode.md b/src/locales/ro/docs/3d-mode.md index d4d4b432..e7125383 100644 --- a/src/locales/ro/docs/3d-mode.md +++ b/src/locales/ro/docs/3d-mode.md @@ -164,6 +164,8 @@ Modul de vizualizare și alegerile simulate/fizice sunt memorate. **Culori de pr Trage mânerele inferior și superior ale barei de jos pentru a izola un interval de înălțime. Pozițiile se aliniază la grila straturilor. Treci cursorul peste segmentele de material pentru informații despre început sau schimbare. +Selectează un mâner și folosește săgețile pentru a avansa câte un strat imprimat. Shift+săgeată sau Page Up/Down mută zece straturi; Home/End duce la limita intervalului. Mânerele nu se pot intersecta. + ![Mânerele limitelor ascund straturi pentru inspecție, dar exportul conține în continuare modelul complet.](19_preview_only.svg) _Schemă. Ascunderea unui strat pe ecran nu îl șterge niciodată din export._ diff --git a/src/locales/uk/docs/3d-mode.md b/src/locales/uk/docs/3d-mode.md index b528e36c..37314153 100644 --- a/src/locales/uk/docs/3d-mode.md +++ b/src/locales/uk/docs/3d-mode.md @@ -164,6 +164,8 @@ Auto-paint приймає справжні кольори філаментів Перетягніть нижню й верхню ручки нижньої смуги, щоб ізолювати діапазон висот. Позиції прив’язуються до сітки шарів. Наведіть на сегменти матеріалів для відомостей про початок або заміну. +Встановіть фокус на ручці та переміщуйте її стрілками на один друкований шар. Shift+стрілка або Page Up/Down переміщує на десять шарів; Home/End — до межі діапазону. Ручки не можуть перетинатися. + ![Ручки меж приховують шари для перевірки, але експорт досі містить повну модель.](19_preview_only.svg) _Схема. Приховування шару на екрані ніколи не видаляє його з експорту._ diff --git a/src/locales/zh-CN/docs/3d-mode.md b/src/locales/zh-CN/docs/3d-mode.md index cec4721b..fbb7afb1 100644 --- a/src/locales/zh-CN/docs/3d-mode.md +++ b/src/locales/zh-CN/docs/3d-mode.md @@ -164,6 +164,8 @@ _轮廓示意比较,不是切片模拟。_ 拖动底部条的下限及上限控制点可隔离一个高度范围。位置吸附到层网格。悬停材料段可查看起始或换料信息。 +聚焦任一控制点后,用方向键每次移动一个打印层。Shift+方向键或 Page Up/Down 每次移动十层;Home/End 移到范围边界。两个控制点不能交叉。 + ![截止控制点为检查隐藏层,但导出仍包含完整模型。](19_preview_only.svg) _示意图。屏幕上隐藏层绝不会将其从导出删除。_ diff --git a/tests/e2e/relief-layer-preview.spec.ts b/tests/e2e/relief-layer-preview.spec.ts new file mode 100644 index 00000000..3d804321 --- /dev/null +++ b/tests/e2e/relief-layer-preview.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; + +test('@smoke relief layer handles step physical boundaries with the keyboard', async ({ page }) => { + await page.addInitScript(() => { + localStorage.clear(); + Object.assign(window, { __KROMACUT_E2E: { buildHistory: [] } }); + localStorage.setItem( + 'kromacut:3d-print-settings', + JSON.stringify({ + pixelSize: 0.1, + layerHeight: 0.12, + slicerFirstLayerHeight: 0.2, + smoothMeshingStrength: 'none', + }) + ); + }); + await page.goto('/app'); + const png = await page.evaluate(() => { + const canvas = document.createElement('canvas'); + canvas.width = 130; + canvas.height = 20; + const ctx = canvas.getContext('2d')!; + // Thirteen layers leave room to verify ten-layer moves without clamping. + for (let i = 0; i < 13; i++) { + const value = Math.round((i * 255) / 12); + ctx.fillStyle = `rgb(${value},${value},${value})`; + ctx.fillRect(i * 10, 0, 10, 20); + } + return canvas.toDataURL().split(',')[1]; + }); + await page.getByTestId('image-file-input').setInputFiles({ + name: 'thirteen-layer-relief.png', + mimeType: 'image/png', + buffer: Buffer.from(png, 'base64'), + }); + await page.getByRole('button', { name: '3D', exact: true }).click(); + await page.getByTestId('build-3d-model').click(); + const low = page.getByRole('slider', { name: 'Preview bottom layer cutoff', exact: true }); + const high = page.getByRole('slider', { name: 'Preview top layer cutoff', exact: true }); + const height = async (slider: typeof high, value: number) => { + await expect(slider).toBeVisible(); + await expect + .poll(async () => parseFloat((await slider.getAttribute('aria-valuenow')) ?? '')) + .toBeCloseTo(value, 6); + }; + await height(high, 1.64); + for (const [key, value] of [ + ['ArrowLeft', 1.52], + ['ArrowDown', 1.4], + ['Home', 0], + ['ArrowUp', 0.2], + ['ArrowRight', 0.32], + ['ArrowLeft', 0.2], + ['ArrowDown', 0], + ['ArrowLeft', 0], + ['ArrowUp', 0.2], + ['ArrowRight', 0.32], + ['Shift+ArrowRight', 1.52], + ['Shift+ArrowLeft', 0.32], + ['PageUp', 1.52], + ['PageDown', 0.32], + ['End', 1.64], + ['Home', 0], + ] as const) { + await high.press(key); + await height(high, value); + } + await high.press('End'); + await low.press('ArrowRight'); + await height(low, 0.2); + await low.press('ArrowUp'); + await height(low, 0.32); + await low.press('End'); + await height(low, 1.64); + await low.press('Home'); + await low.press('PageUp'); + await height(low, 1.28); + await low.press('PageDown'); + await height(low, 0); + await low.press('Shift+ArrowRight'); + await height(low, 1.28); + await low.press('Shift+ArrowRight'); + await height(low, 1.64); + await high.press('Home'); + await height(high, 1.64); + await low.press('Home'); + await height(low, 0); + await high.press('PageDown'); + await height(high, 0.44); + await low.press('End'); + await height(low, 0.44); + await low.press('Home'); + await high.press('PageDown'); + await height(high, 0); + await high.press('PageUp'); + await height(high, 1.28); + await high.press('PageUp'); + await height(high, 1.64); + // Rebuilding the same inputs must restore the complete preview. + await high.press('ArrowLeft'); + await height(high, 1.52); + await low.press('ArrowRight'); + await height(low, 0.2); + await page.getByTestId('build-3d-model').click(); + await height(high, 1.64); + await height(low, 0); +}); diff --git a/tests/previewLayerRange.test.ts b/tests/previewLayerRange.test.ts index 2fa7f36c..a2592566 100644 --- a/tests/previewLayerRange.test.ts +++ b/tests/previewLayerRange.test.ts @@ -35,6 +35,20 @@ test('physical slider boundaries include a thick first layer, exact top, and no ); }); +test('Float32 relief model tops do not add a phantom physical layer', () => { + for (const layers of [268, 269, 536, 1068]) { + const top = 0.2 + (layers - 1) * 0.12; + // Relief model bounds come from the mesher's Float32 vertex positions. + const total = Math.fround(top); + const heights = previewLayerHeights(total, 0.12, 0.2); + assert.equal(heights.length - 1, layers); + assert.equal(heights.at(-1), total, 'retain the exact model top'); + assert.ok(Math.abs(heights.at(-2)! - (top - 0.12)) < 1e-6); + } + // An actual partial top layer must still be reachable. + assert.equal(previewLayerHeights(Math.fround(32.3), 0.12, 0.2).length - 1, 269); +}); + test('each layer cutoff trims merged Flat Paint slabs while preserving complete export positions', () => { const { mesh, original } = slab(); for (const height of previewLayerHeights(1.6, 0.08, 0.16).slice(1).reverse()) {