diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c9cc4..6f3371a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to Kromacut are documented in this file. - **2D touch-up tools** - The 2D preview toolbar now includes five hard-edged pixel tools (Brush, Eraser, Fill, Text, and color picker) for direct image editing with palette-safe colors, an on-canvas text box you type into directly with live move, resize, and word wrap, one undo/redo step per edit, and live non-blocking drawing with adjustments staying non-destructive. - **Calibration theory docs** - New in-app documentation page explaining the science behind filament calibration: the frontlit Beer-Lambert optical model and hiding distance, why the wedge's reference-rail comparison is reliable without a camera, how a single patch read becomes a hiding distance through a just-noticeable-difference solve, per-channel measurement with multiple bases, the session JND fit, and what the confidence score reflects — illustrated with three new diagrams. The Calibrate Filaments dialog links straight to it. - **Reddit community links** - Added r/kromacut links to the app settings and README. +- **Experimental multi-plate mode** - Added an experimental mode for multi-plate projects, where one image may be separated into multiple prints. ### Changed diff --git a/src/App.tsx b/src/App.tsx index 8dbd0c1..59dcd9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ThreeDControls from './components/ThreeDControls'; import { AUTO_PAINT_REPEAT_LIMITS, @@ -55,6 +55,11 @@ import { applyAppSeo } from './lib/seo'; import { appPath, markLaunched } from './lib/routes'; import { isTauri } from '@tauri-apps/api/core'; import { migrateLegacyFilamentTd, sanitizeProfileFilament } from './lib/profileManager'; +import PrintUnlockEffect from './components/PrintUnlockEffect'; +import { + getMultiPlateEnabled, + subscribeToMultiPlateEnabled, +} from './lib/experimentalFeatures'; import { AlertDialog, AlertDialogContent, @@ -132,12 +137,12 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { const sanitized = parsed.filaments .map((filament: unknown) => sanitizeProfileFilament(filament)) .filter( - (filament: ReturnType): filament is NonNullable< - ReturnType - > => filament !== null + ( + filament: ReturnType + ): filament is NonNullable> => + filament !== null ); - const persistedSchema = - typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : 1; + const persistedSchema = typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : 1; const filaments = persistedSchema >= AUTOPAINT_SCHEMA_VERSION ? sanitized @@ -184,6 +189,27 @@ const saveAutoPaintPersisted = (value: AutoPaintPersisted) => { function App(): React.ReactElement | null { const toolPath = appPath(isTauri()); + // Multi-plate mode (issue #35) is still a stub. Per the plan, the flow diverges + // at *image upload*, not at app start — so until an image is uploaded the app + // must look and behave exactly like today. The experimental "Multi-plate mode" + // toggle in Settings arms the flag; the future upload decider will read it (via + // `getMultiPlateEnabled()`) inside the upload path to fork into multi-plate + // handling. The toggle persists (localStorage) like the other settings. Flipping + // it on plays a one-shot 3D-print flourish as feedback, then hands straight back + // to the untouched single-image UI; a persisted-on flag on a fresh load does not + // replay the flourish, so App only reacts to off→on transitions. + const multiPlateEnabledRef = useRef(getMultiPlateEnabled()); + const [printing, setPrinting] = useState(false); + + useEffect(() => { + return subscribeToMultiPlateEnabled((enabled) => { + if (enabled && !multiPlateEnabledRef.current) setPrinting(true); // flourish only on off→on + multiPlateEnabledRef.current = enabled; + }); + }, []); + + const handleEffectDone = useCallback(() => setPrinting(false), []); + // dropzone state managed by hook below // `weight` is the algorithm parameter; `finalColors` is the postprocess target const [weight, setWeight] = useState(128); @@ -269,12 +295,10 @@ function App(): React.ReactElement | null { const [mode, setMode] = useState<'2d' | '3d'>('2d'); const [docsOpen, setDocsOpen] = useState(() => parseDocsLocation(window.location) !== null); const [isOrtho, setIsOrtho] = useState(loadCameraMode); - const [previewRenderMode, setPreviewRenderMode] = useState( - loadPreviewRenderMode - ); - const [previewColorMode, setPreviewColorMode] = useState( - loadPreviewColorMode - ); + const [previewRenderMode, setPreviewRenderMode] = + useState(loadPreviewRenderMode); + const [previewColorMode, setPreviewColorMode] = + useState(loadPreviewColorMode); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 const [exportStep, setExportStep] = useState({ @@ -314,10 +338,8 @@ function App(): React.ReactElement | null { regionWeightingMode: autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode, enhancedColorMatch: autopaintHydrated.enhancedColorMatch ?? prev.enhancedColorMatch, - preserveSeparation: - autopaintHydrated.preserveSeparation ?? prev.preserveSeparation, - maxRepeatedSwaps: - autopaintHydrated.maxRepeatedSwaps ?? prev.maxRepeatedSwaps, + preserveSeparation: autopaintHydrated.preserveSeparation ?? prev.preserveSeparation, + maxRepeatedSwaps: autopaintHydrated.maxRepeatedSwaps ?? prev.maxRepeatedSwaps, transitionOpacity: autopaintHydrated.transitionOpacity ?? prev.transitionOpacity, heightDithering: autopaintHydrated.heightDithering ?? prev.heightDithering, ditherLineWidth: autopaintHydrated.ditherLineWidth ?? prev.ditherLineWidth, @@ -519,10 +541,9 @@ function App(): React.ReactElement | null { exportObjectTo3MFBlob(obj, { layerHeight: builtModelState.layerHeight, firstLayerHeight: builtModelState.slicerFirstLayerHeight, - layerFilamentColors: - builtModelAutoPaint - ? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex) - : undefined, + layerFilamentColors: builtModelAutoPaint + ? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex) + : undefined, onProgress, onZipProgress, }), @@ -533,21 +554,22 @@ function App(): React.ReactElement | null { const processingActive = mode === '2d' && (isQuantizing || isDedithering); return ( -
-
- {docsOpen && ( -
- -
- )} -
+ <> +
+
+ {docsOpen && ( +
+ +
+ )} +
-
+
- {/* Build warning dialog */} - !open && cancelBuild()} - > - - - Performance Warning - -
-

Building the 3D model may be slow due to:

-
    - {buildWarning?.warnings.map((w, i) => ( -
  • {w}
  • - ))} -
-

Do you want to continue?

-
-
-
- - Cancel - Build Anyway - -
-
- - {/* Update checker for Tauri desktop app */} - -
+ {/* Build warning dialog */} + !open && cancelBuild()} + > + + + Performance Warning + +
+

Building the 3D model may be slow due to:

+
    + {buildWarning?.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+

Do you want to continue?

+
+
+
+ + Cancel + + Build Anyway + + +
+
+ + {/* Update checker for Tauri desktop app */} + + + {printing && } + ); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 61750f9..b92ab37 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -39,6 +39,11 @@ import { saveUpdateCheckOnStartup, subscribeToUpdateCheckOnStartup, } from '@/lib/updatePreferences'; +import { + getMultiPlateEnabled, + saveMultiPlateEnabled, + subscribeToMultiPlateEnabled, +} from '@/lib/experimentalFeatures'; import logo from '../assets/logo.png'; import redditIcon from '../assets/reddit.svg'; import { landingPath } from '@/lib/routes'; @@ -57,11 +62,13 @@ export const Header: React.FC = ({ docsOpen, onBackToApp, onOpenDocs }) = const [themeMode, setThemeMode] = React.useState(() => getStoredThemeMode()); const [settingsOpen, setSettingsOpen] = React.useState(false); const [checkOnStartup, setCheckOnStartup] = React.useState(() => getUpdateCheckOnStartup()); + const [multiPlateEnabled, setMultiPlateEnabled] = React.useState(() => getMultiPlateEnabled()); const [updateStatus, setUpdateStatus] = React.useState('idle'); const [availableUpdate, setAvailableUpdate] = React.useState(null); const [updateError, setUpdateError] = React.useState(''); const settingsTitleId = React.useId(); const updateStartupSwitchId = React.useId(); + const multiPlateSwitchId = React.useId(); const isDesktopApp = isDesktopUpdateSupported(); const settingsButtonRef = React.useRef(null); const settingsDialogRef = React.useRef(null); @@ -139,6 +146,10 @@ export const Header: React.FC = ({ docsOpen, onBackToApp, onOpenDocs }) = return subscribeToUpdateCheckOnStartup(setCheckOnStartup); }, []); + React.useEffect(() => { + return subscribeToMultiPlateEnabled(setMultiPlateEnabled); + }, []); + React.useEffect(() => { if (settingsOpen) return; @@ -157,6 +168,14 @@ export const Header: React.FC = ({ docsOpen, onBackToApp, onOpenDocs }) = setCheckOnStartup(enabled); }; + const setMultiPlate = (enabled: boolean) => { + saveMultiPlateEnabled(enabled); + setMultiPlateEnabled(enabled); + // Enabling plays a full-screen unlock flourish; close settings first so it + // plays over the app rather than on top of the open dialog. + if (enabled) setSettingsOpen(false); + }; + const handleCheckForUpdates = async () => { setUpdateStatus('checking'); setAvailableUpdate(null); @@ -486,6 +505,32 @@ export const Header: React.FC = ({ docsOpen, onBackToApp, onOpenDocs }) = )} +
+
Experimental
+
+
+ + +
+
+
+
Kromacut v{appVersion} diff --git a/src/components/PrintUnlockEffect.tsx b/src/components/PrintUnlockEffect.tsx new file mode 100644 index 0000000..7ba1089 --- /dev/null +++ b/src/components/PrintUnlockEffect.tsx @@ -0,0 +1,46 @@ +import React, { useEffect } from 'react'; + +const DURATION_MS = 2600; + +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +/** + * A quick, self-timing FDM "print build" flourish that plays when the + * experimental "Multi-plate mode" settings toggle is switched on. It + * renders a full-screen, click-through overlay of a filament object printing + * layer-by-layer with a sweeping nozzle, then calls `onDone` so the parent can + * unmount it. Bows out immediately for anyone who prefers reduced motion. + */ +function PrintUnlockEffect({ onDone }: { onDone: () => void }): React.ReactElement | null { + const reduced = prefersReducedMotion(); + + useEffect(() => { + const t = window.setTimeout(onDone, reduced ? 0 : DURATION_MS); + return () => window.clearTimeout(t); + }, [onDone, reduced]); + + if (reduced) return null; + + return ( +