Plotters as printers, plus Connected Data and Photo processing utils - #77
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ten commits of feature work, now merged with
mainand sitting on top of the plotter-server architecture.Rebased onto the plotter server
mainmoved 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-builder, and the packaging notes are deleted.src/serial.tsis 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.fetchand says plainly that a feed has to allow cross-origin requests.client,session, control ownership,serverReachable, pause, and the emergency stop that bypasses the command socket are allmain's and untouched.Survived from this branch: the global
activePlottermodel, 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.
plotterIdis dropped fromAppState; v1settingsand v2plotterIdstates 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.
mainhostedSerialPortRowon 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 inPlotterControls, 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,
PrintModalstill 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 ismain's: upload once, progress bar, chime and prompt atawaiting_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_utilsto 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.
dogs/main.pydiag_lines_*dots/main.pycircles/main.pyThe 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, andvite buildpass. 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,nullvalues 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