diff --git a/bindings/c/clay.h b/bindings/c/clay.h index 029d1552..59e25068 100644 --- a/bindings/c/clay.h +++ b/bindings/c/clay.h @@ -11665,6 +11665,17 @@ clay_result clay_voxel_build_plane_pick(const clay_voxel_grid* grid, const float * an edit reaches. That is the promise a host most depends on and the one that * is silent when it breaks: too tight leaves visibly stale bricks at a blend * seam, with nothing on the host's side to point at. */ +/* NOTE (issue #650): this is also DILATED BY THE SMOOTH COMBINES AFTER THE NODE + * in its chain -- at each level, the siblings that follow it or the group + * holding it -- by the widest one's blend support. The node is the running + * value wherever it is the nearest thing, so moving it changes that value far + * from its own box, and a smooth sibling after it reads the running value out + * to its support and carries the change back into the band. Measured before: + * two r = 0.3 spheres, the second smooth at k = 0.3, the first moved 0.1 -- + * band samples moved by up to 0.044 outside the box plus the band, and a cache + * dirtied by it kept 25 stale bricks. Nothing widens for a node with only hard + * siblings after it, which includes every node appended last -- a stroke's + * dabs -- and every document without a smooth blend. */ clay_result clay_layer_node_influence_bound(const clay_document* doc, clay_layer_id layer, clay_node_id node, float out_min[3], float out_max[3], int32_t* out_has_bounds, diff --git a/bindings/c/clay_c.cpp b/bindings/c/clay_c.cpp index 3d84c141..13965aa0 100644 --- a/bindings/c/clay_c.cpp +++ b/bindings/c/clay_c.cpp @@ -7756,6 +7756,10 @@ void drag_frontier(const clay_document* doc, const scene::Layer& layer, // parameter edit to the same node_command_bound and never looks at it. scene::Command probe_cmd{scene::SetDeformersCmd{layer.id, scene::kNoNode, {}}}; auto& probe = std::get(probe_cmd); + // One memo for the loop, for the same reason: each node's bound walks the + // siblings after it (#650), and without it a 1,428-warp drag over 10,000 + // items spent 65 ms there against 0.39 before that term existed. + scene::LayerExtent memo; for (const brush::PreparedMove& p : prepared) { std::uint32_t ordinal = 0; if (!root_ordinal_of(*layer.sdf, p.node, &ordinal)) { @@ -7764,7 +7768,7 @@ void drag_frontier(const clay_document* doc, const scene::Layer& layer, } df.min_ordinal = std::min(df.min_ordinal, ordinal); probe.node = p.node; - const math::Aabb bound = scene::command_influence_bound(d, probe_cmd); + const math::Aabb bound = scene::command_influence_bound(d, probe_cmd, &memo); if (bound.empty()) continue; df.spans.emplace_back(ordinal, bound); } diff --git a/docs/05-claycore-library.md b/docs/05-claycore-library.md index 0abf4c9a..da208051 100644 --- a/docs/05-claycore-library.md +++ b/docs/05-claycore-library.md @@ -1001,6 +1001,47 @@ What remains is one walk per query rather than none: `apply_edit` takes two. Removing that needs a revision-scoped cache on the ABI's document, which `clay_document::cached()` is already shaped for. +### An edit reaches as far as the smooth combines after it (#650) + +A node's own bound says where ITS combine can move the running value. That is +not where its RAW value stops changing: the first node of a chain has no combine +at all -- it is the running value -- and any node is the running value wherever +it is the nearest thing, so moving it changes that value far from its box. +Beyond the band a hard union ignores that, `min()` being exact. A SMOOTH combine +further down does not: it reads the running value out to its support and lowers +the result by the blend, so a difference beyond the band on both sides comes +back inside it. Two r = 0.3 spheres, the second smooth at k = 0.3, the first +moved 0.1: band samples moved by up to 0.044 outside the moved node's box plus +the band, and a cache dirtied by that box kept 25 stale bricks. Seed 5128 of +`benchmarks/undo_bound_oracle_probe` is the same mechanism at one ulp -- a +grab's eased rim changes the node's value far from its surface, and the next +sibling's blend carries it into the band. + +`node_reach_bound` therefore dilates, at each level before the enclosing group's +own support, by the drag of the combines after the node in that chain: the +chain pad's terms (`cull_pad_terms`) over the later siblings, resolved at each +profile's FULL support rather than at the chain envelope the cull uses -- one +blend provably reaches its whole support, and the envelope left 0.012 moves on +the fixture above. A later sibling GROUP contributes its own combine only, since +its children start a chain of their own. Nothing widens for a node with only +hard siblings after it, which is every node appended last and every document +without a smooth blend. + +The head narrowing of #639 does not take the term: a link that is the identity +outside its ball leaves the RAW field bit-identical there, and every combine is +pointwise. What it missed was the clamp into the node's bound, which now holds +the drag. Over 400 plain and 400 rich trials of the oracle the raw-bound +violations went 0 / 10 to 0 / 0 and the bricks the undo bounds refill rose 2.5% +and 2.8%. + +The walk is over the LATER siblings, so a loop over a chain's nodes paid it once +per node: a drag frontier over 1,428 warps of a 10,000-item layer went 0.39 -> +65 ms, and undoing a 2,134-warp Move there 0.85 -> 94.6 ms. `ChainDragMemo` +holds the chain's suffix maxima, filled from the end only as far as a query +asks; `LayerExtent` owns one per query, the drag frontier threads one through +its loop, and an undo step's replay shares one across its commands, clearing it +after any command but a deformer or colour edit (0.45 and 1.04 ms). + ### ...and a MOVE of one is bounded by its sweep The layer-wide answer above is right for the question it answers and ruinous as diff --git a/include/clay/scene/bounds.h b/include/clay/scene/bounds.h index 280247ec..0013fd86 100644 --- a/include/clay/scene/bounds.h +++ b/include/clay/scene/bounds.h @@ -9,7 +9,10 @@ // field arbitrarily far away, so their influence is infinite. #include +#include #include +#include +#include #include "clay/math/geom.h" #include "clay/scene/document.h" @@ -197,6 +200,8 @@ class LayerExtentCache { std::size_t keeps_ = 0; }; +class ChainDragMemo; + class LayerExtent { public: LayerExtent() = default; @@ -205,6 +210,9 @@ class LayerExtent { // (#451). Every caller that already threads a LayerExtent gets that for // free; nothing else changes. explicit LayerExtent(LayerExtentCache* cache) : cache_(cache) {} + // Answering downstream_drag from a memo that outlives this query -- one + // the caller keeps valid across several (see ChainDragMemo). + explicit LayerExtent(ChainDragMemo* drags) : drags_(drags) {} const math::Aabb& of(const SdfContent& content, const Layer& layer) { if (cache_) return cache_->of(content, layer); @@ -225,12 +233,19 @@ class LayerExtent { // here rather than derived from a timing. std::size_t walks() const { return walks_; } + // The drag of the combines after a node, memoized for the query + // (ChainDragMemo::after). Borrowed when the extent was given one. + float downstream_drag(const SdfContent& content, const Layer& layer, NodeId parent, + int index); + private: LayerExtentCache* cache_ = nullptr; + ChainDragMemo* drags_ = nullptr; const SdfContent* content_ = nullptr; const Layer* layer_ = nullptr; math::Aabb extent_; std::size_t walks_ = 0; + std::shared_ptr own_drags_; }; // World-space INFLUENCE bound: the geometry bound for local ops, the LAYER's @@ -413,6 +428,65 @@ struct CullPadTerms { // on a single pad float stays valid (#362). float blend_total(std::size_t n_eff) const; float total(std::size_t n_eff) const { return feather + blend_total(n_eff); } + // The item maxima at each profile's full SUPPORT: the pre-#335 pad, and the + // ceiling blend_total clamps its seam term to. + float item_support_ceiling() const; + // Every term at its full support, seam included, plus the feather: how far + // ONE of these combines can move a result from where its running operand + // changed. What the reach of an edit takes for the combines after it + // (bounds.cpp, downstream_chain_drag); the cull keeps the envelope. + float support_total() const; +}; + +// How far the combines after position `index` in the chain `parent` holds (the +// layer's roots for kNoNode) can drag a change to the running value the node +// there fed (#650): the term node_reach_bound adds per level, as per-chain +// suffix maxima of `cull_pad_terms` resolved at full support. The same number +// the unmemoized walk gives, bit for bit. +// +// A walk over the LATER siblings, which a loop over a chain's nodes pays once +// per node: a drag frontier resolving 1,428 dragged items of a 10,000-item +// layer went 0.39 -> 65 ms, and undoing a 2,134-warp Move over the same count +// 0.85 -> 94.6 ms. Filled from the END and only as far forward as a query asks, +// so a loop walks each chain once -- and a node appended last, which is what a +// stroke's dabs are, walks nothing at all. +// +// VALID WHILE NO CHAIN'S MEMBERS OR THEIR TERMS CHANGE: order, visibility, op, +// blend, mirror participation, a feathered volume's band or scale, the layer's +// symmetry. An edit to a node's deformers or colour touches none of them, which +// is what lets an undo keep one across a whole Move step +// (command_keeps_chain_drag); anything else calls clear(). +class ChainDragMemo { + public: + float after(const SdfContent& content, const Layer& layer, NodeId parent, int index); + void clear() { + chains_.clear(); + content_ = nullptr; + layer_ = nullptr; + } + + private: + // Per chain, the suffix maxima REVERSED: `[j]` is the last j members' + // terms raised together, so `[0]` is none. + // + // A FLAT LIST, searched linearly, and each chain's vector reserved to its + // full length on first use. Not a hash map, and not grown a member at a + // time: this memo is built on every surface drag, and the drag's + // per-item allocation gate (test_sculpt_allocation.cpp) is what caught + // the difference -- an unordered_map plus a vector regrown across a + // 400-node chain added 13 allocations per drag, and MSVC's unordered_map + // allocates even when empty, which took the gate from under 6.0 per item + // to 6.0625 there alone. A query touches one or two chains, and an undo + // step that keeps the memo across commands keeps it on one. + struct Chain { + NodeId parent = kNoNode; + std::vector from_end; + }; + std::vector& from_end_of(NodeId parent, std::size_t chain_length); + + const SdfContent* content_ = nullptr; + const Layer* layer_ = nullptr; + std::vector chains_; }; // One node's contribution, so a caller that has GAINED a node can raise a @@ -545,7 +619,10 @@ float document_cull_pad(const Document& doc); // Where an edit to `id` can change the layer's field: node_influence_bound, // dilated once per enclosing group by that group's blend support, up to the -// root. +// root -- and, at every level before that support, by the drag of the SMOOTH +// combines that follow in that chain (#650; ChainDragMemo, and bounds.cpp's +// downstream_chain_drag for why). Nothing for a node followed only by hard +// ones, which is every node appended last. // // This is the answer to "where does an edit to this node LAND", which is a // different question from "where is this node" and used to be answered with @@ -592,7 +669,8 @@ math::Aabb layer_reach_in_document(const Document& doc, LayerId layer_id, // to a host as a region to dirty (issue #325). // // Per sharing layer this is node_reach_bound -- the node's own bound dilated -// once per enclosing GROUP -- carried the rest of the way up by +// by the smooth combines after it and once per enclosing GROUP -- carried the +// rest of the way up by // layer_reach_in_document. `scene::node_command_bound` IS this function, so the // query a host asks and the region the command path dirties are one expression // and cannot drift. diff --git a/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/proposal.md b/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/proposal.md new file mode 100644 index 00000000..8429f761 --- /dev/null +++ b/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/proposal.md @@ -0,0 +1,88 @@ +## Why + +`benchmarks/undo_bound_oracle_probe` under `RV_RAWBOUND`, seed 5128 in rich +mode, found the raw field moving by an ulp at samples outside the undo bound +dilated by the band, identically on main before and after #648 (#650). The +issue suspected the group blend's support. It is not the group: the group in +that seed is the first root, so its combine never applies (#515). + +The node is the FIRST item of its chain, and the item after it blends smooth at +k = 0.297. The first item of a chain has no combine -- it IS the running value +-- and any item is the running value wherever it is the nearest thing, so an +edit to it changes that value far from its own box. Beyond the band a hard +union leaves that alone (`min()` is exact). A smooth combine further down does +not: `csmin(a, b)` reads `a` wherever `|a - b| < support`, so with `b` in the +band it reads `a` out to band + support and carries the change back into the +band. In the seed, a grab's eased rim changes the node's value by an ulp inside +the grab's ball, 0.3 from the node's surface; the sibling's blend lifts that +into the band, and the undo bound -- the ball clamped into the node's bound -- +stopped at the node's box. + +It is not an ulp in general. Measured on main (8b7a5713), two r = 0.3 spheres +0.3 apart, the second smooth at k = 0.3, the first moved 0.1: + +| | band samples moved outside box + band | worst | stale bricks, `mark_dirty_nodes` | +|---|---:|---:|---:| +| main, at the root | 6,359 | 0.0438 | 25 | +| main, in a blended group | 6,359 | 0.0438 | 25 | +| main, second at k = 0.1 | | | 8 | +| this change | 0 | 0 | 0 | + +## What changes + +- **`node_reach_bound` dilates by the drag of the combines after the node**, at + each level, before the enclosing group's support: `cull_pad_terms` raised + over the later siblings in that chain, resolved at each profile's full + support. A later sibling group contributes its own combine only. That makes + `clay_layer_node_influence_bound`, `clay_brick_cache_mark_dirty_nodes`, the + command path and the undo bound (through the #639 clamp) cover the fillet. +- **Nothing widens for a node with only hard combines after it** -- every node + appended last, so a stroke's dabs, and every document without a smooth blend + report bit-identical bounds. +- **`ChainDragMemo`**, per-chain suffix maxima filled from the end, owned per + query by `LayerExtent`, threaded through the drag frontier's loop and shared + across an undo step's replay. +- No ABI change; `clay.h` gains a note on `clay_layer_node_influence_bound`. + +## What building it found + +- **The envelope is the wrong resolution.** Resolving the later siblings' terms + at the chain envelope the cull uses (`blend_total`) left 110 band samples + moved by up to 0.0123 on the two-sphere fixture: the envelope is a k-multiple + fitted to what a CULL may drop against an fp16 tolerance, and a single blend + provably reaches its whole support. Full support: 0. +- **The #639 head narrowing needs nothing.** The first cut also dilated the + head's ball, and the oracle's refills rose 72% / 56%. A link that is the + identity outside its ball leaves the raw field bit-identical there and every + combine is pointwise, so the ball needs no drag term; only the clamp into the + node's bound was too tight. Reverted: refills rise 2.5% / 2.8%. +- **The walk is over LATER siblings, so loops paid it per node.** A drag + frontier resolving 1,428 warps of a 10,000-item smooth layer: 0.39 -> 65 ms. + Undoing a 2,134-warp Move over the same count: 0.85 -> 94.6 ms. With the + memo: 0.45 ms and 1.04 ms. The replay's memo is cleared after any command but + a deformer or colour edit; a test holds the clear, and fails without it. +- **The frontier's mirrored ridge resumes MORE.** The drag frontier prepares + prefix seeds over each dragged node's reach; under the mirror the wider reach + now spans the four corner bricks, so they resume (16 / 0) where the + unmirrored control still refuses them (12 / 4). The bit-exact parity check + against a fresh oracle holds every frame, so the test's `==` became `>=`. + +## Measured on the oracle (400 trials each, `RV_RAWBOUND`) + +| | raw-bound violations | stale trials | bricks refilled | +|---|---:|---:|---:| +| plain, main | 0 | 0 | 1,397,332 | +| plain, this change | 0 | 0 | 1,432,408 (+2.5%) | +| rich, main | 10 (seed 5128) | 3 | 2,394,500 | +| rich, this change | 0 | 3 | 2,461,088 (+2.8%) | + +The three rich stale trials (5111, 5128, 5229) are the brick build disagreeing +with the raw field, #649's, and identical on main. `RV_FORWARD` reports 12 stale +trials on main and on this change alike. + +## What it does not cover + +A GATED node after the edited one mixes the running value with its own combine, +and a lerp of two beyond-band values is not beyond band, so it can carry the +difference in at any distance. `chain_carries_only_supports` already refuses it +for the intersect surface delta; this term does not model it. diff --git a/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/specs/scene-model/spec.md b/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/specs/scene-model/spec.md new file mode 100644 index 00000000..bedf4472 --- /dev/null +++ b/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/specs/scene-model/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Influence bounds +Every edit item and group SHALL expose a conservative influence bound: its shape AABB dilated by blend radius and rounding. The bound SHALL be conservative in the narrow-band sense that all evaluated storage relies on: outside the bound (dilated by the band width), band-clamped field values are unaffected by the item. (Raw far-field values may legitimately shift when a smooth-blend operand changes — smin deviates wherever |a−b| is inside the support width — which is why the guarantee, like brick storage, is stated band-clamped.) + +When an item carries deformers, its bound SHALL additionally account for the domain warp before transform and dilation: rotational warps (twist, bend) SHALL widen the bound to the axis-aligned hull of the shape's rotational sweep, cross-section scaling (taper) SHALL scale by the largest factor in its range, and displacement SHALL dilate by its amplitude. + +A node held inside one or more groups SHALL additionally expose the bound it reaches THROUGH those groups: its own bound dilated, at each enclosing group in turn, by that group's blend support. This is the conservative answer in the same band-clamped sense as the bound above — outside it, band-clamped values are unaffected by an edit to that node — and it SHALL be the answer given wherever a caller asks where an edit to that node lands. A group's ancestry SHALL contribute its blend support and nothing else: the group's other children are geometry the edit cannot reach, and SHALL NOT widen the answer. + +That answer SHALL also carry, at every level — the node's own chain, then each enclosing group's — the reach of the SMOOTH combines that FOLLOW it in that chain, taken before the group's own support: the widest of those combines' full blend supports (and a feathered replace's band, and a symmetry seam's support, on the terms the chain pad counts them). A node is the running value of its chain wherever it is the nearest thing, so an edit changes that value far from the node's own box; a hard union leaves that beyond-band difference alone, while a smooth combine further down reads the running value out to its support and carries it back into the band. A later sibling GROUP SHALL contribute its own combine only — its children start a chain of their own and never read the running value. A node with only hard combines after it SHALL NOT widen, so a node appended last, and every document without a smooth blend, keeps exactly the bound it had. + +Where the enclosing subtree combines non-locally, the ancestor walk SHALL report the unbounded state rather than a finite box, on the same terms as the influence bound of any non-local node. + +#### Scenario: Bound is conservative +- **WHEN** a property test samples the field with and without an item at points outside the item's influence bound dilated by a band width β, clamping values to ±β +- **THEN** the two clamped fields are bit-identical at every sampled point + +#### Scenario: Deformed item stays inside its bound +- **WHEN** the same property test runs on items carrying twist, bend, taper, and displacement deformers +- **THEN** the clamped fields remain bit-identical outside the widened bound, and per-brick culled tapes over those scenes stay band-clamp identical to the full tape + +#### Scenario: A node inside a group reaches past its own box +- **WHEN** the conservativeness property test runs on a child of a smooth-blended group, sampling outside the child's own bound but inside the group's blend support +- **THEN** the band-clamped fields differ there, so the child's own bound alone is NOT the answer to where an edit to it lands + +#### Scenario: The ancestor-path bound is conservative +- **WHEN** the same property test samples outside the child's bound dilated by every enclosing group's blend support +- **THEN** the two band-clamped fields are bit-identical at every sampled point + +#### Scenario: A far sibling is not part of the answer +- **GIVEN** a group holding one small child and a large one far from it +- **WHEN** the ancestor-path bound of the small child is taken +- **THEN** it is strictly smaller than the group's influence bound and does not contain the far sibling's geometry + +#### Scenario: A smooth sibling after a node carries its edit past the node's own box +- **GIVEN** two spheres in one chain, the first hard and the second blended smooth after it, at the layer root or inside a group whose own combine does not apply +- **WHEN** the conservativeness property test moves the first sphere and samples outside its own bound dilated by the band +- **THEN** the band-clamped fields differ there, and outside its ancestor-path bound, which carries the second sphere's blend support, they are bit-identical + +#### Scenario: Only a later smooth combine widens the answer +- **WHEN** the ancestor-path bound is taken of a node followed only by hard combines, or of the last node of a chain +- **THEN** it is the node's own bound, and a smooth sibling after the node widens it by exactly that sibling's blend support + +#### Scenario: An undone grab clamped into the node's bound still covers the sibling's fillet +- **GIVEN** a grab at the head of the first node's chain whose ball lies beyond the node's band but on the surface of a smooth sibling after it +- **WHEN** the grab is undone and redone and a brick cache refills only the reported bounds +- **THEN** it matches a cache rebuilt from nothing, in both directions diff --git a/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/tasks.md b/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/tasks.md new file mode 100644 index 00000000..00caccf1 --- /dev/null +++ b/openspec/changes/archive/2026-09-23-bound-an-edit-by-the-blends-after-it/tasks.md @@ -0,0 +1,24 @@ +## 1. Reproduce + +- [x] 1.1 Seed 5128, rich, `RV_RAWBOUND` on main (8b7a5713): 10 samples in 2 directions +- [x] 1.2 Isolate: the node alone shows nothing; with either smooth sibling after it, the violation returns +- [x] 1.3 Generalise: two spheres, the first moved, the second smooth at k = 0.3: 6,359 band samples outside box + band, worst 0.0438, 25 stale bricks from `mark_dirty_nodes` + +## 2. Fix + +- [x] 2.1 `node_reach_bound` dilates at each level by the later siblings' `cull_pad_terms`, resolved at full support (`CullPadTerms::support_total`) +- [x] 2.2 Resolve at full support, not at the envelope (0.0123 left at the envelope) +- [x] 2.3 Leave the #639 head reach alone (raw-exact outside its ball); the clamp into the node's bound is what needed the term +- [x] 2.4 `ChainDragMemo` in `LayerExtent`, threaded through the drag frontier and shared across an undo replay, cleared after any command but a deformer or colour edit + +## 3. Prove + +- [x] 3.1 `test_node_reach_bound.cpp`: a smooth sibling after a node carries its edit past its box, at the root and in a group; only a later smooth combine widens; a later sibling of the node's group drags it; the replay memo is cleared (fails with the clear removed); the memo equals the walk +- [x] 3.2 `test_c_undo_bound_grab_support.cpp`: a ball past the node's band on a smooth sibling's surface, undone and redone, refills what a rebuild does (4 stale bricks per direction on main) +- [x] 3.3 New cases fail on main, pass here; full unit suite 10 / 10 shards +- [x] 3.4 Oracle, 400 plain + 400 rich: raw-bound violations 0 / 10 -> 0 / 0, stale trials unchanged, refills +2.5% / +2.8% +- [x] 3.5 Cost: drag frontier 1,428 warps over 10,000 items 0.39 -> 0.45 ms; undo of a 2,134-warp Move 0.85 -> 1.04 ms + +## 4. Document + +- [x] 4.1 `clay.h` note on `clay_layer_node_influence_bound`, `docs/05`, the scene-model requirement diff --git a/openspec/specs/scene-model/spec.md b/openspec/specs/scene-model/spec.md index 4dda46ce..20120c11 100644 --- a/openspec/specs/scene-model/spec.md +++ b/openspec/specs/scene-model/spec.md @@ -36,6 +36,8 @@ When an item carries deformers, its bound SHALL additionally account for the dom A node held inside one or more groups SHALL additionally expose the bound it reaches THROUGH those groups: its own bound dilated, at each enclosing group in turn, by that group's blend support. This is the conservative answer in the same band-clamped sense as the bound above — outside it, band-clamped values are unaffected by an edit to that node — and it SHALL be the answer given wherever a caller asks where an edit to that node lands. A group's ancestry SHALL contribute its blend support and nothing else: the group's other children are geometry the edit cannot reach, and SHALL NOT widen the answer. +That answer SHALL also carry, at every level — the node's own chain, then each enclosing group's — the reach of the SMOOTH combines that FOLLOW it in that chain, taken before the group's own support: the widest of those combines' full blend supports (and a feathered replace's band, and a symmetry seam's support, on the terms the chain pad counts them). A node is the running value of its chain wherever it is the nearest thing, so an edit changes that value far from the node's own box; a hard union leaves that beyond-band difference alone, while a smooth combine further down reads the running value out to its support and carries it back into the band. A later sibling GROUP SHALL contribute its own combine only — its children start a chain of their own and never read the running value. A node with only hard combines after it SHALL NOT widen, so a node appended last, and every document without a smooth blend, keeps exactly the bound it had. + Where the enclosing subtree combines non-locally, the ancestor walk SHALL report the unbounded state rather than a finite box, on the same terms as the influence bound of any non-local node. #### Scenario: Bound is conservative @@ -59,6 +61,20 @@ Where the enclosing subtree combines non-locally, the ancestor walk SHALL report - **WHEN** the ancestor-path bound of the small child is taken - **THEN** it is strictly smaller than the group's influence bound and does not contain the far sibling's geometry +#### Scenario: A smooth sibling after a node carries its edit past the node's own box +- **GIVEN** two spheres in one chain, the first hard and the second blended smooth after it, at the layer root or inside a group whose own combine does not apply +- **WHEN** the conservativeness property test moves the first sphere and samples outside its own bound dilated by the band +- **THEN** the band-clamped fields differ there, and outside its ancestor-path bound, which carries the second sphere's blend support, they are bit-identical + +#### Scenario: Only a later smooth combine widens the answer +- **WHEN** the ancestor-path bound is taken of a node followed only by hard combines, or of the last node of a chain +- **THEN** it is the node's own bound, and a smooth sibling after the node widens it by exactly that sibling's blend support + +#### Scenario: An undone grab clamped into the node's bound still covers the sibling's fillet +- **GIVEN** a grab at the head of the first node's chain whose ball lies beyond the node's band but on the surface of a smooth sibling after it +- **WHEN** the grab is undone and redone and a brick cache refills only the reported bounds +- **THEN** it matches a cache rebuilt from nothing, in both directions + ### Requirement: Blend locality guarantee Because all blends are rigid, an edit whose influence bound does not intersect a region SHALL leave evaluated data (bricks, samples) for that region bit-identical. This SHALL be regression-tested at the brick level. diff --git a/src/scene/bounds.cpp b/src/scene/bounds.cpp index f44d7aea..dd68dbe3 100644 --- a/src/scene/bounds.cpp +++ b/src/scene/bounds.cpp @@ -1,6 +1,7 @@ #include "clay/kernel/ease.h" #include "clay/scene/bounds.h" +#include #include #include @@ -1175,6 +1176,11 @@ float CullPadTerms::blend_total(std::size_t n_eff) const { // a seam k either): each item term is <= its own support <= the ceiling, // so the whole stays <= the pre-#335 pad everywhere, and where the seam // demands more than that pad ever granted the cull is identical to it. + const float seam = profile_chain_pad(BlendProfile::Quadratic, blend_k_seam, n_eff); + return kernel::cmax(pad, kernel::cmin(seam, item_support_ceiling())); +} + +float CullPadTerms::item_support_ceiling() const { float ceiling = blend_fixed; ceiling = kernel::cmax(ceiling, kernel::ctape_blend_support( static_cast(BlendProfile::Quadratic), @@ -1184,8 +1190,13 @@ float CullPadTerms::blend_total(std::size_t n_eff) const { ceiling = kernel::cmax(ceiling, kernel::ctape_blend_support( static_cast(BlendProfile::Circular), blend_k_circular)); - const float seam = profile_chain_pad(BlendProfile::Quadratic, blend_k_seam, n_eff); - return kernel::cmax(pad, kernel::cmin(seam, ceiling)); + return ceiling; +} + +float CullPadTerms::support_total() const { + const float seam = + kernel::ctape_blend_support(static_cast(BlendProfile::Quadratic), blend_k_seam); + return feather + kernel::cmax(item_support_ceiling(), seam); } namespace { @@ -1710,6 +1721,109 @@ bool group_combine_can_move_result(const SdfContent& content, const Node& group) return false; } +namespace { + +// The chain `parent` holds: the layer's roots for kNoNode. +const std::vector* chain_of(const SdfContent& content, NodeId parent) { + if (parent == kNoNode) return &content.roots; + const Node* g = content.find(parent); + return g ? &g->children : nullptr; +} + +// HOW FAR THE COMBINES AFTER A NODE CAN CARRY A CHANGE TO THE RUNNING VALUE IT +// FED (#650): the chain pad, folded over the siblings that follow position +// `index` in the chain `parent` holds (the roots for kNoNode). +// +// A node's own bound says where ITS combine can move the running value, and +// that is not where its RAW value stops changing. The first node of a chain +// has no combine at all -- it IS the running value -- and any node is the +// running value wherever it is the nearest thing: move a sphere and its +// distance changes everywhere. Beyond the band that is harmless under a hard +// union, `min()` being exact. A smooth combine further down is not: it reads +// the running value at up to its support from its own surface and lowers the +// result by the blend, so a difference that was beyond the band on both sides +// comes back inside it. Measured: two r = 0.3 spheres, the second blended at +// k = 0.3, the first moved 0.1 -- in-band samples moved by up to 0.044 at +// 0.25 past the moved node's box dilated by the band, and a brick cache +// dirtied by that box kept 25 stale bricks. Seed 5128 of the undo-bound +// oracle is the same mechanism at one ulp: a grab's eased rim changes the +// node's raw value far from its surface, and the next sibling's k = 0.297 +// carries it into the band. +// +// The terms are `cull_pad`'s, over the combines that actually read this +// running value and no others: a group's children start a chain of their own, +// so only a later sibling GROUP's own combine reads it, which is exactly the +// one term `cull_pad_terms` takes for a group node. A chain whose later +// members are all hard -- every document without a smooth blend, and every +// node appended last, which is what a stroke's dabs are -- adds nothing, and +// its bound is bit for bit what it was. +// +// RESOLVED AT FULL SUPPORT, not at the chain envelope the cull uses. One blend +// provably reaches its whole support -- `csmin(a, b)` reads `a` wherever +// `|a - b| < support`, and with `b` in the band that is `a` up to band + +// support -- while the envelope is a k-multiple fitted to what a CULL may drop +// against an fp16 tolerance. Taken at the envelope, the two-sphere case above +// still moved in-band samples by 0.012 outside the box. +float downstream_chain_drag(const SdfContent& content, NodeId parent, int index, + const Layer& layer) { + const std::vector* chain = chain_of(content, parent); + if (!chain) return 0.0f; + CullPadTerms terms; + for (std::size_t i = static_cast(index) + 1; i < chain->size(); ++i) + if (const Node* n = content.find((*chain)[i])) terms.raise(cull_pad_terms(*n, layer)); + return terms.support_total(); +} + +Aabb dilated_by_downstream_drag(const SdfContent& content, NodeId parent, int index, + const Layer& layer, const Aabb& b, LayerExtent* extent) { + const float drag = extent ? extent->downstream_drag(content, layer, parent, index) + : downstream_chain_drag(content, parent, index, layer); + return drag > 0.0f ? b.dilated(drag) : b; +} + +} // namespace + +std::vector& ChainDragMemo::from_end_of(NodeId parent, std::size_t chain_length) { + for (Chain& c : chains_) + if (c.parent == parent) return c.from_end; + Chain& c = chains_.emplace_back(); + c.parent = parent; + // One allocation for the whole chain: `[j]` for j in 0..chain_length. + c.from_end.reserve(chain_length + 1); + c.from_end.emplace_back(); + return c.from_end; +} + +float ChainDragMemo::after(const SdfContent& content, const Layer& layer, NodeId parent, + int index) { + const std::vector* chain = chain_of(content, parent); + if (!chain) return 0.0f; + if (content_ != &content || layer_ != &layer) { + chains_.clear(); + content_ = &content; + layer_ = &layer; + } + std::vector& suffix = from_end_of(parent, chain->size()); + const std::size_t later = + chain->size() - std::min(static_cast(index) + 1, chain->size()); + while (suffix.size() <= later) { + CullPadTerms t = suffix.back(); + const NodeId id = (*chain)[chain->size() - suffix.size()]; + if (const Node* n = content.find(id)) t.raise(cull_pad_terms(*n, layer)); + suffix.push_back(t); + } + return suffix[later].support_total(); +} + +float LayerExtent::downstream_drag(const SdfContent& content, const Layer& layer, NodeId parent, + int index) { + if (!drags_) { + if (!own_drags_) own_drags_ = std::make_shared(); + drags_ = own_drags_.get(); + } + return drags_->after(content, layer, parent, index); +} + Aabb node_reach_bound(const SdfContent& content, NodeId id, const Layer& layer, LayerExtent* extent) { // Where an edit to `id` can change the layer's field: the node's own @@ -1728,6 +1842,10 @@ Aabb node_reach_bound(const SdfContent& content, NodeId id, const Layer& layer, // a sibling's geometry is not something an edit to `id` can reach, so the // old answer grew with the size of the GROUP rather than with the size of // the edit. + // + // At every level, before the group's own support, the drag of the combines + // that follow in that chain (downstream_chain_drag): they read the running + // value the edit changed, and the node's own bound does not cover them. Aabb b = node_influence_bound(content, id, layer, extent); if (b.empty() || b.is_infinite()) return b; @@ -1740,6 +1858,7 @@ Aabb node_reach_bound(const SdfContent& content, NodeId id, const Layer& layer, NodeId parent = kNoNode; int index = -1; if (!content.locate(cur, &parent, &index)) return Aabb{}; + b = dilated_by_downstream_drag(content, parent, index, layer, b, extent); if (parent == kNoNode) return b; const Node* g = content.find(parent); if (!g) return Aabb{}; diff --git a/src/scene/commands.cpp b/src/scene/commands.cpp index 2e100628..4632c529 100644 --- a/src/scene/commands.cpp +++ b/src/scene/commands.cpp @@ -1843,6 +1843,14 @@ bool UndoStack::perform(Document& doc, const Command& cmd) { namespace { +// Whether a command leaves every chain's members and their drag terms as they +// were, so a ChainDragMemo taken before it still answers after it. A deformer +// chain and a colour are read by no term; everything else might be, and says +// no rather than being argued case by case. +bool command_keeps_chain_drag(const Command& cmd) { + return std::holds_alternative(cmd) || std::holds_alternative(cmd); +} + // `head` pulled into `reach`, corner by corner: each corner clamped into // `reach`'s box. Where the two overlap on an axis that is their overlap; where // they MISS on an axis it is the face of `reach` nearest `head` -- NOT nothing. @@ -1872,11 +1880,20 @@ math::Aabb head_within(const math::Aabb& reach, const math::Aabb& head) { // one Move segment. `command_head_delta_bound` is where the field can actually // have changed, and it is clamped into the node's bound (`head_within`) rather // than substituted, so the result is never larger than the node's bound either. -math::Aabb apply_bounded(Document& doc, const Command& cmd, std::optional* inverse) { - math::Aabb reach = command_influence_bound(doc, cmd); +// +// `drags` is one memo for the whole step (ChainDragMemo): a node's bound walks +// the siblings after it (#650), once per command on each side, and a Move step +// is thousands of commands on one chain. Kept across a command that cannot +// change what it holds, cleared after any other. +math::Aabb apply_bounded(Document& doc, const Command& cmd, std::optional* inverse, + ChainDragMemo* drags) { + LayerExtent before(drags); + math::Aabb reach = command_influence_bound(doc, cmd, &before); const std::optional head = command_head_delta_bound(doc, cmd); *inverse = scene::apply(doc, cmd); - reach.expand(command_influence_bound(doc, cmd)); + if (!command_keeps_chain_drag(cmd)) drags->clear(); + LayerExtent after(drags); + reach.expand(command_influence_bound(doc, cmd, &after)); return head ? head_within(reach, *head) : reach; } @@ -1888,10 +1905,11 @@ math::Aabb apply_bounded(Document& doc, const Command& cmd, std::optional inverse; if (bound) - bound->expand(apply_bounded(doc, *it, &inverse)); + bound->expand(apply_bounded(doc, *it, &inverse, &drags)); else inverse = scene::apply(doc, *it); if (inverse) opposite.inverses.push_back(std::move(*inverse)); diff --git a/tests/unit/test_c_frontier_resume.cpp b/tests/unit/test_c_frontier_resume.cpp index fcdda94c..d824e781 100644 --- a/tests/unit/test_c_frontier_resume.cpp +++ b/tests/unit/test_c_frontier_resume.cpp @@ -1051,7 +1051,7 @@ struct MirroredRidge { // included -- so their prefix holds no accumulator and frontier_seed_for // correctly refuses them per brick. That is why the cases below hold the // mirrored split against the UNMIRRORED fixture's rather than against zero: -// the claim is that the mirror changes nothing about which bricks resume. +// the claim is that the mirror costs nothing in which bricks resume. std::vector ridge_window(int kx_from, int kx_to) { std::vector reqs; for (int kx = kx_from; kx <= kx_to; ++kx) @@ -1084,8 +1084,8 @@ std::uint32_t first_touched_ordinal(clay_document* d, clay_layer_id layer) { TEST_CASE("frontier: a mirrored drag on late-history items states a late frontier and resumes") { // Acceptance (3) of #363. Under the mirror the drag must state the ridge's // own ordinal -- not 0, the base's -- so the pre-drag prefix seeds are - // recorded, the applies min-merge onto them and the window resumes - // exactly as it does without the mirror. On the mirror-expanded selection + // recorded, the applies min-merge onto them and the window resumes at + // least as well as it does without the mirror. On the mirror-expanded selection // the first REQUIRE fails (the drag's earliest ordinal reads 0), and past // it the probe reads MISSING: the entry is erased at every frame, // resumed 0 and refilled 16. @@ -1113,8 +1113,15 @@ TEST_CASE("frontier: a mirrored drag on late-history items states a late frontie std::vector got; const RefillSplit split = refill_counting(fix.doc.d, window, &got); const RefillSplit plain = refill_counting(control.doc.d, window); - CHECK(split.resumed == plain.resumed); - CHECK(split.refilled == plain.refilled); + // AT LEAST as well as without the mirror, and no longer exactly as + // well: the frontier prepares prefix seeds over each dragged node's + // reach, and since #650 that reach carries the drag of the smooth + // ridge balls after it. Under the mirror it spans the reflected copies + // too, which takes it over the four corner bricks, so they resume + // (16 / 0) where the unmirrored fixture still refuses them (12 / 4). + // The parity check below is what says the extra resumes are right. + CHECK(split.resumed >= plain.resumed); + CHECK(split.refilled <= plain.refilled); CHECK(split.resumed > split.refilled); // the window mostly resumes CHECK(probe(fix.doc.d, hot).dirty == kClean); diff --git a/tests/unit/test_c_undo_bound_grab_support.cpp b/tests/unit/test_c_undo_bound_grab_support.cpp index 28aa37c4..ab00df82 100644 --- a/tests/unit/test_c_undo_bound_grab_support.cpp +++ b/tests/unit/test_c_undo_bound_grab_support.cpp @@ -858,3 +858,37 @@ TEST_CASE("a ball that misses the node's box but lies within the band of it is s check_undo_and_redo(doc, cube(1.0f)); } } + +TEST_CASE("a ball past the node's band but inside a later sibling's blend is still refilled") { + // Issue #650. The ball is clamped into the node's bound, and the node's + // bound used to stop at the node's own box: outside it the node's value is + // beyond the band, which a hard union leaves alone. A SMOOTH sibling after + // the node does not -- it reads the running value out to its support, and + // the node is the running value wherever it is the nearest thing. So a + // grab well clear of the node's band, sitting on the sibling's surface, + // moves the sibling's fillet, and a bound clamped short of it left bricks + // stale. Seed 5128 of the undo-bound oracle is the same mechanism inside a + // blended group, at one ulp. + // + // Both shapes: at the layer root, and inside a group that does not combine + // (nothing beneath it, #515), the shape the seed has. + for (const bool in_group : {false, true}) { + CAPTURE(in_group); + Doc doc; + clay_node_id group = 0; + if (in_group) + REQUIRE(clay_layer_add_group(doc.d, doc.layer, 0, -1, CLAY_OP_ADD, + CLAY_BLEND_QUADRATIC, 0.2f, 0.0f, &group) == CLAY_OK); + const clay_node_id* parent = in_group ? &group : nullptr; + const float at[3] = {0.0f, 0.0f, 0.0f}; + const clay_node_id node = add_sphere(doc, 0.5f, at, 0.0f, parent); + const float sib[3] = {1.3f, 0.0f, 0.0f}; + add_sphere(doc, 0.5f, sib, 0.3f, parent); + // On the sibling's near surface (x = 0.8), clear of the node's box + // (x <= 0.5) by more than the band. + add_deformer(doc, node, CLAY_DEFORM_GRAB, + {0.8f, 0.0f, 0.0f, 0.12f, -0.1f, 0.0f, 0.0f, 0.0f}); + REQUIRE(0.8f - 0.12f - 0.5f > 0.15f); + check_undo_and_redo(doc, cube(2.0f)); + } +} diff --git a/tests/unit/test_node_reach_bound.cpp b/tests/unit/test_node_reach_bound.cpp index f240a8a4..f347a365 100644 --- a/tests/unit/test_node_reach_bound.cpp +++ b/tests/unit/test_node_reach_bound.cpp @@ -287,3 +287,200 @@ TEST_CASE("a node command is bounded by the node, not by its root") { CHECK(b.max.x < whole.max.x); CHECK_FALSE(b.contains(cf3(2.0f, 0, 0))); } + +// -- the combines AFTER a node (#650) ----------------------------------------- +// +// A node's own bound says where ITS combine can move the running value. The +// first node of a chain has no combine -- it IS the running value -- and its +// raw distance changes everywhere when it moves. Beyond the band that is +// harmless under a hard union; a SMOOTH combine further down reads the running +// value out to its support and carries the difference back into the band. + +namespace { + +scene::Node smooth(scene::Node n, float k) { + n.blend.profile = scene::BlendProfile::Quadratic; + n.blend.k = k; + return n; +} + +// Two r = 0.3 spheres 0.3 apart, the FIRST hard and the second as given. +// `group` puts both inside a blended group whose own combine does not apply +// (nothing is beneath it), so the only thing dilating the first sphere is +// what follows it. +struct Pair { + scene::Document doc; + scene::LayerId layer_id = 0; + scene::NodeId first = 0; + scene::NodeId second = 0; + + scene::Layer& layer() { return *doc.find_layer(layer_id); } + scene::SdfContent& content() { return *layer().sdf; } +}; + +Pair pair(float second_k, bool group) { + Pair p; + scene::Layer& l = p.doc.add_sdf_layer("l"); + p.layer_id = l.id; + const scene::NodeId parent = group ? l.sdf->insert(group_node(scene::Op::Add, 0.2f)) + : scene::kNoNode; + p.first = l.sdf->insert(clay_test::item(scene::Prim::sphere(0.3f), cf3(-0.5f, 0, 0)), parent); + scene::Node second = clay_test::item(scene::Prim::sphere(0.3f), cf3(0.4f, 0, 0)); + p.second = l.sdf->insert(second_k > 0.0f ? smooth(second, second_k) : second, parent); + return p; +} + +// Band-clamped samples outside `reach` dilated by the band that moved when +// the first sphere moved 0.1 along x. +std::size_t moved_outside(Pair& p, const math::Aabb& reach) { + std::vector outside; + std::vector before; + for (kernel::cfloat3 q : lattice(/*extent=*/2.0f, /*side=*/41)) { + if (reach.dilated(kBand).contains(q)) continue; + outside.push_back(q); + before.push_back(clamped(p.doc, q)); + } + REQUIRE(outside.size() > 100); + p.content().find_mut(p.first)->xform.position = cf3(-0.6f, 0, 0); + std::size_t moved = 0; + for (std::size_t i = 0; i < outside.size(); ++i) + if (clamped(p.doc, outside[i]) != before[i]) ++moved; + return moved; +} + +// The node's reach on both sides of the move, unioned: what a host dirties. +math::Aabb swept_reach(Pair& p) { + math::Aabb reach = scene::node_reach_bound(p.content(), p.first, p.layer()); + scene::Node* n = p.content().find_mut(p.first); + const math::Transform was = n->xform; + n->xform.position = cf3(-0.6f, 0, 0); + reach.expand(scene::node_reach_bound(p.content(), p.first, p.layer())); + n->xform = was; + return reach; +} + +} // namespace + +TEST_CASE("a smooth sibling after a node carries its edit past the node's own box") { + // The premise, and the regression: at the layer root and inside a group + // that does not combine, the first sphere's own bound is not where moving + // it lands. Measured before #650: in-band samples moved by up to 0.044, + // 0.25 outside the box dilated by the band. + for (bool group : {false, true}) { + CAPTURE(group); + Pair p = pair(/*second_k=*/0.3f, group); + math::Aabb own = scene::node_influence_bound(p.content(), p.first, p.layer()); + scene::Node* n = p.content().find_mut(p.first); + n->xform.position = cf3(-0.6f, 0, 0); + own.expand(scene::node_influence_bound(p.content(), p.first, p.layer())); + n->xform.position = cf3(-0.5f, 0, 0); + CHECK(moved_outside(p, own) > 0); + + Pair q = pair(/*second_k=*/0.3f, group); + CHECK(moved_outside(q, swept_reach(q)) == 0); + } +} + +TEST_CASE("the drag is the sibling's support, and only a LATER smooth combine adds it") { + // The half that keeps it tight. A hard sibling after the node drags + // nothing, and a smooth one BEFORE it never reads the value the node feeds + // -- the node combines into it, which its own bound already covers. + Pair hard = pair(/*second_k=*/0.0f, /*group=*/false); + const math::Aabb own = scene::node_influence_bound(hard.content(), hard.first, hard.layer()); + const math::Aabb hard_reach = scene::node_reach_bound(hard.content(), hard.first, hard.layer()); + CHECK(hard_reach.min.x == own.min.x); + CHECK(hard_reach.max.x == own.max.x); + + Pair soft = pair(/*second_k=*/0.3f, /*group=*/false); + const math::Aabb soft_reach = scene::node_reach_bound(soft.content(), soft.first, soft.layer()); + const float support = scene::chain_blend_support( + scene::Op::Add, soft.content().find(soft.second)->blend, 0.0f); + CHECK(soft_reach.max.x == doctest::Approx(own.max.x + support).epsilon(0.001)); + + // The smooth sphere is the one LATER in the chain, so its own reach is + // exactly its own bound. + const math::Aabb last = scene::node_reach_bound(soft.content(), soft.second, soft.layer()); + const math::Aabb last_own = + scene::node_influence_bound(soft.content(), soft.second, soft.layer()); + CHECK(last.min.x == last_own.min.x); + CHECK(last.max.x == last_own.max.x); +} + +TEST_CASE("a smooth sibling after the node's GROUP drags it too") { + // The per-level term: a group's children start a chain of their own, and + // the group's result then feeds the chain OUTSIDE it, where a later smooth + // sibling reads it. + scene::Document doc; + scene::Layer& l = doc.add_sdf_layer("l"); + const scene::NodeId g = l.sdf->insert(group_node(scene::Op::Add, 0.0f)); + const scene::NodeId child = + l.sdf->insert(clay_test::item(scene::Prim::sphere(0.3f), cf3(-0.5f, 0, 0)), g); + const math::Aabb alone = scene::node_reach_bound(*l.sdf, child, l); + l.sdf->insert(smooth(clay_test::item(scene::Prim::sphere(0.3f), cf3(0.4f, 0, 0)), 0.3f)); + const math::Aabb dragged = scene::node_reach_bound(*l.sdf, child, l); + CHECK(dragged.max.x > alone.max.x + 1.0f); +} + +TEST_CASE("an undo that changes a later sibling's blend re-reads the drag after it") { + // One step's replay shares a memo of the chain's drag terms across its + // commands -- a Move step is thousands of them on one chain -- and keeps + // it only across a command that cannot change those terms. The trap is + // one that can, between two that read them: undone here, the step first + // re-bounds the node with its sibling hard, then makes the sibling smooth, + // then re-bounds the node again. A memo kept across the middle command + // would answer the last with the hard sibling's zero drag. + Pair p = pair(/*second_k=*/0.3f, /*group=*/false); + scene::UndoStack undo; + const scene::Blend smooth_blend = p.content().find(p.second)->blend; + scene::Blend hard_blend; + hard_blend.profile = scene::BlendProfile::Hard; + // A twist, not a grab: its bound is the node's whole reach, never a ball. + const scene::Deformer twist = scene::Deformer::twist(0.5f); + undo.begin_group(); + REQUIRE(undo.perform(p.doc, + scene::Command{scene::SetDeformersCmd{p.layer_id, p.first, {twist}}})); + REQUIRE(undo.perform(p.doc, scene::Command{scene::SetOpBlendCmd{p.layer_id, p.second, + scene::Op::Add, hard_blend}})); + REQUIRE(undo.perform(p.doc, scene::Command{scene::SetDeformersCmd{ + p.layer_id, p.first, {twist, twist}}})); + undo.end_group(); + + math::Aabb bound; + REQUIRE(undo.undo(p.doc, &bound)); + REQUIRE(p.content().find(p.second)->blend.k == smooth_blend.k); + const math::Aabb own = scene::node_influence_bound(p.content(), p.first, p.layer()); + const float support = scene::chain_blend_support(scene::Op::Add, smooth_blend, 0.0f); + CHECK(bound.min.x <= own.min.x - support + 1e-4f); +} + +TEST_CASE("the chain-drag memo answers exactly what the walk does") { + // A memo is only an optimisation if nothing can tell it fired. Every node + // of a nested document with mixed hard and smooth siblings, queried in an + // order that makes the memo fill some chains from the middle and extend + // them later, against a fresh walk per query. + scene::Document doc; + scene::Layer& l = doc.add_sdf_layer("l"); + std::vector ids; + const scene::NodeId outer = l.sdf->insert(group_node(scene::Op::Add, 0.1f)); + const scene::NodeId inner = l.sdf->insert(group_node(scene::Op::Add, 0.05f), outer); + ids.push_back(outer); + ids.push_back(inner); + for (int i = 0; i < 12; ++i) { + scene::Node n = clay_test::item(scene::Prim::sphere(0.2f), + cf3(0.3f * static_cast(i) - 1.5f, 0, 0)); + if (i % 3 == 1) n = smooth(n, 0.02f * static_cast(i)); + const scene::NodeId parent = i < 4 ? scene::kNoNode : i < 8 ? outer : inner; + ids.push_back(l.sdf->insert(n, parent)); + } + std::reverse(ids.begin() + 6, ids.end()); + scene::LayerExtent memo; + for (scene::NodeId id : ids) { + CAPTURE(id); + const math::Aabb walked = scene::node_influence_bound_in_document(doc, *l.sdf, id); + const math::Aabb memoized = scene::node_influence_bound_in_document(doc, *l.sdf, id, &memo); + CHECK(walked.min.x == memoized.min.x); + CHECK(walked.max.x == memoized.max.x); + CHECK(walked.min.y == memoized.min.y); + CHECK(walked.max.z == memoized.max.z); + } +} diff --git a/tests/unit/test_sculpt_allocation.cpp b/tests/unit/test_sculpt_allocation.cpp index 706c07bf..a8698408 100644 --- a/tests/unit/test_sculpt_allocation.cpp +++ b/tests/unit/test_sculpt_allocation.cpp @@ -37,8 +37,11 @@ #include "clay/mesh/voxel_remesh.h" #include "clay/mesh/topology_ops.h" #include "clay/mesh/sculpt_layer.h" +#include "clay/scene/bounds.h" +#include "clay/scene/document.h" #include "clay.h" +#include "scene_utils.h" using namespace clay; using namespace clay::kernel; @@ -484,6 +487,61 @@ TEST_CASE("allocation gate: the refused-remesh counter is discriminating") { // have to be re-derived when a fixture changes size, and it is set at 6 — // above the 5.10 this measures and below the 6.16 the regression cost — so it // fails on a return to per-warp vectors and tolerates ordinary churn. +// THE CHAIN-DRAG MEMO, COUNTED ON ITS OWN (#653). The drag gate below caught +// the memo on MSVC only: 6.0625 per item against 6.0, because MSVC's +// unordered_map allocates even when empty. On libc++ the same code passed at +// 5.75, so that gate cannot hold the memo on every compiler. This one can: a +// 400-node chain asked for every suffix, as a Move step asks, is one chain and +// must cost one allocation for the list and one for the chain's reserved +// suffix vector -- not a hash map node and a vector regrown member by member. +namespace { + +// A root chain of `n` smooth-blended spheres. Smooth, so every member carries a +// drag term: a hard Add carries none, and a chain of them lets the memo answer +// 0 without walking. +void add_smooth_chain(scene::Layer& l, int n) { + scene::Blend smooth; + smooth.profile = scene::BlendProfile::Quadratic; + smooth.k = 0.05f; + for (int i = 0; i < n; ++i) + l.sdf->insert(clay_test::item(scene::Prim::sphere(0.05f), + cf3(0.01f * static_cast(i), 0, 0), scene::Op::Add, + smooth)); +} + +// Every suffix of the root chain, asked for the way a Move step asks: the sum +// of the answers, and the allocations it took. +struct MemoWalk { + float total = 0.0f; + std::size_t allocations = 0; +}; +MemoWalk walk_every_suffix(const scene::Layer& l, int n) { + scene::ChainDragMemo memo; + MemoWalk w; + CountingScope scope; + for (int i = 0; i < n; ++i) w.total += memo.after(*l.sdf, l, scene::kNoNode, i); + w.allocations = scope.count(); + return w; +} + +} // namespace + +TEST_CASE("allocation gate: a chain-drag memo allocates per chain, not per member") { + scene::Document doc; + scene::Layer& l = doc.add_sdf_layer("l"); + const int kChain = 400; + add_smooth_chain(l, kChain); + REQUIRE(static_cast(l.sdf->roots.size()) == kChain); + + const MemoWalk w = walk_every_suffix(l, kChain); + // A memo that walked nothing would allocate nothing and answer 0: the + // chain's blend supports must actually have been raised into the suffix. + REQUIRE(w.total > 0.0f); + CAPTURE(w.allocations); + CHECK(w.allocations <= 2); +} + + TEST_CASE("allocation gate: a surface drag's cost per warped item is bounded") { clay_document* doc = clay_document_create(); REQUIRE(doc != nullptr);