Skip to content

Plotters as printers, plus Connected Data and Photo processing utils - #77

Merged
TravisBumgarner merged 11 commits into
mainfrom
plotter-as-printer
Jul 29, 2026
Merged

Plotters as printers, plus Connected Data and Photo processing utils#77
TravisBumgarner merged 11 commits into
mainfrom
plotter-as-printer

Conversation

@TravisBumgarner

@TravisBumgarner TravisBumgarner commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Ten commits of feature work, now merged with main and sitting on top of the plotter-server architecture.

Rebased onto the plotter server

main moved the serial link off the page: server/ owns the cable, runs the send loop, holds the job state machine, and serves this SPA. This branch was making the plotter a print target rather than a document property. The two changes approach the same thing from opposite ends, so the merge kept each side where it is the authority.

Superseded by main:

  • Electron. The desktop shell existed to get a stable storage origin and a serial port picker. The server provides both, over the network, to any browser. The shell, its dev launcher, electron-builder, and the packaging notes are deleted.
  • Web Serial. src/serial.ts is gone; src/plotterClient.ts (REST + WebSocket) replaces it. The port list comes from the server, so the plotter can be on a Pi in another room and the page can be open on a phone.
  • The main-process fetch bridge that Connected Data used to dodge CORS. Probing now uses plain fetch and says plainly that a feed has to allow cross-origin requests.
  • Per-document plotter state on the server side of thingsclient, session, control ownership, serverReachable, pause, and the emergency stop that bypasses the command socket are all main's and untouched.

Survived from this branch: the global activePlotter model, page sizes, dark mode, the folded util header, Connected Data, and the photo studio.

Plotters as printers

Starting an interactive session used to require a configured plotter and a live connection, because a document was created from a plotter — its page was sized to the bed and its state stored a plotterId. A machine was a prerequisite for drawing at all.

Documents are now plotter-independent. Page size is chosen at creation (a plotter's bed, a standard paper, or custom), and which plotter output goes to is a global session choice — the way a printer is picked when printing a document. plotterId is dropped from AppState; v1 settings and v2 plotterId states still load, since zod strips the stale field and the page geometry was already there.

Consequently the plotter picker lives in a top bar mounted above the document/home swap, alongside main's control lock, pause, and emergency stop, so all of it works from anywhere and survives closing a document.

The serial port picker moved with it. main hosted SerialPortRow on the setup screen, which this branch no longer routes through — merged naively there was no way to select a port at all. It now opens from a Connect button in PlotterControls, next to the plotter menu. Same argument the branch was already making: the machine and the link to it are session-scoped, not document-scoped. One consequence: acknowledging an emergency stop no longer closes the project, because there is no longer a setup screen to send you back to.

Since a page can exceed the plotter it's sent to, PrintModal still owns the bed-overflow decision — it has to, because page geometry and bed size are now picked independently, so the mismatch is only knowable at print time and the server receives finished G-code it won't second-guess. Start stays disabled until you tick clip-to-bed or switch to a plotter the page fits. Everything else in that dialog is main's: upload once, progress bar, chime and prompt at awaiting_pen_swap, gated on holding control.

Connected Data util

Polls a JSON endpoint and plots fields from it, configured through a three-step wizard. Scalar numbers become live series sampled on an interval; arrays become snapshots drawn from a single response.

Live series are emitted as one short segment per poll — previous sample to new sample — rather than as a 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 every tick. It also means a series cannot auto-range, since earlier points are already on paper by the time a new extreme arrives.

Feeds must allow CORS. The Electron bridge that used to get around this is gone with the shell; a server-side proxy is a separate decision.

Photo processing util

Ports gcode2dplotterart/experimental_photo_utils to TypeScript and adds the buckets-to-strokes step it never had — in Python that lived inline in each sketch under Plotter-Explorations/bought-a-3d-printer, so it is lifted out and shared.

A full-page studio: controls left, live preview right, two independent tabs.

  • Prepare Image — resize/detail, a levels histogram with black/gray/white handles, contrast, grayscale toggle, and reduction to N inks. With grayscale off, colours are reduced by k-means, the step the Python README listed as planned and never implemented.
  • Style — four shading styles taken from the sketches and offered as presets, plus ink colours, page size, and per-style parameters.
style from
horizontal scan dogs/main.py
diagonal lines diag_lines_*
stipple dots dots/main.py
concentric circles circles/main.py

The result is a saved project with one layer per ink, so the print flow can pause for a pen swap between them.

Notes

Two deliberate departures from the original sketches. Circle grids are inset by one radius — the sketches centred rings on the boundary and let the plotter clip what fell outside, silently dropping ink. And a dot is the shortest possible segment rather than a bare point, since a stroke here needs two ends.

One behaviour that looks like a bug but isn't: under Even pixel count, the gray point does nothing. That split ranks pixels and a tone curve preserves rank, so only clipping moves a pixel between inks. The UI says so, and a test asserts the invariance.

Verification

tsc -b, biome check, and vite build pass. The server's 76 tests still pass unchanged. 375 assertions cover the pure logic — field discovery and axis mapping for Connected Data, and the levels math, k-means determinism, style geometry, and per-preset page-bounds for Photo. These caught three real bugs: circles clipping off-page, null values listed as unselectable fields, and stale strokes surviving a tone edit.

Not verified: anything requiring a browser or hardware. No UI rendering, no round trip against a real plotter. Worth exercising before merging: connecting from the top bar's new port popover, a two-layer print through the pen swap, the photo studio against a large image, and Connected Data against a live endpoint.

🤖 Generated with Claude Code

TravisBumgarner and others added 11 commits July 27, 2026 18:47
Packages the app as an Electron desktop build (main/preload, dev
launcher script, electron-builder config) so the plotter can be driven
without a browser, and pipes main-process logs into the in-app log panel.

Adds a light/dark theme provider with a persisted mode toggle, replacing
the hardcoded light theme and the scattered literal border colors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Starting an interactive session required a configured plotter and a live
connection first, because a document was created *from* a plotter: its
page was sized to the bed and its state stored a plotterId. That made a
machine a prerequisite for drawing at all.

Documents are now plotter-independent. A page size is chosen at creation
(from a plotter's bed, a standard paper size, or custom, defaulting to
whatever was used last), and which plotter output goes to is a global
session choice, the way a printer is picked when printing a document.
plotterId is dropped from AppState; v1 `settings` and v2 `plotterId`
states still load, since zod strips the stale field and the page
geometry we need was already there. That also retires the migration path
that fabricated a plotter from a legacy project, and the delete guard
that blocked removing an in-use plotter.

Consequently the plotter picker, connect/disconnect, pause, and
emergency stop move out of the setup screen into a top bar mounted above
the document/home swap, so they work from anywhere and survive closing a
document. Start, Create, and Open no longer gate on a connection, and
acknowledging an emergency stop keeps your work open instead of kicking
you back to setup.

Since a page can now be larger than the plotter it's sent to, printing
warns on overflow and offers to clip to the bed; the interactive stream
clips silently, as there's no place to prompt per stroke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Connected Data polls a JSON endpoint and plots fields from it, configured
through a three-step wizard on the home screen: fetch a URL, pick which
fields to extract and how often, then choose how they land on the page.
HTML is present as a disabled format placeholder.

A probe fetch is walked once to discover what can be plotted. Scalar
numbers become live series, sampled on the interval; arrays (of numbers,
or of objects with chosen X/Y keys) become snapshots drawn from a single
response. Everything else is listed but not selectable.

Live series are emitted as one short segment per poll — previous sample
to new sample — rather than as a 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 every tick. It also means a series cannot auto-range, since
earlier points are already on paper by the time a new extreme arrives;
the range is fixed up front and seeded from the probe. Snapshots re-emit
only when their data actually changes.

A run is an interactive session whose geometry comes from the poll loop
instead of the pointer, so it reuses the existing live document and
streaming path. Closing the document ends the session.

Fetches go through a new main-process IPC bridge, because renderer fetch
to a third-party feed is subject to CORS and most feeds don't opt in. The
browser build falls back to direct fetch and says so when it fails.

Page-size selection is extracted into a shared PageSizePicker, which
derives its dropdown state from the value and so drops the seeding effect
ProjectGate needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports gcode2dplotterart/experimental_photo_utils to TypeScript and adds
the buckets-to-strokes step it never had — in Python that lived inline in
each sketch under Plotter-Explorations/bought-a-3d-printer, so it is
lifted out here and shared.

The pipeline mirrors the original: decode, resize to fit while preserving
aspect ratio, grayscale (average / luminosity / lightness), then bucket
into N tonal layers by even pixel count or even histogram slice. A
three-step wizard drives it, with a bucketed preview at step 2 and a
stroke preview at step 3.

Four shading styles, each taken from a sketch in the examples repo and
offered as a preset with that sketch's parameters:

  horizontal  scan rows, split on tone change      (dogs)
  diagonal    same along diagonals, with gaps      (diag_lines)
  dots        random stipple fill per cell         (dots)
  circles     concentric rings sized by tone       (circles)

The result is a saved project with one layer per tone rather than a live
session, because a photo plot is a multi-pen print job — the print flow
already pauses between layers for a pen swap.

Two deliberate departures from the originals. Circle grids are inset by
one radius: the sketches centred rings on the boundary and let the
plotter clip whatever fell outside, which silently dropped ink. And a dot
is the shortest possible segment rather than a bare point, since a stroke
here needs two ends; the Python API had add_point.

The generative sketches in that repo (boxes, triangles, dessins,
qrcodes, unique_boxes) are not photo processing and are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The modal wizard forced a linear path through settings that are actually
interdependent — tone and shading get judged against each other, so
stepping forward and back through a dialog was the wrong shape.

It is now a full-page workspace: controls on the left, a live preview of
whatever is being edited on the right. The steps become two tabs,
"Prepare Image" (tone) and "Style" (shading), switchable in either
direction. Until an image is loaded the workspace is replaced entirely by
a picker, which now also takes a drag-and-drop.

Strokes re-render on a debounce instead of on a button, and are only
built while the Style tab is open since that is the expensive step. Any
upstream change drops the previous render outright — otherwise editing
tone on the Prepare tab would leave stale strokes behind and "Create
project" would commit geometry from the settings before the edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every control below depends on the loaded image, so the row now reads as
a header rather than as the first setting: tinted background, a divider
under it, a border on the thumbnail, a bolder filename, and Change as an
outlined button instead of a bare link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preset dropdown on Prepare set the shading style along with tone,
colour count, and pen colours — a style concern reaching across into
image preparation, which made the tab hard to reason about. Presets are
now style and its parameters only, and they live on the Style tab where
they belong.

Prepare becomes a real adjustment pipeline instead: black / gray / white
points, contrast, grayscale as a toggle rather than an assumption, and
reduction to N inks. With grayscale off, colours are reduced by k-means
clustering — the step the Python README listed as planned and never
implemented — with centroids ordered darkest-first so the styles' "ink 0
is the heaviest" assumption holds for colour images too. Seeding is
deterministic, so a re-render never reshuffles the palette.

Pen colours move to per-ink overrides layered over what the image itself
suggests (a grey ramp, or the k-means centroids). Changing the ink count
can no longer strand a stale palette, and the sketch palettes are offered
as one-click chips.

One thing worth stating plainly, because it looks like a bug: under Even
pixel count the gray point does nothing. That split ranks pixels, and a
tone curve preserves rank, so only clipping moves a pixel between inks.
The UI says so where the sliders are, and a test asserts the invariance
so it can't be "fixed" into something that quietly means something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The photo studio drew its own header row directly under the app bar, so
two title rows stacked. The bar now takes over: when a full-page util
owns the view it shows that util's back button and title in place of the
project name.

Root owns which view is showing, since the bar and the view are siblings
and both need it. Building the project from a photo render moves out of
ProjectGate into photoProject.ts along with it — ProjectGate no longer
hosts the studio, it just asks for it.

The settings gear moves to the right, next to the GitHub link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resize was implicit: the style picked a sampling resolution from the page
area and that was that. It is now a Detail slider on Prepare, which is
the knob the Python pipeline's aggressive `resize_image` really was.

Below 100% the source is sampled coarsely and re-expanded with smoothing
off, so the style still gets an image at exactly the resolution its
geometry expects (one source pixel per millimetre) while tonal regions
merge into hard-edged blocks. The plot comes out bolder, with fewer and
longer strokes.

Ink colors move to Style. How many inks the image is reduced to is a
property of the image; which pens draw them is a property of the plot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three unrelated sliders gave no sense of where an image's tones actually
sit, so setting a black point was guesswork. Levels is now the familiar
histogram with three handles under it, sharing the same 0..255 domain so
a handle sits directly beneath the tones it clips. Regions outside the
black and white points are dimmed, since that is what clipping does to
them.

The middle handle is gamma, expressed as a position between the outer two
— the Photoshop convention, and the only way it reads meaningfully
against a distribution. Converting between the two is
gamma = ln(t) / ln(0.5), which is what makes the handle's input value
land on middle grey; a test asserts exactly that, along with the round
trip, because getting the direction backwards would be invisible in a
type check and obvious only on screen.

Bar heights use a square root scale: photographic histograms have a few
huge spikes that flatten everything else into an invisible line under a
linear one.

Resampling also splits out from the reduction, so turning a levels knob
no longer redoes the resample — only the per-pixel pass that actually
depends on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main moved the serial link off the page and onto a Node server that
owns the cable and serves this SPA; this branch had been making the
plotter a print target rather than a document property. Both changes
are about the same thing from opposite ends, so the merge kept each
side where it is the authority:

- Anything about the wire is main's: `client`, `session`, control
  ownership, `serverReachable`, pause, the job state machine, and the
  emergency stop that bypasses the command socket. `serial.ts` is
  gone; `plotterClient.ts` replaces it.
- Anything about the plotter as a model is this branch's: a global
  `activePlotter` instead of `state.plotterId`, plus the photo studio,
  connected data, page sizes, dark mode, and the folded util header.

`PrintModal` is where the two actually interleave. It is main's view
of a server-run job — upload once, `LinearProgress`, chime and prompt
at `awaiting_pen_swap`, gated on holding control — but it builds that
job from `activePlotter` and still owns the bed-overflow decision.
That decision has to happen here and nowhere else: page geometry and
bed size are now chosen independently, so the mismatch is only
knowable at print time, and the server receives finished G-code and
will not second-guess it. Start stays disabled until the user picks
clip-to-bed or a plotter the page fits.

Taking this branch's `ProjectGate` orphaned the port picker, since
main hosted it on the setup screen that this branch no longer routes
through — as merged there was no way to select a serial port at all.
`SerialPortRow` is unchanged and now opens from a Connect button in
`PlotterControls`, next to the plotter menu, which is the same
argument the branch was already making: the machine and the link to
it are session-scoped, not document-scoped. Consequently the emergency
stop acknowledgement no longer closes the project — main closed it to
push you back to the setup screen to reconnect, and there is nothing
to go back to now. Pause and the stop FAB stay in `Toolbar` only; the
copies in `PlotterControls` were merge duplication.

Electron is retired, so the shell, its dev script, and the
`window.desktop` fetch bridge go with it. Connected-data probing now
uses plain `fetch` and says plainly that a feed has to allow CORS.
`package-lock.json` was regenerated rather than hand-resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TravisBumgarner
TravisBumgarner merged commit c6ead4d into main Jul 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant