Skip to content

C ABI: a stroke session that resolves a live stroke as it arrives (#670) - #688

Merged
leonardoaraujosantos merged 4 commits into
mainfrom
feat/670-stroke-session
Oct 4, 2026
Merged

leonardoaraujosantos merged 4 commits into
mainfrom
feat/670-stroke-session

Conversation

@leonardoaraujosantos

@leonardoaraujosantos leonardoaraujosantos commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Problem

brush::StrokeTransaction is what a live-input host needs. It accumulates samples as they arrive and re-resolves the path, so forty samples in one batch and in five batches of eight give the same stamps. But it was C++ only. Every C stroke call takes a whole path, so a host that forwards a Pencil gesture in pieces while the stylus is down restarts spacing, taper, steady and jitter on every call. ClaySpaceIOS works around this by re-implementing the spacing phase and the lazy-mouse state on the host, and it cannot get the end taper right, because no piece knows it is the last (#670).

Design

OpenSpec change: openspec/changes/add-stroke-session/ (proposal, design, tasks, and deltas for brush-engine, c-abi and python-bindings).

The session. clay_stroke_tx_begin(preset), _append(clay_stroke_sample_full*, n, &new_stamps, &revised_from), _end, _stamps (size query, BUFFER_TOO_SMALL on a short buffer), _status_get (clay_stroke_tx_status, leading struct_size, bounded fill), and _destroy.

The revised-stamp rule, which the issue left to the engine: a consumer applies a stamp only once it is settled. Settled means no later append can change the stamp's position, radius, strength, deposit or rotation. The issue's other option, applying every new stamp and letting the preview settle at lift, can't work for most consumers. A voxel brush, a mask stroke, a vertex move or an authored SDF node can't be revised, so an end-tapered stroke would leave a narrowing everywhere the pen paused. I derived the rule from resolve_stroke. A stamp is settled when all four hold:

  • the path has at least two samples;
  • the stamp's station lies on the path received so far;
  • it is outside the end taper at the current length;
  • the preset has no start taper.

A start taper is a fraction of the whole stroke, so nothing of a start-tapered stroke settles before lift. The header states this instead of approximating it. Under the rule, a gesture fed in pieces is bit-identical to the whole-path call. The cost is that applied ink trails the pen: by up to one spacing, by the end-taper fraction of the stroke when there is an end taper, and until lift when there is a start taper. clay_stroke_tx_stamps always returns the stroke as it currently stands, so a host can draw a live preview from it.

The consumers. clay_layer_apply_stroke_tx, clay_voxel_apply_stroke_tx, clay_mask_apply_stroke_tx, clay_mesh_sculptor_apply_stroke_tx, clay_dynamic_sculptor_apply_stroke_tx and clay_multires_sculptor_apply_stroke_tx. Each applies whatever has settled since the last call:

  • The first call binds the session to one target and one brush. A later call that names a different target, handle pointer or scalar is refused with INVALID_ARGUMENT.
  • Descriptors are compared too, but not by bytes, because clay_mesh_brush_desc has padding holes. Every sculptor consumer call decodes its brush, topology and frame. The first call binds with the decoded values, and a later call's decoded values are compared with them field by field, floats by their bits. A later call with a NULL brush, another strength, another topology or a frame the bind didn't have is refused and applies nothing. A rebuilt descriptor with the same values still matches. Each settings struct is destructured with a structured binding that names every member, so appending a field to MeshBrushSettings, DynamicTopologySettings or math::Transform stops the build until the comparison covers it.
  • The call after _end applies the held-back tail and closes the gesture.
  • One gesture is one undo step. For SDF, voxel and mask targets the session holds the owning document's undo group open. Mesh gestures record into the caller's delta record. Adaptive gestures continue one clay_dynamic_delta, and the record is checked before stamps are taken, so a SNAPSHOT_MISMATCH refusal loses none of them.

The mesh consumers are now gestures. A grab carries the region it gathered at its first stamp, a snakehook keeps its anchor, multires begins its level record once, and deferred normals are flushed once at the end. A stroke per call would reset all of that. A test shows the result: a grab fed batch by batch to the whole-path call lands somewhere else. apply_to_mesh, apply_to_multires and apply_to_dynamic[_recorded] are now one MeshStrokeGesture / MultiresStrokeGesture / DynamicStrokeGesture fed once, so the whole-path calls and the session calls run the same code.

Frames. A sculptor that declares a world frame keeps a second transaction in its own space, fed the same samples converted the same way the whole-path call converts them. Its stamps are therefore the same floats the whole-path call produces.

The resolver was quadratic. sample_path searched for each station's segment from the start of the path, so a resolve cost stations × samples. Nobody noticed while each stroke was resolved once, but a session resolves on every append. The search now resumes from the previous station's segment. Stations and cumulative arc length both only increase, so it finds the same segment.

240 Hz stroke stamps last append, before after whole resolve, before after
1 s 50 4.6 µs 2.5 µs 6.3 µs 4.0 µs
5 s 250 60.4 µs 9.7 µs 63.4 µs 13.1 µs
10 s 502 216.3 µs 18.9 µs 219.7 µs 22.8 µs

(These were measured through pyclay, median of 15, Release, Apple M-series. Every figure includes about 2 µs of binding overhead.)

pyclay. clay.StrokeTransaction (append, end, stamps, status), so the host's parity test can run on the existing harness. The consumers are C and C++ only. check_binding_parity.py lists them as a C-only follow-up with the reason.

Not in this PR. clay_multires_sculpt_layer_stroke_* has no whole-path stroke call to be equivalent to, because it takes one descriptor per stamp. That belongs with #671. The *_apply_preset calls carry their own stroke preset, which would conflict with the session's.

Tests

  • tests/unit/test_stroke_settle.cpp: at every append, every settled stamp equals the finished stroke's stamp at that index. This is checked over 10 presets (spacing, both tapers, steady, jitter, pressure, velocity, both rotations, clamped) × 5 schedules (one batch, batches of 1, 5 and 40, and an uneven schedule). After finish() the stamps equal resolve_stroke, along included. It also checks that the rule isn't vacuous: with no start taper, at least a quarter of the stamps settle while the stroke is still moving. Further cases cover the end taper, the lone sample, the station past the end, and the cursor.
  • tests/unit/test_c_stroke_session.cpp:
    • session stamps are byte-equal to clay_stroke_resolve_full over 10 presets × 5 schedules;
    • each of the six consumers leaves its target as the whole-path call does: the saved document byte-equal, voxel cells including dither, mask values, and mesh / adaptive / multires positions byte-equal, for draw, grab and snakehook, and with a declared world frame;
    • one undo step, and one revert of the record undoes the gesture;
    • a grab fed one stroke per call differs;
    • a start taper applies nothing before lift;
    • every binding refusal, including a sculptor's descriptors: another strength, a radius, a NULL brush, a frame and a topology are each refused and apply nothing, and the held-back stamps still apply on the next matching call;
    • a refused record call loses no stamps.
  • bindings/python/tests/test_stroke_transaction.py.
  • tests/unit/test_c_multires_delta.cpp: a recorded multires stroke on a hierarchy with an active sculpt pass records the pass half and reverts bit for bit. Only the recorded stamp was covered before, and with the pass half dropped inside MultiresStrokeGesture the existing suite stayed green. This case fails in that state.

Proof the tests can fail. This is a feature, so there was no pre-fix commit to run them against. Instead I injected each of these defects and confirmed each one fails at least one new test:

  • dropping the end-taper condition, the past-the-end condition, or the start-taper hold;
  • settling nothing until the end;
  • restarting the voxel dither seed per call;
  • restarting the mesh gesture per call;
  • closing the layer undo group per call;
  • not converting samples through a sculptor's frame;
  • skipping the descriptor comparison on a later call;
  • dropping SculptLayerDelta inside MultiresStrokeGesture.

The past-the-end condition originally escaped the general property test, so I added a targeted case for it.

Verification

  • ctest --preset cpu-only: 11/11 passed (unit shards prefix/cull/heavy/rest, pyclay pytest, shard partition, smoke).
  • python3 tools/release_check.py --skip-slow: version, configure, build, tests, parity, layering, dialect, licenses, bindings, kernels, abi and openspec all pass. task-symbols failed only because the new files were not yet tracked, and passes after the commit. The device row and the four hardware rows fail on main as well: the device gate was recorded at 7f6cf38 (190 files have changed on main since), and the hardware waivers name include/clay/eval/bake_volume.h, which this PR does not touch. The device gate is a release-time step.
  • check_binding_parity.py --pyclay build/cpu-only/bindings/python --require-import: imported …/pyclay.cpython-311-darwin.so, OK (773 capabilities).
  • npx @fission-ai/openspec@1.12.0 validate --all --strict: 80 passed (after rebasing onto Undo a multires gesture across the C ABI: clay_multires_delta (#671) #687).
  • GCC 16 -Wall -Wextra -Wpedantic -Wshadow -Werror syntax check of src/brush/stroke.cpp, bindings/c/clay_c.cpp, test_c_stroke_session.cpp and test_c_multires_delta.cpp, to cover libstdc++ and the GCC-only warnings AppleClang doesn't raise.
  • Cognitive complexity (clang-tidy): every new or changed function is ≤ 15. resolve_stroke went from 28 on main to 15 (the per-station stamp is split out). apply_to_mesh went from 19 and apply_to_multires from 24 to at most 10 per method.

ABI 0.125.0 -> 0.126.0 (additive: a new handle, a status descriptor and twelve entry points; the whole-path calls are unchanged). Rebased onto #687 (#671), which took 0.125.0. If another sibling PR lands first, this one takes the next minor at rebase.

Rebase onto #687

#687 gave apply_to_multires a trailing mesh::SculptLayerDelta* layer_deltas and added clay_multires_sculptor_apply_stroke_recorded. Here apply_to_multires is a MultiresStrokeGesture fed once, so MultiresStrokeGesture::apply now takes layer_deltas and passes it to every sculptor.stamp. Without that, a recorded stroke on a hierarchy with a sculpt pass would record nothing. #687's write_multires_report is the same fill as this PR's write_multires_stroke_report, so the session's multires consumer uses it and the duplicate is gone.

The session's multires consumer takes no clay_multires_delta. The adaptive consumer does take a record, so a host that wants undo for a multires gesture fed through a session has no way to get it yet. Adding the parameter before 0.126.0 ships would be free. After that it needs a new entry point. I've left it out of this PR because the review didn't ask for it.

Closes #670

…es (#670)

StrokeTransaction re-resolves the whole path on every append, so a later
append can revise a stamp (along, and both tapers, are fractions of the
whole stroke). A consumer that cannot un-apply a stamp needs to know which
ones are final. settled() counts them: two or more samples, a station on
the received path, outside the end taper at the current length, and no
start taper. finish() settles the rest; StampCursor hands each settled
stamp out once.

apply_to_mesh, apply_to_multires and apply_to_dynamic[_recorded] become one
MeshStrokeGesture / MultiresStrokeGesture / DynamicStrokeGesture fed once,
so a stroke fed in pieces keeps its carried region, anchor, level record
and deferral, and runs the same code as the whole-path call.
apply_to_grid takes the stroke index of its first stamp for the dither.

sample_path resumed its segment search from the start of the path for
every station, which made a resolve stations x samples; it now resumes
from the previous station's segment (same segment, since both only grow).
216 us -> 19 us per append ten seconds into a 240 Hz stroke.
clay_stroke_tx: begin / append / end / stamps / status_get / destroy over
brush::StrokeTransaction, so a live-input host sends samples in whatever
batches the device delivers and gets the stamps of the whole path.

One consumer per representation applies the session's settled stamps as
they settle: clay_layer_apply_stroke_tx, clay_voxel_apply_stroke_tx,
clay_mask_apply_stroke_tx, clay_mesh_sculptor_apply_stroke_tx,
clay_dynamic_sculptor_apply_stroke_tx and
clay_multires_sculptor_apply_stroke_tx. The first call binds the session
to one target and brush; the call after end applies the held-back taper
tail and closes the gesture. A gesture is one undo step and bit-identical
to the whole-path call. A framed sculptor resolves its own copy of the
samples in mesh space, as the whole-path call does.

pyclay gains StrokeTransaction. ABI 0.125.0 -> 0.126.0.
The header promised that a later consumer call with other descriptor
contents is refused, but the bind key held only handle pointers and
scalars. A later call with another clay_mesh_brush_desc, topology or
frame -- or a NULL brush -- returned CLAY_OK and kept the bound brush.

Every sculptor consumer call now decodes its descriptors, binds with the
result on the first call, and compares a later call's decode with the
bind's field by field (floats by their bits). Padding and struct_size are
not compared, so a rebuilt descriptor with the same values still matches.
Each settings struct is destructured with a structured binding naming
every member, so a field appended to MeshBrushSettings,
DynamicTopologySettings or math::Transform fails to compile until the
comparison covers it.

The header now lists exactly what the binding compares; the c-abi spec
delta, design, proposal and docs/07 say the same.
The multires stroke gesture must hand the SculptLayerDelta to every
stamp. Only the recorded stamp was covered with an active pass; with the
pass half dropped inside the gesture, the existing suite stayed green.
This case fails in that state: the record comes back with no pass
entries and a revert leaves the stroke on the surface.
@leonardoaraujosantos
leonardoaraujosantos merged commit 4401b35 into main Oct 4, 2026
@leonardoaraujosantos
leonardoaraujosantos deleted the feat/670-stroke-session branch October 4, 2026 12:05
leonardoaraujosantos added a commit that referenced this pull request Oct 4, 2026
Covers the twenty PRs since v0.120.1 (#655, #667-#669, #673-#688), ABI
minors 0.121.0 through 0.126.0, and the scene format minor 19 -> 20.
The device gate block is a placeholder until the iPad run is recorded.

Carries the four manual hardware gates forward as waivers at 4401b35:
the only kernel-relevant change since 59e42cc is #680's per-brick cull
band in include/clay/eval/bake_volume.h, which changes what a brick
compiles, not kernel arithmetic, and the parity corpus is unchanged.
leonardoaraujosantos added a commit that referenced this pull request Oct 5, 2026
Covers the twenty PRs since v0.120.1 (#655, #667-#669, #673-#688), ABI
minors 0.121.0 through 0.126.0, and the scene format minor 19 -> 20.
The device gate block is a placeholder until the iPad run is recorded.

Carries the four manual hardware gates forward as waivers at 4401b35:
the only kernel-relevant change since 59e42cc is #680's per-brick cull
band in include/clay/eval/bake_volume.h, which changes what a brick
compiles, not kernel arithmetic, and the parity corpus is unchanged.
leonardoaraujosantos added a commit that referenced this pull request Oct 6, 2026
Covers the twenty PRs since v0.120.1 (#655, #667-#669, #673-#688), ABI
minors 0.121.0 through 0.126.0, and the scene format minor 19 -> 20.
The device gate block is a placeholder until the iPad run is recorded.

Carries the four manual hardware gates forward as waivers at 4401b35:
the only kernel-relevant change since 59e42cc is #680's per-brick cull
band in include/clay/eval/bake_volume.h, which changes what a brick
compiles, not kernel arithmetic, and the parity corpus is unchanged.
leonardoaraujosantos pushed a commit that referenced this pull request Oct 6, 2026
The gate passed at 7049e19 against the iOS 27.0.1 baseline #690 recorded
from v0.120.1's engine, so REGRESSION compares v0.120.1 to v0.126.0 on the
same OS: iPad15,5, 75 cases, seven cold sessions with 1800 s cooldowns,
nominal throughout, canary steady at x1.18, median ratio 0.996, none above
both the 1.4 tolerance and the 0.125 ms floor, largest rise 1.04x.

The first run at 7406eb1, before #691, failed on mask_extrude (x4.97,
26992 ms at 1000 stamps against an 8154 ms budget) and mask_extract (x6.26);
with #691 they read 1.01x and 1.04x. The stroke cases read 0.81-0.89x,
named as a candidate for #688 and not a claim. The run needed the iPad's
Wi-Fi off and an air conditioner; the notes say why.
SummerTree pushed a commit to SummerTree/ClayCore that referenced this pull request Oct 7, 2026
clay_multires_sculptor_apply_stroke_tx (ABI 0.126.0, CyberdyneCorp#688) took no
record, so a multires gesture fed through a stroke session could not
enter a host's undo stack, while its mesh and adaptive siblings and the
whole-path _apply_stroke_recorded all take one. 0.126.0 is untagged, so
the parameter is added in place: a nullable clay_multires_delta* record
after defer_normals, as in _apply_stroke_recorded.

The record is part of the session's binding, is continued across every
call of the gesture (base and active-pass halves), and is checked before
the session's stamps are taken, as is the report, so a
CLAY_ERROR_SNAPSHOT_MISMATCH or a malformed report loses no stamps.
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.

C ABI: export StrokeTransaction as an incremental stroke session (begin/append/end) so live-input hosts stop re-segmenting strokes

1 participant