Skip to content

stroke: pen-up diffs only the touched pixels, in drawn order - #69

Draft
foxnne wants to merge 3 commits into
feat/stroke-orderfrom
feat/stroke-touch-order
Draft

foxnne wants to merge 3 commits into
feat/stroke-orderfrom
feat/stroke-touch-order

Conversation

@foxnne

@foxnne foxnne commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Made by: claude

Part of fizzyedit/plans#65 (step A). Stacked on #68 (base is feat/stroke-order); retarget to main once #68 merges.

Why: the pencil/brush always uses the deferred-undo snapshot, so #68's order never reached it (pen-up found pixels by scanning the stroke's whole bounding box, row-major, and #68 correctly skips motion for that). The same scan is the pen-up hitch: its cost is the box's area, however short the stroke.

What:

  • New std-only src/internal/stroke_diff.zig: Touch (layer-sized bitset + ordered list), diffTouched (only noted pixels), diffScan (the old whole-box compare, kept as fallback).
  • drawOffsets notes each pixel it paints in deferred mode (O(stroke)); strokeUndoBegin resets the notes, strokeUndoFreeSnapshot clears them.
  • The flush diffs only the noted pixels. Same indices and before-colours as the scan (property test). Falls back to the scan, and to no motion, when nothing was noted (selection deletes etc.), a note was lost, or a noted pixel is outside the snapshot.
  • Commit is one appendStroke used by the deferred and the live (non-deferred) paths, so both carry first-touch order as Step.motion. Tools that append live are unchanged. Overdraw records first touch once; only pixels the brush wrote are noted, so a selection or bounds cannot add untouched pixels.
  • Undo/redo get the same change set as before (same indices and colours); only the order they were recorded in differs.

Pen-up diff cost, before (box scan) -> after (touched only), headless replica, random pixels in a full-layer box:

layer, pixels Debug ReleaseFast
512², 500 13.4 ms -> 0.05 ms 91 -> 3 us
512², 5000 14.7 ms -> 0.5 ms 137 -> 32 us
512², 50 000 17.3 ms -> 4.4 ms 455 -> 189 us
2048², 5000 217 ms -> 0.9 ms 1.4 ms -> 137 us
2048², 50 000 232 ms -> 12.6 ms 1.8 ms -> 1.6 ms

Ran: zig fmt --check src build.zig, zig build test (new: random-stroke property test touched-vs-scan incl. overdraw and same-colour pixels, outside-snapshot and out-of-layer, reset), zig build; CI on the PR.

Not done: I did not draw in the running app. drawOffsets/flush/appendStroke wiring in File.zig is covered only by compile + the pure stroke_diff tests, so a human stroke (pencil, eraser, line, shift-line; then undo/redo and a tape export) must be tried before ready.

Large brushes (headless, Brush.zig shapes circle and square, long stroke moved 8 px per frame, brush steps one pixel at a time so only each step's edge is painted, except the first full stamp and any jump). Per-frame cost WHILE dragging: pixel writes alone vs writes + the new Touch.mark per painted pixel (avg over 400 frames):

layer, shape, size marks/frame Debug writes -> +marks ReleaseFast writes -> +marks
any, 1 8 0 -> 0 us 0 -> 0 us
2048², circle 8 352 2 -> 7 us 1 -> 1 us
2048², circle 64 18 k 102 -> 323 us 13 -> 52 us
2048², square 64 33 k 173 -> 451 us 22 -> 75 us
512², circle 255 286 k 2.0 -> 3.7 ms 0.21 -> 0.53 ms
2048², square 255 520 k 5.3 -> 7.6 ms 0.34 -> 0.95 ms

(512² numbers are the same within noise at sizes 1-64.) The mark adds ~2-3x the cost of the writes themselves, ~0.3-0.6 ms per frame in ReleaseFast at 255 px; Debug at 255 px is a few ms per frame. The marks/frame at 255 px include the whole-stamp jumps in my path, so it is a worst-ish case.

Memory budget: the bitset is one bit a layer pixel (512 KiB at 2048²); the order list is capped at stroke_diff.max_order = 4 Mi entries (16 MiB). Past it (or if the list cannot grow) the notes are freed and the stroke is valid = false for its remainder: pen-up compares the box (diffScan) so the change is exactly the pixels that changed, no pixel is lost and nothing crashes; only the drawn order is dropped (no motion on that step, so it replays as before). Test: a stroke past the budget keeps its change, not its order. At 2048² the cap is about the layer's pixel count, so it only trips on very long big-brush strokes.

Replay quality note (for plans#65 step B, not implemented): pixel-order motion replays a big brush as each stamp's new pixels appearing in raster order within the stamp (edge order), so a 64-256 px brush looks like blobs growing, not a pen line. To replay as a pen path the format needs, per input sample, the brush centre (x, y as deltas, ~2 bytes), and once per stroke the brush size + shape (+ colour or erase flag) and a per-sample timestamp delta (~1-2 bytes): a few bytes a sample instead of one entry a pixel, and a player re-stamps the brush at each sample (the pixels are then the effect already stored). Repeated samples/overdraw are allowed there, unlike Motion today.

🤖 Generated with Claude Code

t and others added 3 commits October 22, 2026 13:57
…it touched them

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… and the order goes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@foxnne

foxnne commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-22T22:36:49.444955Z b655061 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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