Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
31 changes: 30 additions & 1 deletion src/components/ThreeDView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -711,6 +711,29 @@ export default function ThreeDView({
setPreviewHeight(Math.max(low, high));
};

const handleReliefPreviewKeyDown = (event: KeyboardEvent<HTMLSpanElement>, 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<number | null>(null);
Expand Down Expand Up @@ -2330,13 +2353,19 @@ export default function ThreeDView({
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
aria-label={t('threeDView.previewBottomLayerCutoff')}
onKeyDown={(event) =>
handleReliefPreviewKeyDown(event, false)
}
aria-valuetext={t('threeDView.height', {
height: previewMinHeight.toFixed(2),
})}
className="block h-3.5 w-3.5 rounded-full border border-primary/70 bg-background shadow transition-colors hover:shadow-md focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<SliderPrimitive.Thumb
aria-label={t('threeDView.previewTopLayerCutoff')}
onKeyDown={(event) =>
handleReliefPreviewKeyDown(event, true)
}
aria-valuetext={t('threeDView.height', {
height: previewHeight.toFixed(2),
})}
Expand Down
2 changes: 2 additions & 0 deletions src/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
5 changes: 4 additions & 1 deletion src/lib/previewLayerRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/locales/bn/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ _উদাহরণমূলক কনট্যুরের তুলনা, স

উচ্চতার একটি পরিসর আলাদা করে দেখতে নিচের বারের নিচু ও উঁচু হ্যান্ডল টানুন। অবস্থান স্তরের গ্রিডে আটকায়। শুরু বা বদলের তথ্য দেখতে উপাদানের অংশের ওপর মাউস রাখুন।

যেকোনো হ্যান্ডলে ফোকাস করে তীর কী দিয়ে একবারে একটি মুদ্রিত স্তর সরান। Shift+তীর বা Page Up/Down দশটি স্তর সরায়; Home/End পরিসরের সীমায় নিয়ে যায়। হ্যান্ডল দুটি একে অপরকে অতিক্রম করতে পারে না।

![কাটঅফ হ্যান্ডল পরীক্ষার জন্য স্তর লুকায়, কিন্তু এক্সপোর্টে পুরো মডেল থাকে।](19_preview_only.svg)

_উদাহরণচিত্র। পর্দায় স্তর লুকালে তা এক্সপোর্ট থেকে কখনো মুছে যায় না।_
Expand Down
2 changes: 2 additions & 0 deletions src/locales/de/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
2 changes: 2 additions & 0 deletions src/locales/es/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
2 changes: 2 additions & 0 deletions src/locales/fr/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
2 changes: 2 additions & 0 deletions src/locales/hi/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ _सांकेतिक रूपरेखा तुलना, स्लाइ

नीचे की पट्टी के निचले और ऊपरी हैंडल खींचकर ऊँचाई सीमा अलग करें। स्थितियाँ परत ग्रिड पर स्नैप होती हैं। सामग्री खंड पर होवर से शुरुआत या बदलाव जानकारी मिलती है।

किसी हैंडल पर फ़ोकस करके तीर कुंजियों से एक बार में एक मुद्रित परत आगे बढ़ें। Shift+तीर या Page Up/Down से दस परतें बदलती हैं; Home/End सीमा तक ले जाते हैं। हैंडल एक-दूसरे को पार नहीं कर सकते।

![सीमा हैंडल जाँच के लिए परतें छिपाते हैं, लेकिन निर्यात में पूरा मॉडल रहता है।](19_preview_only.svg)

_सांकेतिक। स्क्रीन पर परत छिपाने से वह निर्यात से कभी नहीं मिटती।_
Expand Down
2 changes: 2 additions & 0 deletions src/locales/it/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ja/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ _輪郭の模式比較であり、スライサーのシミュレーションで

下部バーの下限・上限ハンドルをドラッグして、高さ範囲を絞ります。位置は層の格子にスナップします。材料の区間にポインターを重ねると開始や交換情報を見られます。

いずれかのハンドルにフォーカスして矢印キーを押すと、印刷レイヤー単位で移動します。Shift+矢印または Page Up/Down は10層ずつ、Home/End は範囲の端まで移動します。ハンドル同士は交差しません。

![範囲ハンドルは確認のため層を隠しますが、書き出しにはモデル全体が残ります。](19_preview_only.svg)

_模式図です。画面上で層を隠しても、書き出しから削除されることはありません。_
Expand Down
2 changes: 2 additions & 0 deletions src/locales/pt-PT/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ro/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
2 changes: 2 additions & 0 deletions src/locales/uk/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ Auto-paint приймає справжні кольори філаментів

Перетягніть нижню й верхню ручки нижньої смуги, щоб ізолювати діапазон висот. Позиції прив’язуються до сітки шарів. Наведіть на сегменти матеріалів для відомостей про початок або заміну.

Встановіть фокус на ручці та переміщуйте її стрілками на один друкований шар. Shift+стрілка або Page Up/Down переміщує на десять шарів; Home/End — до межі діапазону. Ручки не можуть перетинатися.

![Ручки меж приховують шари для перевірки, але експорт досі містить повну модель.](19_preview_only.svg)

_Схема. Приховування шару на екрані ніколи не видаляє його з експорту._
Expand Down
2 changes: 2 additions & 0 deletions src/locales/zh-CN/docs/3d-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ _轮廓示意比较,不是切片模拟。_

拖动底部条的下限及上限控制点可隔离一个高度范围。位置吸附到层网格。悬停材料段可查看起始或换料信息。

聚焦任一控制点后,用方向键每次移动一个打印层。Shift+方向键或 Page Up/Down 每次移动十层;Home/End 移到范围边界。两个控制点不能交叉。

![截止控制点为检查隐藏层,但导出仍包含完整模型。](19_preview_only.svg)

_示意图。屏幕上隐藏层绝不会将其从导出删除。_
Expand Down
107 changes: 107 additions & 0 deletions tests/e2e/relief-layer-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading