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
37 changes: 29 additions & 8 deletions .github/workflows/plotter-server-image.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: plotter server image

# Publishes the plotter backend container to GHCR.
# Publishes the plotter appliance container to GHCR: the React renderer and the
# Node server that serves it and owns the serial port, in one image.
#
# Tags are prefixed `plotter-server-` so image releases don't collide with the
# Python package's versioning elsewhere in this repo. Push
Expand All @@ -11,19 +12,19 @@ on:
branches: [main]
tags: ["plotter-server-v*"]
paths:
- "paint-app/server/**"
- "paint-app/**"
- ".github/workflows/plotter-server-image.yml"
pull_request:
paths:
- "paint-app/server/**"
- "paint-app/**"
- ".github/workflows/plotter-server-image.yml"
workflow_dispatch:

env:
IMAGE: ghcr.io/${{ github.repository_owner }}/plotter-server

jobs:
test:
server:
runs-on: ubuntu-latest
defaults:
run:
Expand All @@ -35,13 +36,32 @@ jobs:
node-version: 22
cache: npm
cache-dependency-path: paint-app/server/package-lock.json
# The renderer's API client is tested from here, against the same fake
# Marlin the server uses. It needs the client source on disk but not the
# renderer's dependencies — it imports nothing beyond the protocol types.
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test

client:
runs-on: ubuntu-latest
defaults:
run:
working-directory: paint-app
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: paint-app/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run build

build:
needs: test
needs: [server, client]
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -51,8 +71,9 @@ jobs:
- uses: actions/checkout@v4

# The Pi is arm64 and these runners are amd64. Emulation is slow but the
# only compile step is `tsc`; the native serialport binding comes down as
# a prebuilt arm64 binary rather than being built here.
# only compile steps are `tsc` and `vite build`; the native serialport
# binding comes down as a prebuilt arm64 binary rather than being built
# here.
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

Expand All @@ -76,7 +97,7 @@ jobs:

- uses: docker/build-push-action@v6
with:
context: paint-app/server
context: paint-app
platforms: linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
Expand Down
15 changes: 15 additions & 0 deletions paint-app/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
node_modules
dist
dist-electron
release
server/node_modules
server/dist
*.log
*.tsbuildinfo
.DS_Store
.vite
experiments
deploy.sh
README.md
server/README.md
server/docker-compose.example.yml
32 changes: 27 additions & 5 deletions paint-app/server/Dockerfile → paint-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
# One image: the renderer and the server it is served from.
#
# Debian, not Alpine, on purpose.
#
# `serialport` is a native addon. Its prebuilt binaries are glibc; on musl there
# is no prebuild, so `npm ci` falls back to compiling from source and the image
# needs a whole toolchain — under QEMU that turns a 30-second build into a very
# long one, for a runtime that then behaves subtly differently. bookworm-slim is
# a few MB larger and just works.
FROM node:22-bookworm-slim AS build
WORKDIR /app

# ---------------------------------------------------------------- renderer
FROM node:22-bookworm-slim AS client
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts index.html ./
COPY src ./src
# The client imports the wire types straight out of the server rather than
# keeping a copy of them, so the protocol cannot drift between the two halves
# of an emergency stop. Types only — nothing here ends up in the bundle.
COPY server/src/protocol.ts ./server/src/protocol.ts
RUN npm run build

# ------------------------------------------------------------------ server
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY server/package.json server/package-lock.json ./
RUN npm ci
COPY server/tsconfig.json server/tsconfig.build.json ./
COPY server/src ./src
RUN npm run build

# Reinstall with dev dependencies pruned. `npm ci --omit=dev` from scratch is
# what gets the right prebuild for the target arch into the runtime layer.
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev

# ----------------------------------------------------------------- runtime
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
Expand All @@ -35,7 +52,12 @@ RUN apt-get update \

COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
COPY --from=client /app/dist ./client
COPY server/package.json ./

# The UI and the API come off the same port, from this process. There is no
# second web server to run, and nothing to keep in sync between them.
ENV CLIENT_DIR=/app/client

# The node user needs to be in the group that owns the tty device, or opening
# it fails with EACCES. On Raspberry Pi OS that group is `dialout` (GID 20).
Expand Down
81 changes: 55 additions & 26 deletions paint-app/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# paint-app

A browser-based paint app that draws on virtual pages with stacked, color-coded
layers and emits G-code to a USB-connected pen plotter (a Creality Ender-3 V3
SE running stock Marlin in this repo).
A paint app that draws on virtual pages with stacked, color-coded layers and
emits G-code to a pen plotter (a Creality Ender-3 V3 SE running stock Marlin in
this repo).

The plotter is a network appliance, not a USB peripheral: [`server/`](server/README.md)
runs on a Raspberry Pi wired to the machine, owns the serial port, and serves
this UI. Open it from a laptop, start a page, walk away, and swap pens from a
phone. One container, one port, no desktop app.

## Stack

Expand All @@ -12,22 +17,27 @@ SE running stock Marlin in this repo).
- Zod for runtime schemas
- Framer Motion for layer drag-and-drop reordering
- Biome for lint + format
- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) for plotter I/O — Chrome / Edge only
- A REST + WebSocket client (`src/plotterClient.ts`) against the plotter server
— any modern browser, no Web Serial, no desktop shell

## Develop

```sh
npm install
npm run dev
npm run dev # http://localhost:5173
PLOTTER_SERVER=http://plotter.local:8080 npm run dev # against the Pi
```

The dev server runs on `http://localhost:5173`. Web Serial requires a secure
context, which `localhost` qualifies as.
`vite dev` proxies `/api` and `/ws` to `http://localhost:8080` — run
`npm --prefix server run dev` alongside it, or point `PLOTTER_SERVER` at a real
Pi. In production none of this applies: the server serves the built page, so
the API is same-origin.

```sh
npm run build # production build
npm run check # biome lint + format with --write
npm run lint # biome check (no writes)
npm run build # production build -> dist/
npm run check # biome lint + format with --write
npm run lint # biome check (no writes)
npm run docker:build # the arm64 appliance image, renderer and server together
```

## Plotters
Expand Down Expand Up @@ -113,22 +123,35 @@ 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.** Toolbar → Connect, pick the `wchusbserial*` port, then
Print. The active page is sent: each visible layer becomes a block of G-code
(clipped to the page rectangle and translated to machine origin), with a
pause + audio chime + on-screen prompt between layers so you can swap pens.
- **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,
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
connected device shows the prompt and any of them can continue it. Closing
the tab does not orphan the print.
- **One controller.** Several browsers can watch the same plotter; exactly one
may move it. The top bar always says which you are. Taking control is
allowed mid-print, so the dialog says so before you do it.
- **Emergency stop.** Fixed bottom-right whenever the plotter is connected,
from any device, whether or not you hold control. It does not travel over the
command socket — see the emergency stop section of
[`server/README.md`](server/README.md), including what it does *not*
guarantee.

## Persistence is per-browser

Projects and plotters live in this browser's IndexedDB, not on the server. The
page comes from the Pi but your work does not: open the app on your phone and
you get an empty project list. Export/import JSON is the way to move a project
between devices. This is a deliberate hole, not an oversight — moving
persistence to the server is a separate decision.

## Network backend (`server/`)

Web Serial means the browser has to be on the machine holding the USB cable —
one laptop, tethered, for the whole print. [`server/`](server/README.md) is a
Node backend that owns the port instead, so the plotter can live on a Raspberry
Pi and be driven from anywhere on the network.

It is not wired into this app yet. The client still talks Web Serial and is
unchanged; swapping it over is a follow-up. See
[`server/README.md`](server/README.md) for the protocol and the deployment
notes.
[`server/`](server/README.md) owns the serial port, runs the send loop next to
the USB cable, holds the job state machine, and serves this UI. See its README
for the protocol, the emergency-stop guarantees, and the Pi deployment notes.

## Hardware notes

Expand All @@ -143,13 +166,19 @@ src/
App.tsx # shell + connection wiring
store.tsx # Context + useReducer app state
types.ts # Zod schemas / TS types
serial.ts # Web Serial wrapper (PlotterConnection)
plotterClient.ts # REST + WebSocket client for server/
connection.tsx # React context over it: status, session, job, log
gcode.ts # stroke -> G-code generator
svg-import.ts # SVG -> sampled strokes
clip.ts # Liang-Barsky polyline-vs-rect clipping
components/
Toolbar.tsx
Toolbar.tsx # control lock, pause, emergency stop
SerialPortRow.tsx # the server-side port picker
LayersPanel.tsx
Canvas.tsx
PrintModal.tsx
PrintModal.tsx # uploads the job, renders the server's state
Dockerfile # one image: this build + server/, arm64
```

`plotterClient.ts` is exercised end to end in `server/src/client.test.ts`,
against the same scripted Marlin the server is tested with.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#
# docker compose up -d
#
# The image contains the UI as well as the API, so http://plotter.local:8080 is
# the whole app. There is nothing else to run.
services:
plotter-server:
image: ghcr.io/travisbumgarner/plotter-server:latest
Expand All @@ -10,8 +12,11 @@ services:
- "8080:8080"
environment:
PORT: "8080"
# Point at a mounted renderer build to serve the UI from the same origin.
# The renderer ships inside the image at /app/client and CLIENT_DIR
# already points there. Only override it to serve a build from the host.
# CLIENT_DIR: /client
# Same-origin by default, since the server hands out the page. Only
# matters if you point a `vite dev` session at this box.
# CORS_ORIGIN: "http://plotter.local:5173"

# The container gets the device node itself...
Expand All @@ -23,7 +28,7 @@ services:
# Bind-mounting it does NOT create the device; `devices:` above does that.
volumes:
- /dev/serial/by-id:/dev/serial/by-id:ro
# - ../dist:/client:ro
# - ./dist:/client:ro

# `devices:` grants the node, not permission to open it. The tty is owned by
# root:dialout, so the container's user has to be in that group. Check the
Expand Down
18 changes: 18 additions & 0 deletions paint-app/package-lock.json

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

4 changes: 3 additions & 1 deletion paint-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"preview": "vite preview",
"lint": "biome check src",
"format": "biome format --write src",
"check": "biome check --write src"
"check": "biome check --write src",
"docker:build": "docker buildx build --platform linux/arm64 -t plotter-server:local ."
},
"dependencies": {
"@emotion/react": "^11.14.0",
Expand All @@ -24,6 +25,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^24.13.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
Expand Down
6 changes: 0 additions & 6 deletions paint-app/server/.dockerignore

This file was deleted.

Loading
Loading