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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 35 additions & 22 deletions paint-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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
`<project>.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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions paint-app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 52 additions & 17 deletions paint-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ThemeProvider theme={theme}>
<CssBaseline />
<AppThemeProvider>
<ConnectionProvider>
<StoreProvider>
<PlottersProvider>
<ProjectProvider>
<UIProvider>
<Root />
<ConnectedDataProvider>
<Root />
</ConnectedDataProvider>
</UIProvider>
</ProjectProvider>
</PlottersProvider>
</StoreProvider>
</ConnectionProvider>
</ThemeProvider>
</AppThemeProvider>
);
};

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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, minHeight: 0 }}>{project ? <Shell /> : <ProjectGate />}</div>
{/* One bar for the whole app: plotter selection and the connection are
global, so they must outlive the document being open or closed. */}
<Toolbar
onPrint={() => setPrinting(true)}
utilTitle={util === 'photo' ? 'Photo processing' : null}
onExitUtil={() => setUtil(null)}
/>
<div style={{ flex: 1, minHeight: 0 }}>
{util === 'photo' ? (
<PhotoStudio onCreate={onCreatePhoto} />
) : project ? (
<Shell />
) : (
<ProjectGate onOpenPhoto={() => setUtil('photo')} />
)}
</div>
{showLog && <LogPanel lines={log} />}
{printing && project && <PrintModal onClose={() => setPrinting(false)} />}
</div>
);
};

const Shell = () => {
const [printing, setPrinting] = useState(false);
const { project } = useProject();
const { activePlotter } = usePlotters();
const isInteractive = project?.id === INTERACTIVE_PROJECT_ID;
useInteractiveSync();

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Toolbar onPrint={() => setPrinting(true)} />
{!activePlotter && (
<Alert severity="info" square>
No plotter configured — drawing works, but nothing can be sent. Choose{' '}
<strong>Add plotter</strong> in the top bar when you're ready to plot.
</Alert>
)}
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
{!isInteractive && <LayersPanel />}
<Canvas />
</div>
<BottomBar />
{printing && <PrintModal onClose={() => setPrinting(false)} />}
</div>
);
};
Expand All @@ -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;
Expand Down Expand Up @@ -110,7 +145,7 @@ const LogPanel = ({ lines }: { lines: string[] }) => {
<div
style={{
height,
borderTop: '1px solid #e0e0e0',
borderTop: `1px solid ${theme.palette.divider}`,
background: '#111',
color: '#ddd',
fontFamily: 'ui-monospace, Menlo, monospace',
Expand Down
6 changes: 3 additions & 3 deletions paint-app/src/components/BottomBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,10 @@ export const BottomBar = () => {
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}`,
}}
/>
))}
Expand Down
6 changes: 5 additions & 1 deletion paint-app/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +20,7 @@ type DrawingState =
| { kind: 'shape'; tool: Exclude<Tool, 'pen'>; start: Point; current: Point };

export const Canvas = () => {
const theme = useTheme();
const { state, addPage, addStroke, setActivePage, removePage } = useStore();
const { project } = useProject();
const {
Expand Down Expand Up @@ -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',
}}
>
Expand Down
Loading
Loading