graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(detail): points, vertices, primitives and detail — the Phase 0 container
The triangle soup has no points: three corners of a triangle are three
unrelated entries, a point shared by six faces appears six times with six
copies of every attribute, and there is no edge to measure or identity that
survives a frame. Every Developer operator is a statement about a point and
its neighbours, so none of them can be written against it.
src/detail.rs adds the replacement, standing alone — nothing produces or
consumes it yet:
- Four element classes, each with its own attribute store. Detail is the
single-row class, which is where Analysis writes a range instead of
needing a dictionary type.
- Columnar attributes — one array per name, not a HashMap per element. The
layout a GPU buffer already wants, so the Phase 1 ABI binds a slice.
Integers included: Vitality's ages have to stay whole.
- Stable PointIds, allocated once and preserved through gathers and merges.
A merge reallocates the right-hand side, because both sides numbered their
points from zero and a collision would have a solver write one point over
another.
- Real named groups per class, retiring the group:<name> attribute-key
convention.
- Topology (point->prim, point->point, unique edges) as CSR, built lazily and
dropped on any structural edit — so a chain of ten attribute nodes builds it
once rather than welding from scratch in every node, which is what the relax
resolver does today.
Attribute gather is the one primitive behind delete, reorder and duplicate,
so there is a single place answering what happens to attributes when topology
changes. from_triangle_soup keeps the existing 1e-4 weld quantization, and
triangulate takes a closure so this module stays free of anything that draws.
Co-Authored-By: Claude Opus 5 <[email protected]>
shapeshifter.md | 7 +
src/detail.rs | 1163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 372 +++++++++++++++++-
3 files changed, 1541 insertions(+), 1 deletion(-)
diff --git a/shapeshifter.md b/shapeshifter.md
index 41afb45..77a2c0c 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -89,6 +89,13 @@ touches none of the geometry work and can be picked up in any gap.
*Largest. Blocks Phases 1, 2, 3, 4 and 6 — all of them.*
+> **Underway.** `src/detail.rs` holds the container — points, vertices,
+> primitives, detail; columnar attributes with integers and real groups; stable
+> `PointId`s; lazily built CSR topology. It stands alone and is fully tested;
+> nothing produces or consumes it yet. Remaining: migrate the generators, then
+> the operators, then the consumers (spreadsheet, overlays, kernel ABI), then
+> delete `geometry::Geometry`.
+
Replace the vertex list with **points, vertices, primitives and detail**, each
carrying its own columnar attribute arrays — one `Vec<f32>` per named attribute
rather than a `HashMap` per corner. Columnar is not a nicety here: it is the
diff --git a/src/detail.rs b/src/detail.rs
new file mode 100644
index 0000000..ff2670c
--- /dev/null
+++ b/src/detail.rs
@@ -0,0 +1,1163 @@
+//! The geometry container: points, vertices, primitives and detail.
+//!
+//! This is the Phase 0 replacement for `geometry::Geometry`, the triangle soup
+//! (`Vec<GVertex>`, attributes stored per triangle corner). See
+//! `shapeshifter.md` for why: every attribute operator worth having is a
+//! statement about a point *and its neighbours*, and a soup has no points, no
+//! edges, and no identity that survives a frame.
+//!
+//! Four element classes, exactly Houdini's:
+//!
+//! - **Points** carry position and the attributes that describe a place on the
+//! surface. A point is shared by every primitive that uses it — moving one
+//! moves them all, which is the whole difference from a soup.
+//! - **Vertices** are a primitive's references to points, in winding order. One
+//! per corner. They exist so a point can carry different per-corner data (a
+//! UV seam, a hard normal) without splitting the point itself.
+//! - **Primitives** are runs of vertices. Polygons of any size, not just
+//! triangles; triangulation is a render concern (see [`Detail::triangulate`]).
+//! - **Detail** is the single-element class: one row holding whole-geometry
+//! values, which is where `Analysis` writes a range rather than inventing a
+//! dictionary type.
+//!
+//! Three properties the soup could not have, all load-bearing for later phases:
+//!
+//! **Columnar attributes.** One array per named attribute, not a `HashMap` per
+//! element. This is the layout a GPU buffer already wants, so the Phase 1 kernel
+//! ABI binds a slice instead of marshalling a million little maps.
+//!
+//! **Stable point ids.** [`PointId`] is allocated once per point and preserved by
+//! every operator that does not create points. A solver needs to know that the
+//! point it is looking at is the one it wrote to last step; a positional weld
+//! cannot answer that, because points move.
+//!
+//! **Real groups.** A named membership set per class, not the `group:<name>`
+//! key convention the soup used in its attribute map.
+//!
+//! Topology (point→prim, point→point, the edge list) is *derived*, built lazily
+//! on first ask and dropped on any structural edit — so a chain of ten attribute
+//! nodes builds it once rather than welding from scratch in every node, which is
+//! what `resolve_relax_geometry_with_errors` has to do today.
+
+use glam::Vec3;
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+/// A point's identity, stable across the operators that preserve points and
+/// across simulation steps. Allocated by [`Detail::add_point`]; never reused
+/// within one `Detail`.
+pub type PointId = u64;
+
+/// The conventional color attribute. Read by [`Detail::triangulate`] when
+/// present; geometry without it renders at [`DEFAULT_COLOR`].
+pub const CD: &str = "Cd";
+
+/// What a point renders as when it carries no `Cd`.
+pub const DEFAULT_COLOR: [f32; 3] = [0.8, 0.8, 0.8];
+
+/// Which element class an attribute or group belongs to.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
+pub enum Class {
+ Point,
+ Vertex,
+ Prim,
+ Detail,
+}
+
+impl Class {
+ pub fn name(self) -> &'static str {
+ match self {
+ Class::Point => "point",
+ Class::Vertex => "vertex",
+ Class::Prim => "prim",
+ Class::Detail => "detail",
+ }
+ }
+}
+
+/// An attribute's element type. Integers are here because the Developer set
+/// needs counters and ages that stay whole — `Vitality` writes `_age0..2`, and
+/// rounding a float age is how off-by-one frames happen.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum AttribType {
+ Float,
+ Float2,
+ Float3,
+ Float4,
+ Int,
+}
+
+impl AttribType {
+ /// Component count. `Int` is one component, like `Float`.
+ pub fn components(self) -> usize {
+ match self {
+ AttribType::Float | AttribType::Int => 1,
+ AttribType::Float2 => 2,
+ AttribType::Float3 => 3,
+ AttribType::Float4 => 4,
+ }
+ }
+
+ pub fn name(self) -> &'static str {
+ match self {
+ AttribType::Float => "float",
+ AttribType::Float2 => "float2",
+ AttribType::Float3 => "float3",
+ AttribType::Float4 => "float4",
+ AttribType::Int => "int",
+ }
+ }
+}
+
+/// One element's value, for the get/set paths that do not care about layout.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub enum AttribValue {
+ Float(f32),
+ Float2([f32; 2]),
+ Float3([f32; 3]),
+ Float4([f32; 4]),
+ Int(i32),
+}
+
+impl AttribValue {
+ pub fn ty(self) -> AttribType {
+ match self {
+ AttribValue::Float(_) => AttribType::Float,
+ AttribValue::Float2(_) => AttribType::Float2,
+ AttribValue::Float3(_) => AttribType::Float3,
+ AttribValue::Float4(_) => AttribType::Float4,
+ AttribValue::Int(_) => AttribType::Int,
+ }
+ }
+
+ /// The value as a float, for the readers that treat every scalar alike.
+ /// Wider types yield their first component.
+ pub fn as_f32(self) -> f32 {
+ match self {
+ AttribValue::Float(v) => v,
+ AttribValue::Float2(v) => v[0],
+ AttribValue::Float3(v) => v[0],
+ AttribValue::Float4(v) => v[0],
+ AttribValue::Int(v) => v as f32,
+ }
+ }
+
+ /// The value as a vector, for the readers that treat every vector alike.
+ /// Scalars broadcast across all three components, which is what a scalar
+ /// used as a multiplier means.
+ pub fn as_vec3(self) -> Vec3 {
+ match self {
+ AttribValue::Float(v) => Vec3::splat(v),
+ AttribValue::Float2(v) => Vec3::new(v[0], v[1], 0.0),
+ AttribValue::Float3(v) => Vec3::from(v),
+ AttribValue::Float4(v) => Vec3::new(v[0], v[1], v[2]),
+ AttribValue::Int(v) => Vec3::splat(v as f32),
+ }
+ }
+}
+
+/// One attribute's storage: a single array covering every element of the
+/// owning class, in element order.
+#[derive(Clone, Debug, PartialEq)]
+pub enum AttribData {
+ Float(Vec<f32>),
+ Float2(Vec<[f32; 2]>),
+ Float3(Vec<[f32; 3]>),
+ Float4(Vec<[f32; 4]>),
+ Int(Vec<i32>),
+}
+
+impl AttribData {
+ /// An array of `len` elements, every one the type's zero.
+ pub fn zeroed(ty: AttribType, len: usize) -> Self {
+ match ty {
+ AttribType::Float => AttribData::Float(vec![0.0; len]),
+ AttribType::Float2 => AttribData::Float2(vec![[0.0; 2]; len]),
+ AttribType::Float3 => AttribData::Float3(vec![[0.0; 3]; len]),
+ AttribType::Float4 => AttribData::Float4(vec![[0.0; 4]; len]),
+ AttribType::Int => AttribData::Int(vec![0; len]),
+ }
+ }
+
+ /// An array of `len` elements, every one `value`.
+ pub fn filled(value: AttribValue, len: usize) -> Self {
+ match value {
+ AttribValue::Float(v) => AttribData::Float(vec![v; len]),
+ AttribValue::Float2(v) => AttribData::Float2(vec![v; len]),
+ AttribValue::Float3(v) => AttribData::Float3(vec![v; len]),
+ AttribValue::Float4(v) => AttribData::Float4(vec![v; len]),
+ AttribValue::Int(v) => AttribData::Int(vec![v; len]),
+ }
+ }
+
+ pub fn ty(&self) -> AttribType {
+ match self {
+ AttribData::Float(_) => AttribType::Float,
+ AttribData::Float2(_) => AttribType::Float2,
+ AttribData::Float3(_) => AttribType::Float3,
+ AttribData::Float4(_) => AttribType::Float4,
+ AttribData::Int(_) => AttribType::Int,
+ }
+ }
+
+ pub fn len(&self) -> usize {
+ match self {
+ AttribData::Float(v) => v.len(),
+ AttribData::Float2(v) => v.len(),
+ AttribData::Float3(v) => v.len(),
+ AttribData::Float4(v) => v.len(),
+ AttribData::Int(v) => v.len(),
+ }
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ pub fn get(&self, i: usize) -> Option<AttribValue> {
+ match self {
+ AttribData::Float(v) => v.get(i).copied().map(AttribValue::Float),
+ AttribData::Float2(v) => v.get(i).copied().map(AttribValue::Float2),
+ AttribData::Float3(v) => v.get(i).copied().map(AttribValue::Float3),
+ AttribData::Float4(v) => v.get(i).copied().map(AttribValue::Float4),
+ AttribData::Int(v) => v.get(i).copied().map(AttribValue::Int),
+ }
+ }
+
+ /// Write one element. The value's type must match the array's — a caller
+ /// wanting to change an attribute's type replaces the whole array, so that
+ /// a half-converted attribute is unrepresentable.
+ pub fn set(&mut self, i: usize, value: AttribValue) -> Result<(), String> {
+ macro_rules! put {
+ ($arr:expr, $v:expr) => {{
+ let len = $arr.len();
+ let slot = $arr
+ .get_mut(i)
+ .ok_or_else(|| format!("element {} is out of range ({})", i, len))?;
+ *slot = $v;
+ Ok(())
+ }};
+ }
+ match (self, value) {
+ (AttribData::Float(a), AttribValue::Float(v)) => put!(a, v),
+ (AttribData::Float2(a), AttribValue::Float2(v)) => put!(a, v),
+ (AttribData::Float3(a), AttribValue::Float3(v)) => put!(a, v),
+ (AttribData::Float4(a), AttribValue::Float4(v)) => put!(a, v),
+ (AttribData::Int(a), AttribValue::Int(v)) => put!(a, v),
+ (data, value) => Err(format!(
+ "type mismatch: attribute is {}, value is {}",
+ data.ty().name(),
+ value.ty().name()
+ )),
+ }
+ }
+
+ /// Append one zero element, keeping the array in step with a class that
+ /// just grew.
+ pub fn push_zero(&mut self) {
+ match self {
+ AttribData::Float(v) => v.push(0.0),
+ AttribData::Float2(v) => v.push([0.0; 2]),
+ AttribData::Float3(v) => v.push([0.0; 3]),
+ AttribData::Float4(v) => v.push([0.0; 4]),
+ AttribData::Int(v) => v.push(0),
+ }
+ }
+
+ /// Grow or shrink to `len`, zero-filling any new elements.
+ pub fn resize(&mut self, len: usize) {
+ match self {
+ AttribData::Float(v) => v.resize(len, 0.0),
+ AttribData::Float2(v) => v.resize(len, [0.0; 2]),
+ AttribData::Float3(v) => v.resize(len, [0.0; 3]),
+ AttribData::Float4(v) => v.resize(len, [0.0; 4]),
+ AttribData::Int(v) => v.resize(len, 0),
+ }
+ }
+
+ /// A new array holding this one's elements at `idx`, in that order.
+ ///
+ /// The single primitive every topology-changing operator needs: deleting
+ /// elements, reordering them, and duplicating them are all a gather, so
+ /// there is one place where "what happens to the attributes" is answered.
+ /// Indices out of range contribute a zero rather than panicking — a caller
+ /// building an index map should not be able to corrupt memory with an
+ /// arithmetic slip.
+ pub fn gather(&self, idx: &[u32]) -> AttribData {
+ macro_rules! pick {
+ ($arr:expr, $zero:expr, $wrap:path) => {{
+ let mut out = Vec::with_capacity(idx.len());
+ for &i in idx {
+ out.push($arr.get(i as usize).copied().unwrap_or($zero));
+ }
+ $wrap(out)
+ }};
+ }
+ match self {
+ AttribData::Float(a) => pick!(a, 0.0, AttribData::Float),
+ AttribData::Float2(a) => pick!(a, [0.0; 2], AttribData::Float2),
+ AttribData::Float3(a) => pick!(a, [0.0; 3], AttribData::Float3),
+ AttribData::Float4(a) => pick!(a, [0.0; 4], AttribData::Float4),
+ AttribData::Int(a) => pick!(a, 0, AttribData::Int),
+ }
+ }
+
+ /// The raw floats behind the array, for a GPU upload or a bulk read.
+ /// `Int` has no float view and yields `None`.
+ pub fn as_f32_slice(&self) -> Option<&[f32]> {
+ match self {
+ AttribData::Float(v) => Some(v.as_slice()),
+ AttribData::Float2(v) => Some(bytemuck::cast_slice(v.as_slice())),
+ AttribData::Float3(v) => Some(bytemuck::cast_slice(v.as_slice())),
+ AttribData::Float4(v) => Some(bytemuck::cast_slice(v.as_slice())),
+ AttribData::Int(_) => None,
+ }
+ }
+}
+
+/// Every attribute and group belonging to one element class, plus the element
+/// count they are all kept in step with.
+#[derive(Clone, Debug, Default)]
+pub struct AttribStore {
+ len: usize,
+ attribs: HashMap<String, AttribData>,
+ groups: HashMap<String, Vec<bool>>,
+}
+
+impl AttribStore {
+ pub fn with_len(len: usize) -> Self {
+ Self { len, attribs: HashMap::new(), groups: HashMap::new() }
+ }
+
+ pub fn len(&self) -> usize {
+ self.len
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len == 0
+ }
+
+ /// Attribute names, sorted, so a spreadsheet's columns do not reshuffle
+ /// between frames on `HashMap` iteration order.
+ pub fn names(&self) -> Vec<&str> {
+ let mut names: Vec<&str> = self.attribs.keys().map(|s| s.as_str()).collect();
+ names.sort_unstable();
+ names
+ }
+
+ /// Group names, sorted, for the same reason.
+ pub fn group_names(&self) -> Vec<&str> {
+ let mut names: Vec<&str> = self.groups.keys().map(|s| s.as_str()).collect();
+ names.sort_unstable();
+ names
+ }
+
+ pub fn has(&self, name: &str) -> bool {
+ self.attribs.contains_key(name)
+ }
+
+ pub fn get(&self, name: &str) -> Option<&AttribData> {
+ self.attribs.get(name)
+ }
+
+ pub fn get_mut(&mut self, name: &str) -> Option<&mut AttribData> {
+ self.attribs.get_mut(name)
+ }
+
+ /// Create (or replace) an attribute, every element set to `default`.
+ pub fn create(&mut self, name: &str, default: AttribValue) -> &mut AttribData {
+ self.attribs
+ .insert(name.to_string(), AttribData::filled(default, self.len));
+ self.attribs.get_mut(name).expect("just inserted")
+ }
+
+ /// 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.
+ pub fn get_or_create(&mut self, name: &str, default: AttribValue) -> &mut AttribData {
+ if !self.attribs.contains_key(name) {
+ self.create(name, default);
+ }
+ self.attribs.get_mut(name).expect("present or just created")
+ }
+
+ pub fn remove(&mut self, name: &str) -> Option<AttribData> {
+ self.attribs.remove(name)
+ }
+
+ pub fn value(&self, name: &str, i: usize) -> Option<AttribValue> {
+ self.attribs.get(name).and_then(|a| a.get(i))
+ }
+
+ pub fn set_value(&mut self, name: &str, i: usize, v: AttribValue) -> Result<(), String> {
+ self.attribs
+ .get_mut(name)
+ .ok_or_else(|| format!("no attribute named {:?}", name))?
+ .set(i, v)
+ }
+
+ /// Create an empty group, or empty an existing one.
+ pub fn create_group(&mut self, name: &str) {
+ self.groups.insert(name.to_string(), vec![false; self.len]);
+ }
+
+ pub fn has_group(&self, name: &str) -> bool {
+ self.groups.contains_key(name)
+ }
+
+ pub fn remove_group(&mut self, name: &str) -> bool {
+ self.groups.remove(name).is_some()
+ }
+
+ /// Put one element in a group, creating the group if needed. Out-of-range
+ /// indices are ignored.
+ pub fn add_to_group(&mut self, name: &str, i: usize) {
+ let len = self.len;
+ let members = self
+ .groups
+ .entry(name.to_string())
+ .or_insert_with(|| vec![false; len]);
+ if let Some(slot) = members.get_mut(i) {
+ *slot = true;
+ }
+ }
+
+ pub fn in_group(&self, name: &str, i: usize) -> bool {
+ self.groups.get(name).and_then(|m| m.get(i)).copied().unwrap_or(false)
+ }
+
+ /// The members of a group, in element order. An absent group has no
+ /// members — asking about a group nobody created is not an error, because
+ /// a node's Group parameter is routinely left blank.
+ pub fn group_members(&self, name: &str) -> Vec<u32> {
+ match self.groups.get(name) {
+ Some(m) => m
+ .iter()
+ .enumerate()
+ .filter(|(_, &v)| v)
+ .map(|(i, _)| i as u32)
+ .collect(),
+ None => Vec::new(),
+ }
+ }
+
+ pub fn group_len(&self, name: &str) -> usize {
+ self.groups
+ .get(name)
+ .map(|m| m.iter().filter(|&&v| v).count())
+ .unwrap_or(0)
+ }
+
+ /// Append one element's worth of room to every attribute and group.
+ fn push_element(&mut self) {
+ self.len += 1;
+ for a in self.attribs.values_mut() {
+ a.push_zero();
+ }
+ for g in self.groups.values_mut() {
+ g.push(false);
+ }
+ }
+
+ /// Set the element count, resizing every attribute and group to match.
+ fn set_len(&mut self, len: usize) {
+ self.len = len;
+ for a in self.attribs.values_mut() {
+ a.resize(len);
+ }
+ for g in self.groups.values_mut() {
+ g.resize(len, false);
+ }
+ }
+
+ /// Rebuild the store around a new element order: element `n` of the result
+ /// is element `idx[n]` of this one. See [`AttribData::gather`].
+ fn gather(&self, idx: &[u32]) -> AttribStore {
+ let attribs = self
+ .attribs
+ .iter()
+ .map(|(k, v)| (k.clone(), v.gather(idx)))
+ .collect();
+ let groups = self
+ .groups
+ .iter()
+ .map(|(k, v)| {
+ let picked = idx
+ .iter()
+ .map(|&i| v.get(i as usize).copied().unwrap_or(false))
+ .collect();
+ (k.clone(), picked)
+ })
+ .collect();
+ AttribStore { len: idx.len(), attribs, groups }
+ }
+
+ /// Append `other`'s elements. Attributes present on only one side are
+ /// created on the other and zero-filled there, so a merge never silently
+ /// drops a column.
+ fn append(&mut self, other: &AttribStore) {
+ let (lhs_len, rhs_len) = (self.len, other.len);
+
+ for (name, rhs) in &other.attribs {
+ match self.attribs.get_mut(name) {
+ Some(lhs) if lhs.ty() == rhs.ty() => append_data(lhs, rhs),
+ // A type clash keeps the left side and zero-fills: the
+ // alternative is dropping one side's values entirely, and a
+ // merge is not the place to decide which side is right.
+ Some(lhs) => lhs.resize(lhs_len + rhs_len),
+ None => {
+ let mut fresh = AttribData::zeroed(rhs.ty(), lhs_len);
+ append_data(&mut fresh, rhs);
+ self.attribs.insert(name.clone(), fresh);
+ }
+ }
+ }
+ for (_, lhs) in self.attribs.iter_mut().filter(|(n, _)| !other.attribs.contains_key(*n)) {
+ lhs.resize(lhs_len + rhs_len);
+ }
+
+ for (name, rhs) in &other.groups {
+ let lhs = self
+ .groups
+ .entry(name.clone())
+ .or_insert_with(|| vec![false; lhs_len]);
+ lhs.extend_from_slice(rhs);
+ }
+ for (_, lhs) in self.groups.iter_mut().filter(|(n, _)| !other.groups.contains_key(*n)) {
+ lhs.resize(lhs_len + rhs_len, false);
+ }
+
+ self.len = lhs_len + rhs_len;
+ }
+}
+
+fn append_data(lhs: &mut AttribData, rhs: &AttribData) {
+ match (lhs, rhs) {
+ (AttribData::Float(a), AttribData::Float(b)) => a.extend_from_slice(b),
+ (AttribData::Float2(a), AttribData::Float2(b)) => a.extend_from_slice(b),
+ (AttribData::Float3(a), AttribData::Float3(b)) => a.extend_from_slice(b),
+ (AttribData::Float4(a), AttribData::Float4(b)) => a.extend_from_slice(b),
+ (AttribData::Int(a), AttribData::Int(b)) => a.extend_from_slice(b),
+ (lhs, rhs) => lhs.resize(lhs.len() + rhs.len()),
+ }
+}
+
+/// Derived connectivity: which primitives use a point, which points share an
+/// edge with it, and the unique edge list.
+///
+/// Built on demand by [`Detail::topology`] and dropped by any structural edit.
+/// Everything is CSR — a start-offset array indexed by point, plus one flat
+/// array of contents — so a neighbour walk is a slice, not an allocation, and
+/// the whole thing uploads to a GPU buffer unchanged in Phase 1.
+#[derive(Clone, Debug, Default)]
+pub struct Topology {
+ point_prim_start: Vec<u32>,
+ point_prim: Vec<u32>,
+ point_nbr_start: Vec<u32>,
+ point_nbr: Vec<u32>,
+ edges: Vec<[u32; 2]>,
+}
+
+impl Topology {
+ /// The primitives using point `p`, ascending.
+ pub fn point_prims(&self, p: usize) -> &[u32] {
+ Self::span(&self.point_prim_start, &self.point_prim, p)
+ }
+
+ /// The points sharing an edge with point `p`, ascending and deduplicated.
+ pub fn point_neighbours(&self, p: usize) -> &[u32] {
+ Self::span(&self.point_nbr_start, &self.point_nbr, p)
+ }
+
+ /// Every unique undirected edge, each as `[low, high]`.
+ pub fn edges(&self) -> &[[u32; 2]] {
+ &self.edges
+ }
+
+ /// How many edges meet at point `p` — Houdini's valence, and the number
+ /// incremental remeshing steers toward 6.
+ pub fn valence(&self, p: usize) -> usize {
+ self.point_neighbours(p).len()
+ }
+
+ fn span<'a>(start: &[u32], flat: &'a [u32], i: usize) -> &'a [u32] {
+ if i + 1 >= start.len() {
+ return &[];
+ }
+ let (a, b) = (start[i] as usize, start[i + 1] as usize);
+ flat.get(a..b).unwrap_or(&[])
+ }
+
+ fn build(num_points: usize, vert_point: &[u32], prim_start: &[u32]) -> Topology {
+ let num_prims = prim_start.len().saturating_sub(1);
+
+ // point -> prims, by counting sort: one pass to count, a prefix sum,
+ // then one pass to place. A point appearing twice in one primitive
+ // (a degenerate fan) is counted once.
+ let mut counts = vec![0u32; num_points + 1];
+ let mut seen: Vec<u32> = Vec::new();
+ for prim in 0..num_prims {
+ seen.clear();
+ for &pt in &vert_point[prim_start[prim] as usize..prim_start[prim + 1] as usize] {
+ if !seen.contains(&pt) {
+ seen.push(pt);
+ if (pt as usize) < num_points {
+ counts[pt as usize] += 1;
+ }
+ }
+ }
+ }
+ let mut point_prim_start = vec![0u32; num_points + 1];
+ let mut acc = 0u32;
+ for p in 0..num_points {
+ point_prim_start[p] = acc;
+ acc += counts[p];
+ }
+ point_prim_start[num_points] = acc;
+
+ let mut cursor = point_prim_start.clone();
+ let mut point_prim = vec![0u32; acc as usize];
+ for prim in 0..num_prims {
+ seen.clear();
+ for &pt in &vert_point[prim_start[prim] as usize..prim_start[prim + 1] as usize] {
+ if !seen.contains(&pt) {
+ seen.push(pt);
+ if (pt as usize) < num_points {
+ point_prim[cursor[pt as usize] as usize] = prim as u32;
+ cursor[pt as usize] += 1;
+ }
+ }
+ }
+ }
+
+ // Edges: every consecutive pair around each primitive, closing the
+ // loop. A two-point primitive (an open line segment) contributes one
+ // edge, not two — closing it would invent a neighbour.
+ let mut edges: Vec<[u32; 2]> = Vec::new();
+ for prim in 0..num_prims {
+ let pts = &vert_point[prim_start[prim] as usize..prim_start[prim + 1] as usize];
+ let n = pts.len();
+ if n < 2 {
+ continue;
+ }
+ let span = if n == 2 { 1 } else { n };
+ for i in 0..span {
+ let (a, b) = (pts[i], pts[(i + 1) % n]);
+ if a == b {
+ continue;
+ }
+ edges.push([a.min(b), a.max(b)]);
+ }
+ }
+ edges.sort_unstable();
+ edges.dedup();
+
+ // point -> neighbours, from the deduplicated edge list. Both endpoints
+ // of every edge, counting-sorted the same way.
+ let mut counts = vec![0u32; num_points];
+ for e in &edges {
+ for &p in e {
+ if (p as usize) < num_points {
+ counts[p as usize] += 1;
+ }
+ }
+ }
+ let mut point_nbr_start = vec![0u32; num_points + 1];
+ let mut acc = 0u32;
+ for p in 0..num_points {
+ point_nbr_start[p] = acc;
+ acc += counts[p];
+ }
+ point_nbr_start[num_points] = acc;
+
+ let mut cursor = point_nbr_start.clone();
+ let mut point_nbr = vec![0u32; acc as usize];
+ for e in &edges {
+ let (a, b) = (e[0], e[1]);
+ if (a as usize) < num_points {
+ point_nbr[cursor[a as usize] as usize] = b;
+ cursor[a as usize] += 1;
+ }
+ if (b as usize) < num_points {
+ point_nbr[cursor[b as usize] as usize] = a;
+ cursor[b as usize] += 1;
+ }
+ }
+ for p in 0..num_points {
+ let (a, b) = (point_nbr_start[p] as usize, point_nbr_start[p + 1] as usize);
+ point_nbr[a..b].sort_unstable();
+ }
+
+ Topology { point_prim_start, point_prim, point_nbr_start, point_nbr, edges }
+ }
+}
+
+/// Points, vertices, primitives and detail — one piece of geometry.
+///
+/// See the module docs. Position and [`PointId`] get dedicated fields rather
+/// than living in the point attribute store: every operator touches both, and
+/// neither should cost a name lookup or be removable.
+#[derive(Debug)]
+pub struct Detail {
+ pos: Vec<[f32; 3]>,
+ ids: Vec<PointId>,
+ next_id: PointId,
+ points: AttribStore,
+ /// One entry per vertex: the point it references. Primitives index into
+ /// this array through `prim_start`.
+ vert_point: Vec<u32>,
+ verts: AttribStore,
+ /// CSR offsets into `vert_point`, one per primitive plus a trailing total.
+ /// Always non-empty: a geometry with no primitives still has `[0]`.
+ prim_start: Vec<u32>,
+ prims: AttribStore,
+ detail: AttribStore,
+ topo: OnceLock<Topology>,
+}
+
+impl Default for Detail {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Clone for Detail {
+ /// The topology cache is deliberately *not* cloned. It is derived, the
+ /// clone exists to be modified, and rebuilding is cheaper than reasoning
+ /// about whether a stale cache came along.
+ fn clone(&self) -> Self {
+ Self {
+ pos: self.pos.clone(),
+ ids: self.ids.clone(),
+ next_id: self.next_id,
+ points: self.points.clone(),
+ vert_point: self.vert_point.clone(),
+ verts: self.verts.clone(),
+ prim_start: self.prim_start.clone(),
+ prims: self.prims.clone(),
+ detail: self.detail.clone(),
+ topo: OnceLock::new(),
+ }
+ }
+}
+
+impl Detail {
+ pub fn new() -> Self {
+ Self {
+ pos: Vec::new(),
+ ids: Vec::new(),
+ next_id: 0,
+ points: AttribStore::default(),
+ vert_point: Vec::new(),
+ verts: AttribStore::default(),
+ prim_start: vec![0],
+ prims: AttribStore::default(),
+ detail: AttribStore::with_len(1),
+ topo: OnceLock::new(),
+ }
+ }
+
+ // ---- counts ----
+
+ pub fn num_points(&self) -> usize {
+ self.pos.len()
+ }
+
+ pub fn num_verts(&self) -> usize {
+ self.vert_point.len()
+ }
+
+ pub fn num_prims(&self) -> usize {
+ self.prim_start.len() - 1
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.pos.is_empty()
+ }
+
+ // ---- attribute stores ----
+
+ pub fn points(&self) -> &AttribStore {
+ &self.points
+ }
+
+ pub fn points_mut(&mut self) -> &mut AttribStore {
+ &mut self.points
+ }
+
+ pub fn verts(&self) -> &AttribStore {
+ &self.verts
+ }
+
+ pub fn verts_mut(&mut self) -> &mut AttribStore {
+ &mut self.verts
+ }
+
+ pub fn prims(&self) -> &AttribStore {
+ &self.prims
+ }
+
+ pub fn prims_mut(&mut self) -> &mut AttribStore {
+ &mut self.prims
+ }
+
+ /// The single-row detail store — where whole-geometry values live.
+ pub fn detail(&self) -> &AttribStore {
+ &self.detail
+ }
+
+ pub fn detail_mut(&mut self) -> &mut AttribStore {
+ &mut self.detail
+ }
+
+ pub fn store(&self, class: Class) -> &AttribStore {
+ match class {
+ Class::Point => &self.points,
+ Class::Vertex => &self.verts,
+ Class::Prim => &self.prims,
+ Class::Detail => &self.detail,
+ }
+ }
+
+ pub fn store_mut(&mut self, class: Class) -> &mut AttribStore {
+ match class {
+ Class::Point => &mut self.points,
+ Class::Vertex => &mut self.verts,
+ Class::Prim => &mut self.prims,
+ Class::Detail => &mut self.detail,
+ }
+ }
+
+ // ---- points ----
+
+ pub fn pos(&self, p: usize) -> Vec3 {
+ self.pos.get(p).map(|v| Vec3::from(*v)).unwrap_or(Vec3::ZERO)
+ }
+
+ pub fn set_pos(&mut self, p: usize, v: Vec3) {
+ if let Some(slot) = self.pos.get_mut(p) {
+ *slot = v.to_array();
+ }
+ }
+
+ /// Every position, flat — the slice a GPU buffer takes directly.
+ pub fn positions(&self) -> &[[f32; 3]] {
+ &self.pos
+ }
+
+ /// Positions for in-place editing. Moving points does not change topology,
+ /// so the cache survives; a caller that adds or removes points must go
+ /// through [`Detail::add_point`] or [`Detail::gather_points`] instead.
+ pub fn positions_mut(&mut self) -> &mut [[f32; 3]] {
+ &mut self.pos
+ }
+
+ /// The stable identity of point `p`.
+ pub fn id(&self, p: usize) -> Option<PointId> {
+ self.ids.get(p).copied()
+ }
+
+ pub fn ids(&self) -> &[PointId] {
+ &self.ids
+ }
+
+ /// Where the point carrying `id` currently sits, or `None` if it is gone.
+ /// Linear; a solver resolving many ids at once should build a map with
+ /// [`Detail::id_map`] instead.
+ pub fn index_of_id(&self, id: PointId) -> Option<usize> {
+ self.ids.iter().position(|&i| i == id)
+ }
+
+ /// Identity to index, for the solver path that reconciles two frames.
+ pub fn id_map(&self) -> HashMap<PointId, u32> {
+ self.ids
+ .iter()
+ .enumerate()
+ .map(|(i, &id)| (id, i as u32))
+ .collect()
+ }
+
+ /// Add a point at `pos`, assigning it a fresh identity. Returns its index.
+ pub fn add_point(&mut self, pos: Vec3) -> u32 {
+ let idx = self.pos.len() as u32;
+ self.pos.push(pos.to_array());
+ self.ids.push(self.next_id);
+ self.next_id += 1;
+ self.points.push_element();
+ self.invalidate();
+ idx
+ }
+
+ /// Add `n` points at once, which is what a generator does. Returns the
+ /// index of the first.
+ pub fn add_points(&mut self, positions: &[[f32; 3]]) -> u32 {
+ let first = self.pos.len() as u32;
+ self.pos.extend_from_slice(positions);
+ for _ in 0..positions.len() {
+ self.ids.push(self.next_id);
+ self.next_id += 1;
+ }
+ self.points.set_len(self.pos.len());
+ self.invalidate();
+ first
+ }
+
+ // ---- primitives ----
+
+ /// Add a primitive over the given points, in winding order. One vertex per
+ /// entry. Returns the primitive index.
+ pub fn add_prim(&mut self, points: &[u32]) -> u32 {
+ let idx = self.num_prims() as u32;
+ self.vert_point.extend_from_slice(points);
+ self.prim_start.push(self.vert_point.len() as u32);
+ self.verts.set_len(self.vert_point.len());
+ self.prims.push_element();
+ self.invalidate();
+ idx
+ }
+
+ /// The vertex indices of primitive `p`.
+ pub fn prim_verts(&self, p: usize) -> std::ops::Range<usize> {
+ if p + 1 >= self.prim_start.len() {
+ return 0..0;
+ }
+ self.prim_start[p] as usize..self.prim_start[p + 1] as usize
+ }
+
+ /// The points of primitive `p`, in winding order.
+ pub fn prim_points(&self, p: usize) -> &[u32] {
+ let r = self.prim_verts(p);
+ self.vert_point.get(r).unwrap_or(&[])
+ }
+
+ /// The point a vertex references.
+ pub fn vert_point(&self, v: usize) -> Option<u32> {
+ self.vert_point.get(v).copied()
+ }
+
+ pub fn vert_points(&self) -> &[u32] {
+ &self.vert_point
+ }
+
+ // ---- topology ----
+
+ /// Connectivity, built on first ask and reused until a structural edit
+ /// drops it.
+ pub fn topology(&self) -> &Topology {
+ self.topo
+ .get_or_init(|| Topology::build(self.num_points(), &self.vert_point, &self.prim_start))
+ }
+
+ /// The points sharing an edge with point `p`.
+ pub fn point_neighbours(&self, p: usize) -> &[u32] {
+ self.topology().point_neighbours(p)
+ }
+
+ /// The primitives using point `p`.
+ pub fn point_prims(&self, p: usize) -> &[u32] {
+ self.topology().point_prims(p)
+ }
+
+ /// Every unique undirected edge.
+ pub fn edges(&self) -> &[[u32; 2]] {
+ self.topology().edges()
+ }
+
+ /// Drop the derived topology. Called by every structural edit; public
+ /// because an operator writing `vert_point` through a future bulk path
+ /// must be able to say so.
+ pub fn invalidate(&mut self) {
+ self.topo.take();
+ }
+
+ // ---- bulk edits ----
+
+ /// Rebuild around a new point order: point `n` of the result is point
+ /// `idx[n]` of this one. Identities, positions, point attributes and point
+ /// groups all follow. Primitives are rewired through the inverse map, and
+ /// any primitive referencing a dropped point is dropped with it — a
+ /// half-referenced polygon is not geometry.
+ pub fn gather_points(&mut self, idx: &[u32]) {
+ let mut inverse = vec![u32::MAX; self.num_points()];
+ for (new, &old) in idx.iter().enumerate() {
+ if let Some(slot) = inverse.get_mut(old as usize) {
+ // A point appearing twice keeps its first landing place; the
+ // duplicate still exists, it is simply not what primitives
+ // point at.
+ if *slot == u32::MAX {
+ *slot = new as u32;
+ }
+ }
+ }
+
+ self.pos = idx
+ .iter()
+ .map(|&i| self.pos.get(i as usize).copied().unwrap_or([0.0; 3]))
+ .collect();
+ self.ids = idx
+ .iter()
+ .map(|&i| self.ids.get(i as usize).copied().unwrap_or(0))
+ .collect();
+ self.points = self.points.gather(idx);
+
+ let mut vert_point = Vec::with_capacity(self.vert_point.len());
+ let mut prim_start = vec![0u32];
+ let mut kept_prims: Vec<u32> = Vec::new();
+ let mut kept_verts: Vec<u32> = Vec::new();
+ for prim in 0..self.num_prims() {
+ let range = self.prim_verts(prim);
+ let survives = self.vert_point[range.clone()]
+ .iter()
+ .all(|&pt| inverse.get(pt as usize).copied().unwrap_or(u32::MAX) != u32::MAX);
+ if !survives {
+ continue;
+ }
+ for v in range {
+ kept_verts.push(v as u32);
+ vert_point.push(inverse[self.vert_point[v] as usize]);
+ }
+ prim_start.push(vert_point.len() as u32);
+ kept_prims.push(prim as u32);
+ }
+
+ self.verts = self.verts.gather(&kept_verts);
+ self.prims = self.prims.gather(&kept_prims);
+ self.vert_point = vert_point;
+ self.prim_start = prim_start;
+ self.invalidate();
+ }
+
+ /// Keep the points `keep` marks true, dropping the rest.
+ pub fn keep_points(&mut self, keep: &[bool]) {
+ let idx: Vec<u32> = (0..self.num_points() as u32)
+ .filter(|&i| keep.get(i as usize).copied().unwrap_or(false))
+ .collect();
+ self.gather_points(&idx);
+ }
+
+ /// Append `other`. Identities are reallocated on the way in, so two pieces
+ /// of geometry that were generated independently — and therefore both
+ /// number their points from zero — do not collide.
+ pub fn merge(&mut self, other: &Detail) {
+ let point_offset = self.num_points() as u32;
+ let vert_offset = self.vert_point.len() as u32;
+
+ self.pos.extend_from_slice(&other.pos);
+ for _ in 0..other.num_points() {
+ self.ids.push(self.next_id);
+ self.next_id += 1;
+ }
+ self.points.append(&other.points);
+
+ self.vert_point
+ .extend(other.vert_point.iter().map(|&p| p + point_offset));
+ self.verts.append(&other.verts);
+
+ for w in other.prim_start.iter().skip(1) {
+ self.prim_start.push(w + vert_offset);
+ }
+ self.prims.append(&other.prims);
+
+ self.invalidate();
+ }
+
+ // ---- convenience ----
+
+ /// Point color, from `Cd` where it exists.
+ pub fn color(&self, p: usize) -> [f32; 3] {
+ match self.points.value(CD, p) {
+ Some(AttribValue::Float3(c)) => c,
+ Some(other) => other.as_vec3().to_array(),
+ None => DEFAULT_COLOR,
+ }
+ }
+
+ /// Set point color, creating `Cd` if this is the first writer.
+ pub fn set_color(&mut self, p: usize, c: [f32; 3]) {
+ self.points
+ .get_or_create(CD, AttribValue::Float3(DEFAULT_COLOR));
+ let _ = self.points.set_value(CD, p, AttribValue::Float3(c));
+ }
+
+ /// The axis-aligned bounds, or `None` when there are no points.
+ pub fn bounds(&self) -> Option<(Vec3, Vec3)> {
+ let first = *self.pos.first()?;
+ let (mut lo, mut hi) = (Vec3::from(first), Vec3::from(first));
+ for p in &self.pos[1..] {
+ let v = Vec3::from(*p);
+ lo = lo.min(v);
+ hi = hi.max(v);
+ }
+ Some((lo, hi))
+ }
+
+ /// Weld a triangle soup into points and triangles: coincident positions
+ /// become one point, every three positions become one primitive.
+ ///
+ /// The migration path for generators that still emit soup, and a faithful
+ /// port of `geometry::weld_points` — including its 1e-4 quantization, so
+ /// that vertices a kernel emitted from the same formula weld reliably.
+ /// Color is carried onto `Cd`, taking the first copy of each welded point.
+ pub fn from_triangle_soup(positions: &[[f32; 3]], colors: &[[f32; 3]]) -> Detail {
+ let mut detail = Detail::new();
+ let mut key_to_point: HashMap<(i64, i64, i64), u32> = HashMap::new();
+ let mut point_of: Vec<u32> = Vec::with_capacity(positions.len());
+ let mut cd: Vec<[f32; 3]> = Vec::new();
+
+ for (i, p) in positions.iter().enumerate() {
+ let key = (
+ (p[0] as f64 * 1e4).round() as i64,
+ (p[1] as f64 * 1e4).round() as i64,
+ (p[2] as f64 * 1e4).round() as i64,
+ );
+ let idx = match key_to_point.get(&key) {
+ Some(&idx) => idx,
+ None => {
+ let idx = detail.add_point(Vec3::from(*p));
+ key_to_point.insert(key, idx);
+ cd.push(colors.get(i).copied().unwrap_or(DEFAULT_COLOR));
+ idx
+ }
+ };
+ point_of.push(idx);
+ }
+
+ for tri in point_of.chunks_exact(3) {
+ detail.add_prim(tri);
+ }
+
+ if !cd.is_empty() {
+ detail
+ .points
+ .attribs
+ .insert(CD.to_string(), AttribData::Float3(cd));
+ }
+ detail
+ }
+
+ /// Fan-triangulate every primitive, handing each corner to `make` as
+ /// (position, color).
+ ///
+ /// The render boundary. It takes a closure rather than returning the
+ /// renderer's vertex type so that this module stays free of anything that
+ /// draws — the caller in `geometry.rs` supplies `Vertex3D`.
+ pub fn triangulate<V>(&self, mut make: impl FnMut([f32; 3], [f32; 3]) -> V) -> Vec<V> {
+ let mut out = Vec::new();
+ for prim in 0..self.num_prims() {
+ let pts = self.prim_points(prim);
+ if pts.len() < 3 {
+ continue;
+ }
+ for i in 1..pts.len() - 1 {
+ for &p in &[pts[0], pts[i], pts[i + 1]] {
+ let p = p as usize;
+ out.push(make(
+ self.pos.get(p).copied().unwrap_or([0.0; 3]),
+ self.color(p),
+ ));
+ }
+ }
+ }
+ out
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index c3dd743..64b4643 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,6 +2,7 @@
pub mod app;
pub mod application;
pub mod curve_tool;
+pub mod detail;
// Root-level aliases some modules import via `crate::` paths.
#[allow(unused_imports)]
@@ -70,6 +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};
/// The choosers open in the loaded project's parent — the "current view" —
/// and fall back to cce-files' remembered location only when nothing is
@@ -3342,5 +3344,373 @@ mod tests {
.unwrap_or_else(|e| panic!("tool '{}' does not map to an McpAction: {e}", tool.name));
}
}
-}
+ // ---- Phase 0: the points/vertices/primitives/detail container ----
+ //
+ // These cover what the triangle soup could not do at all: a point shared
+ // by several primitives, an edge, an identity that survives an edit, and
+ // attributes that follow their elements through one. See src/detail.rs.
+
+ /// A 3x3 grid of points wired into four quads — the smallest mesh with an
+ /// interior point, which is the only kind of point neighbour queries are
+ /// interesting on.
+ ///
+ /// ```text
+ /// 6 — 7 — 8
+ /// | | |
+ /// 3 — 4 — 5
+ /// | | |
+ /// 0 — 1 — 2
+ /// ```
+ fn quad_grid() -> Detail {
+ let mut d = Detail::new();
+ for y in 0..3 {
+ for x in 0..3 {
+ d.add_point(Vec3::new(x as f32, y as f32, 0.0));
+ }
+ }
+ for quad in [[0, 1, 4, 3], [1, 2, 5, 4], [3, 4, 7, 6], [4, 5, 8, 7]] {
+ d.add_prim(&quad);
+ }
+ d
+ }
+
+ #[test]
+ fn test_detail_counts_points_verts_and_prims_separately() {
+ let d = quad_grid();
+ assert_eq!(d.num_points(), 9, "nine points, each shared by up to four quads");
+ assert_eq!(d.num_verts(), 16, "four quads of four corners");
+ assert_eq!(d.num_prims(), 4);
+ // The soup would have needed 24 vertices for the same surface and
+ // would have had no way to say that the center is one place.
+ assert_eq!(d.prim_points(0), &[0, 1, 4, 3]);
+ assert_eq!(d.prim_points(3), &[4, 5, 8, 7]);
+ assert!(d.prim_points(4).is_empty(), "no fifth primitive");
+ }
+
+ #[test]
+ fn test_detail_point_ids_are_unique_and_survive_a_delete() {
+ let mut d = quad_grid();
+ let before: Vec<_> = (0..d.num_points()).map(|p| d.id(p).unwrap()).collect();
+ let mut sorted = before.clone();
+ sorted.sort_unstable();
+ sorted.dedup();
+ assert_eq!(sorted.len(), before.len(), "every point has its own identity");
+
+ // Drop the top row. The survivors keep the identity they were born
+ // with even though their indices moved — this is the property a
+ // positional weld can never provide, and the one a solver needs.
+ let keep: Vec<bool> = (0..9).map(|i| i < 6).collect();
+ d.keep_points(&keep);
+ assert_eq!(d.num_points(), 6);
+ for p in 0..d.num_points() {
+ assert_eq!(d.id(p), Some(before[p]));
+ }
+ assert_eq!(d.index_of_id(before[5]), Some(5));
+ assert_eq!(d.index_of_id(before[8]), None, "a deleted point resolves to nothing");
+ }
+
+ #[test]
+ fn test_detail_topology_neighbours_exclude_diagonals() {
+ let d = quad_grid();
+ // The center point touches all four quads but only four points: a
+ // quad's diagonal is not an edge.
+ assert_eq!(d.point_neighbours(4), &[1, 3, 5, 7]);
+ assert_eq!(d.topology().valence(4), 4);
+ assert_eq!(d.point_prims(4).len(), 4);
+ // A corner touches one quad and two points.
+ assert_eq!(d.point_neighbours(0), &[1, 3]);
+ assert_eq!(d.point_prims(0), &[0]);
+ }
+
+ #[test]
+ fn test_detail_topology_counts_a_shared_edge_once() {
+ let d = quad_grid();
+ // 12 rather than 16: the four interior edges are each shared by two
+ // quads, and an edge list that double-counted them would double every
+ // force a solver puts along one.
+ assert_eq!(d.edges().len(), 12);
+ let mut seen = d.edges().to_vec();
+ seen.sort_unstable();
+ seen.dedup();
+ assert_eq!(seen.len(), 12);
+ assert!(d.edges().iter().all(|e| e[0] < e[1]), "edges are stored low-to-high");
+ }
+
+ #[test]
+ fn test_detail_topology_rebuilds_after_a_structural_edit() {
+ let mut d = quad_grid();
+ assert_eq!(d.topology().valence(8), 2, "the far corner starts with two edges");
+
+ // Ask for topology, then change the structure. The cached answer must
+ // not survive — a stale neighbour list is a silently wrong simulation,
+ // not a crash.
+ let p = d.add_point(Vec3::new(3.0, 3.0, 0.0));
+ d.add_prim(&[8, p, 5]);
+ assert_eq!(d.num_prims(), 5);
+ // One more edge, not two: the new triangle's third side (5–8) is the
+ // grid edge that was already there, and must not be counted twice.
+ assert_eq!(d.topology().valence(8), 3);
+ assert_eq!(d.edges().len(), 14);
+ assert_eq!(d.point_neighbours(p as usize), &[5, 8]);
+ }
+
+ #[test]
+ fn test_detail_line_primitive_does_not_close_into_a_loop() {
+ let mut d = Detail::new();
+ let a = d.add_point(Vec3::ZERO);
+ let b = d.add_point(Vec3::X);
+ d.add_prim(&[a, b]);
+ // A two-point primitive is an open segment. Closing the winding would
+ // invent a second edge between the same pair and give both ends a
+ // neighbour they do not have.
+ assert_eq!(d.edges(), &[[0, 1]]);
+ assert_eq!(d.point_neighbours(0), &[1]);
+ }
+
+ #[test]
+ fn test_detail_welds_a_triangle_soup_into_shared_points() {
+ // Two triangles meeting along one edge — six soup corners, four
+ // points.
+ let positions = [
+ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0],
+ [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0],
+ ];
+ let colors = [[1.0, 0.0, 0.0]; 6];
+ let d = Detail::from_triangle_soup(&positions, &colors);
+
+ assert_eq!(d.num_points(), 4, "the shared edge's two corners weld");
+ assert_eq!(d.num_prims(), 2);
+ assert_eq!(d.num_verts(), 6, "vertices still name a corner each");
+ assert_eq!(d.edges().len(), 5, "three edges each, one of them shared");
+ assert_eq!(d.color(0), [1.0, 0.0, 0.0], "color carries onto Cd");
+ }
+
+ #[test]
+ fn test_detail_triangulate_fans_polygons_for_the_renderer() {
+ let mut d = Detail::new();
+ for p in [Vec3::ZERO, Vec3::X, Vec3::new(1.0, 1.0, 0.0), Vec3::Y] {
+ d.add_point(p);
+ }
+ d.add_prim(&[0, 1, 2, 3]);
+
+ let tris = d.triangulate(|pos, col| (pos, col));
+ assert_eq!(tris.len(), 6, "one quad fans into two triangles");
+ assert_eq!(tris[0].0, [0.0, 0.0, 0.0]);
+ assert_eq!(tris[1].1, crate::detail::DEFAULT_COLOR, "no Cd means the default");
+
+ // Soup in, soup out: the round trip preserves the surface even though
+ // the middle of it is no longer a soup.
+ let soup: Vec<[f32; 3]> = vec![
+ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0],
+ [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0],
+ ];
+ let welded = Detail::from_triangle_soup(&soup, &[[0.5; 3]; 6]);
+ let back = welded.triangulate(|pos, _| pos);
+ assert_eq!(back, soup);
+ }
+
+ #[test]
+ fn test_detail_attributes_are_columnar_and_typed() {
+ let mut d = quad_grid();
+ d.points_mut().create("mass", AttribValue::Float(1.0));
+ assert_eq!(d.points().get("mass").map(|a| a.len()), Some(9), "one entry per point");
+ assert_eq!(d.points().get("mass").map(|a| a.ty()), Some(AttribType::Float));
+
+ d.points_mut().set_value("mass", 4, AttribValue::Float(7.5)).unwrap();
+ assert_eq!(d.points().value("mass", 4), Some(AttribValue::Float(7.5)));
+ assert_eq!(d.points().value("mass", 0), Some(AttribValue::Float(1.0)));
+
+ // A type mismatch is refused rather than silently coerced: half a
+ // converted attribute is not a state this should be able to reach.
+ let err = d
+ .points_mut()
+ .set_value("mass", 0, AttribValue::Float3([1.0; 3]))
+ .unwrap_err();
+ assert!(err.contains("float"), "{err}");
+
+ // Integers exist for counters and ages, which must stay whole.
+ d.points_mut().create("age", AttribValue::Int(0));
+ d.points_mut().set_value("age", 2, AttribValue::Int(3)).unwrap();
+ assert_eq!(d.points().value("age", 2), Some(AttribValue::Int(3)));
+ assert_eq!(d.points().names(), vec!["age", "mass"], "sorted, so columns hold still");
+
+ // The float view is what a GPU buffer binds in Phase 1.
+ let flat = d.points().get("mass").unwrap().as_f32_slice().unwrap();
+ assert_eq!(flat.len(), 9);
+ assert!(d.points().get("age").unwrap().as_f32_slice().is_none());
+ }
+
+ #[test]
+ fn test_detail_attribute_values_follow_their_points_through_a_delete() {
+ let mut d = quad_grid();
+ d.points_mut().create("mass", AttribValue::Float(0.0));
+ for p in 0..9 {
+ d.points_mut()
+ .set_value("mass", p, AttribValue::Float(p as f32))
+ .unwrap();
+ }
+
+ // Keep the middle row only. Values must move with their points, not
+ // stay at their old indices.
+ let keep: Vec<bool> = (0..9).map(|i| (3..6).contains(&i)).collect();
+ d.keep_points(&keep);
+ assert_eq!(d.num_points(), 3);
+ let masses: Vec<f32> = (0..3)
+ .map(|p| d.points().value("mass", p).unwrap().as_f32())
+ .collect();
+ assert_eq!(masses, vec![3.0, 4.0, 5.0]);
+ }
+
+ #[test]
+ fn test_detail_dropping_a_point_drops_the_primitives_using_it() {
+ let mut d = quad_grid();
+ // The center point belongs to every quad, so removing it removes all
+ // four: a polygon missing a corner is not geometry.
+ let keep: Vec<bool> = (0..9).map(|i| i != 4).collect();
+ d.keep_points(&keep);
+ assert_eq!(d.num_points(), 8);
+ assert_eq!(d.num_prims(), 0);
+ assert_eq!(d.num_verts(), 0);
+ assert_eq!(d.edges().len(), 0);
+ }
+
+ #[test]
+ fn test_detail_gather_rewires_surviving_primitives() {
+ let mut d = quad_grid();
+ // Keep the bottom-left quad's four points. Its primitive survives and
+ // must now name the points by their new indices.
+ let keep: Vec<bool> = (0..9).map(|i| [0, 1, 3, 4].contains(&i)).collect();
+ d.keep_points(&keep);
+ assert_eq!(d.num_points(), 4);
+ assert_eq!(d.num_prims(), 1);
+ assert_eq!(d.prim_points(0), &[0, 1, 3, 2], "0,1,4,3 renumbered");
+ assert_eq!(d.edges().len(), 4);
+ }
+
+ #[test]
+ fn test_detail_groups_are_named_sets_that_survive_an_edit() {
+ let mut d = quad_grid();
+ d.points_mut().create_group("pinned");
+ for p in [0, 2, 6, 8] {
+ d.points_mut().add_to_group("pinned", p);
+ }
+ assert_eq!(d.points().group_members("pinned"), vec![0, 2, 6, 8]);
+ assert_eq!(d.points().group_len("pinned"), 4);
+ assert!(d.points().in_group("pinned", 8));
+ assert!(!d.points().in_group("pinned", 4));
+ // Asking about a group nobody made is not an error — a node's Group
+ // parameter is routinely blank.
+ assert_eq!(d.points().group_members("nope"), Vec::<u32>::new());
+
+ let keep: Vec<bool> = (0..9).map(|i| i < 6).collect();
+ d.keep_points(&keep);
+ assert_eq!(d.points().group_members("pinned"), vec![0, 2], "membership follows");
+ assert_eq!(d.points().group_names(), vec!["pinned"]);
+ }
+
+ #[test]
+ fn test_detail_merge_reallocates_ids_and_rewires_primitives() {
+ let mut a = quad_grid();
+ let b = quad_grid();
+ let a_ids: Vec<_> = (0..a.num_points()).map(|p| a.id(p).unwrap()).collect();
+
+ a.merge(&b);
+ assert_eq!(a.num_points(), 18);
+ assert_eq!(a.num_prims(), 8);
+ assert_eq!(a.num_verts(), 32);
+
+ // Both sides numbered their points from zero. If the merge kept those
+ // numbers, two different points would answer to one identity and a
+ // solver would write one over the other.
+ let all: Vec<_> = (0..a.num_points()).map(|p| a.id(p).unwrap()).collect();
+ let mut uniq = all.clone();
+ uniq.sort_unstable();
+ uniq.dedup();
+ assert_eq!(uniq.len(), 18, "no identity collides across the merge");
+ assert_eq!(&all[..9], &a_ids[..], "the left side keeps the ids it had");
+
+ // The appended primitives point at the appended points.
+ assert_eq!(a.prim_points(4), &[9, 10, 13, 12]);
+ assert_eq!(a.edges().len(), 24, "two grids, no edges invented between them");
+ }
+
+ #[test]
+ fn test_detail_merge_unions_attributes_and_zero_fills_the_gap() {
+ let mut a = quad_grid();
+ a.points_mut().create("mass", AttribValue::Float(2.0));
+ let mut b = quad_grid();
+ b.points_mut().create("age", AttribValue::Int(5));
+
+ a.merge(&b);
+ // Neither column is dropped; each side gets zeros where it had no
+ // opinion. Silently losing a column here would strand a solver
+ // attribute the moment two streams met.
+ assert_eq!(a.points().names(), vec!["age", "mass"]);
+ assert_eq!(a.points().value("mass", 0), Some(AttribValue::Float(2.0)));
+ assert_eq!(a.points().value("mass", 9), Some(AttribValue::Float(0.0)));
+ assert_eq!(a.points().value("age", 0), Some(AttribValue::Int(0)));
+ assert_eq!(a.points().value("age", 9), Some(AttribValue::Int(5)));
+ assert_eq!(a.points().get("mass").map(|x| x.len()), Some(18));
+ }
+
+ #[test]
+ fn test_detail_holds_per_class_attributes_including_one_detail_row() {
+ let mut d = quad_grid();
+ d.verts_mut().create("uv", AttribValue::Float2([0.0; 2]));
+ d.prims_mut().create("area", AttribValue::Float(1.0));
+ d.detail_mut().create("edges_max", AttribValue::Float(0.0));
+
+ assert_eq!(d.store(Class::Vertex).len(), 16);
+ assert_eq!(d.store(Class::Prim).len(), 4);
+ assert_eq!(d.store(Class::Detail).len(), 1, "detail is the single-row class");
+
+ // Analysis writes a range here rather than needing a dictionary type.
+ d.detail_mut()
+ .set_value("edges_max", 0, AttribValue::Float(1.0))
+ .unwrap();
+ assert_eq!(d.detail().value("edges_max", 0), Some(AttribValue::Float(1.0)));
+ assert_eq!(Class::Detail.name(), "detail");
+ }
+
+ #[test]
+ fn test_detail_attribute_gather_is_the_one_reordering_primitive() {
+ let data = AttribData::Float(vec![10.0, 20.0, 30.0]);
+ // Delete, reorder and duplicate are all the same operation.
+ assert_eq!(data.gather(&[2, 0]), AttribData::Float(vec![30.0, 10.0]));
+ assert_eq!(data.gather(&[1, 1]), AttribData::Float(vec![20.0, 20.0]));
+ // An out-of-range index yields a zero rather than panicking: an index
+ // map built with an arithmetic slip should not be able to take the app
+ // down.
+ assert_eq!(data.gather(&[9]), AttribData::Float(vec![0.0]));
+ }
+
+ #[test]
+ fn test_detail_color_and_bounds_read_back() {
+ let mut d = quad_grid();
+ assert_eq!(d.color(0), crate::detail::DEFAULT_COLOR);
+ d.set_color(0, [0.25, 0.5, 0.75]);
+ assert_eq!(d.color(0), [0.25, 0.5, 0.75]);
+ assert_eq!(d.color(1), crate::detail::DEFAULT_COLOR, "Cd defaults for everyone else");
+
+ let (lo, hi) = d.bounds().unwrap();
+ assert_eq!(lo, Vec3::ZERO);
+ assert_eq!(hi, Vec3::new(2.0, 2.0, 0.0));
+ assert!(Detail::new().bounds().is_none());
+ }
+
+ #[test]
+ fn test_detail_clone_drops_the_derived_topology_but_not_the_geometry() {
+ let d = quad_grid();
+ assert_eq!(d.topology().valence(4), 4);
+
+ // The clone exists to be modified, so it starts with no cache; what it
+ // must not lose is anything the cache was derived from.
+ let mut copy = d.clone();
+ assert_eq!(copy.num_points(), 9);
+ assert_eq!(copy.topology().valence(4), 4);
+ copy.keep_points(&vec![true; 9]);
+ assert_eq!(copy.num_prims(), 4);
+ assert_eq!(d.num_prims(), 4, "the original is untouched");
+ }
+}