Repository navigation
Design vector tooling
Status: agreed design; phase 0 landed except rotate and scale, phases 1 and 2 landed 2026-08-08, phases 3, 4a (pinch), 4b (width) and 4c (simplify) landed 2026-08-09; cut and join not started. Decisions Q47–Q53, answered 2026-08-07. Unblocked by Q26, which has been answered since the same day and which two other documents still describe as open — see Corrections at the end.
Two things phase 1 learned that the design did not predict, both worth having before phase 2 builds on them:
- The fit's tolerance is not the flatten's tolerance, and they pull opposite ways. Flattening is a rendering step and wants to be invisible (0.25 px); fitting is an authoring step and wants a handful of nodes a hand can work with (1.5 px). A fit tight enough to be invisible puts a node on every wobble, which is a path nobody can edit — the tool would appear to work and be useless.
-
Reshaping loses the line's weight unless something carries it, and phase 2
had to add that. A drawn stroke has a pressure at every one of its hundreds
of points; a fit keeps only the pressures at the handful of places its nodes
landed, so re-flattening turns a confident taper into three straight ramps.
Measured on an ordinary tapered arc: the peak drops from 1.00 to 0.89 on
the first node drag.
PressureProfilere-applies the original weight by normalised arc length, so it stretches with the edit instead of being resampled away. This is not a nicety — the roadmap item is worded "a drawn line can be re-shaped and keeps the mark it was drawn with", and pressure is the part of the mark an animator notices first. -
Flatten had to be uniform rather than recursive-adaptive, and pressure is
why. De Casteljau subdivision is the textbook answer and it loses the curve
parameter as it goes, so pressure would have to be carried through the
recursion or interpolated along the wrong variable. Uniform steps keep
tin hand at every sample; the cost is a few extra points on an S-curve, in a record that already holds hundreds of drawn points per stroke.
Two things happened alongside phase 0 that are not phases and are worth finding
here rather than only in the ledgers: B132 (a symbol could not be placed on a
vector layer) and Q52's UI removal were both settled by collapsing
PaintedFrame and VectorFrame into one Frame on 2026-08-08. That touched the
record and the serialized format, so it is named in the phase table below even
though it belongs to no phase.
Lightbox calls itself "a raster + vector desktop application", and the vector half is one true sentence and one missing feature.
A Lightbox stroke is a centreline with a width at every point. In Toon Boom Harmony that is exactly a pencil line — "vector information about their center line and the width of the line" — as against a brush line, which is stored as an outline and can only be edited on its contour. Harmony had to build a separate Centerline Editor to give brush strokes what Lightbox's strokes have by construction. Disney's Meander, the Sci-Tech Award-winning hybrid vector/raster system behind Paperman and Feast, is the same idea: strokes recorded as geometry, so they stay editable and can be interpolated.
So the architecture is already right. What is missing is stated plainly by the roadmap itself:
VectorFrameholdsList<Stroke>and nothing reaches into one after it is drawn. —ROADMAP.md:160
The whole task is tools that reach into the strokes that already exist. Not a vector layer, not a shape model, not SVG.
Krita bolted an SVG shape editor onto a paint program, so its vector layers are a second world. Two consequences, both fatal for line art and both quotable:
- "SVG layers can contain filled areas and even text, but they don't actually contain brush strokes, which makes them useless for most line art."
- You cannot use the brush tool at all while a vector layer is selected.
Clip Studio Paint did the same idea properly — brush strokes on vector layers, control points, a vector eraser that erases up to an intersection — and still pays for the split: vector layers are widely reported as sluggish and cannot hold fills.
Lightbox has no second world to pay for. One Stroke record, one
BrushEngine, one render path. That is the asset this design exists to spend
carefully, and the rule that follows is the first entry under What this must not
become.
The research is one-sided. Every tool that feels safe makes geometry editing a mode you enter deliberately:
| Tool | How you get in | How you get out |
|---|---|---|
| Illustrator | double-click (isolation mode "automatically locks all other objects") | Esc |
| Figma | Enter, or double-click | Esc |
| Grease Pencil | switch to Edit mode | switch back |
Every tool that feels mushy uses a modifier you have to remember. Krita's own vector-tool wiki: "Alt+drag allows you to start a rubber band without accidentally selecting and moving a shape." Inkscape's node tool: "the drag must not begin on a path unless Shift is used."
Modes are safe by default. Modifiers are unsafe by default and ask you to remember the antidote. That is the whole of what Q53 buys.
Q47 chose Bezier handles on every node. The obvious implementation — widening
StrokePoint(X, Y, Pressure) — is a migration and a second curve type in the
renderer, and it is avoidable, because a drawn stroke and an authored path are
different things. A drawn stroke has hundreds of sampled points and wants no
handles; a pen path has a dozen authored nodes and wants nothing else.
// Stroke.cs — one nullable property, absent unless used
public StrokePath? Path { get; set; }
public sealed class StrokePath
{
public List<PathNode> Nodes { get; set; } = [];
public bool Closed { get; set; }
}
public readonly record struct PathNode(
double X, double Y, double Pressure,
double InX, double InY, // handle offsets, relative to the node
double OutX, double OutY,
bool Corner); // handles independent rather than mirroredPoints remains what renders |
BrushEngine, FrameRasterizer, StrokeIndex, ContourTracer, TransformOps and the AI wire format (StrokeWire.PointDto, MaxWirePoints = 32) are unchanged. Invariant 1 holds without an argument |
| Absent unless used, both ways | A hand-drawn stroke writes no path key; clearing a path removes it. Assert.DoesNotContain("\"path\"", json) ships in the same commit |
| No second renderer, no migration | Editing a node re-flattens Path → Points and the engine stamps the new polyline exactly as before |
| Every existing drawing is editable | On demand, by fitting a path (Q50) |
A stroke's
PathandPointsmust never disagree. Any operation that maps points maps the path's nodes and handles too, or drops the path.
TransformOps.TransformStroke is the first caller that must obey it and
StrokeInterpolator the second. Asserted by a test, not by a comment: transform
a stroke that has a path, and flattening the transformed path must reproduce the
transformed points.
Four things, and what makes them safe is that each does exactly one.
| Key | What it touches | |
|---|---|---|
| Select — black arrow | V |
Whole strokes. Click, shift-click, drag a box. Then move, rotate, scale, delete, recolour. Double-click enters isolation |
| Direct select — white arrow | A |
Individual nodes and handles, on the isolated stroke |
| Pen | P |
Places nodes. Click = corner; click-and-drag = smooth node with handles |
| Isolation | double-click in, Esc out |
Not a tool, a state. Everything else greys and stops responding |
Pen modifiers follow Illustrator so the muscle memory transfers: Alt on a
handle breaks the pair, Ctrl temporarily gives Direct select, Shift
constrains to 45°, clicking the first node closes the path.
Then the reshaping set — CSP's Correct line list, which is the proven minimum
for stroke-level editing, and notably contains no booleans and no shape
primitives:
- Pinch a segment — drag the line between two nodes, no node selection. The one artists reach for most.
-
Width along the line — Illustrator's Width tool over the
Pressurearray. Its "width points" are Lightbox's per-point pressure under another name. - Simplify — fewer nodes, with a live count.
- Cut and join.
The transform tool is a modal session with exactly this shape: state on the view
model (TransformActive, BeginTransform, CancelTransform), a gizmo owning
interactive state on the canvas, a zoom-invariant hit test
(tol = 10.0 / (FitScale() * _zoom)), an overlay drawn inside the document
transform with every dimension divided by view.Scale, Enter/Escape handled
above the shortcut switch, and ToolMode saved and restored around the
session.
PathEditSession is a second instance of that, not a new mechanism. The roadmap
reserved the name.
There is no stroke-under-point query anywhere in the codebase. All three pieces exist and are tested; nothing composes them.
| Piece | Where | Note |
|---|---|---|
StrokeIndex.Intersecting(SKRectI) |
src/Lightbox.Raster/StrokeIndex.cs |
Visible only to TiledRasterizer today |
GeometryOps.DistToSegment |
src/Lightbox.Core/Geometry/GeometryOps.cs |
|
BrushEngine.CommitBounds |
src/Lightbox.Raster/BrushEngine.cs |
Already widened by dab reach, blur and clip feather |
StrokePicker queries the index with a tolerance box, refines with
DistToSegment, and returns topmost-first. StrokeIndex's own contract is
ascending record position, not speed — because a renderer that returns strokes
in the wrong order draws a different image. The picker reverses it, and says so
where it does, because getting that backwards picks the line underneath and looks
like a tolerance bug.
CanvasToolMode.Select and the whole SelectionManager picking path already
exist and are orphaned — SyncCanvasToolMode never assigns that mode, so
none of it is reachable. Phase 0 revives it rather than writing a parallel one.
The pen is the first tool here that creates a path, which breaks this document's own title — the rest of it is about lines you already drew. Two things fell out of that, neither predicted:
- A live preview a pen can afford is not the preview a shape tool uses. The shape tool stamps the real brush into the scratch surface on every pointer move, which is right for a gesture that lasts one drag and is exactly wrong for one that lasts a dozen clicks: a full-canvas clear and re-stamp per move, for as long as the artist is thinking. So the pen traces the flattened path as chrome and stamps the brush once, at the commit. The general form is worth keeping: the cost of a preview is set by how long the gesture lives, not by how much work one frame of it is.
-
Escape had to mean the opposite of what the neighbouring tool does. The
polygon selection cancels on Escape, and copying that would have thrown away a
minute of placed nodes on the key everybody presses to mean "I'm done". The
line is whether the thing in progress is artwork: a selection is not, a path
of twelve nodes is. Both Enter and Escape finish and keep, and losing it is
Ctrl+Z— which works because the whole path is one undo step.
A third thing was found rather than learned, and it belongs to phase 2: the node overlay is drawn whatever the tool is, so leaving isolation for the brush left glyphs on screen over a line nothing could reshape any more. B147's exact shape, one tool along. Both arrows keep the session now and everything else ends it.
One branch, one objective.
| Branch | What | |
|---|---|---|
| 0 | feat/canvas/stroke-selection |
StrokePicker; SelectedStrokeIds; the black arrow; move/rotate/scale/delete/recolour selected strokes through the existing transform session with a stroke-id filter. No record change
|
| 0 | landed, partly | Picker, selection, arrow, move, delete and recolour shipped (PRs #74, #75). Rotate and scale did not, and neither did the route this row specifies: no TransformScope can mean "these strokes inside this cel", so move/delete/recolour went through DocumentEditor.PerformDelta instead of the transform session. Finishing phase 0 means adding that scope, and it is a separate objective |
| — | fix/project/B132-one-frame-class |
Not a phase. PaintedFrame + VectorFrame → one Frame with a nullable baseline and nullable placements; the Raster/Vector picker and the R/V badge removed. A record and format change: closes B132, completes Q52's UI half, and drops kind and empty pngBase64 from the file |
| 1 | landed |
StrokePath, PathNode, Stroke.Path, PathFlattener, CurveFitter (Schneider), the agreement invariant obeyed at all three callers that map points. No UI, as specified. A 121-point arc fits to 4 nodes and flattens back within 1.2 px |
| 2 | landed |
PathEditSession, isolation, the white arrow, the node overlay — plus PressureProfile, which the design did not predict and the roadmap item's own wording requires. The white arrow is N, not A: A is this application's black arrow and has been documented as such |
| 3 | landed | The pen and its four modifiers, plus its icon and P. PenSession authors a StrokePath and the view model writes one ordinary stroke from it — the preview is a traced overlay rather than a stamped one, because a pen session outlasts a drag |
| 4 |
splitting; pinch landed 2026-08-09 as feat/canvas/pinch-a-segment
|
Pinch, width, simplify, cut, join — four branches rather than one, see below |
Phase 4 is four objectives wearing one number. The row above was written as
a set because CSP presents it as one — Correct line — and that is a fair
description of what an artist reaches for, not of what gets built. Pinching a
segment is a solve over two control points; width is the pressure array and
nothing else; simplify is the fitter with its tolerance turned up; cut and join
change how many strokes exist. They share a session and share nothing else, and
CLAUDE.md's test applies unchanged — if the sentence describing the branch
needs an "and", it is two branches. So:
| Branch | What | |
|---|---|---|
| 4a |
landed feat/canvas/pinch-a-segment
|
Drag the curve between two nodes. SegmentDrag in Core, because the interesting part is arithmetic |
| 4b |
landed feat/canvas/line-width
|
Illustrator's Width tool over the Pressure array — editing PressureProfile rather than the flattened points, because the flatten regenerates those on every commit. Resamples up and never down, so a drawn taper is not coarsened by the tool being picked up |
| 4c |
landed feat/canvas/simplify-a-line
|
CurveFitter.Fit at a larger tolerance — of the flattened current path, not the drawn points, or a reshape would be silently undone by a button labelled simplify. The count is reported per press; a live slider would sit on the same primitive and is not built |
| 4d | feat/canvas/cut-and-join |
The only one that changes how many strokes a frame holds, which is why it is last and alone |
Named so scope cannot drift into them: cross-frame reshaping (needs the
correspondence work the inbetweener's verifier depends on); SVG export
(ROADMAP.md:209 — "should not be faked", honest only once the vector side is
richer); the app drawing its own icons (ROADMAP.md:211, which the roadmap
already makes depend on exactly this); boolean operations
(DESIGN-performance.md:101 reserves the perf-sweep row and nothing else claims
them).
-
Not a second geometry world. Krita's vector layer cannot take a brush; that
is the whole failure. Every tool here operates on the one
Strokerecord. -
Not a second renderer.
Pointsrenders; a path flattens to points. There is no path-stroking code path, andSKPaintStyle.Strokestill never touches artwork. - Not retained shapes. Q49. A rectangle is a line you can now reshape.
- Not required. An artist who never touches these tools gets no new keys in their files and no change in their renders.
- Picking is exact and topmost-first. Two overlapping strokes; a click in the overlap picks the one drawn later. A click one pixel outside a thin stroke's reach picks nothing.
-
Absence holds both ways. A hand-drawn stroke serializes with no
pathkey; a path created and then cleared leaves the JSON byte-identical. - Path and points cannot disagree. The invariant above, asserted.
-
Reshaping keeps the mark.
ReshapingALineKeepsItsBrush. - The grain shift is asserted, not hidden. A test pins that moving a textured stroke does change its dab pattern, naming Q26 — so the accepted behaviour is written down rather than discovered later as a bug.
- Fitting is reported and reversible. The node count is stated; one undo restores every original point exactly.
- The inbetween rule is visible. Both the carried and the not-carried status messages asserted — Q51's mitigation is part of the decision, not decoration.
- Isolation actually isolates. With a stroke isolated, a click on any other stroke changes nothing.
Found while auditing. Each currently misleads a reader about this exact work.
-
ROADMAP.md:1098andbrush-engine-gap-analysis.md(six places) cite "Q19" as the blocking seed-origin question. Q19 is "Are Linux and macOS shipping targets", answered (a). The real question is Q26, answered. Both documents therefore report vector work as blocked when it is not — and the gap analysis recommends arc-length seeding, which is one of the options Q26 explicitly rejected. Root cause is already filed as B81. -
ROADMAP.md:161still says re-shaping "needs a decision inQUESTIONS.mdbefore it needs code". Q26 closed it. -
ROADMAP.md:80still says symmetry's record question must be answered first. Q15 closed it. -
docs/manual/13-keyboard-and-troubleshooting.mdlists shape tools, vector guides, perspective rulers and grid snapping as Planned; all four are built, green with resolving anchors, and documented in sections 3 and 6. - Q26's manual line does not exist yet — nothing tells an artist that moving a textured line changes its grain.
-
The appendices file vector under Pillar 4 while the roadmap body files it
under Pillar 0, and
ROADMAP.md:1199writes "Pillar 4 (Drawing floor)", conflating the two.
- Toon Boom — About the Pencil Tool, Centerline Editor
- Disney Animation — Meander; Computer-assisted animation of line and paint in Paperman
- Krita — Vector Graphics manual, Vector Tool Reported Bugs, Overview of Krita's vector tools
- Clip Studio Paint — Vector layers, Correct line tool
- Adobe Illustrator — Isolate objects, Width tool, Curvature tool
- Figma — Edit vector layers
- Inkscape — Editing paths with the node tool
- Blender — Grease Pencil sculpting
- Adobe Animate — merge versus object drawing
- Joint Stroke Tracing and Correspondence for 2D Animation, TOG/SIGGRAPH 2024; LayerInbetween, SIGGRAPH 2026
- Topology-Driven Vectorization of Clean Line Drawings, Disney Research
- Getting started
- Documents and projects
- Tools and strokes
- Brushes
- Colour
- Layers, selections and guides
- The timeline
- Onion skin, references and the camera
- Symbols
- Saving and recovery
- Exporting to a game engine
- AI assistance
- Keyboard, performance and what is planned
- Bones and rigging
- Effects — fire, smoke and water
- A simplified 3D space to draw in: planes, parallax first, orbit second
- Brush performance beyond 4k: what the GPU can and cannot buy us
- The add-on boundary
- Making the AI trustworthy
- What an AI request actually costs
- Bones and spline deformers for 2D — design
- Brush tips — a generated library, not a live calculation
- Cloud and SaaS: what the architecture already fits, and what it does not
- Colour mixing — paint that picks up what it is laid on
- DESIGN: Effects
- DESIGN: Fluid effects — fire, smoke and water as drawn line and fill
- Fluid and viscous media — notes for the next brush-engine pass
- Compositing on the GPU
- Decomposing the two big files: which pieces come out, and with which tool
- One memory limit for pictures
- .NET 10: what is safe, what is unverified, and what it narrows
- One picture cache: the paused canvas on tiles
- The paint path: one mark, made once, under the pen
- Pen dynamics: tilt and speed
- A pencil is graphite on the paper's tooth
- Performance: the ratchet and the map
- Playback's first loop
- Pressure curves — a shape, not an exponent
- Importing Procreate brushes
- A project as a game's asset set
- One hierarchy: folders scope everything
- Puppet pins — design and plan
- The raster checkpoint: making a painting cheap to reopen
- Scoped resources — palettes, references and everything else shared
- Sensitive topics, and the three-track flow
- Design: pose-assisted frame-by-frame animation (rigid bones + AI gap filling)
- Stroke groups: why a merge should never turn strokes into pixels
- The project window: what you do between drawings
- Reading the subject before drawing on it
- The ceiling of a swept soft mark (B349)
- Q171 — a symbol owns a layer stack
- Pillar 3 — reusable assets, as live symbols
- What a template is
- Text, and where fonts sit
- The tiled compositor, and getting it off the CPU
- Design tokens
- Transform tool design (M14)
- Why a brush stamps twice, and when it does not
- Migrating to the designed UI
- Undo stores only what changed
- Vector tooling: making the lines you already drew editable
- The smudge/blur family, and aligning brush settings with Photoshop
Generated from the repository. Edit the files under docs/, not these pages.