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

commit04e0029706e87ba1aab53022b1e7c73f5fd27ea4
parentabd065b8be
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 10:27
perf(wire): share a mesh's topology with its copies, and write the wire vertices in parallel

Every copy of a Detail dropped its topology, so every frame's scene, a
copy of the cached simulation state, built one again for the wireframe.
Copies share it now (Arc); every structural writer already drops it, and
moving points or writing attributes leaves it true. A merge into an empty
detail keeps it, and the solve builds it once for each frame it stores
(counted in the checkpoint budget). scene_edge_verts writes its vertices
a piece of edges a thread. The wire edges at 57k points: 4.9 ms to 0.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       | 19 ++++++++++++++++-
 src/detail.rs   | 45 ++++++++++++++++++++++++++++++++++------
 src/geometry.rs | 11 +++++++++-
 src/main.rs     | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/render.rs   | 36 ++++++++++++++++++++++++--------
 5 files changed, 159 insertions(+), 16 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index f95cc3f..5b40e6b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2250,7 +2250,10 @@ need.
   history to every fourth frame while holding about 0.4 GB, and a replay
   of the cached frames 120–240 re-solved three in four at 690 ms a frame;
   counted by size, all 240 are kept at 1.5 GB (1.37 GB resident in a
-  shadow session) and the replay runs no step at 34 ms a frame.
+  shadow session) and the replay runs no step at 34 ms a frame. Each
+  checkpoint carries its frame's topology since later the same day (see
+  "A copy of a mesh shares its topology"), which brings the 240 to 1.9 GB:
+  a simulation larger or longer than that one begins to thin.
   `checkpoints_are_budgeted_by_what_each_holds`. With no
   room the SPACING doubles and stays doubled — what is off the wider
   interval goes, and what arrives after arrives that far apart. Not the
@@ -3494,6 +3497,20 @@ markers cost nothing on the GPU, being instanced; frames are paced to
 the display, so a frame of 8 ms CPU shows on the next 16.7 ms, and one
 over shows on the one after.
 
+**A copy of a mesh shares its topology** (the same day, `Clone for
+Detail`, an `Arc`): it was dropped by every clone, so every frame's scene
+— a copy of the cached simulation state, merged into an empty detail —
+built a topology again for the wire pass. Every structural writer of a
+`Detail` drops it (`invalidate`), and moving points or writing attributes
+leaves a topology that is still true, so a shared one is never stale.
+`merge` into an empty detail keeps the merged one's, the solve builds the
+topology of each frame it stores (about 1% of a step; counted in the
+checkpoints' budget, `Detail::topology_bytes`), and so a replayed scene's
+edges cost nothing; `scene_edge_verts` writes the wire vertices a piece
+of edges a thread into one buffer. The wire edges at 57k points: 4.9 ms
+a frame to 0.6. `a_copy_shares_the_topology_until_it_is_edited`,
+`the_wire_vertices_are_the_edges_in_order`.
+
 **The markers are instanced** (the same day, cce-ui's
 `SceneDraw::instances`): every kind — Show Point Markers, Show Vertex
 Markers, the selected group's, the marked groups' and the spreadsheet
diff --git a/src/detail.rs b/src/detail.rs
index 6322116..8d3cba1 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -1053,7 +1053,8 @@ pub struct Detail {
     prim_start: Vec<u32>,
     prims: AttribStore,
     detail: AttribStore,
-    topo: OnceLock<Topology>,
+    /// Shared by a clone (`Arc`): see [`Clone for Detail`](#impl-Clone-for-Detail).
+    topo: OnceLock<std::sync::Arc<Topology>>,
 }
 
 impl Default for Detail {
@@ -1081,9 +1082,16 @@ impl PartialEq for Detail {
 }
 
 impl Clone for Detail {
-    /// The topology cache is deliberately *not* cloned. It is derived, the
-    /// clone exists to be modified, and rebuilding is cheaper than reasoning
-    /// about whether a stale cache came along.
+    /// The topology, when built, is SHARED with the clone (since
+    /// 2026-10-07; until then it was deliberately dropped). It is derived
+    /// from the primitives and the point count alone, every edit of either
+    /// drops it (`invalidate`, called by every structural writer: `add_point`,
+    /// `add_points`, `add_prim`, the gathers, `fuse_points`, `merge`), and
+    /// moving points or writing attributes does not touch it — so a clone
+    /// edited structurally builds its own, and one that is not keeps a
+    /// topology that is still true. Dropping it cost every copy of a cached
+    /// simulation state its whole topology again, a frame at a time:
+    /// 6 ms at 57k points for the wireframe's edges alone.
     fn clone(&self) -> Self {
         Self {
             pos: self.pos.clone(),
@@ -1095,7 +1103,10 @@ impl Clone for Detail {
             prim_start: self.prim_start.clone(),
             prims: self.prims.clone(),
             detail: self.detail.clone(),
-            topo: OnceLock::new(),
+            topo: match self.topo.get() {
+                Some(t) => OnceLock::from(t.clone()),
+                None => OnceLock::new(),
+            },
         }
     }
 }
@@ -1331,7 +1342,16 @@ impl Detail {
     /// drops it.
     pub fn topology(&self) -> &Topology {
         self.topo
-            .get_or_init(|| Topology::build(self.num_points(), &self.vert_point, &self.prim_start))
+            .get_or_init(|| std::sync::Arc::new(Topology::build(self.num_points(), &self.vert_point, &self.prim_start)))
+    }
+
+    /// About what the topology holds when it is built, in bytes; 0 when it
+    /// is not. For the simulation checkpoints' budget.
+    pub fn topology_bytes(&self) -> usize {
+        self.topo.get().map_or(0, |t| {
+            4 * (t.point_prim_start.len() + t.point_prim.len() + t.point_nbr_start.len() + t.point_nbr.len())
+                + 8 * t.edges.len()
+        })
     }
 
     /// The points sharing an edge with point `p`.
@@ -1377,6 +1397,11 @@ impl Detail {
     /// Drop the derived topology. Called by every structural edit; public
     /// because an operator writing `vert_point` through a future bulk path
     /// must be able to say so.
+    /// Whether the topology is built (and so free to ask for).
+    pub fn has_topology(&self) -> bool {
+        self.topo.get().is_some()
+    }
+
     pub fn invalidate(&mut self) {
         self.topo.take();
     }
@@ -1513,6 +1538,11 @@ impl Detail {
     /// of geometry that were generated independently — and therefore both
     /// number their points from zero — do not collide.
     pub fn merge(&mut self, other: &Detail) {
+        // Merged into nothing, the result has `other`'s structure exactly —
+        // the same points and primitives, only the identities renumbered —
+        // so its topology is `other`'s, built or not. The scene is
+        // assembled this way, one displayed node into an empty detail.
+        let shared = if self.num_points() == 0 && self.num_prims() == 0 { other.topo.get().cloned() } else { None };
         let point_offset = self.num_points() as u32;
         let vert_offset = self.vert_point.len() as u32;
 
@@ -1533,6 +1563,9 @@ impl Detail {
         self.prims.append(&other.prims);
 
         self.invalidate();
+        if let Some(t) = shared {
+            let _ = self.topo.set(t);
+        }
     }
 
     // ---- convenience ----
diff --git a/src/geometry.rs b/src/geometry.rs
index fd0bdcf..b534486 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1177,7 +1177,7 @@ pub const CHECKPOINT_BUDGET: usize = 2048 * 1024 * 1024;
 /// of attributes a point, the primitives' indices, twice. An estimate — the
 /// budget is a guard against a runaway, not an accounting.
 fn checkpoint_bytes(state: &Detail) -> usize {
-    2 * (state.num_points() * 96 + state.num_verts() * 8 + state.num_prims() * 8 + 256)
+    2 * (state.num_points() * 96 + state.num_verts() * 8 + state.num_prims() * 8 + 256) + state.topology_bytes()
 }
 
 /// One solve's checkpoints, in frame order, how far apart they are being
@@ -7603,6 +7603,7 @@ pub fn resolve_simnet_geometry_with_errors(
         // A frame on the interval is kept as the solve passes it — not the
         // frame asked for, which is the entry itself.
         if done % checkpoints.every == 0 && done < due {
+            state.topology();
             checkpoints.keep(Checkpoint { frame: done, state: state.clone(), prev: prev_frame.clone() });
         }
     }
@@ -7617,6 +7618,14 @@ pub fn resolve_simnet_geometry_with_errors(
     if caching && due > 0 && done > resumed_at {
         write_sim_cache(&target.id, key, due, &state, &prev_frame);
     }
+    // A frame solved has its topology built here, once, and every copy of
+    // the state taken from the cache from now on shares it (`Clone for
+    // Detail`): the wireframe's edges and anything else that asks of the
+    // scene are free on a replay, where they were built again every frame.
+    // It costs about 1% of a step and is counted in the checkpoints' budget.
+    if done > resumed_at {
+        state.topology();
+    }
     sim.cache.entries.insert(
         target.id.clone(),
         SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints, edited_at, start: start_frame, chain },
diff --git a/src/main.rs b/src/main.rs
index 737e80c..83a4461 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17967,6 +17967,70 @@ mod tests {
         }
     }
 
+    /// The wire pass's vertices are written a piece of edges a thread, into
+    /// one buffer: what one thread writes, vertex for vertex, on a mesh too
+    /// small to share out and on one large enough to.
+    #[test]
+    fn the_wire_vertices_are_the_edges_in_order() {
+        for (lat, lon) in [(6, 9), (120, 240)] {
+            let mut g = crate::geometry::sphere_detail(Vec3::new(0.2, -0.1, 0.3), 1.0, lat, lon);
+            let n = g.num_points();
+            g.points_mut().insert(crate::detail::CD, crate::detail::AttribData::Float3((0..n).map(|p| [p as f32 / n as f32, 0.5, 0.1]).collect())).unwrap();
+            let want: Vec<[f32; 6]> = g
+                .edges()
+                .iter()
+                .flat_map(|e| e.iter().map(|&p| { let (a, c) = (g.pos(p as usize).to_array(), g.color(p as usize)); [a[0], a[1], a[2], c[0], c[1], c[2]] }).collect::<Vec<_>>())
+                .collect();
+            let got: Vec<[f32; 6]> = crate::render::scene_edge_verts(&g)
+                .iter()
+                .map(|v| [v.position[0], v.position[1], v.position[2], v.color[0], v.color[1], v.color[2]])
+                .collect();
+            assert_eq!(got, want, "{} edges", g.edges().len());
+        }
+    }
+
+    /// A copy of a Detail shares its topology, built once; an edit that
+    /// moves points or writes attributes keeps it, a structural edit drops
+    /// it; a Detail merged into an empty one keeps it; and a simulation's
+    /// solved frame comes out of the cache with it built, so the scene's
+    /// edges cost nothing on a replay.
+    #[test]
+    fn a_copy_shares_the_topology_until_it_is_edited() {
+        let mut g = crate::geometry::sphere_detail(Vec3::ZERO, 1.0, 5, 8);
+        assert!(!g.has_topology());
+        let edges = g.edges().to_vec();
+        let mut copy = g.clone();
+        assert!(copy.has_topology(), "shared by the copy");
+        copy.positions_mut()[0][0] += 1.0;
+        copy.points_mut().create("mass", crate::detail::AttribValue::Float(2.0));
+        assert!(copy.has_topology(), "moving points and writing attributes keep it");
+        assert_eq!(copy.edges(), edges.as_slice());
+        let mut merged = crate::detail::Detail::new();
+        merged.merge(&g);
+        assert!(merged.has_topology(), "merged into nothing keeps it");
+        assert_eq!(merged.edges(), edges.as_slice());
+        merged.merge(&g);
+        assert!(!merged.has_topology(), "merged into something does not");
+        copy.add_prim(&[0, 1, 2]);
+        assert!(!copy.has_topology(), "a structural edit drops it");
+        assert_ne!(copy.edges().len(), 0);
+        assert!(g.has_topology(), "and the original's is its own");
+
+        // A simnet's solved frame, taken back out of the cache, has it.
+        let mut state = State::new(false);
+        let mut redraw = false;
+        state.apply_action(McpAction::AddNode { template_name: "Simnet".into(), name: Some("sim".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+        let slot = state.current_dir().children.iter().position(|c| c.name == "sim").unwrap();
+        state.apply_action(McpAction::SetParam { slot, name: "input".into(), value: "sphere1".into() }, &mut redraw).unwrap();
+        let simnet = state.current_dir().children[slot].clone();
+        let mut cache = crate::geometry::SimCache::default();
+        for frame in [4, 4, 2] {
+            let mut sim = crate::geometry::EvalSim::new(frame, 1, &mut cache);
+            let solved = crate::geometry::resolve_simnet_geometry_with_errors(&state.fs_root, &simnet, &mut Vec::new(), &mut None, &mut sim).unwrap();
+            assert!(solved.has_topology(), "frame {frame} comes with its topology");
+        }
+    }
+
     /// The playbar's cache strip, as a rule: a frame is cached when every
     /// simnet in the tree holds it, stale when one of them holds it from
     /// the chain as it was — before an edit the solve went on across, or
diff --git a/src/render.rs b/src/render.rs
index 96620f0..28ff4ff 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1856,17 +1856,37 @@ pub(crate) fn scene_element_overlays(
 /// colour up by name at both ends of every edge — the largest part of a
 /// replayed frame at 57k points.
 pub(crate) fn scene_edge_verts(geom: &crate::detail::Detail) -> Vec<crate::geometry::Vertex3D> {
+    use crate::geometry::Vertex3D;
     let edges = geom.edge_list();
     let colors = geom.point_colors();
-    let mut wires = Vec::with_capacity(edges.len() * 2);
-    for e in edges.iter() {
-        for &p in e {
-            let p = p as usize;
-            wires.push(crate::geometry::Vertex3D {
-                position: geom.positions()[p],
-                color: colors.get(p).copied().unwrap_or(crate::detail::DEFAULT_COLOR),
-            });
+    let positions = geom.positions();
+    let vertex = |p: u32| {
+        let p = p as usize;
+        Vertex3D { position: positions[p], color: colors.get(p).copied().unwrap_or(crate::detail::DEFAULT_COLOR) }
+    };
+    // Written into one buffer a piece of edges a thread, each piece its own
+    // stretch of it, so the result is the one a single thread writes. A
+    // thread costs tens of microseconds to start, so a piece is at least
+    // `PIECE` edges and a small mesh stays on one.
+    const PIECE: usize = 16_384;
+    let mut wires = vec![Vertex3D { position: [0.0; 3], color: [0.0; 3] }; edges.len() * 2];
+    let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(edges.len() / PIECE).max(1);
+    let per = edges.len().div_ceil(threads).max(1);
+    let fill = |out: &mut [Vertex3D], edges: &[[u32; 2]]| {
+        for (pair, e) in out.chunks_exact_mut(2).zip(edges) {
+            pair[0] = vertex(e[0]);
+            pair[1] = vertex(e[1]);
         }
+    };
+    if threads == 1 {
+        fill(&mut wires, &edges);
+    } else {
+        std::thread::scope(|scope| {
+            for (out, edges) in wires.chunks_mut(per * 2).zip(edges.chunks(per)) {
+                let fill = &fill;
+                scope.spawn(move || fill(out, edges));
+            }
+        });
     }
     wires
 }