git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit17705c2ffcf00ca9398b99c211656e81c6a68ca8
parent31aa678d61
authorLucas Galante <[email protected]>
date2026-09-18 14:56
feat(detail): the pipeline's currency is a Detail

Operators cannot work on points while their input arrives as a triangle soup,
so this is where the type flips. Every resolver, the scene walk, the sim
cache and the feedback stack now trade in Detail, and the operators stop
faking topology out of triangle-corner index arithmetic:

- Group's element types are real. Points are points, Primitives are
  primitives, Edges are the edges topology already knows about — where the
  soup computed `tri * 3 + side` and had to weld on the spot before it could
  draw one random POINT rather than one loose corner. Collision now shares
  that selection code: the two nodes differ only in the predicate, a box test
  versus a ray cast.
- Relax loses its hand-rolled weld and edge build. It welded both shapes by
  position and rebuilt the unique edge list on every call — welding on the
  REST positions specifically, so a displacement already applied to the input
  could not split a point in two. Points are points, so the correspondence is
  the index, and edges() is cached for whoever asks next.
- Membership is a real group, not a `group:` key in an attribute map. Group
  writes a point group (plus a prim group when selecting primitives), and the
  parameter pane reads groups and attributes from separate namespaces.
- The spreadsheet lists POINTS. A sphere was 2304 rows for 362 places, and
  the row number meant nothing you could point at; it is now the point index,
  which is what the Point Numbers overlay draws.
- The overlays stop reconstructing topology by hashing quantized positions
  every frame — a weld in all but name. Normals come from point_prims, wires
  from the edge list.

Three visible changes, all of them the topology becoming visible: a shared
edge is drawn once in the wireframe rather than twice, a quad shows as a quad
(the fan diagonal was never an edge of the mesh), and Attribute's Create with
a Group narrows what it WRITES rather than which points have the attribute,
because a column covers its whole class.

The OpenCL node alone still flattens and comes back — the kernel ABI is
Phase 1's job. A DEFORMER writes its results back onto the points they came
from, so topology, groups, attributes and identities survive it; that matters
most inside a simnet, where a kernel that re-welded every step would hand the
solver new points each frame. A GENERATOR welds into fresh geometry, which is
correct: its points are new.

weld_points is deleted. Verified byte-identical --thumbnail output at 300
samples against the pre-Phase-0 render.

Co-Authored-By: Claude Opus 5 <[email protected]>

 shapeshifter.md  |  13 +-
 src/app.rs       | 149 +++++-----
 src/detail.rs    |  32 +-
 src/geometry.rs  | 878 +++++++++++++++++++++++++++++++------------------------
 src/main.rs      | 310 ++++++++++----------
 src/render.rs    | 107 ++++---
 src/thumbnail.rs |   2 +-
 7 files changed, 811 insertions(+), 680 deletions(-)

diff --git a/shapeshifter.md b/shapeshifter.md
index 77a2c0c..5a28704 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -89,12 +89,15 @@ 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,
+> **Mostly landed.** `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`.
+> `PointId`s; lazily built CSR topology. The generators build it, every operator
+> works on it, and the pipeline's currency IS a `Detail`: the spreadsheet lists
+> points, the overlays read the point and edge lists, and `weld_points` is gone.
+>
+> What remains: the OpenCL node still flattens to a soup and back, because the
+> kernel ABI is Phase 1's job — `detail_to_soup` / `soup_to_detail` are the
+> bridge, and `geometry::Geometry` survives only to serve them.
 
 Replace the vertex list with **points, vertices, primitives and detail**, each
 carrying its own columnar attribute arrays — one `Vec<f32>` per named attribute
diff --git a/src/app.rs b/src/app.rs
index edb8989..cdd72e9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -42,6 +42,7 @@ use cce_ui::colors;
 use glam::{Mat4, Vec3};
 
 use crate::geometry::*;
+use crate::detail::Detail;
 use crate::slots::*;
 use crate::shortcut::{ShortcutManager, Action};
 use cce_ui::vk::{SceneDraw, TextSpan};
@@ -2573,15 +2574,17 @@ impl State {
                     &mut err,
                     &mut sim,
                 ) {
-                    for v in &geom.vertices {
-                        for k in v.attributes.keys() {
-                            if let Some(g) = k.strip_prefix("group:") {
-                                if !g.contains(',') {
-                                    groups.insert(g.to_string());
-                                }
-                            } else if !k.contains(',') {
-                                attrs.insert(k.clone());
-                            }
+                    // Groups and attributes are separate namespaces now, so
+                    // the menus read each directly instead of sifting a
+                    // "group:" prefix out of one attribute map.
+                    for g in geom.points().group_names() {
+                        if !g.contains(',') {
+                            groups.insert(g.to_string());
+                        }
+                    }
+                    for a in geom.points().names() {
+                        if !a.contains(',') {
+                            attrs.insert(a.to_string());
                         }
                     }
                 }
@@ -3154,9 +3157,14 @@ impl State {
     }
 
 
-pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec<Vec<String>>) {
+pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<Vec<String>>) {
+    // One row per POINT, not per triangle corner. The soup listed the same
+    // place once for every face touching it — a sphere came to 2304 rows for
+    // 362 places — and the row number meant nothing a user could point at.
+    // It is now the point index, which is also what the Point Numbers overlay
+    // draws.
     let mut headers = vec![
-        "Vertex".to_string(),
+        "Point".to_string(),
         "Pos.x".to_string(),
         "Pos.y".to_string(),
         "Pos.z".to_string(),
@@ -3165,87 +3173,70 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         "Col.b".to_string(),
     ];
 
-    let mut custom_keys = std::collections::BTreeSet::new();
-    for v in &geom.vertices {
-        for k in v.attributes.keys() {
-            custom_keys.insert(k.clone());
-        }
-    }
-    let custom_keys: Vec<String> = custom_keys.into_iter().collect();
-
-    for key in &custom_keys {
-        if let Some(val) = geom.vertices.iter().find_map(|v| v.attributes.get(key)) {
-            match val {
-                GAttribute::Float(_) => {
-                    headers.push(key.clone());
-                }
-                GAttribute::Float2(_) => {
-                    headers.push(format!("{}.x", key));
-                    headers.push(format!("{}.y", key));
-                }
-                GAttribute::Float3(_) => {
-                    headers.push(format!("{}.x", key));
-                    headers.push(format!("{}.y", key));
-                    headers.push(format!("{}.z", key));
-                }
-                GAttribute::Float4(_) => {
-                    headers.push(format!("{}.x", key));
-                    headers.push(format!("{}.y", key));
-                    headers.push(format!("{}.z", key));
-                    headers.push(format!("{}.w", key));
+    // Columnar storage means the columns are known up front, from the store
+    // rather than from a scan of every element's map. `names()` is sorted, so
+    // they hold still between frames.
+    let attribs: Vec<(String, crate::detail::AttribType)> = geom
+        .points()
+        .names()
+        .into_iter()
+        .filter(|n| *n != crate::detail::CD)
+        .filter_map(|n| geom.points().get(n).map(|a| (n.to_string(), a.ty())))
+        .collect();
+
+    for (name, ty) in &attribs {
+        match ty.components() {
+            1 => headers.push(name.clone()),
+            n => {
+                for c in ["x", "y", "z", "w"].iter().take(n) {
+                    headers.push(format!("{}.{}", name, c));
                 }
             }
         }
     }
 
+    let groups = geom.points().group_names();
+    for g in &groups {
+        headers.push(format!("g:{}", g));
+    }
+
     let mut rows = Vec::new();
-    for (i, v) in geom.vertices.iter().enumerate() {
+    for p in 0..geom.num_points() {
+        let pos = geom.positions()[p];
+        let col = geom.color(p);
         let mut row = vec![
-            i.to_string(),
-            format!("{:.4}", v.pos[0]),
-            format!("{:.4}", v.pos[1]),
-            format!("{:.4}", v.pos[2]),
-            format!("{:.4}", v.col[0]),
-            format!("{:.4}", v.col[1]),
-            format!("{:.4}", v.col[2]),
+            p.to_string(),
+            format!("{:.4}", pos[0]),
+            format!("{:.4}", pos[1]),
+            format!("{:.4}", pos[2]),
+            format!("{:.4}", col[0]),
+            format!("{:.4}", col[1]),
+            format!("{:.4}", col[2]),
         ];
 
-        for key in &custom_keys {
-            if let Some(val) = v.attributes.get(key) {
-                match val {
-                    GAttribute::Float(f) => {
-                        row.push(format!("{:.4}", f));
-                    }
-                    GAttribute::Float2(arr) => {
-                        row.push(format!("{:.4}", arr[0]));
-                        row.push(format!("{:.4}", arr[1]));
-                    }
-                    GAttribute::Float3(arr) => {
-                        row.push(format!("{:.4}", arr[0]));
-                        row.push(format!("{:.4}", arr[1]));
-                        row.push(format!("{:.4}", arr[2]));
-                    }
-                    GAttribute::Float4(arr) => {
-                        row.push(format!("{:.4}", arr[0]));
-                        row.push(format!("{:.4}", arr[1]));
-                        row.push(format!("{:.4}", arr[2]));
-                        row.push(format!("{:.4}", arr[3]));
-                    }
+        for (name, ty) in &attribs {
+            // A column covers its whole class, so there is no "this element
+            // does not have it" case left to render as a dash.
+            match geom.points().value(name, p) {
+                Some(crate::detail::AttribValue::Float(f)) => row.push(format!("{:.4}", f)),
+                Some(crate::detail::AttribValue::Int(i)) => row.push(i.to_string()),
+                Some(crate::detail::AttribValue::Float2(a)) => {
+                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
                 }
-            } else {
-                if let Some(val) = geom.vertices.iter().find_map(|v| v.attributes.get(key)) {
-                    let count = match val {
-                        GAttribute::Float(_) => 1,
-                        GAttribute::Float2(_) => 2,
-                        GAttribute::Float3(_) => 3,
-                        GAttribute::Float4(_) => 4,
-                    };
-                    for _ in 0..count {
-                        row.push("-".to_string());
-                    }
+                Some(crate::detail::AttribValue::Float3(a)) => {
+                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
+                }
+                Some(crate::detail::AttribValue::Float4(a)) => {
+                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
                 }
+                None => row.extend(std::iter::repeat("-".to_string()).take(ty.components())),
             }
         }
+
+        for g in &groups {
+            row.push(if geom.points().in_group(g, p) { "1".to_string() } else { String::new() });
+        }
+
         rows.push(row);
     }
 
diff --git a/src/detail.rs b/src/detail.rs
index 8887edb..293ac85 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -1119,6 +1119,15 @@ impl Detail {
     /// 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 {
+        Self::from_triangle_soup_with_map(positions, colors).0
+    }
+
+    /// [`Detail::from_triangle_soup`], plus the point each input corner welded
+    /// onto — so a caller holding per-corner data can carry it across.
+    pub fn from_triangle_soup_with_map(
+        positions: &[[f32; 3]],
+        colors: &[[f32; 3]],
+    ) -> (Detail, Vec<u32>) {
         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());
@@ -1152,7 +1161,28 @@ impl Detail {
                 .attribs
                 .insert(CD.to_string(), AttribData::Float3(cd));
         }
-        detail
+        (detail, point_of)
+    }
+
+    /// The point behind every corner [`Detail::triangulate`] emits, in the
+    /// same order.
+    ///
+    /// Lets a caller that had to flatten to triangles — the OpenCL launcher,
+    /// until the Phase 1 ABI binds attributes directly — put results back on
+    /// the points they came from instead of welding the output and losing
+    /// every identity.
+    pub fn triangulate_points(&self) -> Vec<u32> {
+        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 {
+                out.extend_from_slice(&[pts[0], pts[i], pts[i + 1]]);
+            }
+        }
+        out
     }
 
     /// Fan-triangulate every primitive, handing each corner to `make` as
diff --git a/src/geometry.rs b/src/geometry.rs
index 9ab9274..d0c2972 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -159,6 +159,12 @@ pub fn cube_vertices() -> Vec<Vertex3D> {
     data.iter().map(|&(p, c)| Vertex3D { position: p, color: c }).collect()
 }
 
+/// A [`Detail`]'s triangles as renderer vertices — the one place the 3D scene
+/// crosses out of the geometry model.
+pub fn detail_vertices(d: &Detail) -> Vec<Vertex3D> {
+    d.triangulate(|position, color| Vertex3D { position, color })
+}
+
 /// Fan-triangulate a [`Detail`] back into the triangle soup the evaluation
 /// pipeline still speaks, carrying attributes onto every corner.
 ///
@@ -207,6 +213,60 @@ pub fn detail_to_soup(d: &Detail) -> Geometry {
     Geometry { vertices }
 }
 
+/// Weld a soup back into a [`Detail`], carrying its per-corner attributes onto
+/// the points they welded into (first corner wins).
+///
+/// The inverse of [`detail_to_soup`], and the other half of the migration
+/// bridge. Attribute transfer is not incidental: the kernel launcher stamps a
+/// default `Norm` and `UV` on every vertex it generates, and a weld that only
+/// took positions and colors would quietly drop them — which is exactly what
+/// the parameter pane's attribute picker reads.
+pub fn soup_to_detail(soup: &Geometry) -> Detail {
+    let positions: Vec<[f32; 3]> = soup.vertices.iter().map(|v| v.pos).collect();
+    let colors: Vec<[f32; 3]> = soup.vertices.iter().map(|v| v.col).collect();
+    let (mut d, point_of) = Detail::from_triangle_soup_with_map(&positions, &colors);
+
+    let mut names: Vec<&str> = Vec::new();
+    for v in &soup.vertices {
+        for k in v.attributes.keys() {
+            if !names.contains(&k.as_str()) {
+                names.push(k);
+            }
+        }
+    }
+    names.sort_unstable();
+
+    for name in names {
+        let mut written = vec![false; d.num_points()];
+        let mut data: Option<AttribData> = None;
+        for (corner, v) in soup.vertices.iter().enumerate() {
+            let (Some(&p), Some(val)) = (point_of.get(corner), v.attributes.get(name)) else {
+                continue;
+            };
+            let p = p as usize;
+            if std::mem::replace(&mut written[p], true) {
+                continue;
+            }
+            let value = detail_attr(val);
+            let arr = data.get_or_insert_with(|| AttribData::zeroed(value.ty(), d.num_points()));
+            let _ = arr.set(p, value);
+        }
+        if let Some(arr) = data {
+            let _ = d.points_mut().insert(name, arr);
+        }
+    }
+    d
+}
+
+fn detail_attr(v: &GAttribute) -> AttribValue {
+    match *v {
+        GAttribute::Float(x) => AttribValue::Float(x),
+        GAttribute::Float2(x) => AttribValue::Float2(x),
+        GAttribute::Float3(x) => AttribValue::Float3(x),
+        GAttribute::Float4(x) => AttribValue::Float4(x),
+    }
+}
+
 fn soup_attr(v: AttribValue) -> GAttribute {
     match v {
         AttribValue::Float(x) => GAttribute::Float(x),
@@ -543,7 +603,7 @@ struct SimSolve {
     key: u64,
     /// The frame `state` is the solution FOR.
     frame: i32,
-    state: Geometry,
+    state: Detail,
 }
 
 /// Per-simnet solved states, keyed by node id. Owned by the caller (the app keeps
@@ -572,7 +632,7 @@ pub struct EvalSim<'a> {
     /// having taken no steps yet.
     pub start_frame: i32,
     pub cache: &'a mut SimCache,
-    feedback: Vec<(String, Geometry)>,
+    feedback: Vec<(String, Detail)>,
 }
 
 impl<'a> EvalSim<'a> {
@@ -587,7 +647,7 @@ impl<'a> EvalSim<'a> {
     }
 
     /// The state an `input` node should yield, if its parent simnet is mid-solve.
-    fn feedback_for(&self, simnet_id: &str) -> Option<&Geometry> {
+    fn feedback_for(&self, simnet_id: &str) -> Option<&Detail> {
         self.feedback
             .iter()
             .rev()
@@ -599,15 +659,15 @@ impl<'a> EvalSim<'a> {
 /// Hash of everything a simnet's solve depends on: its own subtree (so editing any
 /// node in the chain restarts the sim) and the seed geometry (so an upstream change
 /// does too).
-fn sim_solve_key(simnet: &FsNode, seed: &Geometry) -> u64 {
+fn sim_solve_key(simnet: &FsNode, seed: &Detail) -> u64 {
     use std::hash::{Hash, Hasher};
     let mut h = std::collections::hash_map::DefaultHasher::new();
     if let Ok(json) = serde_json::to_string(simnet) {
         json.hash(&mut h);
     }
-    seed.vertices.len().hash(&mut h);
-    for v in &seed.vertices {
-        for c in v.pos {
+    seed.num_points().hash(&mut h);
+    for p in seed.positions() {
+        for c in p {
             c.to_bits().hash(&mut h);
         }
     }
@@ -617,7 +677,7 @@ fn sim_solve_key(simnet: &FsNode, seed: &Geometry) -> u64 {
 /// Evaluate with neither error reporting nor a persistent sim cache. Any simnet
 /// reached this way solves at frame 0 — that is, shows its seed — because there
 /// is no timeline in scope to say otherwise.
-pub fn generate_single_node_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Geometry> {
+pub fn generate_single_node_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Detail> {
     let mut err = None;
     let mut cache = SimCache::default();
     let mut sim = EvalSim::new(0, 0, &mut cache);
@@ -630,7 +690,7 @@ pub fn generate_single_node_geometry_with_errors(
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     // Cycle guard by ID, not name: subnet instances share child names
     // ("output1", "opencl1"), so a name guard falsely blocks a subnet that
     // consumes another subnet's geometry (Extrude eating a Sphere never
@@ -644,20 +704,20 @@ pub fn generate_single_node_geometry_with_errors(
     let res = if target.node_type.eq_ignore_ascii_case("sphere") {
         let idx = find_sphere_index(root, target)?;
         let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
-        Some(sphere_vertices(center, node_param_f32(target, "Radius", 0.5).max(0.05)))
+        Some(sphere_detail(center, node_param_f32(target, "Radius", 0.5).max(0.05), 16, 24))
     } else if target.node_type.eq_ignore_ascii_case("line") {
         let idx = find_sphere_index(root, target)?;
         let start = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
         let length = node_param_f32(target, "Length", 1.0);
         let thickness = node_param_f32(target, "Thickness", 0.02);
         let end = start + Vec3::new(0.0, length, 0.0);
-        Some(line_vertices(start, end, thickness))
+        Some(box_detail(start, end, thickness))
     } else if target.node_type.eq_ignore_ascii_case("curve") {
-        Some(curve_geometry(target))
+        Some(curve_detail(target))
     } else if target.node_type.eq_ignore_ascii_case("points") {
         let idx = find_sphere_index(root, target)?;
         let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
-        Some(points_node_geometry(target, center))
+        Some(points_detail(target, center))
     } else if target.node_type.eq_ignore_ascii_case("transform") {
         resolve_transform_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("scatter") {
@@ -729,7 +789,7 @@ pub fn generate_single_node_geometry_with_errors(
     res
 }
 
-pub fn resolve_transform_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Geometry> {
+pub fn resolve_transform_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Detail> {
     let mut err = None;
     let mut cache = SimCache::default();
     let mut sim = EvalSim::new(0, 0, &mut cache);
@@ -742,23 +802,24 @@ pub fn resolve_transform_geometry_with_errors(
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     let input_name = node_param_str(target, "Input", "");
     if input_name.is_empty() {
         return None;
     }
     let input_node = find_node_by_name(root, &input_name)?;
     let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
-    let translation = node_param_vec3(target, "Translation", Vec3::ZERO);
-    for v in &mut geom.vertices {
-        v.pos[0] += translation.x;
-        v.pos[1] += translation.y;
-        v.pos[2] += translation.z;
+    let translation = node_param_vec3(target, "Translation", Vec3::ZERO).to_array();
+    // Moving points changes no topology, so the cache rides along.
+    for p in geom.positions_mut() {
+        for k in 0..3 {
+            p[k] += translation[k];
+        }
     }
     Some(geom)
 }
 
-pub fn resolve_scatter_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Geometry> {
+pub fn resolve_scatter_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Detail> {
     let mut err = None;
     let mut cache = SimCache::default();
     let mut sim = EvalSim::new(0, 0, &mut cache);
@@ -776,51 +837,35 @@ fn splitmix64(state: &mut u64) -> u64 {
     z ^ (z >> 31)
 }
 
-/// Weld a triangle soup's coincident vertices into points: returns
-/// (point id per vertex, copies per point). Positions quantize to 1e-4 so
-/// vertices a kernel emitted from the same formula weld reliably. "Point"
-/// operations (random point groups, the relax solver) act on welded points
-/// and fan back out to every copy — moving one copy of a shared corner
-/// without its siblings would tear the surface.
-fn weld_points(positions: &[[f32; 3]]) -> (Vec<usize>, Vec<Vec<usize>>) {
-    let mut key_to_point: HashMap<(i64, i64, i64), usize> = HashMap::new();
-    let mut point_of = Vec::with_capacity(positions.len());
-    let mut copies: Vec<Vec<usize>> = 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 id = *key_to_point.entry(key).or_insert_with(|| {
-            copies.push(Vec::new());
-            copies.len() - 1
-        });
-        point_of.push(id);
-        copies[id].push(i);
-    }
-    (point_of, copies)
-}
-
-/// The Group node: pass the input geometry through, tagging the selected
-/// elements with a per-vertex membership attribute `group:<name>` =
-/// Float(1.0). Element Type picks the selection unit over the triangle
-/// soup — Points (per vertex), Primitives (a triangle; all three vertices
-/// tag together), Edges (a triangle edge; its two vertices tag). Mode picks
-/// the selector: Box selects by an axis-aligned box (Center/Size); Random
-/// draws Count elements deterministically from Seed — for Points it draws
-/// welded points (coincident vertices tag together, so "one random point"
-/// is one surface point, not one loose corner of a triangle). Membership
-/// rides GVertex::attributes so it survives merges and native filters —
-/// downstream nodes consume it by reading the same attribute. Highlight
-/// tints members toward a warm accent so the group reads in the viewport.
+/// The Group node: pass the input geometry through, putting the selected
+/// elements in a named group.
+///
+/// Element Type picks the selection unit, and now they are real: Points are
+/// points, Primitives are primitives, Edges are the edges topology already
+/// knows about. The soup had to fake all three out of triangle-corner index
+/// arithmetic — `tri * 3 + side` — and had to weld on the spot before it could
+/// draw one random *point* rather than one random loose corner.
+///
+/// Mode picks the selector: Box selects by an axis-aligned box (Center/Size);
+/// Random draws Count elements deterministically from Seed.
+///
+/// Membership always lands in a POINT group — for Primitives and Edges, the
+/// points of the selected elements — because that is what every consumer
+/// downstream reads (Relax's Pin Group, Attribute's Group, the viewport
+/// markers), and it is what the per-vertex tagging it replaces amounted to.
+/// Selecting primitives additionally writes a prim group of the same name;
+/// groups are per-class, so the two names do not collide, and the operators
+/// that want prims are coming.
+///
+/// Highlight tints members toward a warm accent so the group reads in the
+/// viewport.
 pub fn resolve_group_geometry_with_errors(
     root: &FsNode,
     target: &FsNode,
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     let input_name = node_param_str(target, "Input", "");
     if input_name.is_empty() {
         return None;
@@ -828,22 +873,56 @@ pub fn resolve_group_geometry_with_errors(
     let input_node = find_node_by_name(root, &input_name)?;
     let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
 
-    let group_name = node_param_str(target, "Group Name", "group1");
-    let attr = format!("group:{}", group_name.trim());
+    let group_name = node_param_str(target, "Group Name", "group1").trim().to_string();
     let etype = node_param_str(target, "Element Type", "Points").to_lowercase();
     let center = node_param_vec3(target, "Center", Vec3::ZERO);
     let half = node_param_vec3(target, "Size", Vec3::ONE) * 0.5;
     let invert = node_param_str(target, "Invert", "false") == "true";
     let highlight = node_param_str(target, "Highlight", "true") == "true";
 
-    let inside = |p: &[f32; 3]| -> bool {
-        (p[0] - center.x).abs() <= half.x
-            && (p[1] - center.y).abs() <= half.y
-            && (p[2] - center.z).abs() <= half.z
+    let inside = |p: Vec3| -> bool {
+        (p.x - center.x).abs() <= half.x
+            && (p.y - center.y).abs() <= half.y
+            && (p.z - center.z).abs() <= half.z
+    };
+
+    let (member, prim_member) =
+        select_elements(&geom, &etype, target, |p| inside(p), invert);
+
+    apply_group(
+        &mut geom,
+        &group_name,
+        &member,
+        &prim_member,
+        highlight.then_some([1.0, 0.78, 0.20]),
+    );
+    Some(geom)
+}
+
+/// Which points (and, for a primitive selection, which primitives) a Group or
+/// Collision node selects. Shared because the two nodes differ only in the
+/// predicate: a box test versus a ray-cast or proximity test.
+///
+/// Returns a point mask and a primitive mask. `invert` flips the point mask,
+/// matching what the per-vertex inversion did.
+fn select_elements(
+    geom: &Detail,
+    etype: &str,
+    target: &FsNode,
+    hit: impl Fn(Vec3) -> bool,
+    invert: bool,
+) -> (Vec<bool>, Vec<bool>) {
+    let mut member = vec![false; geom.num_points()];
+    let mut prim_member = vec![false; geom.num_prims()];
+
+    let prim_centroid = |prim: usize| -> Vec3 {
+        let pts = geom.prim_points(prim);
+        if pts.is_empty() {
+            return Vec3::ZERO;
+        }
+        pts.iter().map(|&p| geom.pos(p as usize)).sum::<Vec3>() / pts.len() as f32
     };
 
-    let n = geom.vertices.len();
-    let mut member = vec![false; n];
     let mode = node_param_str(target, "Mode", "Box").to_lowercase();
     if mode == "random" {
         let count = node_param_f32(target, "Count", 1.0).max(0.0) as usize;
@@ -861,86 +940,99 @@ pub fn resolve_group_geometry_with_errors(
             idx.truncate(take);
             idx
         };
-        match etype.as_str() {
+        match etype {
             "primitives" => {
-                for tri in draw(n / 3, count) {
-                    for k in 0..3 {
-                        member[tri * 3 + k] = true;
+                for prim in draw(geom.num_prims(), count) {
+                    prim_member[prim] = true;
+                    for &p in geom.prim_points(prim) {
+                        member[p as usize] = true;
                     }
                 }
             }
             "edges" => {
-                for e in draw((n / 3) * 3, count) {
-                    let (tri, side) = (e / 3, e % 3);
-                    member[tri * 3 + side] = true;
-                    member[tri * 3 + (side + 1) % 3] = true;
+                let edges = geom.edges().to_vec();
+                for e in draw(edges.len(), count) {
+                    member[edges[e][0] as usize] = true;
+                    member[edges[e][1] as usize] = true;
                 }
             }
             _ => {
-                let positions: Vec<[f32; 3]> = geom.vertices.iter().map(|v| v.pos).collect();
-                let (_, copies) = weld_points(&positions);
-                for pt in draw(copies.len(), count) {
-                    for &i in &copies[pt] {
-                        member[i] = true;
-                    }
+                for p in draw(geom.num_points(), count) {
+                    member[p] = true;
                 }
             }
         }
     } else {
-        match etype.as_str() {
+        match etype {
             "primitives" => {
-                for tri in 0..n / 3 {
-                    let b = tri * 3;
-                    let centroid = [
-                        (geom.vertices[b].pos[0] + geom.vertices[b + 1].pos[0] + geom.vertices[b + 2].pos[0]) / 3.0,
-                        (geom.vertices[b].pos[1] + geom.vertices[b + 1].pos[1] + geom.vertices[b + 2].pos[1]) / 3.0,
-                        (geom.vertices[b].pos[2] + geom.vertices[b + 1].pos[2] + geom.vertices[b + 2].pos[2]) / 3.0,
-                    ];
-                    if inside(&centroid) {
-                        member[b] = true;
-                        member[b + 1] = true;
-                        member[b + 2] = true;
+                for prim in 0..geom.num_prims() {
+                    if hit(prim_centroid(prim)) {
+                        prim_member[prim] = true;
+                        for &p in geom.prim_points(prim) {
+                            member[p as usize] = true;
+                        }
                     }
                 }
             }
             "edges" => {
-                for tri in 0..n / 3 {
-                    let b = tri * 3;
-                    for (a, c) in [(0usize, 1usize), (1, 2), (2, 0)] {
-                        if inside(&geom.vertices[b + a].pos) && inside(&geom.vertices[b + c].pos) {
-                            member[b + a] = true;
-                            member[b + c] = true;
-                        }
+                for e in geom.edges() {
+                    let (a, b) = (e[0] as usize, e[1] as usize);
+                    if hit(geom.pos(a)) && hit(geom.pos(b)) {
+                        member[a] = true;
+                        member[b] = true;
                     }
                 }
             }
             _ => {
-                for (i, v) in geom.vertices.iter().enumerate() {
-                    if inside(&v.pos) {
-                        member[i] = true;
+                for p in 0..geom.num_points() {
+                    if hit(geom.pos(p)) {
+                        member[p] = true;
                     }
                 }
             }
         }
     }
+
     if invert {
         for m in member.iter_mut() {
             *m = !*m;
         }
+        for m in prim_member.iter_mut() {
+            *m = !*m;
+        }
     }
+    (member, prim_member)
+}
 
-    for (i, v) in geom.vertices.iter_mut().enumerate() {
-        if member[i] {
-            v.attributes.insert(attr.clone(), GAttribute::Float(1.0));
-            if highlight {
-                let acc = [1.0, 0.78, 0.20];
-                for k in 0..3 {
-                    v.col[k] = v.col[k] * 0.35 + acc[k] * 0.65;
-                }
-            }
+/// Write a selection into a named group, optionally tinting its members.
+fn apply_group(
+    geom: &mut Detail,
+    name: &str,
+    member: &[bool],
+    prim_member: &[bool],
+    highlight: Option<[f32; 3]>,
+) {
+    geom.points_mut().create_group(name);
+    for (p, _) in member.iter().enumerate().filter(|(_, &m)| m) {
+        geom.points_mut().add_to_group(name, p);
+    }
+    if prim_member.iter().any(|&m| m) {
+        geom.prims_mut().create_group(name);
+        for (prim, _) in prim_member.iter().enumerate().filter(|(_, &m)| m) {
+            geom.prims_mut().add_to_group(name, prim);
+        }
+    }
+    if let Some(acc) = highlight {
+        for (p, _) in member.iter().enumerate().filter(|(_, &m)| m) {
+            let base = geom.color(p);
+            let mixed = [
+                base[0] * 0.35 + acc[0] * 0.65,
+                base[1] * 0.35 + acc[1] * 0.65,
+                base[2] * 0.35 + acc[2] * 0.65,
+            ];
+            geom.set_color(p, mixed);
         }
     }
-    Some(geom)
 }
 
 /// Squared distance from `p` to triangle `(a, b, c)` — closest point via the
@@ -1012,7 +1104,7 @@ pub fn resolve_collision_geometry_with_errors(
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     let input_name = node_param_str(target, "Input", "");
     if input_name.is_empty() {
         return None;
@@ -1029,14 +1121,17 @@ pub fn resolve_collision_geometry_with_errors(
     let Some(collider) = generate_single_node_geometry_with_errors(root, collider_node, visited, ocl_error, sim) else {
         return Some(geom);
     };
-    if collider.vertices.len() < 3 || geom.vertices.is_empty() {
+    if collider.num_prims() == 0 || geom.is_empty() {
         return Some(geom);
     }
 
+    // The collider is still tested as triangles: both the ray cast and the
+    // closest-point walk are triangle routines, so a polygon is fanned here
+    // rather than each routine growing an n-gon case.
     let tris: Vec<[Vec3; 3]> = collider
-        .vertices
+        .triangulate(|pos, _| Vec3::from(pos))
         .chunks_exact(3)
-        .map(|t| [Vec3::from(t[0].pos), Vec3::from(t[1].pos), Vec3::from(t[2].pos)])
+        .map(|t| [t[0], t[1], t[2]])
         .collect();
 
     let method = node_param_str(target, "Method", "Inside").to_lowercase();
@@ -1045,8 +1140,7 @@ pub fn resolve_collision_geometry_with_errors(
     // tessellate on the axes, and a ray along one skims edge-on through
     // whole fans of triangles, double-counting crossings.
     let ray_dir = Vec3::new(0.9174771, 0.3369154, 0.2095338).normalize();
-    let hit = |p: &[f32; 3]| -> bool {
-        let pt = Vec3::from(*p);
+    let hit = |pt: Vec3| -> bool {
         if method == "proximity" {
             let d2 = distance * distance;
             tris.iter().any(|t| point_triangle_distance_sq(pt, t[0], t[1], t[2]) <= d2)
@@ -1059,55 +1153,23 @@ pub fn resolve_collision_geometry_with_errors(
         }
     };
 
-    let n = geom.vertices.len();
-    let mut member = vec![false; n];
+    // Collision has no Mode parameter, so `select_elements` takes its Box
+    // branch and applies `hit` per element — which is the whole difference
+    // between this node and Group.
     let etype = node_param_str(target, "Element Type", "Points").to_lowercase();
-    if etype == "primitives" {
-        for tri in 0..n / 3 {
-            let b = tri * 3;
-            let centroid = [
-                (geom.vertices[b].pos[0] + geom.vertices[b + 1].pos[0] + geom.vertices[b + 2].pos[0]) / 3.0,
-                (geom.vertices[b].pos[1] + geom.vertices[b + 1].pos[1] + geom.vertices[b + 2].pos[1]) / 3.0,
-                (geom.vertices[b].pos[2] + geom.vertices[b + 1].pos[2] + geom.vertices[b + 2].pos[2]) / 3.0,
-            ];
-            if hit(&centroid) {
-                member[b] = true;
-                member[b + 1] = true;
-                member[b + 2] = true;
-            }
-        }
-    } else {
-        let positions: Vec<[f32; 3]> = geom.vertices.iter().map(|v| v.pos).collect();
-        let (_, copies) = weld_points(&positions);
-        for c in &copies {
-            if hit(&positions[c[0]]) {
-                for &i in c {
-                    member[i] = true;
-                }
-            }
-        }
-    }
-    if node_param_str(target, "Invert", "false") == "true" {
-        for m in member.iter_mut() {
-            *m = !*m;
-        }
-    }
+    let invert = node_param_str(target, "Invert", "false") == "true";
+    let (member, prim_member) = select_elements(&geom, &etype, target, hit, invert);
 
-    let group_name = node_param_str(target, "Group Name", "collisions");
-    let attr = format!("group:{}", group_name.trim());
+    let group_name = node_param_str(target, "Group Name", "collisions").trim().to_string();
     let highlight = node_param_str(target, "Highlight", "true") == "true";
-    for (i, v) in geom.vertices.iter_mut().enumerate() {
-        if member[i] {
-            v.attributes.insert(attr.clone(), GAttribute::Float(1.0));
-            if highlight {
-                // Contact reads as red — distinct from the Group node's amber.
-                let acc = [1.0, 0.30, 0.24];
-                for k in 0..3 {
-                    v.col[k] = v.col[k] * 0.35 + acc[k] * 0.65;
-                }
-            }
-        }
-    }
+    apply_group(
+        &mut geom,
+        &group_name,
+        &member,
+        &prim_member,
+        // Contact reads as red — distinct from the Group node's amber.
+        highlight.then_some([1.0, 0.30, 0.24]),
+    );
     Some(geom)
 }
 
@@ -1134,7 +1196,7 @@ pub fn resolve_relax_geometry_with_errors(
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     let input_name = node_param_str(target, "Input", "");
     if input_name.is_empty() {
         return None;
@@ -1151,48 +1213,38 @@ pub fn resolve_relax_geometry_with_errors(
     let Some(rest) = generate_single_node_geometry_with_errors(root, rest_node, visited, ocl_error, sim) else {
         return Some(geom);
     };
-    if rest.vertices.len() != geom.vertices.len() || geom.vertices.is_empty() {
+    if rest.num_points() != geom.num_points() || geom.is_empty() {
         return Some(geom);
     }
 
     let stiffness = node_param_f32(target, "Stiffness", 0.5).clamp(0.0, 1.0);
     let iterations = node_param_f32(target, "Iterations", 8.0).max(1.0) as usize;
     let pin = node_param_str(target, "Pin Group", "").trim().to_string();
-    let pin_attr = format!("group:{}", pin);
 
-    // Weld on REST positions: the input may already carry this step's
-    // displacement, and the weld must not split a point the pull moved.
-    let rest_pos: Vec<[f32; 3]> = rest.vertices.iter().map(|v| v.pos).collect();
-    let (point_of, copies) = weld_points(&rest_pos);
-    let m = copies.len();
-    let mut pos: Vec<Vec3> = copies.iter().map(|c| Vec3::from(geom.vertices[c[0]].pos)).collect();
-    let mut pinned = vec![false; m];
-    if !pin.is_empty() {
-        for (i, v) in geom.vertices.iter().enumerate() {
-            if v.attributes.contains_key(&pin_attr) {
-                pinned[point_of[i]] = true;
-            }
-        }
-    }
+    // The edges come from the REST shape's topology. This is where the soup
+    // cost the most: it had to weld both shapes by position and rebuild the
+    // unique edge list inside this node, every call, and weld on the rest
+    // positions specifically so that a displacement already applied to the
+    // input could not split a point apart. Points are points now, so the
+    // correspondence is just the index, and `edges()` is cached on the rest
+    // geometry for anyone else who asks.
+    let mut pos: Vec<Vec3> = (0..geom.num_points()).map(|p| geom.pos(p)).collect();
+    let pinned: Vec<bool> = if pin.is_empty() {
+        vec![false; geom.num_points()]
+    } else {
+        (0..geom.num_points())
+            .map(|p| geom.points().in_group(&pin, p))
+            .collect()
+    };
 
-    // Unique edges over welded points; rest length from the rest shape.
-    let mut edges: Vec<(usize, usize, f32)> = Vec::new();
-    let mut seen = std::collections::HashSet::new();
-    for tri in 0..point_of.len() / 3 {
-        let b = tri * 3;
-        for (a, c) in [(0usize, 1usize), (1, 2), (2, 0)] {
-            let (pa, pc) = (point_of[b + a], point_of[b + c]);
-            if pa == pc {
-                continue;
-            }
-            let key = (pa.min(pc), pa.max(pc));
-            if seen.insert(key) {
-                let ra = Vec3::from(rest.vertices[copies[key.0][0]].pos);
-                let rc = Vec3::from(rest.vertices[copies[key.1][0]].pos);
-                edges.push((key.0, key.1, (rc - ra).length()));
-            }
-        }
-    }
+    let edges: Vec<(usize, usize, f32)> = rest
+        .edges()
+        .iter()
+        .map(|e| {
+            let (a, b) = (e[0] as usize, e[1] as usize);
+            (a, b, (rest.pos(b) - rest.pos(a)).length())
+        })
+        .collect();
 
     for _ in 0..iterations {
         for &(a, b, rest_len) in &edges {
@@ -1214,38 +1266,43 @@ pub fn resolve_relax_geometry_with_errors(
         }
     }
 
-    for (pt, c) in copies.iter().enumerate() {
-        for &i in c {
-            geom.vertices[i].pos = pos[pt].to_array();
-        }
+    for (p, v) in pos.iter().enumerate() {
+        geom.set_pos(p, *v);
     }
     Some(geom)
 }
 
-/// The Attribute node: pass the input geometry through, running one
-/// attribute edit over it. Operation picks the edit —
+/// The Attribute node: pass the input geometry through, running one attribute
+/// edit over its POINTS. Operation picks the edit —
 ///
-/// - **Create** inserts `Attribute Name` on every affected vertex as the
-///   chosen Type parsed from Value, overwriting an existing tag.
-/// - **Modify** combines Value into vertices that already carry the
-///   attribute (Combine = Set / Add / Multiply, componentwise). The
-///   built-ins `Pos` and `Col` are reachable by name here (Float3), so the
-///   node can displace or tint geometry; they cannot be created or deleted.
+/// - **Create** inserts `Attribute Name` as the chosen Type parsed from Value,
+///   overwriting an existing attribute of that name.
+/// - **Modify** combines Value into an attribute that already exists
+///   (Combine = Set / Add / Multiply, componentwise). The built-ins `Pos` and
+///   `Col` are reachable by name here (Float3), so the node can displace or
+///   tint geometry; they cannot be created or deleted.
 /// - **Delete** removes the attribute.
 ///
 /// Value splits on `:`/`,`/space like every vector param; a single-component
 /// Value broadcasts across wider types (`0.5` scales a Float3 uniformly). A
-/// non-empty Group name restricts every operation to the vertices a Group
-/// node tagged `group:<name>`, composing the two nodes. Errors (bad Value,
-/// component mismatch, editing a built-in) surface on the status line and
-/// pass the geometry through unchanged.
+/// non-empty Group name restricts every operation to the points a Group node
+/// put in it, composing the two nodes. Errors (bad Value, component mismatch,
+/// editing a built-in) surface on the status line and pass the geometry
+/// through unchanged.
+///
+/// Create and Delete are whole-attribute operations, so a Group narrows what
+/// they WRITE, not what exists: creating into a group leaves non-members at
+/// the type's zero rather than leaving them without the attribute, because a
+/// column covers its whole class. That is the one behaviour the columnar
+/// store changes here, and it is the reason a solver can read any attribute at
+/// any point without checking whether it is there.
 pub fn resolve_attribute_geometry_with_errors(
     root: &FsNode,
     target: &FsNode,
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     // No visited guard here: `generate_single_node_geometry_with_errors`
     // pushes the target's id before dispatching to this resolver, so a local
     // `visited.contains` check would see it and refuse every call (the trap
@@ -1263,16 +1320,16 @@ pub fn resolve_attribute_geometry_with_errors(
     }
     let op = node_param_str(target, "Operation", "Create").to_lowercase();
     let combine_mode = node_param_str(target, "Combine", "Set").to_lowercase();
-    let builtin = name.eq_ignore_ascii_case("Pos") || name.eq_ignore_ascii_case("Col");
+    let is_pos = name.eq_ignore_ascii_case("Pos");
+    let is_col = name.eq_ignore_ascii_case("Col");
+    let builtin = is_pos || is_col;
 
     let mut fail = String::new();
     let group = node_param_str(target, "Group", "");
-    let group_attr = {
-        let g = group.trim();
-        (!g.is_empty()).then(|| format!("group:{}", g))
-    };
-    let affected =
-        |v: &GVertex| group_attr.as_ref().map_or(true, |ga| v.attributes.contains_key(ga));
+    let group = group.trim().to_string();
+    let affected: Vec<usize> = (0..geom.num_points())
+        .filter(|&p| group.is_empty() || geom.points().in_group(&group, p))
+        .collect();
 
     // Value, as raw components. Delete never reads it; Create/Modify reject
     // the edit outright when any component fails to parse.
@@ -1309,9 +1366,7 @@ pub fn resolve_attribute_geometry_with_errors(
             if builtin {
                 fail = format!("'{}' is built-in and cannot be deleted", name);
             } else {
-                for v in geom.vertices.iter_mut().filter(|v| affected(v)) {
-                    v.attributes.remove(&name);
-                }
+                geom.points_mut().remove(&name);
             }
         }
         "modify" => {
@@ -1320,36 +1375,32 @@ pub fn resolve_attribute_geometry_with_errors(
             } else if builtin {
                 match fit(3) {
                     Some(src) => {
-                        let tint_col = name.eq_ignore_ascii_case("Col");
-                        for v in geom.vertices.iter_mut().filter(|v| affected(v)) {
-                            if tint_col {
-                                combine(&mut v.col, &src);
+                        for &p in &affected {
+                            let mut v = if is_col { geom.color(p) } else { geom.pos(p).to_array() };
+                            combine(&mut v, &src);
+                            if is_col {
+                                geom.set_color(p, v);
                             } else {
-                                combine(&mut v.pos, &src);
+                                geom.set_pos(p, Vec3::from(v));
                             }
                         }
                     }
                     None => fail = format!("Value '{}' does not fit Float3 '{}'", value_str, name),
                 }
             } else {
-                for v in geom.vertices.iter_mut().filter(|v| affected(v)) {
-                    let Some(existing) = v.attributes.get_mut(&name) else { continue };
-                    let src = match existing {
-                        GAttribute::Float(_) => fit(1),
-                        GAttribute::Float2(_) => fit(2),
-                        GAttribute::Float3(_) => fit(3),
-                        GAttribute::Float4(_) => fit(4),
-                    };
-                    let Some(src) = src else {
-                        fail = format!("Value '{}' does not fit '{}'", value_str, name);
-                        break;
-                    };
-                    match existing {
-                        GAttribute::Float(x) => combine(std::slice::from_mut(x), &src),
-                        GAttribute::Float2(x) => combine(x, &src),
-                        GAttribute::Float3(x) => combine(x, &src),
-                        GAttribute::Float4(x) => combine(x, &src),
-                    }
+                match geom.points().get(&name).map(|a| a.ty()) {
+                    None => {}
+                    Some(ty) => match fit(ty.components()) {
+                        None => fail = format!("Value '{}' does not fit '{}'", value_str, name),
+                        Some(src) => {
+                            for &p in &affected {
+                                let Some(cur) = geom.points().value(&name, p) else { continue };
+                                let mut buf = attrib_components(cur);
+                                combine(&mut buf, &src);
+                                let _ = geom.points_mut().set_value(&name, p, components_attrib(ty, &buf));
+                            }
+                        }
+                    },
                 }
             }
         }
@@ -1360,27 +1411,23 @@ pub fn resolve_attribute_geometry_with_errors(
             } else if !value_ok {
                 fail = format!("Value '{}' does not parse as numbers", value_str);
             } else {
-                let ty = node_param_str(target, "Type", "Float").to_lowercase();
-                let width = match ty.as_str() {
-                    "float2" => 2,
-                    "float3" => 3,
-                    "float4" => 4,
-                    _ => 1,
+                let ty = match node_param_str(target, "Type", "Float").to_lowercase().as_str() {
+                    "float2" => crate::detail::AttribType::Float2,
+                    "float3" => crate::detail::AttribType::Float3,
+                    "float4" => crate::detail::AttribType::Float4,
+                    _ => crate::detail::AttribType::Float,
                 };
-                match fit(width) {
+                match fit(ty.components()) {
                     Some(src) => {
-                        let make = || match width {
-                            2 => GAttribute::Float2([src[0], src[1]]),
-                            3 => GAttribute::Float3([src[0], src[1], src[2]]),
-                            4 => GAttribute::Float4([src[0], src[1], src[2], src[3]]),
-                            _ => GAttribute::Float(src[0]),
-                        };
-                        for v in geom.vertices.iter_mut().filter(|v| affected(v)) {
-                            v.attributes.insert(name.clone(), make());
+                        let zero = components_attrib(ty, &vec![0.0; ty.components()]);
+                        let value = components_attrib(ty, &src);
+                        geom.points_mut().create(&name, zero);
+                        for &p in &affected {
+                            let _ = geom.points_mut().set_value(&name, p, value);
                         }
                     }
                     None => {
-                        fail = format!("Value '{}' does not fit {}", value_str, ty);
+                        fail = format!("Value '{}' does not fit {}", value_str, ty.name());
                     }
                 }
             }
@@ -1393,16 +1440,41 @@ pub fn resolve_attribute_geometry_with_errors(
     Some(geom)
 }
 
-/// Positions of the vertices a Group node tagged into `group:<name>` — the
-/// source data for the selected-Group viewport markers. Duplicate positions
-/// (the triangle soup repeats shared corners) are left in; `points_vertices`
-/// dedupes by quantized position.
-pub fn group_member_positions(geom: &Geometry, group_name: &str) -> Vec<Vertex3D> {
-    let attr = format!("group:{}", group_name.trim());
-    geom.vertices
-        .iter()
-        .filter(|v| v.attributes.contains_key(&attr))
-        .map(|v| Vertex3D { position: v.pos, color: [0.0; 3] })
+/// An attribute value as loose components, for the arithmetic that does not
+/// care how wide it is.
+fn attrib_components(v: AttribValue) -> Vec<f32> {
+    match v {
+        AttribValue::Float(x) => vec![x],
+        AttribValue::Float2(x) => x.to_vec(),
+        AttribValue::Float3(x) => x.to_vec(),
+        AttribValue::Float4(x) => x.to_vec(),
+        AttribValue::Int(x) => vec![x as f32],
+    }
+}
+
+/// Components back into a value of the given type, zero-padding a short slice.
+fn components_attrib(ty: crate::detail::AttribType, c: &[f32]) -> AttribValue {
+    let at = |i: usize| c.get(i).copied().unwrap_or(0.0);
+    match ty {
+        crate::detail::AttribType::Float => AttribValue::Float(at(0)),
+        crate::detail::AttribType::Float2 => AttribValue::Float2([at(0), at(1)]),
+        crate::detail::AttribType::Float3 => AttribValue::Float3([at(0), at(1), at(2)]),
+        crate::detail::AttribType::Float4 => AttribValue::Float4([at(0), at(1), at(2), at(3)]),
+        crate::detail::AttribType::Int => AttribValue::Int(at(0) as i32),
+    }
+}
+
+/// Positions of the points in a group — the source data for the
+/// selected-Group viewport markers.
+///
+/// The soup version had to hand back duplicates (it repeated every shared
+/// corner) and relied on `points_vertices` deduping by quantized position.
+/// A point group has each point once.
+pub fn group_member_positions(geom: &Detail, group_name: &str) -> Vec<Vertex3D> {
+    geom.points()
+        .group_members(group_name)
+        .into_iter()
+        .map(|p| Vertex3D { position: geom.positions()[p as usize], color: [0.0; 3] })
         .collect()
 }
 
@@ -1420,7 +1492,7 @@ pub fn resolve_scatter_geometry_with_errors(
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     // No visited guard here: `generate_single_node_geometry_with_errors`
     // pushes the target's id before dispatching to this resolver, so a local
     // `visited.contains` check refused every dispatched call — scatter
@@ -1442,10 +1514,8 @@ pub fn resolve_scatter_geometry_with_errors(
     let mut min_pos = Vec3::splat(f32::MAX);
     let mut max_pos = Vec3::splat(f32::MIN);
 
-    for chunk in geom.vertices.chunks_exact(3) {
-        let v0 = Vec3::from_array(chunk[0].pos);
-        let v1 = Vec3::from_array(chunk[1].pos);
-        let v2 = Vec3::from_array(chunk[2].pos);
+    for chunk in geom.triangulate(|pos, _| Vec3::from(pos)).chunks_exact(3) {
+        let (v0, v1, v2) = (chunk[0], chunk[1], chunk[2]);
 
         min_pos = min_pos.min(v0).min(v1).min(v2);
         max_pos = max_pos.max(v0).max(v1).max(v2);
@@ -1467,10 +1537,10 @@ pub fn resolve_scatter_geometry_with_errors(
     }
 
     let res = if triangles.is_empty() {
-        Geometry::new()
+        Detail::new()
     } else {
         let mut rng = SimpleRng::new(1337);
-        let mut scattered_geom = Geometry::new();
+        let mut scattered_geom = Detail::new();
         let mut found_count = 0;
         let max_attempts = (num_points * 100).max(10_000);
 
@@ -1502,7 +1572,7 @@ pub fn resolve_scatter_geometry_with_errors(
             }
 
             if intersection_count % 2 == 1 {
-                scattered_geom.merge(sphere_vertices_res(candidate, radius, 6, 8));
+                scattered_geom.merge(&sphere_detail(candidate, radius, 6, 8));
                 found_count += 1;
             }
         }
@@ -1726,9 +1796,9 @@ pub fn resolve_opencl_geometry_with_errors(
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     let input_name = node_param_str(target, "Input", "");
-    let mut geom = if !input_name.is_empty() {
+    let input = if !input_name.is_empty() {
         // Siblings first, exactly like the output type's lookup: subnet
         // templates (Extrude) wire their inner opencl to a child named
         // "input1", and a global-first search would resolve to the FIRST
@@ -1736,13 +1806,21 @@ pub fn resolve_opencl_geometry_with_errors(
         let sibling = find_parent_node(root, &target.id)
             .and_then(|p| p.children.iter().find(|c| c.name == input_name || c.id == input_name));
         if let Some(input_node) = sibling.or_else(|| find_node_by_name(root, &input_name)) {
-            generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim).unwrap_or_default()
+            generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)
+                .unwrap_or_default()
         } else {
-            Geometry::default()
+            Detail::new()
         }
     } else {
-        Geometry::default()
+        Detail::new()
     };
+
+    // The kernel ABI is still (positions, colors) over a flat corner list, so
+    // this node — alone among the operators — flattens to a soup and comes
+    // back. Phase 1 widens the ABI to bind named attribute arrays and the
+    // round trip goes away.
+    let corner_point = input.triangulate_points();
+    let mut geom = detail_to_soup(&input);
     let code = node_param_str(target, "Code", "");
     if !code.is_empty() {
         let parsed_params = parse_dynamic_params(&code);
@@ -1802,7 +1880,33 @@ pub fn resolve_opencl_geometry_with_errors(
             }
         }
     }
-    Some(geom)
+
+    // A DEFORMER left the corner count alone, so every corner still belongs to
+    // the point it came from: write the results back in place and the input's
+    // topology, groups, attributes and point identities all survive. That
+    // matters most inside a simnet, where a kernel that re-welded its output
+    // every step would hand the solver a new set of points each frame.
+    //
+    // Where corners of one point disagree — a kernel free to move each corner
+    // independently — the first one wins, which is what deforming a surface
+    // whose points are shared has to mean.
+    //
+    // A GENERATOR built a different corner list, so there is nothing to map
+    // back onto; its output welds into fresh geometry with fresh identities,
+    // which is correct, because the points are genuinely new.
+    if geom.vertices.len() == corner_point.len() {
+        let mut result = input;
+        let mut written = vec![false; result.num_points()];
+        for (corner, v) in geom.vertices.iter().enumerate() {
+            let p = corner_point[corner] as usize;
+            if !std::mem::replace(&mut written[p], true) {
+                result.set_pos(p, Vec3::from(v.pos));
+                result.set_color(p, v.col);
+            }
+        }
+        return Some(result);
+    }
+    Some(soup_to_detail(&geom))
 }
 
 
@@ -2111,7 +2215,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
 /// The scene at the timeline's start frame, with a throwaway sim cache — every
 /// simnet shows its seed. Callers that have a timeline should build their own
 /// [`EvalSim`] and keep its [`SimCache`] across frames.
-pub fn network_sphere_vertices(root: &FsNode) -> Geometry {
+pub fn network_sphere_vertices(root: &FsNode) -> Detail {
     let mut err = None;
     let mut cache = SimCache::default();
     let mut sim = EvalSim::new(0, 0, &mut cache);
@@ -2135,13 +2239,13 @@ pub fn network_sphere_vertices_with_errors(
     start: &FsNode,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Geometry {
+) -> Detail {
     // Inside a simnet the chain is the simulation STEP; drawing its nodes
     // would show one un-iterated pass of the chain. The interior view is the
     // solved state at the current frame — the same geometry the parent level
     // draws for the simnet — toggled by the output child's geometry flag.
     if start.node_type.eq_ignore_ascii_case("simnet") {
-        let mut out = Geometry::new();
+        let mut out = Detail::new();
         let display_on = start
             .children
             .iter()
@@ -2151,19 +2255,19 @@ pub fn network_sphere_vertices_with_errors(
         if display_on {
             let mut visited = Vec::new();
             if let Some(geom) = resolve_simnet_geometry_with_errors(root, start, &mut visited, ocl_error, sim) {
-                out.merge(geom);
+                out.merge(&geom);
             }
         }
         return out;
     }
-    fn visit(root: &FsNode, node: &FsNode, parent_visible: bool, top: bool, count: &mut usize, out: &mut Geometry, ocl_error: &mut Option<String>, sim: &mut EvalSim) {
+    fn visit(root: &FsNode, node: &FsNode, parent_visible: bool, top: bool, count: &mut usize, out: &mut Detail, ocl_error: &mut Option<String>, sim: &mut EvalSim) {
         let is_visible = parent_visible && node.geometry_visible;
         if node.node_type.eq_ignore_ascii_case("sphere") {
             let idx = *count;
             *count += 1;
             if is_visible {
                 let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
-                out.merge(sphere_vertices(center, node_param_f32(node, "Radius", 0.5).max(0.05)));
+                out.merge(&sphere_detail(center, node_param_f32(node, "Radius", 0.5).max(0.05), 16, 24));
             }
         } else if node.node_type.eq_ignore_ascii_case("line") {
             let idx = *count;
@@ -2173,20 +2277,20 @@ pub fn network_sphere_vertices_with_errors(
                 let length = node_param_f32(node, "Length", 1.0);
                 let thickness = node_param_f32(node, "Thickness", 0.02);
                 let end = start + Vec3::new(0.0, length, 0.0);
-                out.merge(line_vertices(start, end, thickness));
+                out.merge(&box_detail(start, end, thickness));
             }
         } else if node.node_type.eq_ignore_ascii_case("curve") {
             // Absolute world coordinates: no grid-index placement, and
             // `count` untouched so find_sphere_index stays aligned.
             if is_visible {
-                out.merge(curve_geometry(node));
+                out.merge(&curve_detail(node));
             }
         } else if node.node_type.eq_ignore_ascii_case("points") {
             let idx = *count;
             *count += 1;
             if is_visible {
                 let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
-                out.merge(points_node_geometry(node, center));
+                out.merge(&points_detail(node, center));
             }
         } else if node.node_type.eq_ignore_ascii_case("transform") {
             let _idx = *count;
@@ -2194,7 +2298,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = resolve_transform_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
         } else if node.node_type.eq_ignore_ascii_case("scatter") {
@@ -2203,7 +2307,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = resolve_scatter_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
         } else if node.node_type.eq_ignore_ascii_case("group") {
@@ -2212,7 +2316,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = resolve_group_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
         } else if node.node_type.eq_ignore_ascii_case("attribute") {
@@ -2221,7 +2325,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = resolve_attribute_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
         } else if node.node_type.eq_ignore_ascii_case("relax") {
@@ -2230,7 +2334,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = resolve_relax_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
         } else if node.node_type.eq_ignore_ascii_case("collision") {
@@ -2239,7 +2343,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = resolve_collision_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
         } else if node.node_type.eq_ignore_ascii_case("opencl") {
@@ -2248,7 +2352,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = resolve_opencl_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
         } else if node.node_type.eq_ignore_ascii_case("simnet") {
@@ -2257,7 +2361,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = resolve_simnet_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
             // The chain inside a simnet is the simulation STEP, not scene
@@ -2279,7 +2383,7 @@ pub fn network_sphere_vertices_with_errors(
             if is_visible {
                 let mut visited = Vec::new();
                 if let Some(geom) = generate_single_node_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
-                    out.merge(geom);
+                    out.merge(&geom);
                 }
             }
             return;
@@ -2289,7 +2393,7 @@ pub fn network_sphere_vertices_with_errors(
         }
     }
 
-    let mut out = Geometry::new();
+    let mut out = Detail::new();
     let mut count = 0;
     for child in &start.children {
         visit(root, child, true, true, &mut count, &mut out, ocl_error, sim);
@@ -2632,6 +2736,15 @@ pub fn grid_vertices(thickness: f32, color: [f32; 3]) -> Vec<Vertex3D> {
 /// Spelled out because it is no longer `lat_steps * lon_steps * 6` — the two
 /// pole bands used to contribute a zero-area triangle each, and welded poles
 /// do not.
+/// Points in a welded UV sphere: the two poles plus `lat_steps - 1` rings.
+///
+/// The counterpart of [`sphere_soup_len`] on the other side of the weld, and
+/// the number every test that used to say `lat * lon * 6` now wants.
+#[cfg(test)]
+pub(crate) const fn sphere_point_len(lat_steps: usize, lon_steps: usize) -> usize {
+    2 + (lat_steps - 1) * lon_steps
+}
+
 #[cfg(test)]
 pub(crate) const fn sphere_soup_len(lat_steps: usize, lon_steps: usize) -> usize {
     (2 + (lat_steps - 2) * 2) * lon_steps * 3
@@ -2953,7 +3066,7 @@ mod tests {
             position: (0.0, 0.0),
         };
         let geom = network_sphere_vertices(&root);
-        assert_eq!(geom.vertices.len(), 5 * super::sphere_soup_len(6, 8));
+        assert_eq!(geom.num_points(), 5 * super::sphere_point_len(6, 8));
 
         // Shape "None": every point sits in the same spot, so all five marker
         // spheres cover an identical (tiny) extent. A spread shape must not.
@@ -3051,10 +3164,10 @@ mod tests {
         // Test normal transform
         let mut visited = Vec::new();
         let geom1 = resolve_transform_geometry(&root, &transform1, &mut visited).unwrap();
-        assert!(!geom1.vertices.is_empty());
-        let avg_x = geom1.vertices.iter().map(|v| v.pos[0]).sum::<f32>() / geom1.vertices.len() as f32;
-        let avg_y = geom1.vertices.iter().map(|v| v.pos[1]).sum::<f32>() / geom1.vertices.len() as f32;
-        let avg_z = geom1.vertices.iter().map(|v| v.pos[2]).sum::<f32>() / geom1.vertices.len() as f32;
+        assert!(!geom1.is_empty());
+        let avg_x = geom1.positions().iter().map(|p| p[0]).sum::<f32>() / geom1.num_points() as f32;
+        let avg_y = geom1.positions().iter().map(|p| p[1]).sum::<f32>() / geom1.num_points() as f32;
+        let avg_z = geom1.positions().iter().map(|p| p[2]).sum::<f32>() / geom1.num_points() as f32;
         assert!((avg_x - -0.875).abs() < 0.01);
         assert!((avg_y - 2.55).abs() < 0.01);
         assert!((avg_z - 3.0).abs() < 0.01);
@@ -3105,9 +3218,9 @@ mod tests {
         };
         let mut visited = Vec::new();
         let geom2 = resolve_transform_geometry(&root_chained, &transform2, &mut visited).unwrap();
-        let avg_chained_x = geom2.vertices.iter().map(|v| v.pos[0]).sum::<f32>() / geom2.vertices.len() as f32;
-        let avg_chained_y = geom2.vertices.iter().map(|v| v.pos[1]).sum::<f32>() / geom2.vertices.len() as f32;
-        let avg_chained_z = geom2.vertices.iter().map(|v| v.pos[2]).sum::<f32>() / geom2.vertices.len() as f32;
+        let avg_chained_x = geom2.positions().iter().map(|p| p[0]).sum::<f32>() / geom2.num_points() as f32;
+        let avg_chained_y = geom2.positions().iter().map(|p| p[1]).sum::<f32>() / geom2.num_points() as f32;
+        let avg_chained_z = geom2.positions().iter().map(|p| p[2]).sum::<f32>() / geom2.num_points() as f32;
         assert!((avg_chained_x - -1.875).abs() < 0.01);
         assert!((avg_chained_y - 1.55).abs() < 0.01);
         assert!((avg_chained_z - 2.0).abs() < 0.01);
@@ -3246,11 +3359,11 @@ mod tests {
         let mut visited = Vec::new();
         let mut err = None;
         let geom = resolve_opencl_geometry_with_errors(&root, &opencl_node, &mut visited, &mut err, &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default())).unwrap();
-        assert!(!geom.vertices.is_empty());
+        assert!(!geom.is_empty());
         assert!(err.is_none());
 
         // The sphere should be translated up by 2.0 on the y axis compared to the standard sphere (which centers around y=0.55 for index 0)
-        let avg_y = geom.vertices.iter().map(|v| v.pos[1]).sum::<f32>() / geom.vertices.len() as f32;
+        let avg_y = geom.positions().iter().map(|p| p[1]).sum::<f32>() / geom.num_points() as f32;
         assert!((avg_y - 2.55).abs() < 0.01);
     }
 
@@ -3338,17 +3451,20 @@ mod tests {
         let geom = resolve_scatter_geometry(&root, &scatter, &mut visited).unwrap();
 
         // 15 scattered spheres, each a lat_steps=6, lon_steps=8 marker.
-        assert_eq!(geom.vertices.len(), 15 * super::sphere_soup_len(6, 8));
+        assert_eq!(geom.num_points(), 15 * super::sphere_point_len(6, 8));
 
         // Center of sphere at idx 0 is Vec3::new(-1.875, 0.55, 0.0). Radius = 0.5.
         // Let's check that each scattered sphere's center is indeed inside the parent sphere.
         let center = Vec3::new(-1.875, 0.55, 0.0);
-        for chunk in geom.vertices.chunks_exact(288) {
+        // Each marker is a lat=6, lon=8 sphere: two poles plus five rings of
+        // eight, and merge lays them down one after another.
+        const MARKER_POINTS: usize = 2 + (6 - 1) * 8;
+        for chunk in geom.positions().chunks_exact(MARKER_POINTS) {
             let mut sum = Vec3::ZERO;
-            for v in chunk {
-                sum += Vec3::from_array(v.pos);
+            for p in chunk {
+                sum += Vec3::from_array(*p);
             }
-            let avg = sum / 288.0;
+            let avg = sum / MARKER_POINTS as f32;
             let dist = avg.distance(center);
             assert!(dist <= 0.5, "Scattered point center {:?} (distance {}) is outside the sphere of radius 0.5", avg, dist);
         }
@@ -3370,7 +3486,7 @@ pub fn resolve_simnet_geometry_with_errors(
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
-) -> Option<Geometry> {
+) -> Option<Detail> {
     let output_node = target
         .children
         .iter()
@@ -3380,7 +3496,7 @@ pub fn resolve_simnet_geometry_with_errors(
     let seed = {
         let input_name = node_param_str(target, "Input", "");
         if input_name.is_empty() {
-            Geometry::new()
+            Detail::new()
         } else {
             find_node_by_name(root, &input_name)
                 .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
@@ -3469,19 +3585,19 @@ mod simnet_tests {
         let sub = node("id-sub", "Sub 1", "node", vec![], vec![inner]);
         let root = node("id-root", "root", "node", vec![], vec![outer, sub]);
 
-        const SPHERE: usize = super::sphere_soup_len(16, 24);
+        const SPHERE: usize = super::sphere_point_len(16, 24);
         let mut err = None;
         let mut cache = SimCache::default();
         let mut sim = EvalSim::new(0, 0, &mut cache);
         let all = network_sphere_vertices_with_errors(&root, &root, &mut err, &mut sim);
-        assert_eq!(all.vertices.len(), 2 * SPHERE);
+        assert_eq!(all.num_points(), 2 * SPHERE);
 
         let mut err = None;
         let mut cache = SimCache::default();
         let mut sim = EvalSim::new(0, 0, &mut cache);
         let scoped =
             network_sphere_vertices_with_errors(&root, &root.children[1], &mut err, &mut sim);
-        assert_eq!(scoped.vertices.len(), SPHERE);
+        assert_eq!(scoped.num_points(), SPHERE);
     }
 
     /// A simnet whose chain is one Transform: each step shifts the geometry by
@@ -3508,7 +3624,7 @@ mod simnet_tests {
         node("id-root", "root", "node", vec![], vec![sphere, sim])
     }
 
-    fn solve_at(root: &FsNode, frame: i32) -> Geometry {
+    fn solve_at(root: &FsNode, frame: i32) -> Detail {
         let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap();
         let mut cache = SimCache::default();
         let mut sim = EvalSim::new(frame, 1, &mut cache);
@@ -3518,8 +3634,8 @@ mod simnet_tests {
             .expect("simnet solves")
     }
 
-    fn min_x(g: &Geometry) -> f32 {
-        g.vertices.iter().map(|v| v.pos[0]).fold(f32::INFINITY, f32::min)
+    fn min_x(g: &Detail) -> f32 {
+        g.positions().iter().map(|p| p[0]).fold(f32::INFINITY, f32::min)
     }
 
     #[test]
@@ -3539,8 +3655,8 @@ mod simnet_tests {
         )
         .expect("seed geometry");
 
-        assert!(!seeded.vertices.is_empty(), "the sim produced nothing at its start frame");
-        assert_eq!(seeded.vertices.len(), raw.vertices.len());
+        assert!(!seeded.is_empty(), "the sim produced nothing at its start frame");
+        assert_eq!(seeded.num_points(), raw.num_points());
         assert!((min_x(&seeded) - min_x(&raw)).abs() < 1e-4,
             "at the start frame the sim has taken no steps, so it must BE the seed");
     }
@@ -3635,7 +3751,7 @@ mod simnet_tests {
         let mut sim = EvalSim::new(4, 1, &mut cache);
         let mut err = None;
         let interior = network_sphere_vertices_with_errors(&root, sim_node, &mut err, &mut sim);
-        assert!(!interior.vertices.is_empty(), "simnet interior rendered empty");
+        assert!(!interior.is_empty(), "simnet interior rendered empty");
         let moved = min_x(&interior) - base;
         assert!((moved - 3.0).abs() < 1e-4, "frame 4 = 3 steps of +1.0, got {moved}");
 
@@ -3649,7 +3765,7 @@ mod simnet_tests {
         let mut sim = EvalSim::new(4, 1, &mut cache);
         let mut err = None;
         let toggled = network_sphere_vertices_with_errors(&hidden, sim_node, &mut err, &mut sim);
-        assert!(toggled.vertices.is_empty(), "output toggle off should hide the solved state");
+        assert!(toggled.is_empty(), "output toggle off should hide the solved state");
     }
 
     /// Dived into a pass-through subnet (input → output, no generator) the
@@ -3665,23 +3781,23 @@ mod simnet_tests {
             vec![inner_input, inner_output]);
         let root = node("id-root", "root", "node", vec![], vec![sphere, sub]);
 
-        const SPHERE: usize = super::sphere_soup_len(16, 24);
+        const SPHERE: usize = super::sphere_point_len(16, 24);
         let mut err = None;
         let mut cache = SimCache::default();
         let mut sim = EvalSim::new(0, 0, &mut cache);
         let all = network_sphere_vertices_with_errors(&root, &root, &mut err, &mut sim);
-        assert_eq!(all.vertices.len(), SPHERE, "outer view must not gain a copy from the arms");
+        assert_eq!(all.num_points(), SPHERE, "outer view must not gain a copy from the arms");
 
         let mut err = None;
         let mut cache = SimCache::default();
         let mut sim = EvalSim::new(0, 0, &mut cache);
         let interior =
             network_sphere_vertices_with_errors(&root, &root.children[1], &mut err, &mut sim);
-        assert_eq!(interior.vertices.len(), 2 * SPHERE,
+        assert_eq!(interior.num_points(), 2 * SPHERE,
             "input draws the seed and output draws the chain result");
     }
 
-    fn eval(root: &FsNode, name: &str) -> Geometry {
+    fn eval(root: &FsNode, name: &str) -> Detail {
         let target = root.children.iter().find(|c| c.name == name).unwrap();
         let mut visited = Vec::new();
         let mut err = None;
@@ -3715,26 +3831,21 @@ mod simnet_tests {
         };
 
         let g = eval(&make_root("7"), "Group 1");
-        let tagged: Vec<usize> = (0..g.vertices.len())
-            .filter(|&i| g.vertices[i].attributes.contains_key("group:pull"))
-            .collect();
-        assert!(!tagged.is_empty(), "random mode selected nothing");
-        let anchor = g.vertices[tagged[0]].pos;
-        let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4);
-        for &i in &tagged {
-            assert!(near(g.vertices[i].pos, anchor), "one random point must be ONE welded position");
-        }
-        for (i, v) in g.vertices.iter().enumerate() {
-            if near(v.pos, anchor) {
-                assert!(tagged.contains(&i), "a coincident copy was left untagged (would tear the surface)");
-            }
-        }
+        let tagged = g.points().group_members("pull");
+        // Count = 1 means ONE point. The soup version had to assert this the
+        // long way round — that every coincident copy of the drawn position was
+        // tagged too, or a downstream move would tear the surface open. There
+        // are no copies to miss now.
+        assert_eq!(tagged.len(), 1, "Count = 1 must select exactly one point");
 
         let again = eval(&make_root("7"), "Group 1");
-        let tagged_again: Vec<usize> = (0..again.vertices.len())
-            .filter(|&i| again.vertices[i].attributes.contains_key("group:pull"))
-            .collect();
-        assert_eq!(tagged, tagged_again, "same Seed must select the same point");
+        assert_eq!(
+            tagged,
+            again.points().group_members("pull"),
+            "same Seed must select the same point"
+        );
+        let other = eval(&make_root("12"), "Group 1");
+        assert_eq!(other.points().group_members("pull").len(), 1);
     }
 
     /// The Collision node's Inside method marks exactly the input points
@@ -3766,11 +3877,11 @@ mod simnet_tests {
         // center-symmetric, so the vertex mean is the center.
         let root = build("Sphere 2", "Inside");
         let s2_geom = eval(&root, "Sphere 2");
-        let n2 = s2_geom.vertices.len() as f32;
+        let n2 = s2_geom.num_points() as f32;
         let mut c2 = [0.0f32; 3];
-        for v in &s2_geom.vertices {
+        for p in s2_geom.positions() {
             for k in 0..3 {
-                c2[k] += v.pos[k] / n2;
+                c2[k] += p[k] / n2;
             }
         }
 
@@ -3779,39 +3890,38 @@ mod simnet_tests {
             ((p[0] - c2[0]).powi(2) + (p[1] - c2[1]).powi(2) + (p[2] - c2[2]).powi(2)).sqrt()
         };
         let mut tagged = 0usize;
-        for v in &g.vertices {
-            let has = v.attributes.contains_key("group:collisions");
-            if dist(v.pos) < 0.7 - 1e-3 {
-                assert!(has, "enclosed point untagged at {:?}", v.pos);
+        for (p, pos) in g.positions().iter().enumerate() {
+            let has = g.points().in_group("collisions", p);
+            if dist(*pos) < 0.7 - 1e-3 {
+                assert!(has, "enclosed point untagged at {:?}", pos);
                 tagged += 1;
-            } else if dist(v.pos) > 0.7 + 1e-2 {
-                assert!(!has, "outside point tagged at {:?}", v.pos);
+            } else if dist(*pos) > 0.7 + 1e-2 {
+                assert!(!has, "outside point tagged at {:?}", pos);
             }
         }
         assert!(tagged > 0, "overlapping spheres must tag the overlap cap");
-        assert!(tagged < g.vertices.len(), "only the cap is enclosed, not the whole sphere");
+        assert!(tagged < g.num_points(), "only the cap is enclosed, not the whole sphere");
 
         // Proximity is a SURFACE band, not containment: every tagged point
         // sits within Distance of the collider's surface, and with the two
         // spheres interpenetrating the band is non-empty.
         let prox = eval(&build("Sphere 2", "Proximity"), "Collision 1");
         let mut band = 0usize;
-        for v in &prox.vertices {
-            if v.attributes.contains_key("group:collisions") {
-                assert!(
-                    (dist(v.pos) - 0.7).abs() <= 0.05 + 1e-2,
-                    "proximity tag outside the band at {:?}",
-                    v.pos
-                );
-                band += 1;
-            }
+        for p in prox.points().group_members("collisions") {
+            let pos = prox.positions()[p as usize];
+            assert!(
+                (dist(pos) - 0.7).abs() <= 0.05 + 1e-2,
+                "proximity tag outside the band at {:?}",
+                pos
+            );
+            band += 1;
         }
         assert!(band > 0, "a 0.05 band around an intersecting surface must catch boundary points");
 
         // No collider configured: pass-through, nothing tagged.
         let clean = eval(&build("", "Inside"), "Collision 1");
         assert!(
-            clean.vertices.iter().all(|v| !v.attributes.contains_key("group:collisions")),
+            clean.points().group_members("collisions").is_empty(),
             "an unconfigured collider must not write the group"
         );
     }
@@ -3868,16 +3978,16 @@ mod simnet_tests {
         let base = eval(&root, "Group 1");
         let pulled = eval(&root, "Pull 1");
         let relaxed = eval(&root, "Relax 1");
-        assert_eq!(relaxed.vertices.len(), base.vertices.len());
+        assert_eq!(relaxed.num_points(), base.num_points());
 
         let dist = |a: [f32; 3], b: [f32; 3]| -> f32 {
             a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
         };
         let mut max_response: f32 = 0.0;
-        for i in 0..base.vertices.len() {
-            let moved = dist(relaxed.vertices[i].pos, base.vertices[i].pos);
-            if base.vertices[i].attributes.contains_key("group:pull") {
-                assert!(dist(relaxed.vertices[i].pos, pulled.vertices[i].pos) < 1e-4,
+        for i in 0..base.num_points() {
+            let moved = dist(relaxed.positions()[i], base.positions()[i]);
+            if base.points().in_group("pull", i) {
+                assert!(dist(relaxed.positions()[i], pulled.positions()[i]) < 1e-4,
                     "the pinned point must keep its pulled position");
             } else {
                 assert!(moved <= 0.5 + 1e-3, "a neighbor overshot the pull itself");
diff --git a/src/main.rs b/src/main.rs
index 64b4643..64fee75 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1239,6 +1239,7 @@ mod tests {
     #[test]
     fn test_template_meshes_wind_ccw_outward() {
         let templates_root = crate::app::load_fs_tree();
+        // Winding is a property of triangles, so this one flattens on purpose.
         let eval_template = |name: &str| -> crate::geometry::Geometry {
             let t = templates_root
                 .children
@@ -1272,7 +1273,7 @@ mod tests {
             )
             .expect("geometry");
             assert!(err.is_none(), "{name}: {err:?}");
-            g
+            crate::geometry::detail_to_soup(&g)
         };
         let tri_cross = |g: &crate::geometry::Geometry, tri: usize| -> [f32; 3] {
             let a = g.vertices[tri * 3].pos;
@@ -1389,13 +1390,13 @@ mod tests {
         ).expect("Geometry generation failed");
         
         assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
-        assert_eq!(geom.vertices.len(), 2304);
+        assert_eq!(geom.num_points(), crate::geometry::sphere_point_len(16, 24));
         
         let mut max_dist: f32 = 0.0;
-        for v in &geom.vertices {
-            let dx = v.pos[0] - 0.0;
-            let dy = v.pos[1] - 0.55;
-            let dz = v.pos[2] - 0.0;
+        for pos in geom.positions() {
+            let dx = pos[0] - 0.0;
+            let dy = pos[1] - 0.55;
+            let dz = pos[2] - 0.0;
             let dist = (dx*dx + dy*dy + dz*dz).sqrt();
             if dist > max_dist {
                 max_dist = dist;
@@ -1435,13 +1436,13 @@ mod tests {
         ).expect("Geometry generation failed");
         
         assert!(ocl_err_2.is_none(), "OpenCL compilation error: {:?}", ocl_err_2);
-        assert_eq!(geom_2.vertices.len(), 2304);
+        assert_eq!(geom_2.num_points(), crate::geometry::sphere_point_len(16, 24));
         
         let mut max_dist_2: f32 = 0.0;
-        for v in &geom_2.vertices {
-            let dx = v.pos[0] - 0.0;
-            let dy = v.pos[1] - 0.55;
-            let dz = v.pos[2] - 0.0;
+        for pos in geom_2.positions() {
+            let dx = pos[0] - 0.0;
+            let dy = pos[1] - 0.55;
+            let dz = pos[2] - 0.0;
             let dist = (dx*dx + dy*dy + dz*dz).sqrt();
             if dist > max_dist_2 {
                 max_dist_2 = dist;
@@ -1480,18 +1481,18 @@ mod tests {
                 .expect("Geometry generation failed")
         };
 
-        // Default: 4 points, 8 segments per span → 3*8 spans × 36 vertices.
+        // Default: 4 points, 8 segments per span → 3*8 spans, one box each.
         let mut instance = curve_template.clone();
         instance.id = "curve_inst".to_string();
         let geom = eval(&make_root(instance.clone()));
-        assert_eq!(geom.vertices.len(), 864);
-        for v in &geom.vertices {
-            assert!(v.pos.iter().all(|c| c.is_finite()), "curve produced non-finite positions");
+        assert_eq!(geom.num_points(), 24 * 8, "one eight-cornered box per span");
+        for pos in geom.positions() {
+            assert!(pos.iter().all(|c| c.is_finite()), "curve produced non-finite positions");
         }
         // The strip reaches both endpoint control points.
-        let near = |g: &crate::geometry::Geometry, p: [f32; 3]| {
-            g.vertices.iter().any(|v| {
-                (0..3).map(|k| (v.pos[k] - p[k]).powi(2)).sum::<f32>().sqrt() < 0.1
+        let near = |g: &crate::detail::Detail, p: [f32; 3]| {
+            g.positions().iter().any(|q| {
+                (0..3).map(|k| (q[k] - p[k]).powi(2)).sum::<f32>().sqrt() < 0.1
             })
         };
         assert!(near(&geom, [-0.75, 0.05, 0.0]), "curve does not reach its first point");
@@ -1502,17 +1503,17 @@ mod tests {
         instance.params.iter_mut().find(|p| p.name == "Points").unwrap().default =
             "0 0 0; 1 0 0".to_string();
         let root = make_root(instance.clone());
-        assert_eq!(eval(&root).vertices.len(), 288);
+        assert_eq!(eval(&root).num_points(), 8 * 8);
         assert_eq!(
-            crate::geometry::network_sphere_vertices(&root).vertices.len(),
-            288,
+            crate::geometry::network_sphere_vertices(&root).num_points(),
+            8 * 8,
             "scene walk and single-node eval disagree"
         );
 
         // No parseable points: empty geometry, not a panic.
         instance.params.iter_mut().find(|p| p.name == "Points").unwrap().default =
             "not points".to_string();
-        assert_eq!(eval(&make_root(instance)).vertices.len(), 0);
+        assert_eq!(eval(&make_root(instance)).num_points(), 0);
     }
 
     /// Points round-trip through the "Points" param format; malformed
@@ -1864,16 +1865,17 @@ mod tests {
             &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
         ).expect("Extrude geometry generation failed");
         assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
-        // 2304 sphere vertices = 768 triangles; 768 * 24 = 18432.
-        assert_eq!(geom.vertices.len(), 18432);
+        // The extrude kernel builds a wall per input triangle, so its output
+        // is a soup of loose shells; welding it is what 1850 counts.
+        assert_eq!(geom.num_points(), 1850);
 
         // Extruding a radius-0.5 sphere outward by the default 0.2 pushes the
         // farthest vertices to ~0.7 from its center.
         let mut max_dist: f32 = 0.0;
-        for v in &geom.vertices {
-            let dx = v.pos[0];
-            let dy = v.pos[1] - 0.55;
-            let dz = v.pos[2];
+        for pos in geom.positions() {
+            let dx = pos[0];
+            let dy = pos[1] - 0.55;
+            let dz = pos[2];
             max_dist = max_dist.max((dx * dx + dy * dy + dz * dz).sqrt());
         }
         assert!((max_dist - 0.7).abs() < 0.02, "Expected max extent ~0.7, got {}", max_dist);
@@ -1892,7 +1894,16 @@ mod tests {
             &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
         ).expect("Extrude geometry generation failed (no base)");
         assert!(ocl_err2.is_none(), "OpenCL compilation error: {:?}", ocl_err2);
-        assert_eq!(geom2.vertices.len(), 16128);
+        // Same POINTS as the based variant: the base cap's corners are the
+        // wall corners, so dropping the cap removes primitives, not places.
+        // The primitive count is where the two variants actually differ.
+        assert_eq!(geom2.num_points(), geom.num_points());
+        assert!(
+            geom2.num_prims() < geom.num_prims(),
+            "no-base extrude should have fewer prims: {} vs {}",
+            geom2.num_prims(),
+            geom.num_prims()
+        );
     }
 
     /// Group membership → viewport markers: the `group:<name>` tags a Group
@@ -1955,16 +1966,16 @@ mod tests {
 
         let members = crate::geometry::group_member_positions(&geom, "group1");
         assert!(!members.is_empty(), "the box should tag the upper hemisphere");
-        assert!(members.len() < geom.vertices.len(), "the box must not tag everything");
+        assert!(members.len() < geom.num_points(), "the box must not tag everything");
         for m in &members {
             assert!(m.position[1] >= 0.55 - 1e-4, "member below the box: y={}", m.position[1]);
         }
-        // The tags and the box agree: every untagged vertex is outside it.
-        let tagged: usize = geom.vertices.iter().filter(|v| v.attributes.contains_key("group:group1")).count();
-        assert_eq!(tagged, members.len());
-        for v in &geom.vertices {
-            if !v.attributes.contains_key("group:group1") {
-                assert!(v.pos[1] <= 0.55 + 1e-4, "non-member inside the box: y={}", v.pos[1]);
+        // The group and the box agree: every non-member is outside it.
+        assert_eq!(geom.points().group_len("group1"), members.len());
+        for p in 0..geom.num_points() {
+            if !geom.points().in_group("group1", p) {
+                let y = geom.positions()[p][1];
+                assert!(y <= 0.55 + 1e-4, "non-member inside the box: y={y}");
             }
         }
 
@@ -2016,7 +2027,7 @@ mod tests {
             inputs: 0,
             outputs: 0,
         };
-        let eval = |root: &FsNode, idx: usize| -> (Option<Geometry>, Option<String>) {
+        let eval = |root: &FsNode, idx: usize| -> (Option<crate::detail::Detail>, Option<String>) {
             let mut visited = Vec::new();
             let mut ocl_err = None;
             let geom = crate::geometry::generate_single_node_geometry_with_errors(
@@ -2065,22 +2076,22 @@ mod tests {
         let (geom, err) = eval(&root, 1);
         let geom = geom.expect("Create");
         assert!(err.is_none(), "{err:?}");
-        assert_eq!(geom.vertices.len(), base.vertices.len());
-        assert!(geom.vertices.iter().all(|v| matches!(
-            v.attributes.get("mass"),
-            Some(GAttribute::Float(x)) if (x - 2.5).abs() < 1e-6
+        assert_eq!(geom.num_points(), base.num_points());
+        assert!((0..geom.num_points()).all(|p| matches!(
+            geom.points().value("mass", p),
+            Some(AttribValue::Float(x)) if (x - 2.5).abs() < 1e-6
         )));
         let (geom, err) = eval(&root, 2);
         let geom = geom.expect("Modify");
         assert!(err.is_none(), "{err:?}");
-        assert!(geom.vertices.iter().all(|v| matches!(
-            v.attributes.get("mass"),
-            Some(GAttribute::Float(x)) if (x - 5.0).abs() < 1e-6
+        assert!((0..geom.num_points()).all(|p| matches!(
+            geom.points().value("mass", p),
+            Some(AttribValue::Float(x)) if (x - 5.0).abs() < 1e-6
         )));
         let (geom, err) = eval(&root, 3);
         let geom = geom.expect("Delete");
         assert!(err.is_none(), "{err:?}");
-        assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
+        assert!(!geom.points().has("mass"), "Delete removes the whole column");
 
         // Modify the Col built-in: multiply by a broadcast 0.5 halves every
         // channel relative to the baseline.
@@ -2097,9 +2108,9 @@ mod tests {
         let (geom, err) = eval(&root, 1);
         let geom = geom.expect("Col modify");
         assert!(err.is_none(), "{err:?}");
-        for (v, b) in geom.vertices.iter().zip(&base.vertices) {
+        for p in 0..geom.num_points() {
             for k in 0..3 {
-                assert!((v.col[k] - b.col[k] * 0.5).abs() < 1e-5);
+                assert!((geom.color(p)[k] - base.color(p)[k] * 0.5).abs() < 1e-5);
             }
         }
 
@@ -2117,11 +2128,11 @@ mod tests {
         let (geom, err) = eval(&root, 1);
         let geom = geom.expect("Pos modify");
         assert!(err.is_none(), "{err:?}");
-        for (v, b) in geom.vertices.iter().zip(&base.vertices) {
-            assert!((v.pos[1] - (b.pos[1] + 0.1)).abs() < 1e-5);
+        for p in 0..geom.num_points() {
+            assert!((geom.positions()[p][1] - (base.positions()[p][1] + 0.1)).abs() < 1e-5);
         }
 
-        // A Group name restricts Create to the tagged vertices.
+        // A Group name restricts what Create WRITES, not what exists.
         let root = root_with(vec![
             instance(sphere_t, "s", "Sphere 1", &[]),
             instance(group_t, "g", "Group 1", &[
@@ -2140,10 +2151,20 @@ mod tests {
         let (geom, err) = eval(&root, 2);
         let geom = geom.expect("grouped Create");
         assert!(err.is_none(), "{err:?}");
-        let tagged = geom.vertices.iter().filter(|v| v.attributes.contains_key("mass")).count();
-        let members = geom.vertices.iter().filter(|v| v.attributes.contains_key("group:group1")).count();
-        assert!(tagged > 0 && tagged < geom.vertices.len());
-        assert_eq!(tagged, members, "Create must land exactly on the group");
+        // A column covers its whole class, so the attribute exists
+        // everywhere; membership is the difference between the value and the
+        // type's zero. This is the one behaviour the columnar store changes,
+        // and it is what lets a solver read any attribute at any point.
+        let members = geom.points().group_members("group1");
+        assert!(!members.is_empty() && members.len() < geom.num_points());
+        for p in 0..geom.num_points() {
+            let want = if members.contains(&(p as u32)) { 1.0 } else { 0.0 };
+            assert_eq!(
+                geom.points().value("mass", p),
+                Some(AttribValue::Float(want)),
+                "point {p}"
+            );
+        }
 
         // A bad Value surfaces an error and passes the geometry through.
         let root = root_with(vec![
@@ -2158,8 +2179,8 @@ mod tests {
         let (geom, err) = eval(&root, 1);
         let geom = geom.expect("bad Value still passes geometry through");
         assert!(err.is_some(), "bad Value must surface an error");
-        assert_eq!(geom.vertices.len(), base.vertices.len());
-        assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
+        assert_eq!(geom.num_points(), base.num_points());
+        assert!(!geom.points().has("mass"));
     }
 
     /// The param pane's attribute/group pickers: selecting an Attribute node
@@ -2296,7 +2317,7 @@ mod tests {
             &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
         ).expect("sphere with meta evaluates");
         assert!(err.is_none(), "{err:?}");
-        assert_eq!(geom.vertices.len(), 16 * 24 * 6);
+        assert_eq!(geom.num_points(), crate::geometry::sphere_point_len(16, 24));
 
         // Overlays: nothing while the prefs are off…
         let mut cache = crate::geometry::SimCache::default();
@@ -2305,8 +2326,9 @@ mod tests {
         assert!(markers.is_empty() && labels.is_empty() && wires.is_empty() && normals.is_empty());
 
         // …all four overlays for the flagged sphere: 240 marker verts per
-        // deduped point, labels matching the same dedupe, and one LINE_LIST
-        // pair per triangle edge (2304 verts = 768 triangles = 4608 pairs).
+        // POINT, one label per point, and one LINE_LIST pair per mesh edge.
+        // All three used to be "per distinct quantized position", reconstructed
+        // every frame; they are now just the point and edge lists.
         {
             let meta = root.children[0].children.iter_mut()
                 .find(|c| c.node_type == "meta").unwrap();
@@ -2317,13 +2339,13 @@ mod tests {
         let mut cache = crate::geometry::SimCache::default();
         let (markers, labels, wires, normals) = crate::render::collect_meta_overlays(
             &root, &root, 0.02, [1.0, 0.5, 0.0], &mut crate::geometry::EvalSim::new(0, 0, &mut cache));
-        assert!(!labels.is_empty() && labels.len() < 16 * 24 * 6);
+        assert_eq!(labels.len(), crate::geometry::sphere_point_len(16, 24), "one label per point");
         assert_eq!(markers.len(), labels.len() * 240);
         assert!(labels.iter().any(|(_, i)| *i > 0));
         // The marker color parameter flows into the vertices (linearized).
         let expect = cce_ui::colors::to_linear_rgb([1.0, 0.5, 0.0]);
         assert!(markers.iter().all(|v| v.color == expect));
-        assert_eq!(wires.len(), (16 * 24 * 6 / 3) * 6);
+        assert_eq!(wires.len(), geom.edges().len() * 2, "one pair per unique edge");
         // Normals: one whisker per distinct point, pointing OUT of the
         // sphere (center (0, 0.55, 0)) — this pins the winding/negation
         // convention, not just the count.
@@ -2394,19 +2416,20 @@ mod tests {
 
         // Defaults: a 16x16 grid at the origin, flat on y = 0.
         let base = build(&[]);
-        assert_eq!(base.vertices.len(), 16 * 16 * 6);
-        assert!(base.vertices.iter().all(|v| v.pos[1].abs() < 1e-6));
+        // A 16x16 cell grid shares its interior points: 17x17 of them.
+        assert_eq!(base.num_points(), 17 * 17);
+        assert!(base.positions().iter().all(|p| p[1].abs() < 1e-6));
 
         // Resolution: 3 columns x 2 rows.
-        assert_eq!(build(&[("Rows", "2"), ("Columns", "3")]).vertices.len(), 3 * 2 * 6);
+        assert_eq!(build(&[("Rows", "2"), ("Columns", "3")]).num_points(), 4 * 3);
 
         // Center: lifts to y = 0.3 and shifts x by 1 (span [0.5, 1.5]).
         let moved = build(&[("Center X", "1.0"), ("Center Y", "0.3")]);
         let (mut min_x, mut max_x) = (f32::MAX, f32::MIN);
-        for v in &moved.vertices {
-            assert!((v.pos[1] - 0.3).abs() < 1e-5);
-            min_x = min_x.min(v.pos[0]);
-            max_x = max_x.max(v.pos[0]);
+        for pos in moved.positions() {
+            assert!((pos[1] - 0.3).abs() < 1e-5);
+            min_x = min_x.min(pos[0]);
+            max_x = max_x.max(pos[0]);
         }
         assert!((min_x - 0.5).abs() < 0.01, "min x {min_x}");
         assert!((max_x - 1.5).abs() < 0.01, "max x {max_x}");
@@ -2499,7 +2522,7 @@ mod tests {
             &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
         ).expect("merged sphere evaluates");
         assert!(err.is_none(), "{err:?}");
-        assert_eq!(geom.vertices.len(), 4 * 6 * 6);
+        assert_eq!(geom.num_points(), crate::geometry::sphere_point_len(4, 6));
 
         // Group (renamed, matched by type): Highlight restored, value kept.
         let g = &root.children[1];
@@ -2557,25 +2580,28 @@ mod tests {
         };
 
         // Defaults: the historical 16x24 sphere.
-        assert_eq!(build(&[]).vertices.len(), 16 * 24 * 6);
+        assert_eq!(build(&[]).num_points(), crate::geometry::sphere_point_len(16, 24));
 
         // A coarse 4x6 tessellation.
         let coarse = build(&[("Rows", "4"), ("Columns", "6")]);
-        assert_eq!(coarse.vertices.len(), 4 * 6 * 6);
+        assert_eq!(coarse.num_points(), crate::geometry::sphere_point_len(4, 6));
 
         // Center X shifts the whole sphere: default spans x in [-0.5, 0.5],
         // shifted spans [0.5, 1.5].
         let shifted = build(&[("Center X", "1.0")]);
         let (mut min_x, mut max_x) = (f32::MAX, f32::MIN);
-        for v in &shifted.vertices {
-            min_x = min_x.min(v.pos[0]);
-            max_x = max_x.max(v.pos[0]);
+        for pos in shifted.positions() {
+            min_x = min_x.min(pos[0]);
+            max_x = max_x.max(pos[0]);
         }
         assert!((min_x - 0.5).abs() < 0.01, "min x {min_x}");
         assert!((max_x - 1.5).abs() < 0.01, "max x {max_x}");
 
         // Degenerate resolutions clamp instead of emitting nothing.
-        assert_eq!(build(&[("Rows", "0"), ("Columns", "0")]).vertices.len(), 2 * 3 * 6);
+        assert_eq!(
+            build(&[("Rows", "0"), ("Columns", "0")]).num_points(),
+            crate::geometry::sphere_point_len(2, 3)
+        );
     }
 
     /// A Scatter consumed downstream must still evaluate: the dispatch pushes
@@ -2634,7 +2660,7 @@ mod tests {
             &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
         ).expect("scatter evaluates on its own");
         assert!(err.is_none(), "{err:?}");
-        assert!(!direct.vertices.is_empty());
+        assert!(!direct.is_empty());
 
         // …and the SAME scatter feeding a downstream node yields the SAME
         // points, tagged by the consumer.
@@ -2649,8 +2675,8 @@ mod tests {
             &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
         ).expect("a node consuming a scatter must see its geometry");
         assert!(err.is_none(), "{err:?}");
-        assert_eq!(chained.vertices.len(), direct.vertices.len());
-        assert!(chained.vertices.iter().all(|v| v.attributes.contains_key("mass")));
+        assert_eq!(chained.num_points(), direct.num_points());
+        assert!(chained.points().has("mass"));
     }
 
     /// The Plane template mirrors the Sphere subnet (an opencl node feeding an
@@ -2707,27 +2733,27 @@ mod tests {
         // Defaults (Width/Length 1.0, Columns/Rows 16): a 16x16 grid of
         // two-triangle cells, flat at y = 0, spanning [-0.5, 0.5] on X and Z.
         let geom = generate(&[], "plane_inst");
-        assert_eq!(geom.vertices.len(), 16 * 16 * 6);
+        assert_eq!(geom.num_points(), 17 * 17);
         let mut max_x: f32 = 0.0;
         let mut max_z: f32 = 0.0;
-        for v in &geom.vertices {
-            assert!(v.pos[1].abs() < 1e-6, "Expected flat plane at y=0, got y={}", v.pos[1]);
-            max_x = max_x.max(v.pos[0].abs());
-            max_z = max_z.max(v.pos[2].abs());
+        for pos in geom.positions() {
+            assert!(pos[1].abs() < 1e-6, "Expected flat plane at y=0, got y={}", pos[1]);
+            max_x = max_x.max(pos[0].abs());
+            max_z = max_z.max(pos[2].abs());
         }
         assert!((max_x - 0.5).abs() < 0.01, "Expected half-width 0.5 on X, got {}", max_x);
         assert!((max_z - 0.5).abs() < 0.01, "Expected half-length 0.5 on Z, got {}", max_z);
 
         // Width and Length size their axes independently.
         let geom_2 = generate(&[("Width", "2.0"), ("Length", "3.0")], "plane_inst_2");
-        let max_x_2 = geom_2.vertices.iter().map(|v| v.pos[0].abs()).fold(0.0f32, f32::max);
-        let max_z_2 = geom_2.vertices.iter().map(|v| v.pos[2].abs()).fold(0.0f32, f32::max);
+        let max_x_2 = geom_2.positions().iter().map(|p| p[0].abs()).fold(0.0f32, f32::max);
+        let max_z_2 = geom_2.positions().iter().map(|p| p[2].abs()).fold(0.0f32, f32::max);
         assert!((max_x_2 - 1.0).abs() < 0.01, "Expected half-width 1.0 on X, got {}", max_x_2);
         assert!((max_z_2 - 1.5).abs() < 0.01, "Expected half-length 1.5 on Z, got {}", max_z_2);
 
         // Columns/Rows control the cell counts per axis.
         let geom_3 = generate(&[("Columns", "4"), ("Rows", "8")], "plane_inst_3");
-        assert_eq!(geom_3.vertices.len(), 4 * 8 * 6);
+        assert_eq!(geom_3.num_points(), 5 * 9, "a 4x8 cell grid is 5x9 points");
     }
 
     #[test]
@@ -2832,76 +2858,50 @@ mod tests {
 
     #[test]
     fn test_geometry_attributes_system() {
-        let mut attrs1 = std::collections::HashMap::new();
-        attrs1.insert("UV".to_string(), GAttribute::Float2([0.1, 0.2]));
-        attrs1.insert("ID".to_string(), GAttribute::Float(42.0));
-
-        let v1 = GVertex {
-            pos: [1.0, 2.0, 3.0],
-            col: [1.0, 0.0, 0.0],
-            attributes: attrs1,
-        };
-
-        let mut attrs2 = std::collections::HashMap::new();
-        attrs2.insert("Norm".to_string(), GAttribute::Float3([0.0, 1.0, 0.0]));
-        attrs2.insert("UV".to_string(), GAttribute::Float2([0.3, 0.4]));
-
-        let v2 = GVertex {
-            pos: [4.0, 5.0, 6.0],
-            col: [0.0, 1.0, 0.0],
-            attributes: attrs2,
-        };
-
-        let mut geom1 = Geometry { vertices: vec![v1] };
-        let geom2 = Geometry { vertices: vec![v2] };
-
-        geom1.merge(geom2);
-        assert_eq!(geom1.vertices.len(), 2);
-
-        let render_verts = geom1.to_vertex3d_vec();
-        assert_eq!(render_verts.len(), 2);
-        assert_eq!(render_verts[0].position, [1.0, 2.0, 3.0]);
-        assert_eq!(render_verts[0].color, [1.0, 0.0, 0.0]);
-        assert_eq!(render_verts[1].position, [4.0, 5.0, 6.0]);
-        assert_eq!(render_verts[1].color, [0.0, 1.0, 0.0]);
-
-        let (headers, rows) = State::geometry_to_spreadsheet_data(&geom1);
-
-        let expected_headers = vec![
-            "Vertex".to_string(),
-            "Pos.x".to_string(),
-            "Pos.y".to_string(),
-            "Pos.z".to_string(),
-            "Col.r".to_string(),
-            "Col.g".to_string(),
-            "Col.b".to_string(),
-            "ID".to_string(),
-            "Norm.x".to_string(),
-            "Norm.y".to_string(),
-            "Norm.z".to_string(),
-            "UV.x".to_string(),
-            "UV.y".to_string(),
-        ];
-        assert_eq!(headers, expected_headers);
+        // Two points, built the way the pipeline builds them.
+        let mut geom = Detail::new();
+        geom.add_point(Vec3::new(1.0, 2.0, 3.0));
+        geom.add_point(Vec3::new(4.0, 5.0, 6.0));
+        geom.set_color(0, [1.0, 0.0, 0.0]);
+        geom.set_color(1, [0.0, 1.0, 0.0]);
+
+        // A column covers its whole class. The soup could hold an attribute on
+        // one vertex and not the next, which is what the dashes in this
+        // spreadsheet used to mean; there is no ragged case left to render.
+        geom.points_mut().create("UV", AttribValue::Float2([0.0; 2]));
+        geom.points_mut().set_value("UV", 0, AttribValue::Float2([0.1, 0.2])).unwrap();
+        geom.points_mut().set_value("UV", 1, AttribValue::Float2([0.3, 0.4])).unwrap();
+        geom.points_mut().create("ID", AttribValue::Int(0));
+        geom.points_mut().set_value("ID", 0, AttribValue::Int(42)).unwrap();
+        geom.points_mut().create_group("pinned");
+        geom.points_mut().add_to_group("pinned", 1);
+
+        assert_eq!(geom.num_points(), 2);
+        let render_verts = crate::geometry::detail_vertices(&geom);
+        assert!(render_verts.is_empty(), "two loose points make no triangles");
+
+        let (headers, rows) = State::geometry_to_spreadsheet_data(&geom);
+        assert_eq!(
+            headers,
+            vec![
+                "Point", "Pos.x", "Pos.y", "Pos.z", "Col.r", "Col.g", "Col.b",
+                "ID", "UV.x", "UV.y", "g:pinned",
+            ]
+        );
 
-        assert_eq!(rows.len(), 2);
+        assert_eq!(rows.len(), 2, "one row per point");
         assert_eq!(rows[0][0], "0");
-        assert_eq!(rows[0][1], "1.0000"); // Pos X
-        assert_eq!(rows[0][7], "42.0000"); // ID
-        assert_eq!(rows[0][8], "-"); // Norm.x
-        assert_eq!(rows[0][9], "-"); // Norm.y
-        assert_eq!(rows[0][10], "-"); // Norm.z
-        assert_eq!(rows[0][11], "0.1000"); // UV.x
-        assert_eq!(rows[0][12], "0.2000"); // UV.y
+        assert_eq!(rows[0][1], "1.0000"); // Pos.x
+        assert_eq!(rows[0][4], "1.0000"); // Col.r
+        assert_eq!(rows[0][7], "42"); // ID, an integer and printed as one
+        assert_eq!(rows[0][8], "0.1000"); // UV.x
+        assert_eq!(rows[0][10], "", "point 0 is not in the group");
 
         assert_eq!(rows[1][0], "1");
-        assert_eq!(rows[1][1], "4.0000"); // Pos X
-        assert_eq!(rows[1][7], "-"); // ID
-        assert_eq!(rows[1][8], "0.0000"); // Norm.x
-        assert_eq!(rows[1][9], "1.0000"); // Norm.y
-        assert_eq!(rows[1][10], "0.0000"); // Norm.z
-        assert_eq!(rows[1][11], "0.3000"); // UV.x
-        assert_eq!(rows[1][12], "0.4000"); // UV.y
+        assert_eq!(rows[1][1], "4.0000");
+        assert_eq!(rows[1][7], "0", "unwritten is the type's zero, not a dash");
+        assert_eq!(rows[1][9], "0.4000"); // UV.y
+        assert_eq!(rows[1][10], "1", "point 1 is in the group");
     }
 
     #[test]
@@ -2910,10 +2910,10 @@ mod tests {
         let end = Vec3::new(0.0, 1.0, 0.0);
         let geom = line_vertices(start, end, 0.02);
         
-        // A box line should contain 36 vertices (6 faces * 2 triangles * 3 vertices)
+        // A box line is 36 soup vertices: 6 faces * 2 triangles * 3 corners.
         assert_eq!(geom.vertices.len(), 36);
-        
-        // Every vertex should have "Norm" and "UV" attributes
+
+        // Every corner still carries Norm and UV through the soup adapter.
         for v in &geom.vertices {
             assert!(v.attributes.contains_key("Norm"));
             assert!(v.attributes.contains_key("UV"));
diff --git a/src/render.rs b/src/render.rs
index 4411dbb..0934793 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -963,7 +963,7 @@ impl State {
             self.update_status_text("Geometry updated successfully.");
         }
 
-        let verts = geom.to_vertex3d_vec();
+        let verts = crate::geometry::detail_vertices(&geom);
         self.vertex_count_spheres = verts.len() as u32;
         // Cache for the path tracer, so RT mode never re-runs the node
         // graph / OpenCL kernels; the version bump invalidates its scene.
@@ -1062,25 +1062,29 @@ pub(crate) fn collect_meta_overlays(
                 root, node, &mut visited, &mut err, sim,
             ) {
                 if want_wires {
-                    // Each triangle's three edges as LINE_LIST pairs carrying
-                    // the geometry's own colors — the sphere_edges expansion,
-                    // scoped to this node.
-                    for tri in geom.vertices.chunks_exact(3) {
-                        for (a, b) in [(0usize, 1usize), (1, 2), (2, 0)] {
-                            for v in [&tri[a], &tri[b]] {
-                                wires.push(crate::geometry::Vertex3D {
-                                    position: v.pos,
-                                    color: v.col,
-                                });
-                            }
+                    // The geometry's own edge list as LINE_LIST pairs, carrying
+                    // its own colors. Two changes from the soup version, both
+                    // of them the topology finally being visible: an edge two
+                    // faces share is drawn once instead of twice, and a quad
+                    // shows as a quad — the fan diagonal was never an edge of
+                    // the mesh, only of its triangulation.
+                    for e in geom.edges() {
+                        for &p in e {
+                            let p = p as usize;
+                            wires.push(crate::geometry::Vertex3D {
+                                position: geom.positions()[p],
+                                color: geom.color(p),
+                            });
                         }
                     }
                 }
                 if want_markers {
+                    // One marker per point. The soup emitted one per corner and
+                    // leaned on points_vertices deduping by position.
                     let src: Vec<crate::geometry::Vertex3D> = geom
-                        .vertices
+                        .positions()
                         .iter()
-                        .map(|v| crate::geometry::Vertex3D { position: v.pos, color: [0.0; 3] })
+                        .map(|&position| crate::geometry::Vertex3D { position, color: [0.0; 3] })
                         .collect();
                     markers.extend(crate::geometry::points_vertices(
                         &src,
@@ -1089,58 +1093,51 @@ pub(crate) fn collect_meta_overlays(
                     ));
                 }
                 if want_normals {
-                    // Smooth vertex normals from topology: per distinct
-                    // position, the normalized sum of touching triangles'
-                    // face normals. Template meshes wind CCW seen from
-                    // outside (the raster culling convention — the sphere's
-                    // historical CW winding is fixed), so the plain
-                    // cross(B-A, C-A) points outward. The kernel outputs'
-                    // Norm attribute is a default up-vector — useless here.
+                    // Smooth point normals: for each point, the normalized sum
+                    // of the face normals of the primitives touching it.
+                    // Template meshes wind CCW seen from outside (the raster
+                    // culling convention), so the plain cross(B-A, C-A) points
+                    // outward. The kernel outputs' Norm attribute is a default
+                    // up-vector — useless here.
+                    //
+                    // The soup had to reconstruct "which triangles touch this
+                    // point" by hashing quantized positions, every frame. That
+                    // was a weld in all but name, and it is what point_prims
+                    // answers directly.
                     use glam::Vec3;
-                    let quant = |p: &[f32; 3]| {
-                        (
-                            (p[0] * 1000.0).round() as i32,
-                            (p[1] * 1000.0).round() as i32,
-                            (p[2] * 1000.0).round() as i32,
-                        )
-                    };
-                    let mut acc: std::collections::HashMap<(i32, i32, i32), ([f32; 3], Vec3)> =
-                        std::collections::HashMap::new();
-                    for tri in geom.vertices.chunks_exact(3) {
-                        let a = Vec3::from_array(tri[0].pos);
-                        let b = Vec3::from_array(tri[1].pos);
-                        let c = Vec3::from_array(tri[2].pos);
-                        let n = (b - a).cross(c - a);
-                        if n.length_squared() <= 1e-12 {
-                            continue;
-                        }
-                        for v in tri {
-                            acc.entry(quant(&v.pos)).or_insert((v.pos, Vec3::ZERO)).1 += n;
-                        }
-                    }
                     let len = point_size * 4.0;
                     let color = cce_ui::colors::to_linear_rgb([0.45, 0.8, 1.0]);
-                    for (pos, sum) in acc.values() {
+                    for p in 0..geom.num_points() {
+                        let mut sum = Vec3::ZERO;
+                        for &prim in geom.point_prims(p) {
+                            let pts = geom.prim_points(prim as usize);
+                            if pts.len() < 3 {
+                                continue;
+                            }
+                            let a = geom.pos(pts[0] as usize);
+                            let b = geom.pos(pts[1] as usize);
+                            let c = geom.pos(pts[2] as usize);
+                            let n = (b - a).cross(c - a);
+                            if n.length_squared() > 1e-12 {
+                                sum += n;
+                            }
+                        }
                         let n = sum.normalize_or_zero();
                         if n == Vec3::ZERO {
                             continue;
                         }
-                        let tip = Vec3::from_array(*pos) + n * len;
-                        normals.push(crate::geometry::Vertex3D { position: *pos, color });
+                        let pos = geom.positions()[p];
+                        let tip = geom.pos(p) + n * len;
+                        normals.push(crate::geometry::Vertex3D { position: pos, color });
                         normals.push(crate::geometry::Vertex3D { position: tip.to_array(), color });
                     }
                 }
                 if want_numbers {
-                    let mut seen = std::collections::HashSet::new();
-                    for (i, v) in geom.vertices.iter().enumerate() {
-                        let key = (
-                            (v.pos[0] * 1000.0).round() as i32,
-                            (v.pos[1] * 1000.0).round() as i32,
-                            (v.pos[2] * 1000.0).round() as i32,
-                        );
-                        if seen.insert(key) {
-                            labels.push((v.pos, i as u32));
-                        }
+                    // The point's index, which is now also its spreadsheet row.
+                    // The soup numbered by first-corner-at-this-position, so
+                    // the overlay and the spreadsheet disagreed.
+                    for p in 0..geom.num_points() {
+                        labels.push((geom.positions()[p], p as u32));
                     }
                 }
             }
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index 2a44dae..6266fcd 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -41,7 +41,7 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>) -> Resul
         // Non-fatal: OpenCL nodes just contribute nothing, like the viewport.
         eprintln!("thumbnail: OpenCL error (geometry partially skipped): {e}");
     }
-    let verts = geom.to_vertex3d_vec();
+    let verts = crate::geometry::detail_vertices(&geom);
     let (tris, mats) = rt_scene_from_verts(&verts);
 
     // Frame the scene: bounding sphere fit into a 0.9 rad vertical FOV from a