diff --git a/paint-app/README.md b/paint-app/README.md index e8ce728..fa9248c 100644 --- a/paint-app/README.md +++ b/paint-app/README.md @@ -11,8 +11,8 @@ phone. One container, one port, no desktop app. ## Stack -- Vite + React + TypeScript -- Material UI (theme + dialogs + menus) +- Vite + React + TypeScript, served in production by the plotter server +- Material UI (theme + dialogs + menus), light and dark - Dexie (IndexedDB) for project persistence - Zod for runtime schemas - Framer Motion for layer drag-and-drop reordering @@ -40,21 +40,32 @@ npm run lint # biome check (no writes) npm run docker:build # the arm64 appliance image, renderer and server together ``` +There is nothing to package. `npm run build` produces `dist/`, and the server +serves it — `npm run docker:build` bakes both into the arm64 appliance image. +An earlier version of this app shipped an Electron shell to get a stable +storage origin and a serial port picker; the server provides both, over the +network, to any browser, so the shell is gone. + ## Plotters Plotters are first-class entities, separate from projects. Each plotter has a name, bed size, feed rates, and pen Z heights. Plotters are **immutable once -created** — to change a parameter you must create a new plotter and switch the -project over to it. This keeps documents reproducible: a project that -references plotter `X` will always print exactly the way it did when you saved -it. - -- A project picks its plotter at creation time and is bound to it for life. - This mirrors the immutability of plotters themselves — the (project, plotter) - pair is a fixed contract once you click Create. -- Plotters are managed from the Settings menu (top-left ⚙ → "Plotters…") or - via the "Manage…" button in the home-screen New project form. -- Deleting a plotter is blocked while any project references it. +created** — to change a parameter you make a new plotter and print to that one +instead. + +A plotter is a **print target, not a document property**: a document owns its +page geometry and nothing else, exactly the way a document doesn't know which +printer you'll send it to. + +- The active plotter is chosen in the top bar and applies to whatever you do + next, in any document. Switching machines mid-session is a menu click. +- The serial port lives next to it, in the same top bar — the "Connect" button + opens the list of ports the *server* can see. +- Because the page and the bed are chosen independently, a page can be larger + than the machine it's aimed at. `Print` refuses to start in that case until + you tick "clip to the bed and print what fits". +- Plotters are managed from the top bar's plotter menu → "Manage…", or from the + Settings menu (⚙ → "Plotters…"). - Two ways to define a new plotter: - **Manual** — type each field (bed size, feeds, pen Z heights). - **Calibrate** — connect the plotter and walk through six steps (left, @@ -78,10 +89,9 @@ normal document, with two differences: (`G21 / G90 / G28`), and subsequent strokes are queued sequentially over the serial bus. -A red "Interactive · live" chip appears in the AppBar while a session is -active and connected. If the plotter is disconnected, the chip turns gray -("offline") and strokes you draw are NOT replayed when you reconnect — only -strokes drawn while connected get plotted. +A "Live" chip appears in the top bar while a session is active and connected. +If the plotter is disconnected the chip turns red, and strokes you draw are NOT +replayed when you reconnect — only strokes drawn while connected get plotted. ## Saving and loading @@ -93,8 +103,8 @@ strokes drawn while connected get plotted. the current project is `saved`, `saving…`, `saving soon`, or `unsaved`. - **Export / import JSON.** `Settings → Export JSON` downloads `.json`. `Import JSON` validates with Zod and creates a new project. -- **Beforeunload guard.** Closing the tab while changes are unsaved triggers - the browser's "leave this page?" prompt. +- **Beforeunload guard.** Closing while changes are unsaved prompts first with + the browser's "leave this page?" dialog. ## Features @@ -123,8 +133,9 @@ strokes drawn while connected get plotted. star (3–32 points). Polygon/star spawn a small `sides` / `pts` input under the palette when active; they're built as polylines so they emit clean G-code paths just like freehand strokes. -- **Plotter output.** Home screen → pick the serial port the *server* can see - → Connect, then Print. The active page is turned into one job — a prologue, +- **Plotter output.** Top bar → pick the plotter, then Connect (which lists the + serial ports the *server* can see), then Print. The active page is turned + into one job — a prologue, a block of G-code per visible layer (clipped to the page rectangle and translated to machine origin), an epilogue — uploaded in one request, and run by the server. Between layers the job parks in `awaiting_pen_swap`; every @@ -168,12 +179,14 @@ src/ types.ts # Zod schemas / TS types plotterClient.ts # REST + WebSocket client for server/ connection.tsx # React context over it: status, session, job, log + plotters.tsx # the plotter list and the active print target gcode.ts # stroke -> G-code generator svg-import.ts # SVG -> sampled strokes clip.ts # Liang-Barsky polyline-vs-rect clipping components/ Toolbar.tsx # control lock, pause, emergency stop - SerialPortRow.tsx # the server-side port picker + PlotterControls.tsx # active plotter + the connection, in the top bar + SerialPortRow.tsx # the server-side port picker it opens LayersPanel.tsx Canvas.tsx PrintModal.tsx # uploads the job, renders the server's state diff --git a/paint-app/package-lock.json b/paint-app/package-lock.json index 450f1bc..859e8a4 100644 --- a/paint-app/package-lock.json +++ b/paint-app/package-lock.json @@ -1417,9 +1417,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" diff --git a/paint-app/src/App.tsx b/paint-app/src/App.tsx index aaaf3e0..9fd7d24 100644 --- a/paint-app/src/App.tsx +++ b/paint-app/src/App.tsx @@ -1,68 +1,102 @@ -import { CssBaseline, createTheme, ThemeProvider } from '@mui/material'; +import { Alert, useTheme } from '@mui/material'; import { useCallback, useEffect, useRef, useState } from 'react'; import { BottomBar } from './components/BottomBar'; import { Canvas } from './components/Canvas'; import { LayersPanel } from './components/LayersPanel'; +import { type PhotoResult, PhotoStudio } from './components/PhotoStudio'; import { PrintModal } from './components/PrintModal'; import { ProjectGate } from './components/ProjectGate'; import { Toolbar } from './components/Toolbar'; +import { ConnectedDataProvider, useConnectedDataSync } from './connectedDataSession'; import { ConnectionProvider, useConnection } from './connection'; import { useInteractiveSync } from './interactive'; -import { PlottersProvider } from './plotters'; +import { createPhotoProject } from './photoProject'; +import { PlottersProvider, usePlotters } from './plotters'; import { INTERACTIVE_PROJECT_ID, ProjectProvider, useProject } from './project'; import { StoreProvider } from './store'; +import { AppThemeProvider } from './theme'; import { UIProvider } from './ui'; -const theme = createTheme({ - palette: { mode: 'light' }, - shape: { borderRadius: 6 }, -}); - export const App = () => { return ( - - + - + + + - + ); }; const Root = () => { - const { project } = useProject(); + const { project, setProject } = useProject(); const { log, showLog } = useConnection(); + const [printing, setPrinting] = useState(false); + // Which full-page util has taken over the view, if any. Root owns this so + // the top bar can show its title and back button in the same row. + const [util, setUtil] = useState<'photo' | null>(null); + // Lives at Root, not in Shell: the poll loop has to keep running across + // anything that remounts the document view. + useConnectedDataSync(); + + const onCreatePhoto = async (result: PhotoResult) => { + const { project: created, state } = await createPhotoProject(result); + setUtil(null); + setProject({ id: created.id, name: created.name }, state); + }; + return (
-
{project ? : }
+ {/* One bar for the whole app: plotter selection and the connection are + global, so they must outlive the document being open or closed. */} + setPrinting(true)} + utilTitle={util === 'photo' ? 'Photo processing' : null} + onExitUtil={() => setUtil(null)} + /> +
+ {util === 'photo' ? ( + + ) : project ? ( + + ) : ( + setUtil('photo')} /> + )} +
{showLog && } + {printing && project && setPrinting(false)} />}
); }; const Shell = () => { - const [printing, setPrinting] = useState(false); const { project } = useProject(); + const { activePlotter } = usePlotters(); const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; useInteractiveSync(); return (
- setPrinting(true)} /> + {!activePlotter && ( + + No plotter configured — drawing works, but nothing can be sent. Choose{' '} + Add plotter in the top bar when you're ready to plot. + + )}
{!isInteractive && }
- {printing && setPrinting(false)} />}
); }; @@ -71,6 +105,7 @@ const LOG_HEIGHT_LS_KEY = 'paint-app:logHeight'; const MIN_LOG_HEIGHT = 60; const LogPanel = ({ lines }: { lines: string[] }) => { + const theme = useTheme(); const [height, setHeight] = useState(() => { const raw = Number(localStorage.getItem(LOG_HEIGHT_LS_KEY)); return raw >= MIN_LOG_HEIGHT ? raw : 120; @@ -110,7 +145,7 @@ const LogPanel = ({ lines }: { lines: string[] }) => {
{ borderRadius: '50%', cursor: 'pointer', background: c, - border: + border: (t) => c.toLowerCase() === activeColor.toLowerCase() - ? '2px solid #1976d2' - : '1px solid rgba(0,0,0,0.3)', + ? `2px solid ${t.palette.primary.main}` + : `1px solid ${t.palette.divider}`, }} /> ))} diff --git a/paint-app/src/components/Canvas.tsx b/paint-app/src/components/Canvas.tsx index 09cd49c..9bbd889 100644 --- a/paint-app/src/components/Canvas.tsx +++ b/paint-app/src/components/Canvas.tsx @@ -1,3 +1,4 @@ +import { useTheme } from '@mui/material'; import { useEffect, useMemo, useRef, useState } from 'react'; import { INTERACTIVE_PROJECT_ID, useProject } from '../project'; import { buildEllipse, buildLine, buildPolygon, buildRect, buildStar } from '../shapes'; @@ -19,6 +20,7 @@ type DrawingState = | { kind: 'shape'; tool: Exclude; start: Point; current: Point }; export const Canvas = () => { + const theme = useTheme(); const { state, addPage, addStroke, setActivePage, removePage } = useStore(); const { project } = useProject(); const { @@ -256,7 +258,9 @@ export const Canvas = () => { style={{ flex: 1, overflow: isInteractive ? 'hidden' : 'auto', - background: '#f0f0f0', + // Pages themselves stay white — they stand in for physical paper — + // so only the surrounding backdrop follows the theme. + background: theme.palette.mode === 'dark' ? '#181818' : '#f0f0f0', position: 'relative', }} > diff --git a/paint-app/src/components/ConnectedDataWizard.tsx b/paint-app/src/components/ConnectedDataWizard.tsx new file mode 100644 index 0000000..6f4d970 --- /dev/null +++ b/paint-app/src/components/ConnectedDataWizard.tsx @@ -0,0 +1,646 @@ +import ShowChartIcon from '@mui/icons-material/ShowChart'; +import TimelineIcon from '@mui/icons-material/Timeline'; +import { + Alert, + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Step, + StepLabel, + Stepper, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@mui/material'; +import { useMemo, useState } from 'react'; +import { + type ConnectedDataConfig, + DEFAULT_INTERVAL_SECONDS, + DEFAULT_MARGIN_MM, + DEFAULT_WINDOW_MINUTES, + type DiscoveredField, + discoverFields, + getNumber, + plotArea, + SERIES_COLORS, + type Selection, + seedRange, + snapshotPoints, + valueToY, +} from '../connectedData'; +import { fetchText } from '../fetchText'; +import { loadLastPageSize, type PageSize } from '../pageSizes'; +import { PageSizePicker } from './PageSizePicker'; + +const STEPS = ['Source', 'Data', 'Plot']; + +const INTERVAL_PRESETS = [ + { label: '5s', value: 5 }, + { label: '30s', value: 30 }, + { label: '1m', value: 60 }, + { label: '5m', value: 300 }, + { label: '1h', value: 3600 }, +]; + +type Probe = { + json: unknown; + fields: DiscoveredField[]; +}; + +type Props = { + open: boolean; + onClose: () => void; + onStart: (config: ConnectedDataConfig) => void; +}; + +/** Whether a discovered field can be plotted at all. */ +const isPlottable = (field: DiscoveredField) => + (field.kind === 'scalar' && field.valueKind === 'number') || + field.kind === 'numberArray' || + field.kind === 'objectArray'; + +const numericItemFields = (field: DiscoveredField) => + field.kind === 'objectArray' ? field.itemFields.filter((f) => f.kind === 'number') : []; + +export const ConnectedDataWizard = ({ open, onClose, onStart }: Props) => { + const [step, setStep] = useState(0); + + // Step 1 + const [url, setUrl] = useState(''); + const [format, setFormat] = useState<'json' | 'html'>('json'); + const [probing, setProbing] = useState(false); + const [probeError, setProbeError] = useState(null); + const [probe, setProbe] = useState(null); + + // Step 2 + const [selections, setSelections] = useState>({}); + const [intervalSeconds, setIntervalSeconds] = useState(DEFAULT_INTERVAL_SECONDS); + + // Step 3 + const [pageSize, setPageSize] = useState(() => loadLastPageSize()); + const [marginMm, setMarginMm] = useState(DEFAULT_MARGIN_MM); + const [windowMinutes, setWindowMinutes] = useState(DEFAULT_WINDOW_MINUTES); + + const chosen = useMemo(() => Object.values(selections), [selections]); + const hasSeries = chosen.some((s) => s.mode === 'series'); + + const reset = () => { + setStep(0); + setUrl(''); + setProbe(null); + setProbeError(null); + setSelections({}); + setIntervalSeconds(DEFAULT_INTERVAL_SECONDS); + setMarginMm(DEFAULT_MARGIN_MM); + setWindowMinutes(DEFAULT_WINDOW_MINUTES); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const runProbe = async () => { + const trimmed = url.trim(); + if (!trimmed) return; + setProbing(true); + setProbeError(null); + setProbe(null); + setSelections({}); + try { + const { body } = await fetchText(trimmed); + const json = JSON.parse(body); + const fields = discoverFields(json); + if (fields.length === 0) { + setProbeError('Fetched successfully, but no plottable fields were found in the response.'); + } else { + setProbe({ json, fields }); + } + } catch (e) { + const message = (e as Error).message; + setProbeError(message.includes('JSON') ? `Response was not valid JSON: ${message}` : message); + } finally { + setProbing(false); + } + }; + + const toggleField = (field: DiscoveredField) => { + setSelections((prev) => { + if (prev[field.path]) { + const { [field.path]: _removed, ...rest } = prev; + return rest; + } + const color = SERIES_COLORS[Object.keys(prev).length % SERIES_COLORS.length]; + if (field.kind === 'scalar') { + const value = probe ? (getNumber(probe.json, field.path) ?? 0) : 0; + return { + ...prev, + [field.path]: { + id: field.path, + path: field.path, + label: field.label, + mode: 'series', + color, + xField: null, + yField: null, + autoRange: false, + ...seedRange(value), + }, + }; + } + const numeric = numericItemFields(field); + return { + ...prev, + [field.path]: { + id: field.path, + path: field.path, + label: field.label, + mode: 'snapshot', + color, + xField: null, + yField: field.kind === 'objectArray' ? (numeric[0]?.path ?? null) : null, + autoRange: true, + yMin: 0, + yMax: 1, + }, + }; + }); + }; + + const patch = (id: string, changes: Partial) => + setSelections((prev) => ({ ...prev, [id]: { ...prev[id], ...changes } })); + + const canAdvance = + step === 0 + ? probe !== null + : step === 1 + ? chosen.length > 0 && + chosen.every((s) => s.mode !== 'snapshot' || s.yField !== null || isNumberArray(s, probe)) + : true; + + const finish = () => { + onStart({ + url: url.trim(), + format: 'json', + intervalSeconds, + pageSize, + marginMm, + windowMinutes, + selections: chosen, + }); + reset(); + }; + + return ( + + Connected Data + + + {STEPS.map((label) => ( + + {label} + + ))} + + + {step === 0 && ( + + v && setFormat(v)} + > + JSON + + + + HTML + + + + + + setUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && runProbe()} + autoFocus + /> + + + + + + {probeError && {probeError}} + {probe && ( + + Found {probe.fields.length} field + {probe.fields.length === 1 ? '' : 's'}, of which{' '} + {probe.fields.filter(isPlottable).length} can be plotted. + + )} + + )} + + {step === 1 && probe && ( + + + Numbers are sampled repeatedly to build a live series. Arrays are drawn all at once + from a single response. + + + + }> + {probe.fields.map((field) => { + const selected = selections[field.path]; + const plottable = isPlottable(field); + return ( + + + toggleField(field)} + sx={{ p: 0.5 }} + /> + + + {field.label} + {field.kind !== 'scalar' && '[]'} + + + {field.sample} + + + : } + label={ + field.kind === 'scalar' + ? field.valueKind === 'number' + ? 'live series' + : field.valueKind + : field.kind === 'numberArray' + ? `${field.length} numbers` + : `${field.length} objects` + } + /> + + + {selected && ( + + {field.kind === 'objectArray' && ( + <> + + X axis + + + + Y axis + + + + )} + + {selected.mode === 'snapshot' && ( + + patch(selected.id, { autoRange: e.target.checked }) + } + /> + } + label={Fit to data} + /> + )} + + {!selected.autoRange && ( + <> + + patch(selected.id, { + yMin: Number.parseFloat(e.target.value) || 0, + }) + } + sx={{ width: 110 }} + /> + + patch(selected.id, { + yMax: Number.parseFloat(e.target.value) || 0, + }) + } + sx={{ width: 110 }} + /> + + )} + + + + )} + + ); + })} + + + + + Fetch every + {INTERVAL_PRESETS.map((preset) => ( + setIntervalSeconds(preset.value)} + /> + ))} + + setIntervalSeconds(Math.max(1, Number.parseInt(e.target.value, 10) || 1)) + } + sx={{ width: 110 }} + /> + + + {hasSeries && ( + + A live series can't auto-range: earlier points are already drawn (and possibly + plotted) by the time a new extreme arrives, so the Y range is fixed up front. The + defaults come from the value in the response you just fetched. + + )} + + )} + + {step === 2 && probe && ( + + + + setMarginMm(Math.max(0, Number.parseFloat(e.target.value) || 0))} + sx={{ width: 130 }} + /> + {hasSeries && ( + + setWindowMinutes(Math.max(1, Number.parseFloat(e.target.value) || 1)) + } + sx={{ width: 180 }} + /> + )} + + + + + + + )} + + + + + + {step > 0 && } + {step < STEPS.length - 1 ? ( + + ) : ( + + )} + + + ); +}; + +/** numberArray selections need no Y field; objectArray ones do. */ +const isNumberArray = (selection: Selection, probe: Probe | null) => + probe?.fields.find((f) => f.path === selection.path)?.kind === 'numberArray'; + +const PlotPreview = ({ + json, + selections, + pageSize, + marginMm, +}: { + json: unknown; + selections: Selection[]; + pageSize: PageSize; + marginMm: number; +}) => { + const area = plotArea(pageSize, marginMm); + + return ( + + + Plot preview + + + {selections.map((selection) => { + if (selection.mode === 'snapshot') { + const points = snapshotPoints(json, selection, area); + if (points.length < 2) return null; + return ( + `${p.x},${p.y}`).join(' ')} + fill="none" + stroke={selection.color} + strokeWidth={0.8} + strokeLinejoin="round" + /> + ); + } + // A live series has exactly one real point so far; show where it + // starts and which way it will grow. + const value = getNumber(json, selection.path); + if (value === null) return null; + const y = valueToY(value, selection.yMin, selection.yMax, area); + return ( + + + + + ); + })} + + + ); +}; + +const PlotSummary = ({ + selections, + intervalSeconds, + windowMinutes, +}: { + selections: Selection[]; + intervalSeconds: number; + windowMinutes: number; +}) => { + const series = selections.filter((s) => s.mode === 'series'); + const snapshots = selections.filter((s) => s.mode === 'snapshot'); + const samples = Math.floor((windowMinutes * 60) / Math.max(1, intervalSeconds)); + + return ( + + + {series.length > 0 && ( + + {series.length} live series ({series.map((s) => s.label).join(', ')}) — + one segment drawn per fetch, about {samples} points before the page is + full at {intervalSeconds}s intervals. + + )} + {snapshots.length > 0 && ( + + {snapshots.length} snapshot series ( + {snapshots.map((s) => s.label).join(', ')}) — redrawn whenever the fetched data changes. + + )} + + Strokes go to the plotter as they're drawn, exactly like an interactive session. Connect a + plotter from the top bar; anything drawn while disconnected stays on screen only. + + + + ); +}; diff --git a/paint-app/src/components/DebugModal.tsx b/paint-app/src/components/DebugModal.tsx index 002b7e6..81e5184 100644 --- a/paint-app/src/components/DebugModal.tsx +++ b/paint-app/src/components/DebugModal.tsx @@ -35,8 +35,7 @@ const JOG_STEPS = [0.1, 1, 10]; export const DebugModal = ({ onClose }: Props) => { const { client } = useConnection(); const { state } = useStore(); - const { getPlotter } = usePlotters(); - const plotter = getPlotter(state.plotterId) ?? null; + const { activePlotter: plotter } = usePlotters(); const activePage = state.pages.find((p) => p.id === state.activePageId) ?? state.pages[0]; const [busy, setBusy] = useState(false); diff --git a/paint-app/src/components/LayersPanel.tsx b/paint-app/src/components/LayersPanel.tsx index d3f5d82..7efc8ee 100644 --- a/paint-app/src/components/LayersPanel.tsx +++ b/paint-app/src/components/LayersPanel.tsx @@ -2,12 +2,22 @@ import AddIcon from '@mui/icons-material/Add'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; -import { Box, Button, IconButton, InputBase, TextField, Tooltip, Typography } from '@mui/material'; +import { + Box, + Button, + IconButton, + InputBase, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; import { Reorder } from 'framer-motion'; import { useStore } from '../store'; import type { Layer } from '../types'; export const LayersPanel = () => { + const theme = useTheme(); const { state, addLayer, removeLayer, setActiveLayer, updateLayer, reorderLayers } = useStore(); const { layers, activeLayerId } = state; @@ -63,8 +73,10 @@ export const LayersPanel = () => { value={layer} style={{ padding: 10, - borderBottom: '1px solid #eee', - background: isActive ? '#eef6ff' : '#fff', + borderBottom: `1px solid ${theme.palette.divider}`, + background: isActive + ? theme.palette.action.selected + : theme.palette.background.paper, cursor: 'grab', }} onPointerDown={() => setActiveLayer(layer.id)} diff --git a/paint-app/src/components/PageSizePicker.tsx b/paint-app/src/components/PageSizePicker.tsx new file mode 100644 index 0000000..0a5e0d8 --- /dev/null +++ b/paint-app/src/components/PageSizePicker.tsx @@ -0,0 +1,117 @@ +import { + FormControl, + InputLabel, + ListSubheader, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useMemo, useState } from 'react'; +import { formatSize, type PageSize, plotterPageSize, STANDARD_SIZES } from '../pageSizes'; +import { usePlotters } from '../plotters'; + +const CUSTOM_KEY = 'custom'; + +type Props = { + value: PageSize; + onChange: (size: PageSize) => void; + label?: string; + minWidth?: number; +}; + +/** + * Page size as a single dropdown over plotter beds and standard papers, with + * hand-entered dimensions as the fallback. The selected key is derived from + * the value rather than stored, so the control has no state to get out of sync + * — except the deliberate choice to sit on Custom while typing a size that + * happens to coincide with a preset. + */ +export const PageSizePicker = ({ value, onChange, label = 'Page size', minWidth = 200 }: Props) => { + const { plotters } = usePlotters(); + const [forceCustom, setForceCustom] = useState(false); + + const options = useMemo(() => { + const fromPlotters = plotters.map((p) => ({ + key: `plotter:${p.id}`, + group: 'From a plotter', + label: `${p.name} (bed)`, + size: plotterPageSize(p), + })); + const standard = STANDARD_SIZES.map((s) => ({ + key: `std:${s.label}`, + group: 'Standard', + label: s.label, + size: s.size, + })); + return [...fromPlotters, ...standard]; + }, [plotters]); + + const matched = options.find( + (o) => o.size.width === value.width && o.size.height === value.height, + ); + const sizeKey = forceCustom || !matched ? CUSTOM_KEY : matched.key; + + const fromPlotters = options.filter((o) => o.group === 'From a plotter'); + const standard = options.filter((o) => o.group === 'Standard'); + + return ( + + + {label} + + + + {sizeKey === CUSTOM_KEY && ( + + onChange({ ...value, width: Number.parseFloat(e.target.value) || 0 })} + sx={{ width: 130 }} + /> + × + onChange({ ...value, height: Number.parseFloat(e.target.value) || 0 })} + sx={{ width: 130 }} + /> + + )} + + ); +}; diff --git a/paint-app/src/components/PhotoStudio.tsx b/paint-app/src/components/PhotoStudio.tsx new file mode 100644 index 0000000..76a3732 --- /dev/null +++ b/paint-app/src/components/PhotoStudio.tsx @@ -0,0 +1,1054 @@ +import PhotoIcon from '@mui/icons-material/Photo'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Paper, + Select, + Slider, + Stack, + Switch, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PageSize } from '../pageSizes'; +import { + BUCKET_METHODS, + type BucketMethod, + DEFAULT_ADJUST, + DEFAULT_PREPARE, + GRAYSCALE_METHODS, + type GrayscaleMethod, + gammaToMidpoint, + type LayerStrokes, + type LevelsParams, + luminanceHistogram, + midpointToGamma, + PALETTE_PRESETS, + PHOTO_PRESETS, + type PhotoStyle, + type PreparedImage, + type PrepareParams, + paletteFor, + prepareImage, + renderStyle, + type StyleParams, + targetResolution, +} from '../photo'; +import { bucketPreviewUrl, decodeFile, fitWithin, resampleWithDetail } from '../photoDecode'; +import { PageSizePicker } from './PageSizePicker'; + +export type PhotoResult = { + name: string; + pageSize: PageSize; + marginMm: number; + /** One entry per bucket, darkest first. Page-local mm. */ + layers: { color: string; strokes: LayerStrokes }[]; +}; + +type Props = { + onCreate: (result: PhotoResult) => void; +}; + +type TabKey = 'prepare' | 'style'; + +const STYLE_LABELS: Record = { + horizontal: 'Horizontal scan', + diagonal: 'Diagonal lines', + dots: 'Stipple dots', + circles: 'Concentric circles', +}; + +const SIDEBAR_WIDTH = 380; +/** Long enough that dragging a slider doesn't re-render on every frame. */ +const RENDER_DEBOUNCE_MS = 350; +/** Past this the plot is impractical and the canvas will crawl. */ +const STROKE_WARN_THRESHOLD = 60_000; + +/** + * Full-page photo-to-plot workspace: controls on the left, a live preview of + * whichever stage is being edited on the right. The two tabs are independent + * rather than sequential — tone and shading are adjusted against each other, + * so moving between them has to be free. + */ +export const PhotoStudio = ({ onCreate }: Props) => { + const [tab, setTab] = useState('prepare'); + + const [file, setFile] = useState(null); + const [bitmap, setBitmap] = useState(null); + const [sourceUrl, setSourceUrl] = useState(null); + const [loadError, setLoadError] = useState(null); + const [dragging, setDragging] = useState(false); + + // Preparation: everything about the image itself. + const [prepare, setPrepare] = useState(DEFAULT_PREPARE); + // How finely the source is sampled, as a fraction of what the style would + // use on its own. Below 1 the image is coarsened and re-expanded. + const [detail, setDetail] = useState(1); + // Per-ink colour overrides. Anything unset falls back to what the image + // suggests, so changing the ink count never strands a stale palette. + const [inkOverrides, setInkOverrides] = useState>({}); + + // Shading: everything about how it lands on paper. + const [presetId, setPresetId] = useState(PHOTO_PRESETS[0].id); + const [style, setStyle] = useState(PHOTO_PRESETS[0].style); + const [params, setParams] = useState(PHOTO_PRESETS[0].params); + const [pageSize, setPageSize] = useState({ width: 210, height: 297 }); + const [marginMm, setMarginMm] = useState(10); + + const [rendering, setRendering] = useState(false); + const [rendered, setRendered] = useState(null); + + const fileInputRef = useRef(null); + + const areaWidth = Math.max(1, pageSize.width - 2 * marginMm); + const areaHeight = Math.max(1, pageSize.height - 2 * marginMm); + + const applyPreset = useCallback((id: string) => { + const preset = PHOTO_PRESETS.find((p) => p.id === id); + if (!preset) return; + setPresetId(id); + setStyle(preset.style); + setParams(preset.params); + }, []); + + const patchPrepare = useCallback( + (changes: Partial) => setPrepare((previous) => ({ ...previous, ...changes })), + [], + ); + + useEffect(() => { + return () => { + if (sourceUrl) URL.revokeObjectURL(sourceUrl); + }; + }, [sourceUrl]); + + /** + * The source resampled to whatever resolution the chosen style needs. Kept + * separate from the reduction below so that turning a levels knob doesn't + * redo the resample — only the cheap per-pixel pass. + */ + const sampled = useMemo(() => { + if (!bitmap) return null; + const target = targetResolution(style, areaWidth, areaHeight, params); + const fitted = fitWithin(bitmap.width, bitmap.height, target.width, target.height); + return resampleWithDetail(bitmap, fitted.width, fitted.height, detail); + }, [bitmap, style, areaWidth, areaHeight, params, detail]); + + const histogram = useMemo( + () => + sampled + ? luminanceHistogram( + sampled.rgba, + prepare.grayscale ? prepare.grayscaleMethod : 'luminosity', + ) + : null, + [sampled, prepare.grayscale, prepare.grayscaleMethod], + ); + + /** The image reduced to ink indices. */ + const processed: PreparedImage | null = useMemo( + () => (sampled ? prepareImage(sampled.rgba, sampled.width, sampled.height, prepare) : null), + [sampled, prepare], + ); + + const colors = useMemo(() => { + const base = processed?.suggestedPalette ?? paletteFor(['#000000'], prepare.colorCount); + return base.map((color, i) => inkOverrides[i] ?? color); + }, [processed, inkOverrides, prepare.colorCount]); + + const previewUrl = useMemo( + () => + processed + ? bucketPreviewUrl(processed.data, processed.width, processed.height, colors) + : null, + [processed, colors], + ); + + // Any change upstream invalidates the strokes immediately. Without this, + // editing tone on the Prepare tab would leave the previous render in place + // and "Create project" would happily commit strokes from the old settings. + // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate on input identity, not on reading them + useEffect(() => { + setRendered(null); + }, [processed, style, params]); + + // Strokes are the expensive step, so they're only built while the Style tab + // is open, and only once the knobs have settled. + useEffect(() => { + if (!processed || tab !== 'style') return; + setRendering(true); + const timer = setTimeout(() => { + setRendered(renderStyle(style, processed, params)); + setRendering(false); + }, RENDER_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [processed, style, params, tab]); + + const loadImage = async (picked: File) => { + setLoadError(null); + try { + const decoded = await decodeFile(picked); + setFile(picked); + setBitmap(decoded); + setRendered(null); + setSourceUrl((previous) => { + if (previous) URL.revokeObjectURL(previous); + return URL.createObjectURL(picked); + }); + } catch (e) { + setLoadError((e as Error).message); + } + }; + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const picked = e.dataTransfer.files?.[0]; + if (picked) loadImage(picked); + }; + + const strokeCount = rendered?.reduce((sum, l) => sum + l.length, 0) ?? 0; + + const finish = () => { + if (!rendered) return; + onCreate({ + name: file ? file.name.replace(/\.[^.]+$/, '') : 'Photo', + pageSize, + marginMm, + layers: rendered.map((strokes, i) => ({ color: colors[i], strokes })), + }); + }; + + const hiddenInput = ( + { + const picked = e.target.files?.[0]; + if (picked) loadImage(picked); + e.target.value = ''; + }} + /> + ); + + // Nothing here is meaningful without a source image, so the whole workspace + // is replaced by the picker until there is one. + if (!bitmap) { + return ( + + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }} + > + + + + Choose a photo to plot + + + Drop an image here, or pick one from your computer. Everything else unlocks once an + image is loaded. + + + {loadError && ( + + {loadError} + + )} + + {hiddenInput} + + + ); + } + + return ( + + {/* ── Controls ── */} + + {/* The source image is the one thing every control below depends on, + so it is set apart rather than reading as the first setting. */} + + + + + {file?.name} + + + {bitmap.width}×{bitmap.height}px + + + + + {hiddenInput} + + setTab(v as TabKey)} variant="fullWidth"> + + + + + + + {tab === 'prepare' ? ( + +
setDetail(1) : undefined}> + `${Math.round(v * 100)}%`} + onChange={setDetail} + /> + + {detail >= 1 + ? 'Sampling at full resolution for the chosen style.' + : `Sampled at ${Math.round(detail * 100)}% and re-expanded, so tonal regions merge into larger blocks — fewer, longer strokes and a bolder plot.`} + {processed && ` Currently ${processed.width}×${processed.height}.`} + +
+ + + +
patchPrepare(DEFAULT_ADJUST)}> + + patchPrepare({ contrast: v })} + /> + {prepare.grayscale && prepare.bucketMethod === 'even-pixels' && ( + + Even pixel count splits by rank, so a tone curve on its own can't move a pixel + between inks — the gray point will do nothing here. Only clipping (black/white + points, or hard contrast) changes the split. Switch to Even histogram to make + these fully effective. + + )} +
+ + + +
+ patchPrepare({ grayscale: e.target.checked })} + /> + } + label={Convert to grayscale} + /> + + {prepare.grayscale ? ( + <> + + Method + + + + {GRAYSCALE_METHODS.find((m) => m.value === prepare.grayscaleMethod)?.hint} + + + ) : ( + + Colors are reduced with k-means clustering, which picks the inks that best + represent the image. + + )} +
+ + + +
+ patchPrepare({ colorCount: v as number })} + /> + {prepare.grayscale && ( + <> + + Split tones by + + + + {BUCKET_METHODS.find((m) => m.value === prepare.bucketMethod)?.hint} + + + )} +
+
+ ) : ( + + + Preset + + + + {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} + + + + Style + + + + + +
0 ? () => setInkOverrides({}) : undefined + } + > + + {colors.map((color, i) => ( + + + setInkOverrides((previous) => ({ ...previous, [i]: e.target.value })) + } + style={{ width: 34, height: 26, border: 'none', background: 'none' }} + /> + + {i === 0 ? 'dark' : i === colors.length - 1 ? 'light' : i + 1} + + + ))} + + + {PALETTE_PRESETS.map((preset) => ( + + setInkOverrides( + Object.fromEntries( + paletteFor(preset.colors, colors.length).map((c, i) => [i, c]), + ), + ) + } + /> + ))} + +
+ + + + + setMarginMm(Math.max(0, v))} + /> + + + + {(style === 'horizontal' || style === 'diagonal') && ( + <> + setParams({ ...params, lineSpacing: v })} + /> + setParams({ ...params, colinearGap: v })} + /> + + )} + {style === 'dots' && ( + setParams({ ...params, boxSide: v })} + /> + )} + {style === 'circles' && ( + <> + setParams({ ...params, sampleLength: v })} + /> + setParams({ ...params, circleDiameter: v })} + /> + setParams({ ...params, lineWidth: v })} + /> + + )} +
+ )} +
+ + {/* ── Result summary + commit ── */} + + + {rendered && !rendering && ( + + + {strokeCount.toLocaleString()} strokes across {rendered.length}{' '} + layers + + + {rendered.map((layer, i) => ( + + ))} + + {strokeCount > STROKE_WARN_THRESHOLD && ( + + That's a lot of strokes — the canvas will be sluggish and the plot will take + hours. Raise the line spacing or the gap after each run. + + )} + + )} + + {!rendered && ( + + Open the Style tab to generate strokes. + + )} + +
+ + {/* ── Preview ── */} + + {tab === 'prepare' ? ( + previewUrl && ( + + + {processed && ( + + Sampled at {processed.width}×{processed.height} for the {STYLE_LABELS[style]}{' '} + style + + )} + + ) + ) : rendering ? ( + + + + Generating strokes… + + + ) : ( + + )} + +
+ ); +}; + +const HISTOGRAM_HEIGHT = 72; + +/** + * Levels as a histogram with the three handles beneath it, Photoshop-style. + * The handles share the histogram's 0..255 domain so a thumb sits directly + * under the tones it clips; the middle one is the gamma control, expressed as + * a position between the outer two because that is how it reads against the + * distribution. + */ +const LevelsControl = ({ + histogram, + blackPoint, + whitePoint, + gamma, + onChange, +}: { + histogram: Int32Array | null; + blackPoint: number; + whitePoint: number; + gamma: number; + onChange: (changes: Partial) => void; +}) => { + const midpoint = gammaToMidpoint(gamma, blackPoint, whitePoint); + + const bars = useMemo(() => { + if (!histogram) return null; + let peak = 0; + for (const v of histogram) if (v > peak) peak = v; + if (peak === 0) return null; + // Square root rather than linear: photographic histograms have a few huge + // spikes that would flatten everything else into an invisible line. + return Array.from(histogram, (v) => Math.sqrt(v / peak) * HISTOGRAM_HEIGHT); + }, [histogram]); + + const handleChange = (value: number | number[], activeThumb: number) => { + if (!Array.isArray(value)) return; + const [black, mid, white] = value; + if (activeThumb === 1) { + onChange({ gamma: midpointToGamma(mid, blackPoint, whitePoint) }); + return; + } + // Dragging an outer handle keeps gamma where it is; the midpoint handle + // just follows, since its position is derived from gamma. + const nextBlack = Math.min(black, white - 2); + const nextWhite = Math.max(white, nextBlack + 2); + onChange({ blackPoint: nextBlack, whitePoint: nextWhite }); + }; + + return ( + + + {bars ? ( + + Tonal distribution + {/* Everything outside the black/white points is crushed flat. */} + + + {bars.map((height, i) => ( + whitePoint ? 0.3 : 0.75} + /> + ))} + + + ) : ( + + + No histogram yet + + + )} + + + handleChange(value, activeThumb)} + sx={{ + mt: 0, + py: 1, + // The track would read as a second axis competing with the + // histogram above; only the handles carry meaning here. + '& .MuiSlider-rail, & .MuiSlider-track': { opacity: 0 }, + }} + /> + + + + Black {Math.round(blackPoint)} + + + Gray {gamma.toFixed(2)} + + + White {Math.round(whitePoint)} + + + + ); +}; + +/** A titled group of controls, with an optional reset affordance. */ +const Section = ({ + title, + onReset, + children, +}: { + title: string; + onReset?: () => void; + children: React.ReactNode; +}) => ( + + + + {title} + + {onReset && ( + + )} + + {children} + +); + +const LabelledSlider = ({ + label, + value, + min, + max, + step = 1, + format = (v: number) => String(Math.round(v)), + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + step?: number; + format?: (v: number) => string; + onChange: (v: number) => void; +}) => ( + + + + {label} + + + {format(value)} + + + onChange(v as number)} + /> + +); + +const NumField = ({ + label, + value, + onChange, + step = 1, +}: { + label: string; + value: number; + onChange: (v: number) => void; + step?: number; +}) => ( + onChange(Number.parseFloat(e.target.value) || 0)} + slotProps={{ htmlInput: { step, min: 0 } }} + /> +); + +/** + * Every stroke as SVG. Past a few thousand this gets heavy to lay out, so the + * preview thins the set — the created project always keeps all of them. + */ +const PREVIEW_STROKE_LIMIT = 4000; + +const StrokePreview = ({ + layers, + colors, + pageSize, + marginMm, +}: { + layers: LayerStrokes[] | null; + colors: string[]; + pageSize: PageSize; + marginMm: number; +}) => { + if (!layers) { + return ( + + Adjust the knobs to generate a preview. + + ); + } + + const total = layers.reduce((sum, l) => sum + l.length, 0); + const stride = Math.max(1, Math.ceil(total / PREVIEW_STROKE_LIMIT)); + + return ( + + + Plot preview + + {layers.map((strokes, layerIndex) => ( + + {strokes + .filter((_s, i) => i % stride === 0) + .map((stroke, i) => ( + `${p.x},${p.y}`).join(' ')} + fill="none" + strokeWidth={0.3} + /> + ))} + + ))} + + + + {pageSize.width}×{pageSize.height}mm + {stride > 1 && + ` · showing 1 in ${stride} strokes for speed, project gets all ${total.toLocaleString()}`} + + + ); +}; diff --git a/paint-app/src/components/PlotterControls.tsx b/paint-app/src/components/PlotterControls.tsx new file mode 100644 index 0000000..b7f66b3 --- /dev/null +++ b/paint-app/src/components/PlotterControls.tsx @@ -0,0 +1,177 @@ +import PrintIcon from '@mui/icons-material/Print'; +import UsbIcon from '@mui/icons-material/Usb'; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Popover, + Tooltip, +} from '@mui/material'; +import { useEffect, useState } from 'react'; +import { useConnection } from '../connection'; +import { usePlotters } from '../plotters'; +import { PlottersModal } from './PlottersModal'; +import { SerialPortRow } from './SerialPortRow'; + +/** + * Which plotter output goes to, and the live state of the link to it. Global + * rather than per-document — the same session can draw one thing and send it + * to whichever machine is plugged in, so these controls live in the top bar + * and stay mounted whether or not a document is open. + * + * That includes picking the serial port. It used to be a step on the setup + * screen, back when a document owned its plotter and you passed through the + * gate on the way in; now that the plotter is a print target rather than a + * document property there is no such moment, so the port lives here, one click + * from anywhere, next to the machine it belongs to. + */ +export const PlotterControls = () => { + const { plotters, activePlotter, setActivePlotter } = usePlotters(); + const { + serverReachable, + connected, + connecting, + connectPhase, + portPath, + connectError, + dismissConnectError, + emergencyStopped, + acknowledgeEmergencyStop, + } = useConnection(); + + const [anchor, setAnchor] = useState(null); + const [portAnchor, setPortAnchor] = useState(null); + const [plottersOpen, setPlottersOpen] = useState(false); + + // The picker has done its job the moment the link comes up; leaving it open + // over the canvas is just something else to dismiss. + useEffect(() => { + if (connected) setPortAnchor(null); + }, [connected]); + + const connectLabel = !serverReachable + ? 'Server offline' + : connected + ? (portPath ?? 'Connected') + : connectPhase === 'homing' + ? 'Homing…' + : connecting + ? 'Connecting…' + : 'Connect'; + + return ( + <> + + + + setAnchor(null)}> + {plotters.map((p) => ( + { + setActivePlotter(p.id); + setAnchor(null); + }} + > + + + ))} + {plotters.length > 0 && } + { + setPlottersOpen(true); + setAnchor(null); + }} + > + + + + + + + + + + + setPortAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + + + + + setPlottersOpen(false)} /> + + + Connection failed + + {connectError} + + + + + + + + Emergency stop triggered + + + The plotter received an emergency stop (M112) and its firmware is now halted. It will + not respond to any commands until you power cycle the 3D printer (turn + it off, wait a few seconds, then turn it back on). + + + After power cycling, reconnect from the top bar. Your work stays open. + + + + + + + + ); +}; diff --git a/paint-app/src/components/PlottersModal.tsx b/paint-app/src/components/PlottersModal.tsx index 2a25e77..9f805fb 100644 --- a/paint-app/src/components/PlottersModal.tsx +++ b/paint-app/src/components/PlottersModal.tsx @@ -7,6 +7,7 @@ import { Box, Button, ButtonBase, + Chip, Dialog, DialogActions, DialogContent, @@ -21,8 +22,7 @@ import { TextField, Typography, } from '@mui/material'; -import { useEffect, useState } from 'react'; -import { db } from '../db'; +import { useState } from 'react'; import { type PlotterDraft, usePlotters } from '../plotters'; import { PlotterCalibration } from './PlotterCalibration'; @@ -41,29 +41,11 @@ type Mode = 'manual' | 'calibrate' | null; type Props = { open: boolean; onClose: () => void }; export const PlottersModal = ({ open, onClose }: Props) => { - const { plotters, createPlotter, updatePlotter, deletePlotter } = usePlotters(); + const { plotters, activePlotter, setActivePlotter, createPlotter, updatePlotter, deletePlotter } = + usePlotters(); const [draft, setDraft] = useState(EMPTY_DRAFT); const [mode, setMode] = useState(null); const [editingId, setEditingId] = useState(null); - const [usageById, setUsageById] = useState>({}); - - useEffect(() => { - if (!open) return; - let cancelled = false; - (async () => { - const all = await db.projects.toArray(); - const counts: Record = {}; - for (const p of all) { - const id = p.state.plotterId; - if (!id) continue; - counts[id] = (counts[id] ?? 0) + 1; - } - if (!cancelled) setUsageById(counts); - })(); - return () => { - cancelled = true; - }; - }, [open]); const onSubmit = async () => { if (!draft.name.trim()) return; @@ -88,13 +70,6 @@ export const PlottersModal = ({ open, onClose }: Props) => { }; const onDelete = async (id: string) => { - const inUse = usageById[id] ?? 0; - if (inUse > 0) { - alert( - `This plotter is used by ${inUse} project${inUse === 1 ? '' : 's'} and can't be deleted.`, - ); - return; - } if (!confirm('Delete this plotter? This cannot be undone.')) return; await deletePlotter(id); }; @@ -110,10 +85,9 @@ export const PlottersModal = ({ open, onClose }: Props) => { Plotters - - Projects reference a plotter by id. Editing one changes every project that uses it the - next time it builds G-code — create a separate plotter instead if you only want the change - for a new project. + + Plotters are output targets, like printers — drawings don't belong to one. Pick which + plotter to send to from the top bar at any time. @@ -126,13 +100,20 @@ export const PlottersModal = ({ open, onClose }: Props) => { ) : ( {plotters.map((p) => { - const usage = usageById[p.id] ?? 0; + const isActive = activePlotter?.id === p.id; return ( + + {isActive ? ( + + ) : ( + + )} onEdit(p.id)} title="Edit"> @@ -140,10 +121,7 @@ export const PlottersModal = ({ open, onClose }: Props) => { edge="end" size="small" onClick={() => onDelete(p.id)} - disabled={usage > 0} - title={ - usage > 0 ? `Used by ${usage} project${usage === 1 ? '' : 's'}` : 'Delete' - } + title="Delete" > @@ -156,7 +134,6 @@ export const PlottersModal = ({ open, onClose }: Props) => { <> Bed {p.bedWidth}×{p.bedHeight}mm · travel {p.travelFeed} · draw {p.drawFeed}{' '} · Z up {p.penUpZ} / down {p.penDownZ} - {usage > 0 && ` · used by ${usage} project${usage === 1 ? '' : 's'}`} } /> diff --git a/paint-app/src/components/PrintModal.tsx b/paint-app/src/components/PrintModal.tsx index c94c0b7..fe372ce 100644 --- a/paint-app/src/components/PrintModal.tsx +++ b/paint-app/src/components/PrintModal.tsx @@ -1,17 +1,20 @@ import { Alert, + AlertTitle, Box, Button, + Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, + FormControlLabel, LinearProgress, Typography, } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; import { useConnection } from '../connection'; -import { buildLayerPrograms, EPILOGUE, PROLOGUE } from '../gcode'; +import { buildLayerPrograms, EPILOGUE, PROLOGUE, pageFitsBed } from '../gcode'; import { usePlotters } from '../plotters'; import { useProject } from '../project'; import { useStore } from '../store'; @@ -27,7 +30,8 @@ const Swatch = ({ color }: { color: string }) => ( height: 14, borderRadius: '50%', background: color, - border: '1px solid #ccc', + border: '1px solid', + borderColor: 'divider', flexShrink: 0, }} /> @@ -59,16 +63,28 @@ const playChime = () => { * and the page was orphaned half-drawn. Now the whole job is uploaded once, the * server runs it next to the cable, and this is a view of that job's state. Any * device in the session sees the same thing, including the swap prompt. + * + * The one decision still made here is what to do when the page does not fit the + * plotter. A document is sized independently of any machine, and the plotter is + * a print target picked in the top bar, so the mismatch is only knowable at + * this moment — and it has to be settled before the job is built and uploaded, + * because the server receives finished G-code and will not second-guess it. */ export const PrintModal = ({ onClose }: Props) => { const { state } = useStore(); - const { getPlotter } = usePlotters(); + const { activePlotter: plotter } = usePlotters(); const { project } = useProject(); const { client, job, isController, controllerName, connected, paused } = useConnection(); - const { pages, layers, activePageId, plotterId } = state; + const { pages, layers, activePageId } = state; const activePage = pages.find((p) => p.id === activePageId); - const plotter = getPlotter(plotterId) ?? null; - const programs = activePage && plotter ? buildLayerPrograms(layers, activePage, plotter) : []; + + // Printing is blocked until the user decides what happens to the overflow. + const [clipToBed, setClipToBed] = useState(false); + const overflows = Boolean(activePage && plotter && !pageFitsBed(activePage, plotter)); + const blockedByOverflow = overflows && !clipToBed; + + const programs = + activePage && plotter ? buildLayerPrograms(layers, activePage, plotter, clipToBed) : []; const [jobId, setJobId] = useState(null); const [busy, setBusy] = useState(false); @@ -101,7 +117,12 @@ export const PrintModal = ({ onClose }: Props) => { const start = () => guard(async () => { - if (!plotter) throw new Error('No plotter selected for this project.'); + if (!plotter) throw new Error('No plotter selected. Choose one from the top bar.'); + if (blockedByOverflow) { + throw new Error( + 'The page is larger than the bed. Clip it to the bed, or pick a plotter it fits on.', + ); + } if (!activePage || programs.length === 0) { throw new Error('Nothing to print on the active page.'); } @@ -155,9 +176,33 @@ export const PrintModal = ({ onClose }: Props) => { Plotter: {plotter?.name ?? '— none —'} + {overflows && activePage && plotter && ( + + Page is larger than the bed + This page is {activePage.width}×{activePage.height}mm but {plotter.name} can only + reach {plotter.bedWidth}×{plotter.bedHeight}mm + {activePage.width > plotter.bedWidth && activePage.height > plotter.bedHeight + ? ' — it overflows in both directions.' + : activePage.width > plotter.bedWidth + ? ' — it overflows horizontally.' + : ' — it overflows vertically.'} + setClipToBed(e.target.checked)} + /> + } + label="Clip to the bed and print what fits" + /> + + )} About to print {programs.length} visible layer - {programs.length === 1 ? '' : 's'} on the active page. The whole job is uploaded to + {programs.length === 1 ? '' : 's'} on the active page + {clipToBed && overflows ? ', clipped to the bed' : ''}. The whole job is uploaded to the plotter server, which runs it — you can close this tab once it starts. {programs.map((p) => ( @@ -221,7 +266,14 @@ export const PrintModal = ({ onClose }: Props) => { diff --git a/paint-app/src/components/ProjectGate.tsx b/paint-app/src/components/ProjectGate.tsx index f41bdbb..adeb99b 100644 --- a/paint-app/src/components/ProjectGate.tsx +++ b/paint-app/src/components/ProjectGate.tsx @@ -1,81 +1,46 @@ import BoltIcon from '@mui/icons-material/Bolt'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; +import PhotoIcon from '@mui/icons-material/Photo'; import { - AppBar, Box, Button, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, Divider, - FormControl, IconButton, - InputLabel, List, ListItem, ListItemButton, ListItemText, - MenuItem, - Toolbar as MuiToolbar, Paper, - Select, Stack, TextField, - Tooltip, Typography, } from '@mui/material'; import { useCallback, useEffect, useState } from 'react'; -import { useConnection } from '../connection'; +import type { ConnectedDataConfig } from '../connectedData'; +import { useConnectedData } from '../connectedDataSession'; import { db, type Project } from '../db'; -import { type PlotterDraft, usePlotters } from '../plotters'; +import { loadLastPageSize, type PageSize, saveLastPageSize } from '../pageSizes'; import { INTERACTIVE_PROJECT_ID, useProject } from './../project'; import { createInitialState } from '../store'; -import { type AppState, AppStateSchema, LegacyAppStateSchema } from '../types'; -import { PlottersModal } from './PlottersModal'; -import { SerialPortRow } from './SerialPortRow'; -import { SettingsMenu } from './SettingsMenu'; +import { AppStateSchema } from '../types'; +import { ConnectedDataWizard } from './ConnectedDataWizard'; +import { PageSizePicker } from './PageSizePicker'; const uid = () => Math.random().toString(36).slice(2, 10); -/** - * Bring a stored AppState onto the current schema. Returns `[migratedState, plotterToCreate?]` - * where plotterToCreate is non-null if we had to fabricate a plotter from a - * legacy project's inline `settings` block. - */ -const migrateState = (raw: unknown): { state: AppState; plotterDraft?: PlotterDraft } | null => { - const direct = AppStateSchema.safeParse(raw); - if (direct.success) return { state: direct.data }; - const legacy = LegacyAppStateSchema.safeParse(raw); - if (legacy.success) { - const tempPlotterId = uid(); - return { - state: { - pages: legacy.data.pages, - layers: legacy.data.layers, - activePageId: legacy.data.activePageId, - activeLayerId: legacy.data.activeLayerId, - plotterId: tempPlotterId, - }, - plotterDraft: { - name: 'Migrated plotter', - ...legacy.data.settings, - }, - }; - } - return null; +type Props = { + onOpenPhoto: () => void; }; -export const ProjectGate = () => { +export const ProjectGate = ({ onOpenPhoto }: Props) => { const { setProject } = useProject(); - const { plotters, ready: plottersReady, createPlotter } = usePlotters(); - const { connected, connectError, dismissConnectError } = useConnection(); + const { start: startConnectedData } = useConnectedData(); const [projects, setProjects] = useState(null); const [name, setName] = useState(''); - const [pickedPlotterId, setPickedPlotterId] = useState(''); - const [plottersOpen, setPlottersOpen] = useState(false); + + const [size, setSize] = useState(() => loadLastPageSize()); + const [connectedDataOpen, setConnectedDataOpen] = useState(false); const refresh = useCallback(async () => { const all = await db.projects.orderBy('updatedAt').reverse().toArray(); @@ -86,23 +51,15 @@ export const ProjectGate = () => { refresh(); }, [refresh]); - // Default the new-project plotter selector to the most recent plotter. - useEffect(() => { - if (!pickedPlotterId && plotters.length > 0) { - setPickedPlotterId(plotters[plotters.length - 1].id); - } - }, [plotters, pickedPlotterId]); - const onCreate = async () => { const trimmed = name.trim(); - if (!trimmed || !pickedPlotterId) return; - const plotter = plotters.find((p) => p.id === pickedPlotterId); - if (!plotter) return; + if (!trimmed || size.width <= 0 || size.height <= 0) return; + saveLastPageSize(size); const now = Date.now(); const project: Project = { id: uid(), name: trimmed, - state: createInitialState(plotter), + state: createInitialState(size), createdAt: now, updatedAt: now, }; @@ -113,32 +70,35 @@ export const ProjectGate = () => { const onOpen = async (id: string) => { const project = await db.projects.get(id); if (!project) return; - const migrated = migrateState(project.state); - if (!migrated) { + // Older documents carried plotter references (v1 `settings`, v2 + // `plotterId`); the schema drops them and keeps the page geometry. + const parsed = AppStateSchema.safeParse(project.state); + if (!parsed.success) { alert('This project has an unrecognised state shape and cannot be opened.'); return; } - let { state } = migrated; - if (migrated.plotterDraft) { - const newPlotter = await createPlotter(migrated.plotterDraft); - state = { ...state, plotterId: newPlotter.id }; - } - if (state !== project.state) { - await db.projects.update(id, { state }); - } - setProject({ id: project.id, name: project.name }, state); + setProject({ id: project.id, name: project.name }, parsed.data); }; - const onStartInteractive = async () => { - const plotter = plotters[plotters.length - 1]; - if (!plotter) return; - // Interactive sessions are ephemeral. Wipe any leftover persisted record - // (from earlier app versions or aborted runs) and start in memory only. + /** Ephemeral: wipe any leftover persisted record and start in memory only. */ + const startLiveSession = async (name: string, pageSize: PageSize) => { await db.projects.delete(INTERACTIVE_PROJECT_ID).catch(() => {}); - setProject( - { id: INTERACTIVE_PROJECT_ID, name: 'Interactive session' }, - createInitialState(plotter), - ); + setProject({ id: INTERACTIVE_PROJECT_ID, name }, createInitialState(pageSize)); + }; + + const onStartInteractive = () => startLiveSession('Interactive session', loadLastPageSize()); + + // A Connected Data run is an interactive session whose strokes come from a + // poll loop rather than the pointer, so it reuses the same live document and + // streaming path — only the source of the geometry differs. + const onStartConnectedData = async (config: ConnectedDataConfig) => { + setConnectedDataOpen(false); + let host = config.url; + try { + host = new URL(config.url).host; + } catch {} + await startLiveSession(`Connected Data · ${host}`, config.pageSize); + startConnectedData(config); }; const visibleProjects = (projects ?? []).filter((p) => p.id !== INTERACTIVE_PROJECT_ID); @@ -150,183 +110,147 @@ export const ProjectGate = () => { }; return ( - - - - - - paint-app - - - - - - - - {!plottersReady ? ( - - Loading plotters… - - ) : ( - - - {plotters.length === 0 ? ( - - No plotters configured — define one before starting. - - ) : ( - - Plotter - - - )} - - - - - - )} + + + + + + + + Interactive session + + + One live document — every stroke is sent to the plotter as soon as you draw it. + Choose and connect a plotter from the top bar once you're in. + + + - + - 0} - arrow - > - - - - - Interactive session - - - One live document — every stroke is sent to the plotter as soon as you draw it. - - - - - + - - - - - Projects - - {!plottersReady ? ( - - Loading plotters… - - ) : plotters.length === 0 ? ( + {projects === null ? ( - Create a plotter above first. + Loading… + + ) : visibleProjects.length === 0 ? ( + + No saved projects yet. ) : ( - - - setName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && onCreate()} - autoFocus - /> - - - {projects === null ? ( - - Loading… - - ) : visibleProjects.length === 0 ? ( - - No saved projects yet. - - ) : ( - - {visibleProjects.map((p) => ( - onDelete(p.id)} - > - - - } + + {visibleProjects.map((p) => ( + onDelete(p.id)} > - onOpen(p.id)} disabled={!connected}> - - - - ))} - - )} - + + + } + > + onOpen(p.id)}> + + + + ))} + )} - - - - + + - setPlottersOpen(false)} /> + + + + + Custom Utils + + + + + + Connected Data + + + Poll a JSON endpoint and plot fields from it — numbers as a live series, arrays as + a chart. + + + + + + + + + + Photo processing + + + Turn a photo into a multi-pen plot — grayscale, split into tonal layers, then + shade with lines, dots, or circles. + + + + + + + - - Connection failed - - {connectError} - - - - - + setConnectedDataOpen(false)} + onStart={onStartConnectedData} + /> ); }; diff --git a/paint-app/src/components/SettingsMenu.tsx b/paint-app/src/components/SettingsMenu.tsx index 5c49568..4cc5452 100644 --- a/paint-app/src/components/SettingsMenu.tsx +++ b/paint-app/src/components/SettingsMenu.tsx @@ -1,4 +1,5 @@ import AddIcon from '@mui/icons-material/Add'; +import DarkModeIcon from '@mui/icons-material/DarkMode'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; import DownloadIcon from '@mui/icons-material/Download'; import EditIcon from '@mui/icons-material/Edit'; @@ -31,6 +32,7 @@ import { db, type Project } from '../db'; import { usePlotters } from '../plotters'; import { INTERACTIVE_PROJECT_ID, useProject } from '../project'; import { useStore } from '../store'; +import { type ThemeMode, useThemeMode } from '../theme'; import { AppStateSchema, PlotterSchema } from '../types'; import { GRID_SIZE_MAX, GRID_SIZE_MIN, useUI } from '../ui'; import { PlottersModal } from './PlottersModal'; @@ -52,6 +54,8 @@ type MenuCtx = { setGridSize: (n: number) => void; showLog: boolean; setShowLog: (v: boolean) => void; + themeMode: ThemeMode; + toggleThemeMode: () => void; // project file ops autosave: boolean; setAutosave: (v: boolean) => void; @@ -124,6 +128,16 @@ const ShowGridItem: MenuItemComponent = ({ ctx }) => ( ); +const DarkModeItem: MenuItemComponent = ({ ctx }) => ( + + + + + + + +); + const ShowLogItem: MenuItemComponent = ({ ctx }) => ( ctx.setShowLog(!ctx.showLog)}> @@ -210,15 +224,15 @@ const CloseItem: MenuItemComponent = ({ ctx }) => ( type MenuKind = 'home' | 'project' | 'interactive'; const MENUS: Record = { - home: [[ShowLogItem], [ImportItem]], + home: [[DarkModeItem, ShowLogItem], [ImportItem]], project: [ - [AutosaveItem, ShowGridItem, ShowLogItem, SaveNowItem], + [AutosaveItem, ShowGridItem, DarkModeItem, ShowLogItem, SaveNowItem], [PlottersItem], [ExportItem, ImportItem], [RenameItem, DeleteItem], [CloseItem], ], - interactive: [[ShowGridItem, ShowLogItem], [PlottersItem], [CloseItem]], + interactive: [[ShowGridItem, DarkModeItem, ShowLogItem], [PlottersItem], [CloseItem]], }; const itemKey = (Comp: MenuItemComponent) => Comp.displayName ?? Comp.name; @@ -227,9 +241,10 @@ const itemKey = (Comp: MenuItemComponent) => Comp.displayName ?? Comp.name; export const SettingsMenu = () => { const { state } = useStore(); - const { ingestPlotter, getPlotter } = usePlotters(); + const { ingestPlotter } = usePlotters(); const { showLog, setShowLog } = useConnection(); const { showGrid, setShowGrid, gridSize, setGridSize } = useUI(); + const { mode: themeMode, toggleMode: toggleThemeMode } = useThemeMode(); const { project, autosave, @@ -255,12 +270,12 @@ export const SettingsMenu = () => { const onExport = () => { if (!project) return; - const plotter = getPlotter(state.plotterId); + // v3 documents carry no plotter: which machine they print on is a + // session choice, not a property of the drawing. const payload = { - version: 2, + version: 3, name: project.name, state, - plotter: plotter ?? null, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -278,8 +293,8 @@ export const SettingsMenu = () => { const parsed = JSON.parse(text); const importedState = AppStateSchema.parse(parsed?.state); - // If the export bundled a plotter snapshot, ingest it so the imported - // project's plotterId resolves locally. + // v2 exports bundled a plotter snapshot. The document no longer + // references it, but adopting the machine definition is still useful. const rawPlotter = parsed?.plotter; if (rawPlotter) { const plotterParsed = PlotterSchema.safeParse(rawPlotter); @@ -357,6 +372,8 @@ export const SettingsMenu = () => { setGridSize, showLog, setShowLog, + themeMode, + toggleThemeMode, autosave, setAutosave, isDirty, @@ -379,7 +396,7 @@ export const SettingsMenu = () => { return ( <> - setAnchor(e.currentTarget)} edge="start"> + setAnchor(e.currentTarget)}> diff --git a/paint-app/src/components/Toolbar.tsx b/paint-app/src/components/Toolbar.tsx index a4fa25c..c83fcd5 100644 --- a/paint-app/src/components/Toolbar.tsx +++ b/paint-app/src/components/Toolbar.tsx @@ -1,5 +1,7 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import BoltIcon from '@mui/icons-material/Bolt'; import BugReportIcon from '@mui/icons-material/BugReport'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import CloudOffIcon from '@mui/icons-material/CloudOff'; import DangerousIcon from '@mui/icons-material/Dangerous'; import GitHubIcon from '@mui/icons-material/GitHub'; @@ -19,19 +21,26 @@ import { DialogContent, DialogContentText, DialogTitle, + Divider, IconButton, Toolbar as MuiToolbar, Tooltip, Typography, } from '@mui/material'; import { useState } from 'react'; +import { useConnectedData } from '../connectedDataSession'; import { useConnection } from '../connection'; +import { usePlotters } from '../plotters'; import { INTERACTIVE_PROJECT_ID, useProject } from '../project'; import { DebugModal } from './DebugModal'; +import { PlotterControls } from './PlotterControls'; import { SettingsMenu } from './SettingsMenu'; type Props = { onPrint: () => void; + /** Set when a full-page util owns the view; it takes over the bar's title. */ + utilTitle: string | null; + onExitUtil: () => void; }; /** @@ -93,42 +102,53 @@ const ControlChip = ({ ); }; -export const Toolbar = ({ onPrint }: Props) => { - const { project, isDirty, isSaving, autosave, closeProject } = useProject(); +/** + * The application's single top bar. Mounted above the document/home swap so + * plotter selection and the connection live at app scope: you can connect, + * switch machines, or emergency-stop from anywhere, including the home screen. + */ +export const Toolbar = ({ onPrint, utilTitle, onExitUtil }: Props) => { + const { project, isDirty, isSaving, autosave } = useProject(); const { connected, paused, pause, resume, emergencyStop, - emergencyStopped, - acknowledgeEmergencyStop, isController, controllerName, takeControl, session, serverReachable, } = useConnection(); + const { activePlotter } = usePlotters(); + const connectedData = useConnectedData(); const [takeoverOpen, setTakeoverOpen] = useState(false); const observers = (session?.clients.length ?? 1) - 1; - const acknowledgeStop = () => { - acknowledgeEmergencyStop(); - closeProject(); - }; const [debugOpen, setDebugOpen] = useState(false); - const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; + const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; const status = isSaving ? 'saving…' : isDirty ? (autosave ? 'saving soon' : 'unsaved') : 'saved'; return ( - - - {project?.name ?? 'paint-app'} - - {project && !isInteractive && ( + {utilTitle ? ( + <> + + + {utilTitle} + + + ) : ( + + {project?.name ?? 'paint-app'} + + )} + {!utilTitle && project && !isInteractive && ( {isSaving && } @@ -144,12 +164,31 @@ export const Toolbar = ({ onPrint }: Props) => { observers={observers} onTakeControl={() => setTakeoverOpen(true)} /> + {isInteractive && ( + + } + label={!activePlotter ? 'No plotter' : connected ? 'Live' : 'Disconnected'} + color={!activePlotter ? 'default' : connected ? 'success' : 'error'} + size="small" + sx={{ ml: 1 }} + /> + + )} {connected && ( @@ -166,13 +205,48 @@ export const Toolbar = ({ onPrint }: Props) => { )} - {!isInteractive && ( + {connectedData.config && ( + + } + label={ + connectedData.status.lastError + ? 'Fetch failed' + : connectedData.status.windowFull + ? 'Window full' + : `${connectedData.status.polls} polls` + } + color={ + connectedData.status.lastError + ? 'error' + : connectedData.status.windowFull + ? 'warning' + : 'info' + } + variant="outlined" + size="small" + sx={{ ml: 1 }} + /> + + )} + + + + {project && !isInteractive && ( @@ -198,24 +272,14 @@ export const Toolbar = ({ onPrint }: Props) => { )} - - {isInteractive && ( - - } - label={connected ? 'Connected' : 'Disconnected'} - color={connected ? 'success' : 'error'} - size="small" - /> - - )} - + + + + + + + + { {debugOpen && setDebugOpen(false)} />} - - Emergency stop triggered - - - The plotter received an emergency stop (M112) and its firmware is now halted. It will - not respond to any commands until you power cycle the 3D printer (turn - it off, wait a few seconds, then turn it back on). - - - After power cycling, reconnect from the setup screen. - - - - - - ); }; diff --git a/paint-app/src/connectedData.ts b/paint-app/src/connectedData.ts new file mode 100644 index 0000000..b3685e4 --- /dev/null +++ b/paint-app/src/connectedData.ts @@ -0,0 +1,285 @@ +import type { PageSize } from './pageSizes'; +import type { Point } from './types'; + +// ─── Discovery ─────────────────────────────────────────────────────────── +// A probe fetch is walked once to produce a flat list of plottable fields. +// Only two things can be plotted: a scalar number (sampled repeatedly over +// time) and an array (drawn all at once as a series). + +export type ScalarKind = 'number' | 'string' | 'boolean' | 'null'; + +export type ItemField = { path: string; kind: ScalarKind; sample: string }; + +export type DiscoveredField = { + /** Dot path from the response root. Empty string means the root itself. */ + path: string; + label: string; + sample: string; +} & ( + | { kind: 'scalar'; valueKind: ScalarKind } + | { kind: 'numberArray'; length: number } + | { kind: 'objectArray'; length: number; itemFields: ItemField[] } +); + +/** Guards against pathological payloads turning discovery into a hang. */ +const MAX_DEPTH = 6; +const MAX_FIELDS = 400; +/** How many array entries to union when working out an array's item shape. */ +const ITEM_SHAPE_SAMPLE = 20; + +const scalarKind = (v: unknown): ScalarKind | null => { + if (v === null) return 'null'; + if (typeof v === 'number' && Number.isFinite(v)) return 'number'; + if (typeof v === 'string') return 'string'; + if (typeof v === 'boolean') return 'boolean'; + return null; +}; + +export const sampleText = (v: unknown): string => { + if (v === null) return 'null'; + if (typeof v === 'string') return v.length > 40 ? `"${v.slice(0, 40)}…"` : `"${v}"`; + if (Array.isArray(v)) return `${v.length} items`; + if (typeof v === 'object') return '{…}'; + return String(v); +}; + +const joinPath = (base: string, key: string) => (base ? `${base}.${key}` : key); + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** Union of the scalar keys across the first N entries — entries are often ragged. */ +const itemShape = (items: unknown[]): ItemField[] => { + const byPath = new Map(); + for (const item of items.slice(0, ITEM_SHAPE_SAMPLE)) { + if (!isPlainObject(item)) continue; + for (const [key, value] of Object.entries(item)) { + if (byPath.has(key)) continue; + const kind = scalarKind(value); + // A key that is null in this entry may be a number in the next, so keep + // looking rather than recording it as null. + if (!kind || kind === 'null') continue; + byPath.set(key, { path: key, kind, sample: sampleText(value) }); + } + } + return [...byPath.values()]; +}; + +const describeArray = (path: string, label: string, arr: unknown[]): DiscoveredField | null => { + if (arr.length === 0) return null; + if (arr.every((v) => typeof v === 'number' && Number.isFinite(v))) { + return { + path, + label, + kind: 'numberArray', + length: arr.length, + sample: `[${arr.slice(0, 4).join(', ')}${arr.length > 4 ? ', …' : ''}]`, + }; + } + if (arr.some(isPlainObject)) { + const itemFields = itemShape(arr); + if (itemFields.length === 0) return null; + return { + path, + label, + kind: 'objectArray', + length: arr.length, + itemFields, + sample: `${arr.length} objects · ${itemFields.map((f) => f.path).join(', ')}`, + }; + } + return null; +}; + +/** + * Flatten a parsed JSON response into every field worth plotting. Nested + * objects are walked; arrays terminate the walk and become a single field + * describing the whole series. + */ +export const discoverFields = (root: unknown): DiscoveredField[] => { + const out: DiscoveredField[] = []; + + const walk = (value: unknown, path: string, label: string, depth: number) => { + if (out.length >= MAX_FIELDS || depth > MAX_DEPTH) return; + + if (Array.isArray(value)) { + const field = describeArray(path, label || '(root)', value); + if (field) out.push(field); + return; + } + + if (isPlainObject(value)) { + for (const [key, child] of Object.entries(value)) { + walk(child, joinPath(path, key), joinPath(label, key), depth + 1); + } + return; + } + + const kind = scalarKind(value); + // `null` carries no type information: it can't be configured (there's no + // observed value to seed a range from) and might be a number on the next + // poll, so listing it would only add unselectable noise. + if (!kind || kind === 'null') return; + out.push({ + path, + label: label || '(root)', + kind: 'scalar', + valueKind: kind, + sample: sampleText(value), + }); + }; + + walk(root, '', '', 0); + return out; +}; + +/** Resolve a dot path produced by `discoverFields`. */ +export const getByPath = (root: unknown, path: string): unknown => { + if (!path) return root; + let current: unknown = root; + for (const key of path.split('.')) { + if (!isPlainObject(current)) return undefined; + current = current[key]; + } + return current; +}; + +export const getNumber = (root: unknown, path: string): number | null => { + const v = getByPath(root, path); + return typeof v === 'number' && Number.isFinite(v) ? v : null; +}; + +// ─── Selection & config ────────────────────────────────────────────────── + +export type SelectionMode = 'series' | 'snapshot'; + +export type Selection = { + id: string; + /** Path of the source field, as discovered. */ + path: string; + label: string; + mode: SelectionMode; + color: string; + /** objectArray only: which item key supplies X. Null means the item index. */ + xField: string | null; + /** objectArray only: which item key supplies Y. Null means the item itself. */ + yField: string | null; + /** Snapshots can range off their own data; live series cannot (see below). */ + autoRange: boolean; + yMin: number; + yMax: number; +}; + +export type ConnectedDataConfig = { + url: string; + format: 'json'; + intervalSeconds: number; + pageSize: PageSize; + marginMm: number; + /** How much elapsed time the page width represents, for `series` selections. */ + windowMinutes: number; + selections: Selection[]; +}; + +export const DEFAULT_INTERVAL_SECONDS = 60; +export const DEFAULT_WINDOW_MINUTES = 60; +export const DEFAULT_MARGIN_MM = 10; + +export const SERIES_COLORS = [ + '#1e88e5', + '#e53935', + '#43a047', + '#fb8c00', + '#8e24aa', + '#00897b', + '#d81b60', + '#3949ab', +]; + +// ─── Plot geometry ─────────────────────────────────────────────────────── + +export type PlotArea = { x: number; y: number; width: number; height: number }; + +export const plotArea = (page: PageSize, marginMm: number): PlotArea => { + // A margin so wide it inverts the area would silently produce mirrored + // geometry, so clamp it to a quarter of the shorter side. + const limit = Math.min(page.width, page.height) / 4; + const m = Math.max(0, Math.min(marginMm, limit)); + return { x: m, y: m, width: page.width - 2 * m, height: page.height - 2 * m }; +}; + +const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t); + +/** + * Map a value onto a page-local Y. Page coordinates run +y down, so a larger + * value sits closer to the top — the orientation a reader expects of a chart. + */ +export const valueToY = (value: number, min: number, max: number, area: PlotArea): number => { + const span = max - min; + const t = span === 0 ? 0.5 : clamp01((value - min) / span); + return area.y + area.height * (1 - t); +}; + +/** Fraction of the time window elapsed → page-local X. */ +export const elapsedToX = (elapsedMs: number, windowMinutes: number, area: PlotArea): number => { + const windowMs = Math.max(1, windowMinutes * 60_000); + return area.x + area.width * clamp01(elapsedMs / windowMs); +}; + +export const seriesWindowFull = (elapsedMs: number, windowMinutes: number): boolean => + elapsedMs >= windowMinutes * 60_000; + +/** + * Extract the (x, y) pairs a snapshot selection should draw, in page-local mm. + * X comes from the chosen field or the item index; both axes are normalised + * over the array's own extent, so a snapshot always fills the plot area. + */ +export const snapshotPoints = (root: unknown, selection: Selection, area: PlotArea): Point[] => { + const raw = getByPath(root, selection.path); + if (!Array.isArray(raw)) return []; + + const pairs: { x: number; y: number }[] = []; + raw.forEach((item, index) => { + const yRaw = + selection.yField === null ? item : isPlainObject(item) ? item[selection.yField] : undefined; + if (typeof yRaw !== 'number' || !Number.isFinite(yRaw)) return; + + let xRaw: number = index; + if (selection.xField !== null) { + const candidate = isPlainObject(item) ? item[selection.xField] : undefined; + if (typeof candidate !== 'number' || !Number.isFinite(candidate)) return; + xRaw = candidate; + } + pairs.push({ x: xRaw, y: yRaw }); + }); + + if (pairs.length < 2) return []; + + const xs = pairs.map((p) => p.x); + const xMin = Math.min(...xs); + const xMax = Math.max(...xs); + const xSpan = xMax - xMin; + + const ys = pairs.map((p) => p.y); + const yMin = selection.autoRange ? Math.min(...ys) : selection.yMin; + const yMax = selection.autoRange ? Math.max(...ys) : selection.yMax; + + return pairs.map((p) => ({ + x: area.x + area.width * (xSpan === 0 ? 0.5 : clamp01((p.x - xMin) / xSpan)), + y: valueToY(p.y, yMin, yMax, area), + })); +}; + +/** A sensible starting range for a live series, given its first observed value. */ +export const seedRange = (value: number): { yMin: number; yMax: number } => { + if (value === 0) return { yMin: -1, yMax: 1 }; + const pad = Math.abs(value) * 0.5; + const min = value - pad; + const max = value + pad; + // Round outward to something legible rather than 13.7419… + const step = 10 ** Math.floor(Math.log10(Math.max(1e-9, max - min))) / 2; + return { + yMin: Math.floor(min / step) * step, + yMax: Math.ceil(max / step) * step, + }; +}; diff --git a/paint-app/src/connectedDataSession.tsx b/paint-app/src/connectedDataSession.tsx new file mode 100644 index 0000000..578d6c1 --- /dev/null +++ b/paint-app/src/connectedDataSession.tsx @@ -0,0 +1,183 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + type ConnectedDataConfig, + elapsedToX, + getNumber, + plotArea, + seriesWindowFull, + snapshotPoints, + valueToY, +} from './connectedData'; +import { fetchText } from './fetchText'; +import { useProject } from './project'; +import { useStore } from './store'; +import type { Point } from './types'; + +export type ConnectedDataStatus = { + polls: number; + lastPollAt: number | null; + lastError: string | null; + /** Live series have run off the right edge of the page; they stop extending. */ + windowFull: boolean; +}; + +const IDLE_STATUS: ConnectedDataStatus = { + polls: 0, + lastPollAt: null, + lastError: null, + windowFull: false, +}; + +type ConnectedDataCtx = { + config: ConnectedDataConfig | null; + status: ConnectedDataStatus; + start: (config: ConnectedDataConfig) => void; + stop: () => void; + /** Called by the sync loop. Stable identity — the loop depends on it. */ + reportStatus: (status: ConnectedDataStatus) => void; +}; + +const ConnectedDataContext = createContext(null); + +export const ConnectedDataProvider = ({ children }: { children: ReactNode }) => { + const [config, setConfig] = useState(null); + const [status, setStatus] = useState(IDLE_STATUS); + + const start = useCallback((next: ConnectedDataConfig) => { + setStatus(IDLE_STATUS); + setConfig(next); + }, []); + + const stop = useCallback(() => { + setConfig(null); + setStatus(IDLE_STATUS); + }, []); + + const reportStatus = useCallback((next: ConnectedDataStatus) => setStatus(next), []); + + const value = useMemo( + () => ({ config, status, start, stop, reportStatus }), + [config, status, start, stop, reportStatus], + ); + + return {children}; +}; + +export const useConnectedData = () => { + const ctx = useContext(ConnectedDataContext); + if (!ctx) throw new Error('useConnectedData must be used within ConnectedDataProvider'); + return ctx; +}; + +/** + * Drives a Connected Data session: polls the endpoint on its interval and + * turns each response into strokes. + * + * Live series are emitted as one short segment per poll (previous sample → + * new sample) rather than as one ever-growing polyline. That is what makes + * them plottable: the interactive streamer sends each new stroke exactly once, + * so the pen extends the curve instead of retracing it from the origin on + * every tick. Snapshots are the opposite — the whole array is a single stroke, + * re-emitted only when the underlying data actually changes. + */ +export const useConnectedDataSync = () => { + // Only `config` and `reportStatus` are read: both are stable across status + // updates, so a status report can't tear down and restart the poll loop. + const { config, reportStatus, stop } = useConnectedData(); + const { state, addStroke } = useStore(); + const { project } = useProject(); + + // The loop must survive every store change, so mutable reads go through refs. + const stateRef = useRef(state); + stateRef.current = state; + const addStrokeRef = useRef(addStroke); + addStrokeRef.current = addStroke; + + // Closing the document ends the session; otherwise it would keep polling + // into a store that no longer belongs to it. + useEffect(() => { + if (!project && config) stop(); + }, [project, config, stop]); + + useEffect(() => { + if (!config || !project) return; + + let cancelled = false; + const startedAt = Date.now(); + const lastPoint = new Map(); + const lastSnapshot = new Map(); + let polls = 0; + + const poll = async () => { + if (cancelled) return; + try { + const { body } = await fetchText(config.url); + if (cancelled) return; + const json = JSON.parse(body); + + const snapshot = stateRef.current; + const page = snapshot.pages.find((p) => p.id === snapshot.activePageId); + if (!page) return; + const layerId = snapshot.activeLayerId; + const area = plotArea(config.pageSize, config.marginMm); + const toWorld = (p: Point): Point => ({ x: p.x + page.x, y: p.y + page.y }); + + const elapsed = Date.now() - startedAt; + const full = seriesWindowFull(elapsed, config.windowMinutes); + + for (const selection of config.selections) { + if (selection.mode === 'series') { + if (full) continue; + const value = getNumber(json, selection.path); + if (value === null) continue; + const point: Point = { + x: elapsedToX(elapsed, config.windowMinutes, area), + y: valueToY(value, selection.yMin, selection.yMax, area), + }; + const previous = lastPoint.get(selection.id); + lastPoint.set(selection.id, point); + // The first sample is only an anchor — a segment needs two ends. + if (previous) { + addStrokeRef.current(layerId, [toWorld(previous), toWorld(point)], selection.color); + } + continue; + } + + const points = snapshotPoints(json, selection, area); + if (points.length < 2) continue; + const key = JSON.stringify(points); + if (lastSnapshot.get(selection.id) === key) continue; + lastSnapshot.set(selection.id, key); + addStrokeRef.current(layerId, points.map(toWorld), selection.color); + } + + polls += 1; + reportStatus({ polls, lastPollAt: Date.now(), lastError: null, windowFull: full }); + } catch (e) { + if (cancelled) return; + reportStatus({ + polls, + lastPollAt: Date.now(), + lastError: (e as Error).message, + windowFull: false, + }); + } + }; + + poll(); + const timer = setInterval(poll, Math.max(1, config.intervalSeconds) * 1000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [config, project, reportStatus]); +}; diff --git a/paint-app/src/fetchText.ts b/paint-app/src/fetchText.ts new file mode 100644 index 0000000..ed68668 --- /dev/null +++ b/paint-app/src/fetchText.ts @@ -0,0 +1,29 @@ +export type FetchTextResult = { + body: string; + contentType: string; +}; + +/** + * GET a URL as text. + * + * This used to prefer an Electron main-process bridge, because arbitrary data + * feeds rarely send CORS headers and the desktop shell could ignore them. The + * shell is gone, so a connected-data feed has to be one the browser is allowed + * to read. `fetch` says only "Failed to fetch" when it isn't, which reads like + * a dead link, so the reason is spelled out here. + */ +export const fetchText = async (url: string): Promise => { + let response: Response; + try { + response = await fetch(url); + } catch (e) { + throw new Error( + `${(e as Error).message}. This is usually CORS — the endpoint has to allow cross-origin requests from a browser.`, + ); + } + if (!response.ok) throw new Error(`Request failed with HTTP ${response.status}`); + return { + body: await response.text(), + contentType: response.headers.get('content-type') ?? '', + }; +}; diff --git a/paint-app/src/gcode.ts b/paint-app/src/gcode.ts index fb682f0..7a3f6c4 100644 --- a/paint-app/src/gcode.ts +++ b/paint-app/src/gcode.ts @@ -33,13 +33,47 @@ export type LayerProgram = { lines: string[]; }; +// Page dimensions are user-entered mm; a page exactly the size of the bed +// shouldn't read as overflowing because of float noise. +const FIT_EPSILON = 1e-6; + +/** + * Whether the page fits inside the plotter's bed. Documents are sized + * independently of any machine, so a page can legitimately be larger than the + * plotter it's being sent to. + */ +export const pageFitsBed = (page: Page, plotter: Plotter): boolean => + page.width <= plotter.bedWidth + FIT_EPSILON && page.height <= plotter.bedHeight + FIT_EPSILON; + +/** + * The rect geometry is clipped against, in world coordinates. Always the page; + * additionally capped to the bed when the page overflows and the caller opted + * into clipping. The bed is anchored at the page's top-left because that + * corner maps to the machine origin (see `toMachine`). + */ +const clipRectFor = (page: Page, plotter: Plotter, clipToBed: boolean): Rect => { + const full: Rect = { x: page.x, y: page.y, width: page.width, height: page.height }; + if (!clipToBed || pageFitsBed(page, plotter)) return full; + return { + x: page.x, + y: page.y, + width: Math.min(page.width, plotter.bedWidth), + height: Math.min(page.height, plotter.bedHeight), + }; +}; + /** * Build G-code for a single freshly-drawn stroke, clipped to the page rect * and translated into machine coordinates. Used by interactive mode where we * stream individual strokes as the user finishes them. */ -export const buildStrokeLines = (points: Point[], page: Page, plotter: Plotter): string[] => { - const rect: Rect = { x: page.x, y: page.y, width: page.width, height: page.height }; +export const buildStrokeLines = ( + points: Point[], + page: Page, + plotter: Plotter, + clipToBed = false, +): string[] => { + const rect = clipRectFor(page, plotter, clipToBed); const subs = clipPolyline(points, rect); const lines: string[] = []; for (const sub of subs) { @@ -53,8 +87,9 @@ export const buildLayerPrograms = ( layers: Layer[], page: Page, plotter: Plotter, + clipToBed = false, ): LayerProgram[] => { - const rect: Rect = { x: page.x, y: page.y, width: page.width, height: page.height }; + const rect = clipRectFor(page, plotter, clipToBed); return layers .filter((l) => l.visible) .map((layer) => { diff --git a/paint-app/src/index.css b/paint-app/src/index.css index dc54cb8..1983f58 100644 --- a/paint-app/src/index.css +++ b/paint-app/src/index.css @@ -1,8 +1,7 @@ :root { font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif; font-size: 14px; - color: #1a1a1a; - background: #fafafa; + /* Colors come from the MUI theme via CssBaseline so both modes work. */ } * { diff --git a/paint-app/src/interactive.tsx b/paint-app/src/interactive.tsx index 03cfc11..b591072 100644 --- a/paint-app/src/interactive.tsx +++ b/paint-app/src/interactive.tsx @@ -15,6 +15,10 @@ import { useStore } from './store'; * Strokes are sent through a serialized promise queue so concurrent draws * don't interleave G-code on the bus. * + * Geometry outside the target plotter's bed is clipped away silently — there's + * no point prompting per stroke the way the print flow does, and driving the + * machine past its limits is worse than dropping the overflow. + * * Each stroke crosses the network as one batch, not a line at a time; the * server's send loop still waits for Marlin's `ok` per line, over USB. */ @@ -22,16 +26,16 @@ export const useInteractiveSync = () => { const { state } = useStore(); const { project } = useProject(); const { client, connected, isController } = useConnection(); - const { getPlotter } = usePlotters(); + const { activePlotter } = usePlotters(); const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; const sentRef = useRef>(new Set()); const queueRef = useRef>(Promise.resolve()); const sessionKeyRef = useRef(null); - // (Re-)prime the "already sent" set whenever we enter the mode or the - // connection comes back. This is what stops the existing project history - // from being plotted on load. + // (Re-)prime the "already sent" set whenever we enter the mode, the + // connection comes back, or the target plotter changes. This is what stops + // the existing document from being replotted from the top. // biome-ignore lint/correctness/useExhaustiveDependencies: only re-prime on session-boundary changes useEffect(() => { if (!isInteractive || !connected || !isController) { @@ -45,13 +49,13 @@ export const useInteractiveSync = () => { } sentRef.current = ids; sessionKeyRef.current = null; - }, [isInteractive, connected, isController, project?.id]); + }, [isInteractive, connected, isController, project?.id, activePlotter?.id]); // Stream new strokes whenever the store changes. Read-only observers watch // the canvas without their strokes reaching the machine. useEffect(() => { if (!isInteractive || !connected || !isController || !project) return; - const plotter = getPlotter(state.plotterId); + const plotter = activePlotter; if (!plotter) return; const activePage = state.pages.find((p) => p.id === state.activePageId); if (!activePage) return; @@ -64,7 +68,7 @@ export const useInteractiveSync = () => { for (const stroke of layer.strokes) { if (sentRef.current.has(stroke.id)) continue; sentRef.current.add(stroke.id); - const lines = buildStrokeLines(stroke.points, activePage, plotter); + const lines = buildStrokeLines(stroke.points, activePage, plotter, true); if (lines.length > 0) newPrograms.push(lines); } } @@ -87,5 +91,5 @@ export const useInteractiveSync = () => { console.error('interactive stream failed', e); } }); - }, [state, isInteractive, connected, isController, project, client, getPlotter]); + }, [state, isInteractive, connected, isController, project, client, activePlotter]); }; diff --git a/paint-app/src/pageSizes.ts b/paint-app/src/pageSizes.ts new file mode 100644 index 0000000..615fd92 --- /dev/null +++ b/paint-app/src/pageSizes.ts @@ -0,0 +1,44 @@ +import type { Plotter } from './types'; + +/** Page dimensions in mm. Origin/position is decided by the store. */ +export type PageSize = { width: number; height: number }; + +const LAST_SIZE_LS_KEY = 'paint-app:lastPageSize'; + +export const DEFAULT_PAGE_SIZE: PageSize = { width: 210, height: 297 }; + +export const STANDARD_SIZES: { label: string; size: PageSize }[] = [ + { label: 'A4', size: { width: 210, height: 297 } }, + { label: 'A5', size: { width: 148, height: 210 } }, + { label: 'Letter', size: { width: 216, height: 279 } }, + { label: 'Square 200mm', size: { width: 200, height: 200 } }, +]; + +export const formatSize = (size: PageSize) => `${size.width}×${size.height}mm`; + +/** A plotter's bed, offered as a page size so "fill the machine" stays one click. */ +export const plotterPageSize = (plotter: Plotter): PageSize => ({ + width: plotter.bedWidth, + height: plotter.bedHeight, +}); + +/** + * The size a new document defaults to — whatever was used last, so the common + * case (same paper every time) needs no interaction. Interactive sessions use + * this directly instead of prompting. + */ +export const loadLastPageSize = (): PageSize => { + try { + const raw = localStorage.getItem(LAST_SIZE_LS_KEY); + if (!raw) return DEFAULT_PAGE_SIZE; + const parsed = JSON.parse(raw); + const width = Number(parsed?.width); + const height = Number(parsed?.height); + if (width > 0 && height > 0) return { width, height }; + } catch {} + return DEFAULT_PAGE_SIZE; +}; + +export const saveLastPageSize = (size: PageSize) => { + localStorage.setItem(LAST_SIZE_LS_KEY, JSON.stringify(size)); +}; diff --git a/paint-app/src/photo.ts b/paint-app/src/photo.ts new file mode 100644 index 0000000..c97edd9 --- /dev/null +++ b/paint-app/src/photo.ts @@ -0,0 +1,732 @@ +import type { Point } from './types'; + +/** + * Port of `gcode2dplotterart/experimental_photo_utils` plus the buckets→strokes + * step the Python side never had (it lived inline in each sketch under + * Plotter-Explorations/bought-a-3d-printer). + * + * The pipeline is: decode → resize to fit → adjust → reduce to N inks → + * render strokes. Everything here is pure and works on plain arrays; decoding + * and resizing need a canvas and live in `photoDecode.ts`. + * + * "Adjust" and "reduce" are about the image alone and know nothing about how + * it will be shaded; the styles below only ever see ink indices. + */ + +// ─── Adjustments ───────────────────────────────────────────────────────── + +export type LevelsParams = { + /** Input value that becomes pure black, 0..254. */ + blackPoint: number; + /** Input value that becomes pure white, 1..255. */ + whitePoint: number; + /** Midtone bias. 1 is neutral; below 1 darkens, above 1 lightens. */ + gamma: number; +}; + +export type AdjustParams = LevelsParams & { + /** -100 (flat) .. 100 (harsh). 0 leaves the image alone. */ + contrast: number; +}; + +export const DEFAULT_ADJUST: AdjustParams = { + blackPoint: 0, + whitePoint: 255, + gamma: 1, + contrast: 0, +}; + +const clamp255 = (v: number) => (v < 0 ? 0 : v > 255 ? 255 : v); + +/** + * Build a 256-entry lookup table for levels + contrast, so the per-pixel work + * is a single array read no matter how many pixels there are. + */ +export const buildAdjustLut = (params: AdjustParams): Uint8ClampedArray => { + const lut = new Uint8ClampedArray(256); + const black = Math.min(params.blackPoint, 254); + const white = Math.max(params.whitePoint, black + 1); + const gamma = Math.max(0.01, params.gamma); + // Standard contrast factor: maps -100..100 onto a slope through mid-grey. + const c = Math.max(-255, Math.min(255, (params.contrast / 100) * 255)); + const factor = (259 * (c + 255)) / (255 * (259 - c)); + + for (let v = 0; v < 256; v++) { + let t = (v - black) / (white - black); + t = t < 0 ? 0 : t > 1 ? 1 : t; + let out = 255 * t ** (1 / gamma); + out = factor * (out - 128) + 128; + lut[v] = clamp255(out); + } + return lut; +}; + +/** Apply a channel LUT to RGB, leaving alpha alone. */ +export const applyLut = (rgba: Uint8ClampedArray, lut: Uint8ClampedArray): Uint8ClampedArray => { + const out = new Uint8ClampedArray(rgba.length); + for (let i = 0; i < rgba.length; i += 4) { + out[i] = lut[rgba[i]]; + out[i + 1] = lut[rgba[i + 1]]; + out[i + 2] = lut[rgba[i + 2]]; + out[i + 3] = rgba[i + 3]; + } + return out; +}; + +// ─── Grayscale ─────────────────────────────────────────────────────────── + +export type GrayscaleMethod = 'average' | 'luminosity' | 'lightness'; + +export const GRAYSCALE_METHODS: { value: GrayscaleMethod; label: string; hint: string }[] = [ + { value: 'average', label: 'Average', hint: '(R + G + B) / 3' }, + { + value: 'luminosity', + label: 'Luminosity', + hint: '0.21R + 0.72G + 0.07B — matches perceived brightness', + }, + { value: 'lightness', label: 'Lightness', hint: '(max + min) / 2' }, +]; + +/** RGBA bytes → one 0..255 float per pixel. */ +export const toGrayscale = (rgba: Uint8ClampedArray, method: GrayscaleMethod): Float32Array => { + const out = new Float32Array(rgba.length / 4); + for (let i = 0, p = 0; i < rgba.length; i += 4, p++) { + const r = rgba[i]; + const g = rgba[i + 1]; + const b = rgba[i + 2]; + if (method === 'average') { + out[p] = (r + g + b) / 3; + } else if (method === 'luminosity') { + out[p] = r * 0.21 + g * 0.72 + b * 0.07; + } else { + out[p] = (Math.max(r, g, b) + Math.min(r, g, b)) / 2; + } + } + return out; +}; + +// ─── Bucketing ─────────────────────────────────────────────────────────── + +export type BucketMethod = 'even-pixels' | 'even-histogram'; + +export const BUCKET_METHODS: { value: BucketMethod; label: string; hint: string }[] = [ + { + value: 'even-pixels', + label: 'Even pixel count', + hint: 'Each layer covers roughly the same number of pixels — even ink per pen', + }, + { + value: 'even-histogram', + label: 'Even histogram', + hint: 'Each layer covers an equal slice of the 0–255 range — truer tones, uneven ink', + }, +]; + +export type BucketedImage = { + width: number; + height: number; + /** Bucket index per pixel, row-major. 0 is darkest. */ + data: Uint8Array; + layerCount: number; +}; + +/** + * Tonal distribution of the source, for the levels display. Uses whichever + * grayscale method is in play so the shape matches what actually gets split. + */ +export const luminanceHistogram = ( + rgba: Uint8ClampedArray, + method: GrayscaleMethod = 'luminosity', +): Int32Array => histogram256(toGrayscale(rgba, method)); + +/** + * Where the midtone handle sits between the black and white points, given a + * gamma. Inverse of `midpointToGamma`. + */ +export const gammaToMidpoint = (gamma: number, black: number, white: number): number => + black + (white - black) * 0.5 ** Math.max(0.01, gamma); + +/** + * Gamma that maps `mid` onto middle grey — the Photoshop levels convention. + * Solving 0.5 = t^(1/gamma) for gamma gives ln(t) / ln(0.5). + */ +export const midpointToGamma = (mid: number, black: number, white: number): number => { + const span = white - black; + if (span <= 0) return 1; + const t = (mid - black) / span; + if (t <= 0.001 || t >= 0.999) return t <= 0.001 ? 3 : 0.1; + return Math.max(0.1, Math.min(3, Math.log(t) / Math.log(0.5))); +}; + +const histogram256 = (gray: Float32Array): Int32Array => { + const hist = new Int32Array(256); + for (const value of gray) { + const v = value < 0 ? 0 : value > 255 ? 255 : Math.floor(value); + hist[v]++; + } + return hist; +}; + +/** + * `np.digitize(x, bins)` with ascending bins: the number of bins <= x, which + * is the count of thresholds the value has passed. + */ +const digitize = (value: number, bins: number[]): number => { + let low = 0; + let high = bins.length; + while (low < high) { + const mid = (low + high) >> 1; + if (bins[mid] <= value) low = mid + 1; + else high = mid; + } + return low; +}; + +/** Thresholds that split the histogram into equal-population slices. */ +const evenPixelBins = (gray: Float32Array, layerCount: number): number[] => { + const hist = histogram256(gray); + const target = gray.length / layerCount; + const bins: number[] = []; + let count = 0; + for (let value = 0; value < 256; value++) { + if (count >= target && bins.length < layerCount - 1) { + count = 0; + bins.push(value); + } + count += hist[value]; + } + return bins; +}; + +/** Thresholds at equal intervals across the 0..255 range. */ +const evenHistogramBins = (layerCount: number): number[] => { + const bins: number[] = []; + const segment = 256 / layerCount; + for (let i = 1; i < layerCount; i++) bins.push(Math.round(i * segment)); + return bins; +}; + +export const bucketImage = ( + gray: Float32Array, + width: number, + height: number, + layerCount: number, + method: BucketMethod, +): BucketedImage => { + const count = Math.max(2, Math.floor(layerCount)); + const bins = method === 'even-pixels' ? evenPixelBins(gray, count) : evenHistogramBins(count); + const data = new Uint8Array(gray.length); + for (let i = 0; i < gray.length; i++) { + const bucket = digitize(gray[i], bins); + data[i] = bucket > count - 1 ? count - 1 : bucket; + } + return { width, height, data, layerCount: count }; +}; + +export const bucketAt = (image: BucketedImage, x: number, y: number): number => + image.data[y * image.width + x]; + +// ─── Colour reduction ──────────────────────────────────────────────────── + +/** Deterministic RNG so a re-render never reshuffles the palette. */ +const seededRandom = (seed: number) => () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; +}; + +/** Perceived brightness, used to order inks darkest-first. */ +const luminance = (r: number, g: number, b: number) => 0.21 * r + 0.72 * g + 0.07 * b; + +const toHex = (r: number, g: number, b: number) => + `#${[r, g, b].map((v) => Math.round(clamp255(v)).toString(16).padStart(2, '0')).join('')}`; + +/** Above this, centroids are fitted on a sample — the result is the same. */ +const KMEANS_SAMPLE = 20_000; +const KMEANS_ITERATIONS = 12; + +/** + * K-means colour quantization — the step the Python README listed but never + * implemented. Returns one ink index per pixel plus the centroid colours, + * ordered darkest to lightest so the styles' "index 0 is the heaviest ink" + * assumption holds for colour images too. + */ +export const quantizeColors = ( + rgba: Uint8ClampedArray, + colorCount: number, +): { data: Uint8Array; palette: string[] } => { + const pixelCount = rgba.length / 4; + const k = Math.max(2, Math.min(colorCount, pixelCount)); + const random = seededRandom(0x5eed); + + const stride = Math.max(1, Math.floor(pixelCount / KMEANS_SAMPLE)); + const sample: number[] = []; + for (let p = 0; p < pixelCount; p += stride) sample.push(p * 4); + + // k-means++ seeding: spread the initial centroids out, which converges far + // more reliably than picking at random. + const centroids: number[][] = []; + const firstIndex = sample[Math.floor(random() * sample.length)]; + centroids.push([rgba[firstIndex], rgba[firstIndex + 1], rgba[firstIndex + 2]]); + while (centroids.length < k) { + const distances = sample.map((i) => { + let best = Number.POSITIVE_INFINITY; + for (const c of centroids) { + const d = (rgba[i] - c[0]) ** 2 + (rgba[i + 1] - c[1]) ** 2 + (rgba[i + 2] - c[2]) ** 2; + if (d < best) best = d; + } + return best; + }); + const total = distances.reduce((sum, d) => sum + d, 0); + let target = random() * total; + let chosen = sample[sample.length - 1]; + for (let s = 0; s < sample.length; s++) { + target -= distances[s]; + if (target <= 0) { + chosen = sample[s]; + break; + } + } + centroids.push([rgba[chosen], rgba[chosen + 1], rgba[chosen + 2]]); + } + + const nearest = (r: number, g: number, b: number) => { + let best = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let c = 0; c < centroids.length; c++) { + const d = + (r - centroids[c][0]) ** 2 + (g - centroids[c][1]) ** 2 + (b - centroids[c][2]) ** 2; + if (d < bestDistance) { + bestDistance = d; + best = c; + } + } + return best; + }; + + for (let iteration = 0; iteration < KMEANS_ITERATIONS; iteration++) { + const sums = centroids.map(() => [0, 0, 0, 0]); + for (const i of sample) { + const c = nearest(rgba[i], rgba[i + 1], rgba[i + 2]); + sums[c][0] += rgba[i]; + sums[c][1] += rgba[i + 1]; + sums[c][2] += rgba[i + 2]; + sums[c][3]++; + } + let moved = false; + for (let c = 0; c < centroids.length; c++) { + if (sums[c][3] === 0) continue; // Keep an empty cluster where it is. + const next = [sums[c][0] / sums[c][3], sums[c][1] / sums[c][3], sums[c][2] / sums[c][3]]; + if (next.some((v, axis) => Math.abs(v - centroids[c][axis]) > 0.5)) moved = true; + centroids[c] = next; + } + if (!moved) break; + } + + // Darkest first, so ink 0 is the heaviest everywhere in the app. + const order = centroids + .map((c, index) => ({ index, lum: luminance(c[0], c[1], c[2]) })) + .sort((a, b) => a.lum - b.lum); + const remap = new Uint8Array(centroids.length); + order.forEach((entry, position) => { + remap[entry.index] = position; + }); + + const data = new Uint8Array(pixelCount); + for (let p = 0; p < pixelCount; p++) { + const i = p * 4; + data[p] = remap[nearest(rgba[i], rgba[i + 1], rgba[i + 2])]; + } + + return { + data, + palette: order.map(({ index }) => + toHex(centroids[index][0], centroids[index][1], centroids[index][2]), + ), + }; +}; + +// ─── Prepare ───────────────────────────────────────────────────────────── + +export type PrepareParams = AdjustParams & { + /** Off keeps the source colours and reduces them with k-means instead. */ + grayscale: boolean; + grayscaleMethod: GrayscaleMethod; + /** How many inks the image is reduced to. */ + colorCount: number; + /** Grayscale only — colour images are split by k-means. */ + bucketMethod: BucketMethod; +}; + +export const DEFAULT_PREPARE: PrepareParams = { + ...DEFAULT_ADJUST, + grayscale: true, + grayscaleMethod: 'luminosity', + colorCount: 4, + bucketMethod: 'even-pixels', +}; + +export type PreparedImage = BucketedImage & { + /** + * Colour per ink as the image itself suggests: a black-to-white ramp for + * grayscale, the k-means centroids for colour. The UI may override these. + */ + suggestedPalette: string[]; +}; + +/** Evenly spaced greys, darkest first. */ +const grayRamp = (count: number): string[] => + Array.from({ length: count }, (_, i) => { + const v = Math.round((i / Math.max(1, count - 1)) * 255); + return toHex(v, v, v); + }); + +/** + * Everything between a decoded image and a set of ink indices: levels, + * contrast, then reduction to `colorCount` inks. + */ +export const prepareImage = ( + rgba: Uint8ClampedArray, + width: number, + height: number, + params: PrepareParams, +): PreparedImage => { + const adjusted = applyLut(rgba, buildAdjustLut(params)); + const count = Math.max(2, Math.floor(params.colorCount)); + + if (params.grayscale) { + const gray = toGrayscale(adjusted, params.grayscaleMethod); + const bucketed = bucketImage(gray, width, height, count, params.bucketMethod); + return { ...bucketed, suggestedPalette: grayRamp(count) }; + } + + const { data, palette } = quantizeColors(adjusted, count); + return { width, height, data, layerCount: count, suggestedPalette: palette }; +}; + +// ─── Styles ────────────────────────────────────────────────────────────── + +export type PhotoStyle = 'horizontal' | 'diagonal' | 'dots' | 'circles'; + +export type StyleParams = { + /** Rows/diagonals to skip between drawn lines. 1 draws every one. */ + lineSpacing: number; + /** Pixels skipped after each emitted segment, thinning dense areas. */ + colinearGap: number; + /** Dots: side of the cell each source pixel expands into, in mm. */ + boxSide: number; + /** Circles: source pixels averaged per output circle. */ + sampleLength: number; + /** Circles: diameter of the largest circle, in mm. */ + circleDiameter: number; + /** Circles: spacing between concentric rings, in mm. */ + lineWidth: number; +}; + +export const DEFAULT_STYLE_PARAMS: StyleParams = { + lineSpacing: 3, + colinearGap: 1, + boxSide: 2, + sampleLength: 10, + circleDiameter: 2, + lineWidth: 0.4, +}; + +/** + * Strokes for one layer, in page-local mm. Index in the outer array is the + * bucket, so layer 0 is the darkest tone. + */ +export type LayerStrokes = Point[][]; + +const emptyLayers = (count: number): LayerStrokes[] => + Array.from({ length: count }, () => [] as LayerStrokes); + +/** + * Walk a path of pixels, emitting one segment per run of constant bucket. + * Shared by the horizontal and diagonal styles — the only difference between + * them is the path. + */ +const emitRuns = ( + path: { x: number; y: number }[], + image: BucketedImage, + layers: LayerStrokes[], + colinearGap: number, + toMm: (p: { x: number; y: number }) => Point, +) => { + if (path.length < 2) return; + let start = path[0]; + let bucket = bucketAt(image, start.x, start.y); + let index = 0; + + while (index < path.length) { + const point = path[index]; + if (bucketAt(image, point.x, point.y) === bucket) { + index++; + continue; + } + layers[bucket].push([toMm(start), toMm(point)]); + // Skipping ahead after a run is what thins out busy regions; a gap of 1 + // keeps every run, which is the faithful "draw everything" setting. + index += Math.max(1, colinearGap); + if (index >= path.length) return; + start = path[index]; + bucket = bucketAt(image, start.x, start.y); + } + // Close the final run against the end of the path. + const last = path[path.length - 1]; + if (last !== start) layers[bucket].push([toMm(start), toMm(last)]); +}; + +const horizontalStrokes = (image: BucketedImage, params: StyleParams): LayerStrokes[] => { + const layers = emptyLayers(image.layerCount); + const step = Math.max(1, Math.floor(params.lineSpacing)); + for (let y = 0; y < image.height; y += step) { + const path = Array.from({ length: image.width }, (_, x) => ({ x, y })); + emitRuns(path, image, layers, params.colinearGap, (p) => ({ x: p.x, y: p.y })); + } + return layers; +}; + +/** + * Diagonals running up-and-right, seeded down the left edge and then along the + * bottom, so the whole image is covered exactly once. + */ +const diagonalStrokes = (image: BucketedImage, params: StyleParams): LayerStrokes[] => { + const layers = emptyLayers(image.layerCount); + const step = Math.max(1, Math.floor(params.lineSpacing)); + + const buildPath = (startX: number, startY: number) => { + const path: { x: number; y: number }[] = []; + let x = startX; + let y = startY; + while (x >= 0 && x < image.width && y >= 0 && y < image.height) { + path.push({ x, y }); + x++; + y--; + } + return path; + }; + + let lastY = 0; + for (let y = 0; y < image.height; y += step) { + emitRuns(buildPath(0, y), image, layers, params.colinearGap, (p) => ({ x: p.x, y: p.y })); + lastY = y; + } + // Pick up along the bottom edge where the left-edge sweep left off, so the + // spacing stays even across the seam. + const delta = Math.max(0, Math.abs(lastY - image.height) - 1); + for (let x = delta; x < image.width; x += step) { + emitRuns(buildPath(x, image.height - 1), image, layers, params.colinearGap, (p) => ({ + x: p.x, + y: p.y, + })); + } + return layers; +}; + +/** + * Each source pixel becomes a `boxSide` mm cell; the darker the pixel, the + * more sub-cells get a dot. Positions are shuffled so the fill reads as + * stipple rather than a grid. + */ +const dotStrokes = ( + image: BucketedImage, + params: StyleParams, + random: () => number, +): LayerStrokes[] => { + const layers = emptyLayers(image.layerCount); + const side = Math.max(1, Math.floor(params.boxSide)); + const perCell = side * side; + // A stroke needs two points, so a "dot" is the shortest segment that still + // puts ink down — the equivalent of the Python API's add_point. + const DOT_MM = 0.2; + + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const bucket = bucketAt(image, x, y); + // Bucket 0 is darkest and should be the most filled. + const fill = Math.min(perCell, Math.round(perCell * (1 - bucket / (image.layerCount - 1)))); + if (fill <= 0) continue; + + const cells: Point[] = []; + for (let r = 0; r < side; r++) { + for (let c = 0; c < side; c++) cells.push({ x: x * side + c, y: y * side + r }); + } + for (let i = cells.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [cells[i], cells[j]] = [cells[j], cells[i]]; + } + for (const cell of cells.slice(0, fill)) { + layers[bucket].push([cell, { x: cell.x + DOT_MM, y: cell.y }]); + } + } + } + return layers; +}; + +/** + * Blocks of `sampleLength` pixels are averaged into one cell, drawn as + * concentric rings — darker cells get a bigger outer radius, so tone reads as + * ink density. + */ +const circleStrokes = (image: BucketedImage, params: StyleParams): LayerStrokes[] => { + const layers = emptyLayers(image.layerCount); + const sample = Math.max(1, Math.floor(params.sampleLength)); + const diameter = Math.max(0.1, params.circleDiameter); + const ringGap = Math.max(0.05, params.lineWidth); + const SEGMENTS = 24; + + for (let row = 0; row + sample <= image.height; row += sample) { + for (let col = 0; col + sample <= image.width; col += sample) { + let total = 0; + for (let i = row; i < row + sample; i++) { + for (let j = col; j < col + sample; j++) total += bucketAt(image, j, i); + } + const bucket = Math.round(total / (sample * sample)); + // Darkest bucket → widest circle. + const scale = 1 - bucket / Math.max(1, image.layerCount - 1); + const outer = (diameter / 2) * (0.1 + 0.8 * scale); + if (outer <= 0) continue; + + // Centres are inset by one radius so the leftmost and topmost rings sit + // inside the plot area. The original sketch centred on the boundary and + // relied on the plotter clipping whatever fell outside. + const cx = diameter / 2 + (col / sample) * diameter; + const cy = diameter / 2 + (row / sample) * diameter; + for (let r = outer; r > 0; r -= ringGap) { + const ring: Point[] = []; + for (let s = 0; s <= SEGMENTS; s++) { + const angle = (s / SEGMENTS) * Math.PI * 2; + ring.push({ x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }); + } + layers[bucket].push(ring); + } + } + } + return layers; +}; + +/** + * Source-image resolution a style needs, given the target area in mm. The + * styles differ in how many source pixels back one millimetre of output. + */ +export const targetResolution = ( + style: PhotoStyle, + areaWidthMm: number, + areaHeightMm: number, + params: StyleParams, +): { width: number; height: number } => { + if (style === 'dots') { + const side = Math.max(1, Math.floor(params.boxSide)); + return { + width: Math.max(1, Math.floor(areaWidthMm / side)), + height: Math.max(1, Math.floor(areaHeightMm / side)), + }; + } + if (style === 'circles') { + const sample = Math.max(1, Math.floor(params.sampleLength)); + const diameter = Math.max(0.1, params.circleDiameter); + return { + width: Math.max(1, Math.floor((areaWidthMm / diameter) * sample)), + height: Math.max(1, Math.floor((areaHeightMm / diameter) * sample)), + }; + } + // One source pixel per millimetre. + return { + width: Math.max(1, Math.floor(areaWidthMm)), + height: Math.max(1, Math.floor(areaHeightMm)), + }; +}; + +export const renderStyle = ( + style: PhotoStyle, + image: BucketedImage, + params: StyleParams, + random: () => number = Math.random, +): LayerStrokes[] => { + switch (style) { + case 'horizontal': + return horizontalStrokes(image, params); + case 'diagonal': + return diagonalStrokes(image, params); + case 'dots': + return dotStrokes(image, params, random); + case 'circles': + return circleStrokes(image, params); + } +}; + +// ─── Presets ───────────────────────────────────────────────────────────── + +/** + * A shading preset — style and its parameters only. Presets deliberately say + * nothing about tone, colour count, or pen colours: those belong to preparing + * the image and are the user's to set once, independent of how it's shaded. + */ +export type PhotoPreset = { + id: string; + name: string; + description: string; + /** Which sketch in Plotter-Explorations/bought-a-3d-printer this came from. */ + source: string; + style: PhotoStyle; + params: StyleParams; +}; + +export const PHOTO_PRESETS: PhotoPreset[] = [ + { + id: 'diagonal-dense', + name: 'Diagonal lines (dense)', + description: + 'Diagonal sweeps broken into runs of equal tone, keeping every run. The heaviest coverage.', + source: 'diag_lines_attempt_2/process_photo_even_pixel_buckets.py', + style: 'diagonal', + params: { ...DEFAULT_STYLE_PARAMS, lineSpacing: 3, colinearGap: 1 }, + }, + { + id: 'diagonal-sparse', + name: 'Diagonal lines (sparse)', + description: 'The original, thinner pass — wider gaps after each run leave more paper showing.', + source: 'diag_lines_original/diag_lines.py', + style: 'diagonal', + params: { ...DEFAULT_STYLE_PARAMS, lineSpacing: 3, colinearGap: 3 }, + }, + { + id: 'horizontal-scan', + name: 'Horizontal scan', + description: + 'Every row scanned left to right, split wherever the tone changes. The simplest and fastest to plot.', + source: 'dogs/main.py', + style: 'horizontal', + params: { ...DEFAULT_STYLE_PARAMS, lineSpacing: 1, colinearGap: 1 }, + }, + { + id: 'dots-stipple', + name: 'Stipple dots', + description: + 'Each pixel becomes a small cell filled with a random scatter of dots — more dots where the image is darker.', + source: 'dots/main.py', + style: 'dots', + params: { ...DEFAULT_STYLE_PARAMS, boxSide: 2 }, + }, + { + id: 'circles-concentric', + name: 'Concentric circles', + description: 'Blocks averaged into a grid of rings, sized by tone. Slow to plot but striking.', + source: 'circles/main.py', + style: 'circles', + params: { ...DEFAULT_STYLE_PARAMS, sampleLength: 10, circleDiameter: 2, lineWidth: 0.4 }, + }, +]; + +/** Pen sets lifted from the original sketches, offered when picking inks. */ +export const PALETTE_PRESETS: { name: string; colors: string[] }[] = [ + { name: 'Black', colors: ['#000000'] }, + { name: 'CMYK', colors: ['#000000', '#00b7eb', '#ff00ff', '#ffff00'] }, + { name: 'CMY + grey', colors: ['#a9a9a9', '#00b7eb', '#ff00ff', '#ffff00'] }, + { name: 'Purple / orange / yellow', colors: ['#8e3392', '#e76500', '#e0c200'] }, + { name: 'Red', colors: ['#dd3031'] }, +]; + +/** Repeat a palette out to `count` pens when it is shorter. */ +export const paletteFor = (palette: string[], count: number): string[] => + Array.from({ length: count }, (_, i) => palette[i % palette.length] ?? '#000000'); diff --git a/paint-app/src/photoDecode.ts b/paint-app/src/photoDecode.ts new file mode 100644 index 0000000..1f80e45 --- /dev/null +++ b/paint-app/src/photoDecode.ts @@ -0,0 +1,127 @@ +/** + * Canvas-backed half of the photo pipeline: decoding a file and resampling it. + * Kept apart from `photo.ts` so the arithmetic there stays pure and testable. + */ + +export type DecodedImage = { + rgba: Uint8ClampedArray; + width: number; + height: number; +}; + +export const decodeFile = async (file: File): Promise => { + try { + return await createImageBitmap(file); + } catch { + throw new Error( + `Could not decode "${file.name}". Supported formats are whatever the browser can open — JPEG, PNG, WebP, GIF.`, + ); + } +}; + +/** Largest size fitting inside the bounds without changing the aspect ratio. */ +export const fitWithin = ( + width: number, + height: number, + maxWidth: number, + maxHeight: number, +): { width: number; height: number } => { + if (width <= 0 || height <= 0) throw new Error('Image dimensions cannot be zero'); + if (maxWidth <= 0 || maxHeight <= 0) throw new Error('Maximum dimensions must be positive'); + const scale = Math.min(maxWidth / width, maxHeight / height); + return { + width: Math.max(1, Math.floor(width * scale)), + height: Math.max(1, Math.floor(height * scale)), + }; +}; + +/** + * Resample to exactly the given size. Callers pass dimensions already derived + * from `fitWithin`, so the aspect ratio is preserved by construction. + */ +export const resample = (source: ImageBitmap, width: number, height: number): DecodedImage => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Could not get a 2D canvas context'); + ctx.drawImage(source, 0, 0, width, height); + return { rgba: ctx.getImageData(0, 0, width, height).data, width, height }; +}; + +/** + * Resample at a reduced detail level. The style still receives an image at + * exactly the resolution it expects (its geometry assumes one source pixel per + * millimetre), but the content is sampled coarsely and re-expanded without + * smoothing. The result is chunkier tonal regions: fewer, longer strokes and a + * bolder plot, which is what the Python pipeline's aggressive `resize_image` + * was really buying. + */ +export const resampleWithDetail = ( + source: ImageBitmap, + width: number, + height: number, + detail: number, +): DecodedImage => { + if (detail >= 1) return resample(source, width, height); + + const coarseWidth = Math.max(1, Math.round(width * detail)); + const coarseHeight = Math.max(1, Math.round(height * detail)); + + const coarse = document.createElement('canvas'); + coarse.width = coarseWidth; + coarse.height = coarseHeight; + const coarseCtx = coarse.getContext('2d'); + if (!coarseCtx) throw new Error('Could not get a 2D canvas context'); + coarseCtx.drawImage(source, 0, 0, coarseWidth, coarseHeight); + + const full = document.createElement('canvas'); + full.width = width; + full.height = height; + const ctx = full.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Could not get a 2D canvas context'); + // Nearest-neighbour on the way back up, so the blocks stay hard-edged + // instead of being blurred back into a gradient. + ctx.imageSmoothingEnabled = false; + ctx.drawImage(coarse, 0, 0, width, height); + + return { rgba: ctx.getImageData(0, 0, width, height).data, width, height }; +}; + +/** Data URL of a bucketed image, for the wizard's preview. */ +export const bucketPreviewUrl = ( + data: Uint8Array, + width: number, + height: number, + palette: string[], +): string => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return ''; + const image = ctx.createImageData(width, height); + const rgb = palette.map(hexToRgb); + for (let i = 0; i < data.length; i++) { + const [r, g, b] = rgb[data[i]] ?? [0, 0, 0]; + image.data[i * 4] = r; + image.data[i * 4 + 1] = g; + image.data[i * 4 + 2] = b; + image.data[i * 4 + 3] = 255; + } + ctx.putImageData(image, 0, 0); + return canvas.toDataURL(); +}; + +const hexToRgb = (hex: string): [number, number, number] => { + const clean = hex.replace('#', ''); + const full = + clean.length === 3 + ? clean + .split('') + .map((c) => c + c) + .join('') + : clean; + const int = Number.parseInt(full, 16); + return [(int >> 16) & 255, (int >> 8) & 255, int & 255]; +}; diff --git a/paint-app/src/photoProject.ts b/paint-app/src/photoProject.ts new file mode 100644 index 0000000..3616ef9 --- /dev/null +++ b/paint-app/src/photoProject.ts @@ -0,0 +1,54 @@ +import type { PhotoResult } from './components/PhotoStudio'; +import { db, type Project } from './db'; +import { saveLastPageSize } from './pageSizes'; +import type { AppState } from './types'; + +const uid = () => Math.random().toString(36).slice(2, 10); + +/** + * Persist a photo render as a project. A photo plot is a multi-pen print job + * rather than a live session, so each ink becomes its own layer and the print + * flow can pause for a pen swap between them. + */ +export const createPhotoProject = async ( + result: PhotoResult, +): Promise<{ project: Project; state: AppState }> => { + const now = Date.now(); + const pageId = uid(); + + const layers = result.layers.map((layer, index) => ({ + id: uid(), + name: `Pen ${index + 1}${index === 0 ? ' (darkest)' : ''}`, + color: layer.color, + thickness: 0.5, + visible: true, + strokes: layer.strokes.map((points) => ({ + id: uid(), + // Strokes arrive page-local; the margin is applied here so the + // renderer's world space stays the single source of truth. + points: points.map((p) => ({ x: p.x + result.marginMm, y: p.y + result.marginMm })), + color: layer.color, + })), + })); + + const state: AppState = { + pages: [ + { id: pageId, x: 0, y: 0, width: result.pageSize.width, height: result.pageSize.height }, + ], + layers, + activePageId: pageId, + activeLayerId: layers[0].id, + }; + + const project: Project = { + id: uid(), + name: result.name, + state, + createdAt: now, + updatedAt: now, + }; + + await db.projects.put(project); + saveLastPageSize(result.pageSize); + return { project, state }; +}; diff --git a/paint-app/src/plotters.tsx b/paint-app/src/plotters.tsx index f1bd725..49f803d 100644 --- a/paint-app/src/plotters.tsx +++ b/paint-app/src/plotters.tsx @@ -12,11 +12,20 @@ import type { Plotter } from './types'; const uid = () => Math.random().toString(36).slice(2, 10); +const ACTIVE_PLOTTER_LS_KEY = 'paint-app:activePlotterId'; + export type PlotterDraft = Omit; type PlottersCtx = { plotters: Plotter[]; ready: boolean; + /** + * The plotter output currently goes to — global and session-scoped, like the + * selected printer in a word processor. Documents never reference it, so + * switching targets mid-session is free. Null until one is configured. + */ + activePlotter: Plotter | null; + setActivePlotter: (id: string | null) => void; createPlotter: (draft: PlotterDraft) => Promise; /** * Overwrite an existing plotter's parameters in place (id and createdAt @@ -39,6 +48,15 @@ const PlottersContext = createContext(null); export const PlottersProvider = ({ children }: { children: ReactNode }) => { const [plotters, setPlotters] = useState([]); const [ready, setReady] = useState(false); + const [activePlotterId, setActivePlotterIdState] = useState(() => + localStorage.getItem(ACTIVE_PLOTTER_LS_KEY), + ); + + const setActivePlotter = useCallback((id: string | null) => { + setActivePlotterIdState(id); + if (id) localStorage.setItem(ACTIVE_PLOTTER_LS_KEY, id); + else localStorage.removeItem(ACTIVE_PLOTTER_LS_KEY); + }, []); useEffect(() => { let cancelled = false; @@ -54,12 +72,26 @@ export const PlottersProvider = ({ children }: { children: ReactNode }) => { }; }, []); - const createPlotter = useCallback(async (draft: PlotterDraft) => { - const plotter: Plotter = { id: uid(), ...draft, createdAt: Date.now() }; - await db.plotters.put(plotter); - setPlotters((prev) => [...prev, plotter].sort((a, b) => a.createdAt - b.createdAt)); - return plotter; - }, []); + // Keep the selection pointing at something real: fall back to the most + // recent plotter when none is chosen or the stored id has been deleted, so + // single-plotter users never have to touch the picker. + useEffect(() => { + if (!ready || plotters.length === 0) return; + if (activePlotterId && plotters.some((p) => p.id === activePlotterId)) return; + setActivePlotter(plotters[plotters.length - 1].id); + }, [ready, plotters, activePlotterId, setActivePlotter]); + + const createPlotter = useCallback( + async (draft: PlotterDraft) => { + const plotter: Plotter = { id: uid(), ...draft, createdAt: Date.now() }; + await db.plotters.put(plotter); + setPlotters((prev) => [...prev, plotter].sort((a, b) => a.createdAt - b.createdAt)); + // A freshly configured plotter is almost always the one you meant to use. + setActivePlotter(plotter.id); + return plotter; + }, + [setActivePlotter], + ); const ingestPlotter = useCallback(async (snapshot: Plotter) => { const existing = await db.plotters.get(snapshot.id); @@ -87,17 +119,34 @@ export const PlottersProvider = ({ children }: { children: ReactNode }) => { const getPlotter = useCallback((id: string) => plotters.find((p) => p.id === id), [plotters]); + const activePlotter = useMemo( + () => (activePlotterId ? (plotters.find((p) => p.id === activePlotterId) ?? null) : null), + [plotters, activePlotterId], + ); + const value = useMemo( () => ({ plotters, ready, + activePlotter, + setActivePlotter, createPlotter, updatePlotter, ingestPlotter, deletePlotter, getPlotter, }), - [plotters, ready, createPlotter, updatePlotter, ingestPlotter, deletePlotter, getPlotter], + [ + plotters, + ready, + activePlotter, + setActivePlotter, + createPlotter, + updatePlotter, + ingestPlotter, + deletePlotter, + getPlotter, + ], ); return {children}; @@ -108,12 +157,3 @@ export const usePlotters = () => { if (!ctx) throw new Error('usePlotters must be used within PlottersProvider'); return ctx; }; - -/** - * Resolve the active project's plotter. Returns null if no plotter has been - * selected yet, or if the referenced plotter no longer exists. - */ -export const useActivePlotter = (plotterId: string): Plotter | null => { - const { getPlotter } = usePlotters(); - return getPlotter(plotterId) ?? null; -}; diff --git a/paint-app/src/store.tsx b/paint-app/src/store.tsx index 74de231..06333b1 100644 --- a/paint-app/src/store.tsx +++ b/paint-app/src/store.tsx @@ -1,20 +1,21 @@ import { createContext, type ReactNode, useCallback, useContext, useMemo, useReducer } from 'react'; -import type { AppState, Layer, Page, Plotter, Point, Stroke } from './types'; +import type { PageSize } from './pageSizes'; +import type { AppState, Layer, Page, Point, Stroke } from './types'; const uid = () => Math.random().toString(36).slice(2, 10); const HISTORY_LIMIT = 100; /** - * Create a fresh AppState for a new project. The plotter governs both the - * referenced id and the initial page dimensions (which match the bed so the - * default canvas fills the printable area). + * Create a fresh AppState at the given page size. Documents are + * plotter-independent — the size is picked when the document is created, and + * which plotter it eventually prints to is decided separately. */ -export const createInitialState = (plotter: Plotter): AppState => { +export const createInitialState = (size: PageSize): AppState => { const pageId = uid(); const layerId = uid(); return { - pages: [{ id: pageId, x: 0, y: 0, width: plotter.bedWidth, height: plotter.bedHeight }], + pages: [{ id: pageId, x: 0, y: 0, width: size.width, height: size.height }], layers: [ { id: layerId, @@ -27,7 +28,6 @@ export const createInitialState = (plotter: Plotter): AppState => { ], activePageId: pageId, activeLayerId: layerId, - plotterId: plotter.id, }; }; @@ -45,7 +45,6 @@ const PLACEHOLDER_STATE: AppState = { ], activePageId: 'placeholder', activeLayerId: 'placeholder-layer', - plotterId: '', }; export type AddDirection = 'top' | 'right' | 'bottom' | 'left'; diff --git a/paint-app/src/theme.tsx b/paint-app/src/theme.tsx new file mode 100644 index 0000000..352054a --- /dev/null +++ b/paint-app/src/theme.tsx @@ -0,0 +1,73 @@ +import { CssBaseline, createTheme, ThemeProvider } from '@mui/material'; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'; + +export type ThemeMode = 'light' | 'dark'; + +const THEME_MODE_LS_KEY = 'paint-app:themeMode'; + +const readStoredMode = (): ThemeMode => { + const raw = localStorage.getItem(THEME_MODE_LS_KEY); + if (raw === 'light' || raw === 'dark') return raw; + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +}; + +type ThemeModeCtx = { + mode: ThemeMode; + setMode: (m: ThemeMode) => void; + toggleMode: () => void; +}; + +const ThemeModeContext = createContext(null); + +export const AppThemeProvider = ({ children }: { children: ReactNode }) => { + const [mode, setModeState] = useState(readStoredMode); + + const setMode = useCallback((m: ThemeMode) => { + setModeState(m); + localStorage.setItem(THEME_MODE_LS_KEY, m); + }, []); + + const value = useMemo( + () => ({ + mode, + setMode, + toggleMode: () => setMode(mode === 'dark' ? 'light' : 'dark'), + }), + [mode, setMode], + ); + + const theme = useMemo( + () => + createTheme({ + palette: + mode === 'dark' + ? { + mode: 'dark', + background: { default: '#121212', paper: '#1e1e1e' }, + } + : { mode: 'light' }, + shape: { borderRadius: 6 }, + components: { + // Tells the browser to render native widgets (scrollbars, the + // color/number inputs used in the layer + color pickers) to match. + MuiCssBaseline: { styleOverrides: { ':root': { colorScheme: mode } } }, + }, + }), + [mode], + ); + + return ( + + + + {children} + + + ); +}; + +export const useThemeMode = () => { + const ctx = useContext(ThemeModeContext); + if (!ctx) throw new Error('useThemeMode must be used within AppThemeProvider'); + return ctx; +}; diff --git a/paint-app/src/types.ts b/paint-app/src/types.ts index 1fb5d13..11f7681 100644 --- a/paint-app/src/types.ts +++ b/paint-app/src/types.ts @@ -59,32 +59,20 @@ export const PlotterSchema = z.object({ }); export type Plotter = z.infer; -export const AppStateSchema = z.object({ - pages: z.array(PageSchema).min(1), - layers: z.array(LayerSchema).min(1), - activePageId: z.string(), - activeLayerId: z.string(), - plotterId: z.string(), -}); -export type AppState = z.infer; - /** - * Pre-plotter v1 state shape — kept around so we can migrate older projects - * (which embedded plotter params inline as `settings`) into the new - * plotter-by-id model. + * A document is plotter-independent: it owns its page geometry and nothing + * else. The plotter is a print target chosen at output time (see + * `plotters.tsx`), the way a printer is picked when printing a document. + * + * Two older shapes parse into this one for free, because zod strips unknown + * keys: v1 embedded plotter params inline as `settings`, and v2 referenced a + * plotter by `plotterId`. Both carry the page dimensions we actually need, so + * both are read as-is and simply lose the stale field on the next save. */ -export const LegacyAppStateSchema = z.object({ +export const AppStateSchema = z.object({ pages: z.array(PageSchema).min(1), layers: z.array(LayerSchema).min(1), activePageId: z.string(), activeLayerId: z.string(), - settings: z.object({ - bedWidth: z.number().positive(), - bedHeight: z.number().positive(), - travelFeed: z.number().positive(), - drawFeed: z.number().positive(), - penUpZ: z.number(), - penDownZ: z.number(), - }), }); -export type LegacyAppState = z.infer; +export type AppState = z.infer;