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 @@ -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

Expand Down
158 changes: 92 additions & 66 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -132,12 +137,12 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => {
const sanitized = parsed.filaments
.map((filament: unknown) => sanitizeProfileFilament(filament))
.filter(
(filament: ReturnType<typeof sanitizeProfileFilament>): filament is NonNullable<
ReturnType<typeof sanitizeProfileFilament>
> => filament !== null
(
filament: ReturnType<typeof sanitizeProfileFilament>
): filament is NonNullable<ReturnType<typeof sanitizeProfileFilament>> =>
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
Expand Down Expand Up @@ -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<number>(128);
Expand Down Expand Up @@ -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<PreviewRenderMode>(
loadPreviewRenderMode
);
const [previewColorMode, setPreviewColorMode] = useState<PreviewColorMode>(
loadPreviewColorMode
);
const [previewRenderMode, setPreviewRenderMode] =
useState<PreviewRenderMode>(loadPreviewRenderMode);
const [previewColorMode, setPreviewColorMode] =
useState<PreviewColorMode>(loadPreviewColorMode);
const [exportingSTL, setExportingSTL] = useState(false);
const [exportProgress, setExportProgress] = useState(0); // 0..1
const [exportStep, setExportStep] = useState<ExportProgressStep>({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}),
Expand All @@ -533,21 +554,22 @@ function App(): React.ReactElement | null {
const processingActive = mode === '2d' && (isQuantizing || isDedithering);

return (
<div className="box-border text-inherit font-sans flex flex-col flex-1 min-w-0 max-w-full min-h-0 h-screen w-full">
<Header
docsOpen={docsOpen}
onBackToApp={backToApp}
onOpenDocs={openDocs}
/>
{docsOpen && (
<div className="flex flex-1 min-h-0 w-full">
<DocsPage />
</div>
)}
<div
className={`${docsOpen ? 'hidden' : 'flex'} flex-1 min-h-0 w-full`}
ref={layoutRef}
>
<>
<div className="box-border text-inherit font-sans flex flex-col flex-1 min-w-0 max-w-full min-h-0 h-screen w-full">
<Header
docsOpen={docsOpen}
onBackToApp={backToApp}
onOpenDocs={openDocs}
/>
{docsOpen && (
<div className="flex flex-1 min-h-0 w-full">
<DocsPage />
</div>
)}
<div
className={`${docsOpen ? 'hidden' : 'flex'} flex-1 min-h-0 w-full`}
ref={layoutRef}
>
<ResizableSplitter defaultSize={30} minSize={20} maxSize={50}>
<aside className="w-full bg-card border-r border-border flex flex-col min-h-0">
<ModeTabs mode={mode} onChange={setMode} />
Expand Down Expand Up @@ -877,38 +899,42 @@ function App(): React.ReactElement | null {
</div>
</main>
</ResizableSplitter>
</div>
</div>

{/* Build warning dialog */}
<AlertDialog
open={buildWarning !== null}
onOpenChange={(open) => !open && cancelBuild()}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Performance Warning</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>Building the 3D model may be slow due to:</p>
<ul className="list-disc pl-5 space-y-1">
{buildWarning?.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
<p>Do you want to continue?</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmBuild}>Build Anyway</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* Update checker for Tauri desktop app */}
<UpdateChecker />
</div>
{/* Build warning dialog */}
<AlertDialog
open={buildWarning !== null}
onOpenChange={(open) => !open && cancelBuild()}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Performance Warning</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>Building the 3D model may be slow due to:</p>
<ul className="list-disc pl-5 space-y-1">
{buildWarning?.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
<p>Do you want to continue?</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmBuild}>
Build Anyway
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* Update checker for Tauri desktop app */}
<UpdateChecker />
</div>
{printing && <PrintUnlockEffect onDone={handleEffectDone} />}
</>
);
}

Expand Down
45 changes: 45 additions & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -57,11 +62,13 @@ export const Header: React.FC<Props> = ({ docsOpen, onBackToApp, onOpenDocs }) =
const [themeMode, setThemeMode] = React.useState<ThemeMode>(() => getStoredThemeMode());
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [checkOnStartup, setCheckOnStartup] = React.useState(() => getUpdateCheckOnStartup());
const [multiPlateEnabled, setMultiPlateEnabled] = React.useState(() => getMultiPlateEnabled());
const [updateStatus, setUpdateStatus] = React.useState<UpdateCheckStatus>('idle');
const [availableUpdate, setAvailableUpdate] = React.useState<VersionInfo | null>(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<HTMLButtonElement>(null);
const settingsDialogRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -139,6 +146,10 @@ export const Header: React.FC<Props> = ({ docsOpen, onBackToApp, onOpenDocs }) =
return subscribeToUpdateCheckOnStartup(setCheckOnStartup);
}, []);

React.useEffect(() => {
return subscribeToMultiPlateEnabled(setMultiPlateEnabled);
}, []);

React.useEffect(() => {
if (settingsOpen) return;

Expand All @@ -157,6 +168,14 @@ export const Header: React.FC<Props> = ({ 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);
Expand Down Expand Up @@ -486,6 +505,32 @@ export const Header: React.FC<Props> = ({ docsOpen, onBackToApp, onOpenDocs }) =
</section>
)}

<section className="mt-5 space-y-3 border-t border-border pt-5">
<div className="text-sm font-medium text-foreground">Experimental</div>
<div className="rounded-md border border-border bg-background p-3">
<div className="flex items-center justify-between gap-4">
<label
htmlFor={multiPlateSwitchId}
className="min-w-0 cursor-pointer"
>
<div className="text-sm font-medium text-foreground">
Multi-plate mode
</div>
<div className="mt-1 text-xs text-muted-foreground">
Unfinished multi-plate workflow. No effect yet; may
change or break.
</div>
</label>
<Switch
id={multiPlateSwitchId}
checked={multiPlateEnabled}
onCheckedChange={setMultiPlate}
aria-label="Enable experimental multi-plate mode"
/>
</div>
</div>
</section>

<div className="mt-5 flex items-center justify-between border-t border-border pt-4 text-xs text-muted-foreground">
<span>Kromacut</span>
<span className="font-mono">v{appVersion}</span>
Expand Down
46 changes: 46 additions & 0 deletions src/components/PrintUnlockEffect.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="feat35-overlay" aria-hidden="true">
<div className="feat35-stage">
<div className="feat35-object" />
<div className="feat35-nozzle">
<div className="feat35-tip" />
</div>
<div className="feat35-caption">
<span className="feat35-caption-main">MULTI-PLATE ONLINE</span>
<span className="feat35-caption-sub">experimental feature</span>
</div>
</div>
</div>
);
}

export default PrintUnlockEffect;
Loading