Repository navigation
C ABI: a stroke session that resolves a live stroke as it arrives (#670) - #688
Merged
Merged
Conversation
…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
force-pushed
the
feat/670-stroke-session
branch
from
October 4, 2026 12:03
7d44c58 to
3f165e6
Compare
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.
Merged
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
brush::StrokeTransactionis 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_SMALLon a short buffer),_status_get(clay_stroke_tx_status, leadingstruct_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: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_stampsalways 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_txandclay_multires_sculptor_apply_stroke_tx. Each applies whatever has settled since the last call:INVALID_ARGUMENT.clay_mesh_brush_deschas 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 toMeshBrushSettings,DynamicTopologySettingsormath::Transformstops the build until the comparison covers it._endapplies the held-back tail and closes the gesture.clay_dynamic_delta, and the record is checked before stamps are taken, so aSNAPSHOT_MISMATCHrefusal 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_multiresandapply_to_dynamic[_recorded]are now oneMeshStrokeGesture/MultiresStrokeGesture/DynamicStrokeGesturefed 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_pathsearched 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.resolve, before(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.pylists 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_presetcalls 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). Afterfinish()the stamps equalresolve_stroke,alongincluded. 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:clay_stroke_resolve_fullover 10 presets × 5 schedules;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 insideMultiresStrokeGesturethe 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:
SculptLayerDeltainsideMultiresStrokeGesture.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 nameinclude/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).-Wall -Wextra -Wpedantic -Wshadow -Werrorsyntax check ofsrc/brush/stroke.cpp,bindings/c/clay_c.cpp,test_c_stroke_session.cppandtest_c_multires_delta.cpp, to cover libstdc++ and the GCC-only warnings AppleClang doesn't raise.resolve_strokewent from 28 on main to 15 (the per-station stamp is split out).apply_to_meshwent from 19 andapply_to_multiresfrom 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_multiresa trailingmesh::SculptLayerDelta* layer_deltasand addedclay_multires_sculptor_apply_stroke_recorded. Hereapply_to_multiresis aMultiresStrokeGesturefed once, soMultiresStrokeGesture::applynow takeslayer_deltasand passes it to everysculptor.stamp. Without that, a recorded stroke on a hierarchy with a sculpt pass would record nothing. #687'swrite_multires_reportis the same fill as this PR'swrite_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