Shapes, Docs, and Polish

This was a dense session. The kind where you sit down to do one thing and four hours later the commit log has eight entries across three different areas of the codebase. Here’s everything that happened.

Six new shape primitives

Lux had four shapes: Circle, Rect, Line, Path. That’s enough to prove the renderer works, but not enough to actually make things. Now there are ten.

Ellipse — independent x/y radii. Uses a dedicated DrawCommand::Ellipse variant so Vello can render it with native kurbo ellipses rather than approximating with path segments.

RoundRect — rectangle with a corner radius pin. Same approach as Ellipse — a dedicated DrawCommand that maps directly to kurbo’s RoundedRect.

Polygon — regular polygon with a configurable number of sides. Generates path segments procedurally. Sides are clamped to 3–1000 to prevent OOM from absurd values.

Star — alternating outer and inner radii. The inner_radius / outer_radius ratio controls how pointy the star is. First tip points upward. Points capped at 1000.

Arc — partial circle defined by start angle and sweep. Outputs an open path by default (no fill), which makes it useful for progress indicators and dials.

Ring — annulus (donut shape). Two concentric circles with even-odd fill rule to cut the hole. Initially used 360 line segments per circle — the adversarial review replaced that with 4 cubic Beziers per circle, cutting allocations by 27x.

All six follow the same fill/stroke pattern as Circle and Rect: fill color, stroke color, stroke width. All fully documented with summaries, descriptions, tags, see_also, and pin descriptions.

Inline documentation for everything

This was the biggest change by line count. A new NodeDoc struct in lux-core gives every node four documentation fields: summary, description, tags, and see_also. Four builder methods on NodeInfo populate them.

Then input_doc() and output_doc() were added for per-pin descriptions. These attach help text to the most recently added pin, so the builder chain reads naturally:

.input("damping", PinType::Number, PinValue::Number(10.0))
    .input_doc("Friction (higher = less bounce)")

The numbers: 110 nodes now have summaries, descriptions, tags, and cross-references. 203 pins across 72 nodes have descriptions — prioritising pins where the name alone doesn’t tell you the units, range, or what “higher” means.

Two CI tests enforce the contract: every node must have a summary, and every see_also reference must resolve to a real node name. Undocumented nodes break the build.

A --dump-docs CLI flag exports everything as JSON for static site generation.

Smarter tooltips and inspector

Pin tooltips used to be a single line: radius: Number. Now they have two layers — the pin name and type as a header, and the pin description in smaller, dimmer text below. The tooltip auto-sizes up to 280px and uses theme constants for all colours and sizes.

The inspector panel got repositioned: node documentation now appears below the pin controls instead of above them. Pins are what you interact with — they should be the first thing you see. The description is reference material, not the headline.

The inspector also learned to hide when nothing is selected. No more empty panel eating 220px of canvas for nothing.

Z-ordered nodes

Node rendering used to iterate a HashMap — random order every frame. Overlapping nodes were a visual coin flip. Now CanvasState maintains a draw_order: Vec<NodeId>. New nodes go on top. Clicking a node calls bring_to_front(). The renderer follows the vector order. Simple fix, big difference.

Search at cursor

Pressing Space or Tab to open the node search used to place the popup at a hardcoded [400, 300] screen position. If you’d panned the canvas, the new node would appear somewhere random. Now the editor tracks last_cursor_canvas on every mouse move, and search opens right where you’re looking. Create a node and it drops at your cursor.

Filter node

A new spread operation: Filter takes a spread and a boolean mask, returns only the elements where the mask is true. Wire a comparison node into the mask and you’ve got conditional data flow without branching logic. Handles mismatched lengths, truthy coercion for numeric masks, and empty inputs. Eight tests.

Adversarial review

After building the six shape nodes, I ran an adversarial review pass and caught 14 issues:

  • Ring/Arc circles used 360 line segments — replaced with 4 cubic Beziers (the standard approximation with < 0.027% error). 27x fewer allocations per ring.
  • Polygon/Star cloned path segments unnecessarily — removed the .clone().
  • No radius clamping — negative radii could produce inverted or invisible shapes. All 8 shape nodes now clamp to 0.0.
  • Polygon sides and Star points were unbounded — set at 1000 max to prevent OOM.
  • fill/stroke_color pins lacked documentation — added input_doc() across all shapes.
  • Circle and Rect see_also were stale — updated to include the new shapes.

Theme constants

Every hardcoded magic number in the inspector and tooltip rendering — panel width, pin dot size, spacing, tooltip offset — moved to theme::sizes and theme::colors constants. No behaviour change, but the entire visual language is now defined in one file.

The numbers

WhatCount
New shape nodes6
Nodes documented110
Pins documented203
New spread nodes1
Adversarial fixes14
New tests30+
Commits8
← Back to blog