An open-source, lightweight, pixel-first design studio for technical and blueprint work. Every action is a replayable command, usable from the GUI or the CLI, growing toward simple CAD and AI assistance.
See ROADMAP.md for the phased development plan and PLAN.md (or
Plans/archive/) for the plan driving the current or most recently completed
phase.
crates/core(pixelcad-core) — headless document model and typed command engine. No UI, GPU, or platform dependency; depends only onthiserror.crates/app(pixelcad-app) — Slint desktop shell; dispatches commands topixelcad-coreand renders the composited document.crates/cli(pixelcad-cli) — headless CLI that replays command scripts and project files throughpixelcad-core, exports PNG, and imports PNG.
cargo build --workspace
cargo test --workspace# Render a script or a project to PNG
cargo run -p pixelcad-cli -- run docs/samples/blueprint.pxcproj --out blueprint.png
cargo run -p pixelcad-cli -- run docs/samples/ship.pxc --out ship.png
# Turn a PNG into an editable project
cargo run -p pixelcad-cli -- import photo.png --out photo.pxcproj
# The local automation API (Phase 2): stable text formats for scripts
cargo run -p pixelcad-cli -- list-commands # every command + signature
cargo run -p pixelcad-cli -- macro list drawing.pxcproj
cargo run -p pixelcad-cli -- macro run drawing.pxcproj --name "hull" --out hull.png
cargo run -p pixelcad-cli -- query drawing.pxcproj # canvas/layers/palette/macroslist-commands, macro list/run, and query output deterministic plain
text, one field per line, so shell scripts can drive and inspect documents
without a GUI. See crates/cli/tests/automation.rs for the pinned formats.
Rendering the same input twice always produces byte-identical output. That is a hard invariant, enforced by tests, not an incidental property.
cargo run -p pixelcad-appOn first launch the app detects your operating system and asks once whether to use the matching window layout; the choice is saved and can be changed later in Settings → Style.
Two layouts share one codebase:
- Linux stacks horizontal bands (title bar, ribbon tabs with the Home toolbar, file tabs, canvas, style row, command strip, status tail), in the GIMP/Krita idiom.
- macOS keeps the native title bar, condenses the file controls into an in-content top bar, and docks the eight panel icons (File / Main / Style / Secondary / Tool / Command / Layers / AI) in a horizontal strip along the bottom edge. Clicking an icon expands its panel vertically upward; clicking again collapses it. Fullscreen splits the top bar into two rows and reveals (WIP) per-panel pop-out markers.
Features from the design that are not implemented yet render as dashed,
amber-marked WIP controls rather than being hidden. The full
LIVE/PARTIAL/WIP classification is in docs/design/ui-redesign-spec.md,
and each WIP item's destination phase is in
docs/design/ui-roadmap-mapping.md.
Settings (gear icon) persist to settings.toml in the platform config
directory (e.g. ~/Library/Application Support/dev.PixelCAD.PixelCAD/settings.toml
on macOS, ~/.config/pixelcad/settings.toml on Linux). Style offers interface
style (Auto / Linux / macOS), colour scheme (dark / light / high contrast),
colour design (cyanotype / graphite / amber / phosphor), text style
(compact / normal / comfortable) plus a monospace-labels switch, icon size,
and an accent-colour override. Basic offers the default canvas size, autosave,
exit confirmation, recent-files length and the zoom suggestion default. Everything applies live and saves
immediately — there is no Apply button.
Shortcuts use Cmd on macOS and Ctrl on Linux/Windows; both are written
below as accel.
| Action | How |
|---|---|
| Pick a tool | b brush, e eraser, g fill, i eyedropper, l line, r rectangle, o ellipse, p polyline, a arrow, t text, m marquee, f lasso |
| Vector shape tools | R rect, O ellipse, L line, P polygon, A arrow — drag to draw a persistent object (shape.add); a shape-tool drag that starts on an existing object moves it instead |
| Select / move a shape | Click an object with any shape tool (topmost hit); drag to move it; arrow keys nudge the selected object (takes priority over pixel nudge); Delete removes it |
| Draw / erase | Left-click-drag with the Brush or Eraser |
| Brush size / shape | [ / ], or the − / + buttons; the brush-shape button toggles the tip |
| Fill tolerance | tol− / tol+ (0 = exact match, like Phase 1) |
| Stroke opacity | op− / op+ (sets the alpha written; see the note below) |
| Pan | Right-click-drag, hold Space and left-click-drag, or scroll the mouse wheel (vertical and horizontal) |
| Zoom | accel+Scroll, the − / + zoom buttons, or + / -; Fit = accel+9, 1:1 = accel+0 |
| Grid | Grid on/off, or G (appears at 8× and above) |
| Palette slot | 1–8, or click a swatch; Recent keeps the last 8 colours |
| Edit a swatch | Select it, type a hex value, press Set |
| Select all | accel+A, or Select all |
| Clear selection | Shift+accel+D, or Clear selection |
| Delete selection | Delete / Backspace, or Delete |
| Nudge selection | Arrow keys (moves the pixels, not just the marquee) |
| Cut / copy / paste | accel+X / C / V |
| Copy visible layers | Shift+accel+C, or Copy visible |
| Duplicate selection | accel+D |
| Flip / rotate | accel+H / accel+U / accel+R, or the flip and rotate buttons in the Select group |
| Crop to selection | Crop |
| Resize canvas | Type width and height, press Resize; Scale sel resamples the selection (bottom bar) |
| Undo / redo | accel+Z, Shift+accel+Z, or the buttons |
| Layers | Add (accel+N), Duplicate (accel+J), Merge down (accel+E), Raise / Lower, Delete; click a row to select it, its checkbox to hide it |
| Save / open | Save project and Open write and read .pxcproj; Save script writes plain .pxc |
| Command editor | The command strip (Linux) or Command tab (macOS) takes .pxc commands directly: type one and press Enter; ↑/↓ walks the history; Tab completes the word being typed; errors show inline |
| Command palette | Shift+accel+P opens a fuzzy search over every command: Enter runs a parameterless command or pre-fills the editor with a valid template |
| Macros | Record … draw or type commands … Stop recording saves them under the typed name; run a macro from the palette (they appear as macro: <name> rows) or the CLI |
One pointer drag is one undo step, no matter how many commands it emits.
A note on opacity. Pixel writes replace rather than blend — that is
what makes the eraser erase instead of painting white. Opacity is therefore
the alpha actually written, so painting twice at 50 % gives 50 %, not 75 %.
Accumulating "flow" needs a blend mode in the write path and is in
BACKLOG.md.
Plain text, one command per line. Blank lines and lines whose first
non-whitespace character is # are comments. Values containing whitespace
must be double-quoted; the serializer always quotes colours and names.
Colours are #rrggbb (implicit ff alpha) or #rrggbbaa.
# Canvas and palette
canvas.new width=<u32> height=<u32>
palette.set index=<0..15> color=<hex>
color.set color=<hex> # active drawing colour
color.pick x=<i64> y=<i64> # eyedropper (reads the composite)
# Macros (Phase 2): named blocks of commands, run as one undo step.
macro.begin name="<text>"
macro.end
macro.run name="<text>"
macro.begin / macro.end pair around the macro body; macro.run expands
it at that point (undefined macros and self-recursion are rejected with
typed errors). The GUI records macros from any session; the palette lists
them alongside commands.
layer.add name="" # inserted above the active layer layer.select index= layer.remove index= # never the last remaining layer layer.rename index= name="" layer.move from= to= layer.visible index= value=<true|false> layer.opacity index= value=<0..255>
select.rect x= y= width= height= select.clear
layer.duplicate index= # independent copy, above layer.merge index= # composite down into index-1
select.all select.rect x= y= width= height= select.lasso points="x,y x,y ..." # closed polygon, boundary included select.clear
selection.delete selection.move dx= dy= # moves the pixels, not just the marquee selection.flip axis=<horizontal|vertical> selection.rotate degrees=<90|180|270> selection.scale width= height= # nearest-neighbour
canvas.crop x= y= width= height= canvas.resize width= height= # nearest-neighbour
pixel.set x= y= color= line.draw x0= y0= x1= y1= color= brush.stroke x0= y0= x1= y1= size= [shape=<square|round>] color= rect.draw x0= y0= x1= y1= [radius=] fill= color= ellipse.draw x0= y0= x1= y1= fill= color= polygon.draw x= y= radius= sides= [rotation=] fill= color= arrow.draw x0= y0= x1= y1= [head=] color= polyline.draw points="x,y x,y ..." color= fill.bucket x= y= [tolerance=<0-255>] color= text.draw x= y= text="..." [font=<small|bold>] [scale=] [align=<left|center|right>] color= image.import x= y= width= height= data=""
shape.add kind=<rect|ellipse|line|polygon|arrow> color= [id=] shape.edit id= kind=<...> color= shape.move id= dx= dy= shape.remove id= shape.select id= # the selected object (nudge/delete target) shape.select.clear
Shapes are materialized command state, not baked pixels: a project file
stores no shape list — replaying `shape.*` commands rebuilds it. `id` is
optional on `shape.add` (the engine assigns `max(existing ids) + 1`,
replay-stable); shapes render above the raster stack, so the CLI export
shows exactly the GUI.
Fields in `[brackets]` are optional. Every one of them defaults to the
pre-1.5 behaviour (`shape=square`, `radius=0`, `tolerance=0`,
`font=small`, `scale=1`, `align=left`, `rotation=0`), which is why scripts
written before those parameters existed still render byte-identically.
Two things worth knowing:
- **There is no eraser command.** Pixel writes replace rather than blend, so
`brush.stroke … color="#00000000"` *is* the eraser.
- **A selection is a shape, not just a box.** `select.lasso` carries a
coverage mask, so drawing, deleting and moving all respect the traced
outline rather than its bounding rectangle.
- **Text is a bitmap operation.** It uses the in-repo 5x7 typeface
(`crates/core/src/font.rs`), not a system font — a system font would be
floating point, platform-dependent, and would drag a shaping dependency
into `pixelcad-core`.
- **Geometry is integer-only**, including a fixed-point sine table for
`polygon.draw` and an integer square root for `arrow.draw`, so shapes are
bit-identical on every platform rather than merely similar.
- `pixel.set` and `line.draw` **error** on an out-of-canvas coordinate;
`brush.stroke`, `rect.draw`, `fill.bucket` and `image.import` clip
silently, because a wide brush at the border must not abort a stroke.
### `.pxcproj` — versioned project file
A `.pxc` command log with a version header as its first non-comment line:
```text
pixelcad.project version=1
canvas.new width=128 height=96
...
It is a text container, not a zip. The reasoning is in
crates/core/src/project.rs: no dependency, no timestamps or compressor
behaviour to threaten byte-determinism, git-diffable, and it makes "open a
project" literally "replay its commands".
A file with no header loads as legacy version 0 — that is exactly a
pre-1.0 .pxc script, and those keep working. A file declaring a version
newer than this build supports is rejected outright, before any command
executes, so it can never half-apply to an open document.
Phase 2 added macro blocks, and with them format version 2: a project
whose history contains a macro definition declares version=2; macro-free
projects stay version=1 so older builds keep opening them.
docs/samples/ship.pxc— the Phase 0 artefact: a 64×64 single-layer ship outline. Its rendered sha256 is pinned by a test as a regression guard.docs/samples/blueprint.pxcproj— the Phase 1 artefact: a 128×96 four-layer historical ship blueprint (paper, grid, hull, annotation) exercising every tool, layer opacity, and a selection-masked fill.docs/samples/paint_parity.pxcproj— the Phase 1.5 artefact: a 160×120 four-layer tool sampler covering ellipses, rounded rectangles, polygons, arrows, polylines, bitmap text, lasso selection, flip/rotate/move, a tolerance fill, and layer duplicate/merge.docs/samples/macro_demo.pxcproj— the Phase 2 artefact: a version-2 project defining a "plank" macro, running it, and echoing the same drawing inline; a test proves the two paths are byte-identical and pins the composite hash.docs/samples/vector_demo.pxcproj— the Phase 2 session 2A artefact: one persistent shape object per kind plus a move, an edit, and a select; a test pins the composite hash and proves the edit/move are load-bearing.
A test asserts each sample still uses the commands it exists to demonstrate, so a sample cannot quietly stop dogfooding them.
MIT — see LICENSE.