Skip to content

Undo a multires gesture across the C ABI: clay_multires_delta (#671) - #687

Merged
leonardoaraujosantos merged 2 commits into
mainfrom
feat/671-multires-delta-abi
Oct 4, 2026
Merged

leonardoaraujosantos merged 2 commits into
mainfrom
feat/671-multires-delta-abi

Conversation

@leonardoaraujosantos

Copy link
Copy Markdown
Contributor

Problem

A C host could not put a multiresolution gesture into its undo stack (#671). The engine records both kinds of gesture — mesh::MultiresDelta (cage positions, a level's own coefficients) and mesh::SculptLayerDelta (one sculpt pass) — but clay.h said twice that neither crossed the ABI, and pointed at pyclay, which does not expose them either. A clay_multires is a standalone handle no document layer owns, so clay_document_undo cannot reach it, and clay_multires_sculpt_layer_stroke_cancel is exact only before commit. After commit, a host's only undo was a whole-hierarchy clay_multires_serialize snapshot per gesture.

What lands

clay_multires_delta, the clay_dynamic_delta shape, so a host keeps one pattern for every sculptable representation:

  • clay_multires_delta_create / _destroy / _clear / _stats_get / _levels / _revert / _apply / _serialize / _deserialize, and the clay_multires_delta_stats descriptor (struct_size first).
  • Three ways in: clay_multires_sculptor_stamp_recorded, clay_multires_sculptor_apply_stroke_recorded, clay_multires_sculpt_layer_stroke_commit_into. The shipped _stamp, _apply_stroke and _commit are unchanged; each now shares one body with its recorded twin, called with a null record.
  • C++: mesh::MultiresGesture (new) holds both halves, the binding and the envelope; MultiresDelta / SculptLayerDelta gain matches, split counts and encoded_size; brush::apply_to_multires gains a trailing, defaulted SculptLayerDelta*.
  • The two "does not cross this ABI" paragraphs now name the new calls. OpenSpec change undo-a-multires-gesture-across-the-abi (proposal with measurements, design, tasks, c-abi delta). docs/05 and docs/07 § 8d are updated.

What measuring found

  1. The C++ records accept a hierarchy they do not describe. Their revert refuses only by entry counts. A twin built from the same cage, and the same hierarchy after remove_highest_level + add_level, both pass that check and were written. The record now carries the hierarchy's structure_revision plus a per-process origin (the counter restarts in every process), and refuses anything else with CLAY_ERROR_SNAPSHOT_MISMATCH before writing. With the binding disabled, 8 assertions fail.
  2. The plain sculptor's record was half a record. apply_to_multires passed only the base record. With an active pass the sculptor writes the pass, so a recorded stroke came back empty. Nulling the layer half fails 7 of 14 cases (31 assertions).
  3. A reverted pass marked no patch. Pass content is recomposed lazily, so after a layer-half revert clay_multires_dirty_block_count was 0 while the evaluated revision had moved. A pass replay now evaluates the display level before returning. This answers the issue's "please confirm revert/apply mark those blocks dirty": they do now, for both halves.
  4. Cost. 16x16 triangle cage, seven strokes, then one recorded nine-stamp Draw stroke at the top level:
levels top-level vertices entries encoded resident whole-hierarchy snapshot
2 6,273 1,109 35,544 79,172 95,816
3 24,833 4,445 142,296 315,812 317,104
4 98,817 17,759 568,344 1,262,012 1,202,160

The encoding is 37–47% of the snapshot here. The resident record is not smaller than the snapshot on this fixture, because it holds slot maps (about 2.2x its encoding). What changes is the scaling: a snapshot carries all the detail sculpted so far, while a record carries one gesture. A host spills records with _serialize. One stamp and forty stamps on the same spot record identical entry counts and bytes.

Design decisions a reviewer should check

  • The binding is structure_revision + process origin, not a structural fingerprint. A fingerprint would let a record survive relevel-and-back and a decode of the hierarchy's own bytes, but it accepts a twin, which the acceptance criteria refuse. The cost: a record does not survive a level change, even one later undone, nor a snapshot restore into a new handle. This is the same lifetime rule clay_dynamic_delta already states, and it is in the header under WHAT IT DOES NOT PROMISE. For ClaySpaceIOS, undoing past a level change still restores from that change's snapshot, and multires records older than it are then refused. If the host needs those older records to survive, the follow-up is level add/remove records that restore the structure token. That is the issue's optional item, and this PR does not include it.
  • Replay goes through the hierarchy, not a sculptor. A multires sculptor reads the hierarchy's own chunk table and level mesh, which the replay updates. The same stroke stamped through the same sculptor after a revert lands bit-identically on the first.
  • Continuing a record is refused only while a different pass is active. A base write can join either half.
  • commit_into requires an empty record. If refused, the stroke stays open.
  • No LIFO enforcement. Records hold absolute values: disjoint records commute, and the host replays overlapping ones last in, first out. This is stated in the header.
  • _serialize follows clay_dynamic_delta_serialize (clay_result + size_t* count, size query, BUFFER_TOO_SMALL) rather than the issue's size_t return, so that a short buffer is retryable.

Tests, and proof they bite

tests/unit/test_c_multires_delta.cpp, 14 cases, 703 assertions, through the C ABI only. They cover:

  • stroke → revert → revert → apply → revert, comparing the base checksum, the sculpt-layer checksum and every level's positions bit-for-bit, for:
    • the plain stroke and stamp
    • level 0 (cage)
    • a stroke across a regionally refined region's rim (writes more than one level)
    • a mirrored pair in one record
    • a sculpt-layer transaction via commit_into
    • the plain sculptor on an active pass
  • the same stroke again after an undo
  • recorded stroke == unrecorded stroke, and a null record == the unrecorded call
  • twin / relevel / removed-pass refusals with the surface unchanged
  • coalescing (1 vs 40 stamps)
  • encoded_bytes == formula == serialize size; spill round trip; truncated / trailing / newer-version bytes refused
  • dirty patches and the evaluated revision after base and pass replays
  • empty and cleared records, size queries, refusal order (malformed descriptor before record)

This is a feature, so the file does not compile on main. Each engine decision was proven by mutation:

mutation result
binding always true (the C++ records' own behaviour) 1 case / 8 assertions fail
layer half nulled (the old apply_to_multires) 7 cases / 31 assertions fail
no display-level evaluation after a pass replay dirty-patch case fails
continuation accepted into a different pass 2 assertions fail

Verification

  • cmake --preset cpu-only -DCLAY_BUILD_TESTS=ON, full build clean (no warnings); ctest --preset cpu-only: 10/10 passed.
  • GCC 16, -Wall -Wextra -Wpedantic -Wshadow -Werror -fsyntax-only on every touched TU and the new test.
  • python3 tools/release_check.py --skip-slow: version, configure, build, tests (11/11), parity, layering, dialect, licenses, task-symbols, bindings (imported pyclay, 768 capabilities), kernels, abi, openspec all PASS.
    • device and the four hardware/* rows fail. These are release-path gates and fail on origin/main too: the engine changed since 7f6cf38ff, and bake_volume.h changed since 59e42ccf9, which this PR does not touch.
  • tools/check_test_shards.py: 3048 cases across 4 shards. tools/check_doc_latency.py: OK. npx @fission-ai/openspec@1.12.0 validate --all --strict: 78/78.
  • tools/check_c_abi.py: write_multires_report and multires_apply_stroke( are registered as bounded fills. The recorded stroke zeroes *out_applied (a size_t), and the whole-struct heuristic could not tell that apart from a report write.
  • Cognitive complexity: the gesture decoder was split (read_version, decode_half) and the stroke frame resolution was extracted (resolve_stroke_frame), so every new or moved function stays under 15.

ABI 0.124.0 -> 0.125.0 (additive: twelve entry points, one descriptor; no shipped signature or layout changes). Version lines moved in CMakeLists.txt, bindings/c/clay.h, pyproject.toml. No scene or multires format change.

Closes #671

A clay_multires is a standalone handle no document layer owns, and
neither MultiresDelta nor SculptLayerDelta crossed the ABI, so a host's
only undo for a committed multires gesture was a whole-hierarchy
snapshot.

clay_multires_delta holds both halves of a gesture and is captured by
clay_multires_sculptor_stamp_recorded / _apply_stroke_recorded and
clay_multires_sculpt_layer_stroke_commit_into; the shipped calls are
unchanged and share one implementation with a null record. Revert and
apply go through the hierarchy, are idempotent, and leave the moved
patches in clay_multires_dirty_blocks.

mesh::MultiresGesture adds the binding the C++ records lack: they refuse
only by entry counts, so a twin hierarchy or a relevelled one accepted a
record and was written. The gesture keeps the structure_revision and a
per-process origin at capture and refuses anything else with
CLAY_ERROR_SNAPSHOT_MISMATCH before writing.

Also: apply_to_multires records the active-pass half (it dropped it, so
a recorded stroke on a hierarchy with passes was empty), and a pass
replay evaluates the display level so its patches are marked.

ABI 0.124.0 -> 0.125.0, additive.
Round trips through every way a gesture lands (plain stroke and stamp,
level 0, a refined region's rim, a mirrored pair, a sculpt-layer
transaction, the plain sculptor on an active pass), the same stroke
again after an undo, the twin / relevel / removed-pass refusals,
coalescing, the byte formula and spill, and dirty patches after a
replay.
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: a multires gesture cannot enter a host undo stack; expose MultiresDelta / SculptLayerDelta as a clay_multires_delta record

1 participant