graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(simnet): live and derivative data, declared and enforced at the step boundary
The distinction developer.md draws — live data "runs like a stream through the
simulation", derivative data is "calculated anew every frame based on live
data" — becomes a property of the ATTRIBUTE rather than a convention or a list
of names on the solver. The node that creates a value declares its nature at
the point of creation, where the author knows the answer.
The step boundary now has two halves:
Going in, every Derivative attribute is zeroed. A chain that forgets to rebuild
one reads zero rather than quietly carrying the last step's value forward —
which is the failure that looks like a physics bug and is not one. Houdini has
no such notion; there every attribute simply persists and scratch values
accumulate silently.
Coming out, any Live attribute the chain DROPPED is restored from the previous
state, matching points by identity. A node that rebuilds geometry mid-chain no
longer takes the simulation's memory with it: a surviving point gets its value
back, a genuinely new point gets the type's zero, which is the only honest
answer for a place with no history. This is what makes a remesh safe to put
inside a solve, which Phase 3 needs.
Restoration bridges a REBUILD, not a deletion. If the chain handed back the
same identities in the same order it kept the geometry it was given, so an
attribute that is gone was taken out on purpose and putting it back would
override the author. Only a changed point set is evidence of loss rather than
intent.
Live is the default, because its failure mode is the visible one: a value that
should have been cleared and was not drifts where you can watch it, while one
that should have persisted and was cleared just quietly reads zero.
Analysis and Time write Derivative — a measurement describes this step's state,
and carrying one into the next would be describing the past. The spreadsheet
marks derivative columns with a trailing `~`, since telling them apart is the
first question you ask when a solve misbehaves.
test_live_data_accumulates_across_steps_and_derivative_data_does_not is the
whole contract in one test: the same graph, the same chain, five steps of +1.
Live reaches 5. Derivative stays 1, however long it runs. The only difference
is what the attribute was declared to be.
Co-Authored-By: Claude Opus 5 <[email protected]>
nodes/attribute.json | 5 ++
shapeshifter.md | 11 ++++
src/app.rs | 20 ++++--
src/detail.rs | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++-
src/geometry.rs | 108 ++++++++++++++++++++++++++++++--
src/main.rs | 119 +++++++++++++++++++++++++++++++++++-
6 files changed, 419 insertions(+), 13 deletions(-)
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 693a6f0..6799c66 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -83,6 +83,11 @@
"name": "Target",
"type": "choice:Sum,Maximum,Range",
"default": "Maximum"
+ },
+ {
+ "name": "Kind",
+ "type": "choice:Live,Derivative",
+ "default": "Live"
}
]
}
diff --git a/shapeshifter.md b/shapeshifter.md
index 066b43e..18f4db1 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -167,6 +167,17 @@ Touches: `geometry.rs`, `kernel_cpu.rs`, `nodes/*.json`.
*Medium. Needs Phases 0 and 1.*
+> **Started.** Live and derivative data is a declared property of the
+> ATTRIBUTE (`AttribKind`), set where it is created rather than listed on the
+> solver. The step boundary zeroes every derivative attribute going in, and
+> coming out restores any live attribute the chain DROPPED, matching points by
+> identity — so a node that rebuilds geometry mid-chain no longer takes the
+> simulation's memory with it. Restoration bridges a rebuild, not a delete: an
+> unchanged point set means a missing attribute was removed on purpose.
+> Analysis and Time write derivative; the spreadsheet marks them with `~`.
+>
+> Outstanding: substeps, an explicit seed frame, a disk cache, and Visualize.
+
`simnet` is already the Developer Solver — feedback stack, per-node cache keyed
on the subtree, restart on edit, one step per played frame. What it lacks is a
contract for what survives a step.
diff --git a/src/app.rs b/src/app.rs
index 448f209..379be00 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3184,12 +3184,23 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
.filter_map(|n| geom.points().get(n).map(|a| (n.to_string(), a.ty())))
.collect();
+ // A Derivative attribute wears a trailing `~`: it resets at every step
+ // boundary, and telling that apart from a value that persists is the first
+ // question you ask when a solve misbehaves.
+ let mark = |geom: &Detail, class: crate::detail::Class, name: &str| -> String {
+ match geom.store(class).kind(name) {
+ crate::detail::AttribKind::Derivative => format!("{}~", name),
+ crate::detail::AttribKind::Live => name.to_string(),
+ }
+ };
+
for (name, ty) in &attribs {
+ let label = mark(geom, crate::detail::Class::Point, name);
match ty.components() {
- 1 => headers.push(name.clone()),
+ 1 => headers.push(label),
n => {
for c in ["x", "y", "z", "w"].iter().take(n) {
- headers.push(format!("{}.{}", name, c));
+ headers.push(format!("{}.{}", label, c));
}
}
}
@@ -3210,11 +3221,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
.filter_map(|n| geom.detail().get(n).map(|a| (n.to_string(), a.ty())))
.collect();
for (name, ty) in &detail {
+ let label = mark(geom, crate::detail::Class::Detail, name);
match ty.components() {
- 1 => headers.push(format!("d:{}", name)),
+ 1 => headers.push(format!("d:{}", label)),
n => {
for c in ["x", "y", "z", "w"].iter().take(n) {
- headers.push(format!("d:{}.{}", name, c));
+ headers.push(format!("d:{}.{}", label, c));
}
}
}
diff --git a/src/detail.rs b/src/detail.rs
index 293ac85..f01f462 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -156,6 +156,34 @@ impl AttribValue {
}
}
+/// Whether an attribute survives a simulation step.
+///
+/// The distinction `developer.md` draws between **live data**, which "runs
+/// like a stream through the simulation", and **derivative data**, "calculated
+/// anew every frame based on live data". Houdini has no such notion — there,
+/// every attribute simply persists, and a chain that forgets to reset its
+/// scratch values accumulates them silently until the sim goes wrong in a way
+/// that looks like a physics bug.
+///
+/// Making it a property of the ATTRIBUTE rather than a list of names on the
+/// solver means the node that creates a value declares its nature at the point
+/// of creation, where the author knows the answer, instead of somewhere else
+/// that has to be kept in step.
+///
+/// [`AttribKind::Live`] is the default, because it is the one whose failure
+/// mode is visible: a value that should have been cleared and was not shows up
+/// as a drift you can watch, where a value that should have persisted and was
+/// cleared just quietly reads zero.
+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
+pub enum AttribKind {
+ /// Carried across the step boundary, by point identity where the geometry
+ /// was rebuilt underneath it.
+ #[default]
+ Live,
+ /// Zeroed at the start of every step; the chain is expected to rebuild it.
+ Derivative,
+}
+
/// One attribute's storage: a single array covering every element of the
/// owning class, in element order.
#[derive(Clone, Debug, PartialEq)]
@@ -321,12 +349,16 @@ impl AttribData {
pub struct AttribStore {
len: usize,
attribs: HashMap<String, AttribData>,
+ /// Only the attributes that are NOT the default kind appear here, so an
+ /// absent entry reads as [`AttribKind::Live`] and nothing has to remember
+ /// to register an ordinary attribute.
+ kinds: HashMap<String, AttribKind>,
groups: HashMap<String, Vec<bool>>,
}
impl AttribStore {
pub fn with_len(len: usize) -> Self {
- Self { len, attribs: HashMap::new(), groups: HashMap::new() }
+ Self { len, attribs: HashMap::new(), kinds: HashMap::new(), groups: HashMap::new() }
}
pub fn len(&self) -> usize {
@@ -365,12 +397,77 @@ impl AttribStore {
}
/// Create (or replace) an attribute, every element set to `default`.
+ ///
+ /// The attribute is [`AttribKind::Live`]; use [`AttribStore::create_kind`]
+ /// for one the solver should clear each step. Replacing an attribute
+ /// replaces its kind too — a name reused for a different purpose is a
+ /// different attribute.
pub fn create(&mut self, name: &str, default: AttribValue) -> &mut AttribData {
+ self.create_kind(name, default, AttribKind::Live)
+ }
+
+ /// Create (or replace) an attribute, declaring whether it survives a step.
+ pub fn create_kind(
+ &mut self,
+ name: &str,
+ default: AttribValue,
+ kind: AttribKind,
+ ) -> &mut AttribData {
self.attribs
.insert(name.to_string(), AttribData::filled(default, self.len));
+ match kind {
+ AttribKind::Live => self.kinds.remove(name),
+ other => self.kinds.insert(name.to_string(), other),
+ };
self.attribs.get_mut(name).expect("just inserted")
}
+ /// Whether an attribute survives a simulation step. An attribute nobody
+ /// declared is Live.
+ pub fn kind(&self, name: &str) -> AttribKind {
+ self.kinds.get(name).copied().unwrap_or_default()
+ }
+
+ /// Declare an existing attribute's kind without disturbing its values.
+ pub fn set_kind(&mut self, name: &str, kind: AttribKind) {
+ if !self.attribs.contains_key(name) {
+ return;
+ }
+ match kind {
+ AttribKind::Live => self.kinds.remove(name),
+ other => self.kinds.insert(name.to_string(), other),
+ };
+ }
+
+ /// The names of every attribute of one kind, sorted.
+ pub fn names_of_kind(&self, kind: AttribKind) -> Vec<&str> {
+ let mut names: Vec<&str> = self
+ .attribs
+ .keys()
+ .filter(|n| self.kind(n) == kind)
+ .map(|s| s.as_str())
+ .collect();
+ names.sort_unstable();
+ names
+ }
+
+ /// Zero every Derivative attribute, keeping the columns themselves — the
+ /// step that follows is expected to rebuild the values, and a reader
+ /// between the two should find the attribute present and empty rather than
+ /// missing.
+ pub fn clear_derivatives(&mut self) {
+ let names: Vec<String> = self.kinds
+ .iter()
+ .filter(|(_, &k)| k == AttribKind::Derivative)
+ .map(|(n, _)| n.clone())
+ .collect();
+ for name in names {
+ if let Some(data) = self.attribs.get_mut(&name) {
+ *data = AttribData::zeroed(data.ty(), self.len);
+ }
+ }
+ }
+
/// Create the attribute if it is absent, leaving an existing one — and its
/// values — alone. The read path for an operator that wants to write into
/// an attribute it does not own.
@@ -382,6 +479,7 @@ impl AttribStore {
}
pub fn remove(&mut self, name: &str) -> Option<AttribData> {
+ self.kinds.remove(name);
self.attribs.remove(name)
}
@@ -509,7 +607,7 @@ impl AttribStore {
(k.clone(), picked)
})
.collect();
- AttribStore { len: idx.len(), attribs, groups }
+ AttribStore { len: idx.len(), attribs, kinds: self.kinds.clone(), groups }
}
/// Append `other`'s elements. Attributes present on only one side are
@@ -536,6 +634,12 @@ impl AttribStore {
lhs.resize(lhs_len + rhs_len);
}
+ // A kind declared on either side sticks: the left side wins a
+ // disagreement, the same way its values do.
+ for (name, kind) in &other.kinds {
+ self.kinds.entry(name.clone()).or_insert(*kind);
+ }
+
for (name, rhs) in &other.groups {
let lhs = self
.groups
@@ -1111,6 +1215,67 @@ impl Detail {
Some((lo, hi))
}
+ /// Zero every Derivative attribute on every class — the step boundary's
+ /// first act. See [`AttribKind`].
+ pub fn clear_derivatives(&mut self) {
+ self.points.clear_derivatives();
+ self.verts.clear_derivatives();
+ self.prims.clear_derivatives();
+ self.detail.clear_derivatives();
+ }
+
+ /// Restore this geometry's Live point attributes from `prev`, matching
+ /// points by identity.
+ ///
+ /// The other half of the contract, and the one that makes a rebuild safe
+ /// to put in the middle of a solve. When a step's chain hands back geometry
+ /// that has lost an attribute — a kernel generator that rebuilt its points,
+ /// and in Phase 3 a remesh — the values are not gone, they are in the
+ /// previous state, attached to identities. A point that survived gets its
+ /// value back; a point that is genuinely new gets the type's zero, which is
+ /// the only honest answer for a place that did not exist last step.
+ ///
+ /// Attributes the new geometry DOES carry are left alone: the chain
+ /// computed them this step and that is the whole point of running it.
+ /// Derivative attributes are not restored at all — they are meant to be
+ /// rebuilt, and carrying one across would be exactly the silent
+ /// accumulation the kind exists to prevent.
+ pub fn restore_live_from(&mut self, prev: &Detail) {
+ // Restoration bridges a REBUILD, not a deletion. If the chain handed
+ // back the same identities in the same order, it kept the geometry it
+ // was given — so an attribute that is gone was taken out on purpose,
+ // and putting it back would override the author. Only when the point
+ // set itself changed underneath is a missing attribute evidence of
+ // loss rather than intent.
+ if self.ids == prev.ids {
+ return;
+ }
+ let missing: Vec<&str> = prev
+ .points
+ .names_of_kind(AttribKind::Live)
+ .into_iter()
+ .filter(|n| !self.points.has(n))
+ .collect();
+ if missing.is_empty() {
+ return;
+ }
+ let was: HashMap<PointId, u32> = prev.id_map();
+ for name in missing {
+ let Some(src) = prev.points.get(name) else { continue };
+ let ty = src.ty();
+ let mut data = AttribData::zeroed(ty, self.num_points());
+ for p in 0..self.num_points() {
+ let Some(id) = self.id(p) else { continue };
+ let Some(&old) = was.get(&id) else { continue };
+ if let Some(v) = src.get(old as usize) {
+ let _ = data.set(p, v);
+ }
+ }
+ let _ = self.points.insert(name, data);
+ self.points.set_kind(name, AttribKind::Live);
+ }
+ }
+
/// Weld a triangle soup into points and triangles: coincident positions
/// become one point, every three positions become one primitive.
///
diff --git a/src/geometry.rs b/src/geometry.rs
index 6da9809..0caf498 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1358,6 +1358,8 @@ pub(crate) fn apply_analysis(geom: &mut Detail, target: &FsNode, ocl_error: &mut
let average = if n == 0 { 0.0 } else { sum / n as f32 };
let spread = max - min;
+ // A measurement is derivative by definition: it describes this step's
+ // state, and carrying one into the next would be describing the past.
for (suffix, value) in [
("min", min),
("max", max),
@@ -1366,8 +1368,11 @@ pub(crate) fn apply_analysis(geom: &mut Detail, target: &FsNode, ocl_error: &mut
("spread", spread),
("count", n as f32),
] {
- geom.detail_mut()
- .create(&format!("{}_{}", label, suffix), AttribValue::Float(value));
+ geom.detail_mut().create_kind(
+ &format!("{}_{}", label, suffix),
+ AttribValue::Float(value),
+ crate::detail::AttribKind::Derivative,
+ );
}
}
@@ -1415,7 +1420,10 @@ pub(crate) fn apply_time(geom: &mut Detail, target: &FsNode, frame: i32) {
} else {
t
};
- geom.detail_mut().create(&name, AttribValue::Float(t));
+ // Also derivative: it is a function of the frame, so every step computes
+ // it afresh and none of them should inherit it.
+ geom.detail_mut()
+ .create_kind(&name, AttribValue::Float(t), crate::detail::AttribKind::Derivative);
}
/// Smooth point normals: for each point, the normalized sum of the face
@@ -2234,7 +2242,17 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
Some(src) => {
let zero = components_attrib(ty, &vec![0.0; ty.components()]);
let value = components_attrib(ty, &src);
- geom.points_mut().create(&name, zero);
+ // Declared where the author knows the answer: at the
+ // point of creation, not in a list somewhere else that
+ // has to be kept in step.
+ let kind = if node_param_str(target, "Kind", "Live")
+ .eq_ignore_ascii_case("derivative")
+ {
+ crate::detail::AttribKind::Derivative
+ } else {
+ crate::detail::AttribKind::Live
+ };
+ geom.points_mut().create_kind(&name, zero, kind);
for &p in &affected {
let _ = geom.points_mut().set_value(&name, p, value);
}
@@ -4747,12 +4765,28 @@ pub fn resolve_simnet_geometry_with_errors(
};
while done < due {
+ // The step boundary, and the contract that makes a chain composable:
+ //
+ // Going in, every Derivative attribute is zeroed. The chain is
+ // expected to rebuild them from live data this step, and one it
+ // forgets reads zero rather than quietly carrying last step's value
+ // forward — which is the failure that looks like a physics bug and
+ // is not one.
+ state.clear_derivatives();
+ let prev = state.clone();
+
sim.feedback.push((target.id.clone(), state));
let stepped = generate_single_node_geometry_with_errors(root, &output_node, visited, ocl_error, sim);
let fed_back = sim.feedback.pop().map(|(_, g)| g);
// A step that yields nothing (an unwired chain, a failed kernel) holds
// the previous state rather than collapsing the sim to empty geometry.
state = stepped.or(fed_back).unwrap_or_default();
+
+ // Coming out, any Live attribute the chain DROPPED is restored from
+ // the previous state by point identity. A node that rebuilds geometry
+ // mid-chain — a kernel generator today, a remesh in Phase 3 — no
+ // longer silently takes the simulation's memory with it.
+ state.restore_live_from(&prev);
done += 1;
}
@@ -5620,6 +5654,72 @@ mod simnet_tests {
g.positions().iter().map(|p| p[0]).fold(f32::INFINITY, f32::min)
}
+ /// A sim whose step adds 1 to `acc` every frame. The seed declares `acc`
+ /// with the given kind, which is the only difference between the two runs.
+ fn accumulating_graph(kind: &str) -> FsNode {
+ let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+ let seed = node(
+ "id-seed",
+ "Seed 1",
+ "attribute",
+ vec![
+ param("Input", "Sphere 1"),
+ param("Operation", "Create"),
+ param("Attribute Name", "acc"),
+ param("Type", "Float"),
+ param("Value", "0.00"),
+ param("Kind", kind),
+ ],
+ vec![],
+ );
+ let inner_input = node("id-in", "input1", "input", vec![], vec![]);
+ let step = node(
+ "id-step",
+ "step1",
+ "attribute",
+ vec![
+ param("Input", "input1"),
+ param("Operation", "Modify"),
+ param("Attribute Name", "acc"),
+ param("Combine", "Add"),
+ param("Value", "1.00"),
+ ],
+ vec![],
+ );
+ let inner_output = node("id-out", "output1", "output", vec![param("Input", "step1")], vec![]);
+ let sim = node(
+ "id-sim",
+ "Simnet 1",
+ "simnet",
+ vec![param("Input", "Seed 1")],
+ vec![inner_input, step, inner_output],
+ );
+ node("id-root", "root", "node", vec![], vec![sphere, seed, sim])
+ }
+
+ #[test]
+ fn test_live_data_accumulates_across_steps_and_derivative_data_does_not() {
+ let acc = |root: &FsNode, frame: i32| -> f32 {
+ solve_at(root, frame).points().value("acc", 0).unwrap().as_f32()
+ };
+
+ // Live data "runs like a stream through the simulation": each step
+ // reads what the last one wrote, so five steps of +1 is 5.
+ let live = accumulating_graph("Live");
+ assert_eq!(acc(&live, 1), 0.0, "the start frame is the seed");
+ assert_eq!(acc(&live, 4), 3.0);
+ assert_eq!(acc(&live, 6), 5.0);
+
+ // Derivative data is "calculated anew every frame": the boundary zeroes
+ // it before the chain runs, so every step starts from nothing and the
+ // answer is 1 however long the sim runs. Same graph, same chain — the
+ // ONLY difference is what the attribute was declared to be.
+ let derived = accumulating_graph("Derivative");
+ assert_eq!(acc(&derived, 4), 1.0);
+ assert_eq!(acc(&derived, 6), 1.0);
+ assert_eq!(acc(&derived, 60), 1.0);
+ }
+
#[test]
fn test_simnet_at_start_frame_is_its_seed() {
let root = stepping_graph();
diff --git a/src/main.rs b/src/main.rs
index 70a7e68..d6675b1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -71,7 +71,7 @@ mod tests {
use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX};
use crate::shortcut::{Shortcut, ShortcutManager, Action};
use crate::geometry::{GAttribute, GVertex, Geometry, line_vertices};
- use crate::detail::{AttribData, AttribType, AttribValue, Class, Detail};
+ use crate::detail::{AttribData, AttribKind, AttribType, AttribValue, Class, Detail};
/// The choosers open in the loaded project's parent — the "current view" —
/// and fall back to cce-files' remembered location only when nothing is
@@ -2877,7 +2877,11 @@ mod tests {
geom.points_mut().add_to_group("pinned", 1);
// A detail attribute — what Analysis writes — shows as a `d:` column,
// constant down the table, which is what a detail attribute is.
- geom.detail_mut().create("mass_max", AttribValue::Float(9.5));
+ geom.detail_mut().create_kind(
+ "mass_max",
+ AttribValue::Float(9.5),
+ AttribKind::Derivative,
+ );
assert_eq!(geom.num_points(), 2);
let render_verts = crate::geometry::detail_vertices(&geom);
@@ -2888,7 +2892,7 @@ mod tests {
headers,
vec![
"Point", "Pos.x", "Pos.y", "Pos.z", "Col.r", "Col.g", "Col.b",
- "ID", "UV.x", "UV.y", "g:pinned", "d:mass_max",
+ "ID", "UV.x", "UV.y", "g:pinned", "d:mass_max~",
]
);
@@ -2907,6 +2911,8 @@ mod tests {
assert_eq!(rows[1][10], "1", "point 1 is in the group");
assert_eq!(rows[0][11], "9.5000");
assert_eq!(rows[1][11], "9.5000", "a detail value repeats down the column");
+ // The trailing ~ says this one resets at every step boundary.
+ assert!(headers[11].ends_with('~'), "{}", headers[11]);
}
#[test]
@@ -3350,6 +3356,113 @@ mod tests {
}
}
+ // ---- Phase 2: the solver contract ----
+
+ #[test]
+ fn test_attribute_kind_defaults_to_live_and_is_declared_at_creation() {
+ let mut d = quad_grid();
+ d.points_mut().create("mass", AttribValue::Float(1.0));
+ d.points_mut()
+ .create_kind("scratch", AttribValue::Float(1.0), AttribKind::Derivative);
+
+ // Live is the default because its failure mode is the visible one: a
+ // value that should have been cleared and was not drifts where you can
+ // watch it, while one that should have persisted and was cleared just
+ // quietly reads zero.
+ assert_eq!(d.points().kind("mass"), AttribKind::Live);
+ assert_eq!(d.points().kind("scratch"), AttribKind::Derivative);
+ assert_eq!(d.points().kind("never-declared"), AttribKind::Live);
+ assert_eq!(d.points().names_of_kind(AttribKind::Derivative), vec!["scratch"]);
+
+ d.clear_derivatives();
+ // The column stays and the values reset: a reader between two steps
+ // finds the attribute present and empty, not missing.
+ assert!(d.points().has("scratch"));
+ assert_eq!(d.points().value("scratch", 0), Some(AttribValue::Float(0.0)));
+ assert_eq!(d.points().value("mass", 0), Some(AttribValue::Float(1.0)));
+
+ // A name reused for a different purpose is a different attribute, so
+ // re-creating it re-declares the kind.
+ d.points_mut().create("scratch", AttribValue::Float(2.0));
+ assert_eq!(d.points().kind("scratch"), AttribKind::Live);
+ }
+
+ #[test]
+ fn test_attribute_kinds_survive_the_structural_rewrites() {
+ let mut d = quad_grid();
+ d.points_mut()
+ .create_kind("scratch", AttribValue::Float(1.0), AttribKind::Derivative);
+
+ let mut kept = d.clone();
+ kept.keep_points(&(0..9).map(|i| i < 6).collect::<Vec<_>>());
+ assert_eq!(kept.points().kind("scratch"), AttribKind::Derivative, "through a gather");
+
+ let mut merged = quad_grid();
+ merged.merge(&d);
+ assert_eq!(merged.points().kind("scratch"), AttribKind::Derivative, "through a merge");
+ }
+
+ #[test]
+ fn test_live_attributes_come_back_across_a_rebuild_by_identity() {
+ let mut prev = quad_grid();
+ prev.points_mut().create("mass", AttribValue::Float(0.0));
+ for p in 0..9 {
+ prev.points_mut()
+ .set_value("mass", p, AttribValue::Float(p as f32))
+ .unwrap();
+ }
+ prev.points_mut()
+ .create_kind("scratch", AttribValue::Float(7.0), AttribKind::Derivative);
+
+ // A step that rebuilt the geometry: it kept six of the nine points,
+ // added one genuinely new one, and lost every attribute on the way —
+ // which is what a remesh does, and what a kernel generator does today.
+ let mut next = prev.clone();
+ next.keep_points(&(0..9).map(|i| i < 6).collect::<Vec<_>>());
+ next.points_mut().remove("mass");
+ next.points_mut().remove("scratch");
+ next.add_point(Vec3::new(9.0, 9.0, 0.0));
+
+ next.restore_live_from(&prev);
+
+ // The simulation's memory is not gone: it is in the previous state,
+ // attached to identities.
+ assert!(next.points().has("mass"));
+ for p in 0..6 {
+ assert_eq!(next.points().value("mass", p), Some(AttribValue::Float(p as f32)));
+ }
+ // A point that did not exist last step gets the type's zero, which is
+ // the only honest answer for a place with no history.
+ assert_eq!(next.points().value("mass", 6), Some(AttribValue::Float(0.0)));
+ // Derivative attributes are NOT restored — carrying one across is
+ // exactly the silent accumulation the kind exists to prevent.
+ assert!(!next.points().has("scratch"));
+ }
+
+ #[test]
+ fn test_restoration_bridges_a_rebuild_and_does_not_undo_a_delete() {
+ let mut prev = quad_grid();
+ prev.points_mut().create("mass", AttribValue::Float(3.0));
+
+ // The chain handed back the same identities in the same order, so it
+ // kept the geometry it was given: an attribute that is gone was taken
+ // out on purpose, and putting it back would override the author.
+ let mut same = prev.clone();
+ same.points_mut().remove("mass");
+ same.restore_live_from(&prev);
+ assert!(!same.points().has("mass"), "a deliberate delete must stick");
+
+ // An attribute the new state DOES carry is left alone: the chain
+ // computed it this step, which is the whole point of running it.
+ let mut recomputed = prev.clone();
+ recomputed
+ .points_mut()
+ .set_value("mass", 0, AttribValue::Float(99.0))
+ .unwrap();
+ recomputed.restore_live_from(&prev);
+ assert_eq!(recomputed.points().value("mass", 0), Some(AttribValue::Float(99.0)));
+ }
+
// ---- Phase 0: the points/vertices/primitives/detail container ----
//
// These cover what the triangle soup could not do at all: a point shared