graphic design tool
git clone https://git.lucas.co/cce-designer.git
perf(replay): budget checkpoints by size, and build the wire edges alone
Checkpoints were budgeted as a count, every frame charged at the size of
the latest: a simulation growing to 57k points was thinned to every
fourth frame at a fraction of the budget, and replaying the cached
frames re-solved three in four (690 ms a frame). They are budgeted by
the sum of their sizes now: all 240 frames kept, the replay 34 ms.
The wireframe built a whole topology every frame for its edge list.
Detail::edge_list builds the edges alone, sorted by bucket (the same
list in the same order), and point_colors reads Cd once for the wire
pass and the fill. A replayed frame's CPU half at 57k points: 20 ms to 13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 32 +++++++++++++++-
src/detail.rs | 115 +++++++++++++++++++++++++++++++++++++++++++++-----------
src/geometry.rs | 72 +++++++++++++++++++++++++++++++----
src/main.rs | 61 ++++++++++++++++++++++++++++++
src/render.rs | 14 +++++--
5 files changed, 261 insertions(+), 33 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 03f6085..c7af123 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2245,7 +2245,17 @@ need.
hand does not (the next section).
- **Within a count and a budget** (`CHECKPOINTS_MAX` 1024,
`CHECKPOINT_BUDGET` 2 GB per simnet by an estimate of a state's size;
- 48 and 512 MB until every frame was kept). With no
+ 48 and 512 MB until every frame was kept). **The budget is the SUM of
+ what the kept checkpoints hold, each at its own size** (since
+ 2026-10-07, `Checkpoints::bytes`); it was the budget over the size of
+ the checkpoint just kept, which charged every frame of a growing
+ simulation at the size of the latest. On the user's project, a surface
+ growing from 162 points to 57k over 240 frames, that thinned the whole
+ 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.
+ `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
oldest: a scrub is as likely to land near the start. And not every
@@ -3459,6 +3469,26 @@ fix above, a refresh of the table on a selected simnet at 11k points went
from about 9 ms to 1.1 (evaluation 0.37, the columns 0.7, the widget
0.01); the cells read as they did (`{:.4}`, the same strings).
+**A replayed frame, profiled whole** (2026-10-07, the user's project,
+point markers, wireframe and two visualizers on, frames 120–240 replayed
+from the cache): the CPU half was 11 ms a frame at 33k points and 20 at
+57k, the wireframe's edge list most of it — every frame's scene is a
+fresh copy, whose topology is not kept, so the wire pass built a whole
+topology (point→prims, neighbours and edges, the edges by one sort of
+every edge) to draw its edges, and looked each end's colour up by name.
+`Detail::edge_list` builds the edges alone unless the topology is
+already built, `unique_edges` sorts them by bucket rather than as one
+list (the same edges in the same order,
+`unique_edges_match_a_sort_of_every_edge`; it also builds every
+topology's edges, so the solvers' too), and `Detail::point_colors` reads
+the colour column once, for the wire pass and `triangulate`'s fill. The
+CPU half is now 6 ms at 33k and 13 at 57k: the wire edges 4, the graph's
+evaluation (the simnet's cached state copied out) 2.5, the fill 2, the
+point markers' instances 1.6, the 2D frame 1.5. In a shadow session the
+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.
+
**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 b3deaa4..6322116 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -926,27 +926,7 @@ impl Topology {
}
}
- // Edges: every consecutive pair around each primitive, closing the
- // loop. A two-point primitive (an open line segment) contributes one
- // edge, not two — closing it would invent a neighbour.
- let mut edges: Vec<[u32; 2]> = Vec::new();
- for prim in 0..num_prims {
- let pts = &vert_point[prim_start[prim] as usize..prim_start[prim + 1] as usize];
- let n = pts.len();
- if n < 2 {
- continue;
- }
- let span = if n == 2 { 1 } else { n };
- for i in 0..span {
- let (a, b) = (pts[i], pts[(i + 1) % n]);
- if a == b {
- continue;
- }
- edges.push([a.min(b), a.max(b)]);
- }
- }
- edges.sort_unstable();
- edges.dedup();
+ let edges = unique_edges(num_points, vert_point, prim_start);
// point -> neighbours, from the deduplicated edge list. Both endpoints
// of every edge, counting-sorted the same way.
@@ -988,6 +968,71 @@ impl Topology {
}
}
+/// The mesh's edges, each once as `[low, high]`, in ascending order: every
+/// consecutive pair around each primitive, closing the loop. A two-point
+/// primitive (an open line segment) contributes one edge, not two — closing
+/// it would invent a neighbour.
+///
+/// Sorted by bucket rather than as one list: the edges are counted into
+/// their low point's bucket and each bucket, a handful long, is sorted and
+/// deduplicated on its own — the order a sort of the whole list gives, and
+/// the same list (`unique_edges_match_a_sort_of_every_edge`). Until
+/// 2026-10-07 it was that whole sort, two thirds of building a 57k-point
+/// mesh's topology, and a playing simulation's wireframe built one every
+/// frame. A primitive naming a point past the end (a hand-built mesh) falls
+/// back to the whole sort.
+fn unique_edges(num_points: usize, vert_point: &[u32], prim_start: &[u32]) -> Vec<[u32; 2]> {
+ let num_prims = prim_start.len().saturating_sub(1);
+ let mut all: Vec<[u32; 2]> = Vec::with_capacity(vert_point.len());
+ for prim in 0..num_prims {
+ let pts = &vert_point[prim_start[prim] as usize..prim_start[prim + 1] as usize];
+ let n = pts.len();
+ if n < 2 {
+ continue;
+ }
+ let span = if n == 2 { 1 } else { n };
+ for i in 0..span {
+ let (a, b) = (pts[i], pts[(i + 1) % n]);
+ if a == b {
+ continue;
+ }
+ all.push([a.min(b), a.max(b)]);
+ }
+ }
+ if all.iter().any(|e| e[0] as usize >= num_points) {
+ all.sort_unstable();
+ all.dedup();
+ return all;
+ }
+ let mut start = vec![0u32; num_points + 1];
+ for e in &all {
+ start[e[0] as usize + 1] += 1;
+ }
+ for p in 0..num_points {
+ start[p + 1] += start[p];
+ }
+ let mut cursor = start.clone();
+ let mut highs = vec![0u32; all.len()];
+ for e in &all {
+ let at = &mut cursor[e[0] as usize];
+ highs[*at as usize] = e[1];
+ *at += 1;
+ }
+ let mut edges = Vec::with_capacity(all.len() / 2 + 1);
+ for a in 0..num_points {
+ let bucket = &mut highs[start[a] as usize..start[a + 1] as usize];
+ bucket.sort_unstable();
+ let mut last = None;
+ for &b in bucket.iter() {
+ if last != Some(b) {
+ edges.push([a as u32, b]);
+ last = Some(b);
+ }
+ }
+ }
+ edges
+}
+
/// Points, vertices, primitives and detail — one piece of geometry.
///
/// See the module docs. Position and [`PointId`] get dedicated fields rather
@@ -1304,6 +1349,31 @@ impl Detail {
self.topology().edges()
}
+ /// The edges, as [`Detail::edges`] lists them, without building the
+ /// rest of the topology when it is not already built: what the wire
+ /// pass needs, every frame of a playing simulation, of a scene nothing
+ /// else asks the topology of.
+ pub fn edge_list(&self) -> std::borrow::Cow<'_, [[u32; 2]]> {
+ match self.topo.get() {
+ Some(topo) => std::borrow::Cow::Borrowed(topo.edges()),
+ None => std::borrow::Cow::Owned(unique_edges(self.num_points(), &self.vert_point, &self.prim_start)),
+ }
+ }
+
+ /// Every point's colour as [`Detail::color`] reads it, the column found
+ /// once — for the readers that walk every point or corner, where a
+ /// lookup by name each time was most of what they cost.
+ pub fn point_colors(&self) -> std::borrow::Cow<'_, [[f32; 3]]> {
+ let n = self.num_points();
+ match self.points.get(CD) {
+ Some(AttribData::Float3(c)) => std::borrow::Cow::Borrowed(c),
+ Some(other) => std::borrow::Cow::Owned(
+ (0..n).map(|p| other.get(p).map_or(DEFAULT_COLOR, |v| v.as_vec3().to_array())).collect(),
+ ),
+ None => std::borrow::Cow::Owned(vec![DEFAULT_COLOR; n]),
+ }
+ }
+
/// 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.
@@ -1797,6 +1867,7 @@ impl Detail {
/// renderer's vertex type so that this module stays free of anything that
/// draws — the caller in `geometry.rs` supplies `Vertex3D`.
pub fn triangulate<V>(&self, mut make: impl FnMut([f32; 3], [f32; 3]) -> V) -> Vec<V> {
+ let colors = self.point_colors();
let mut out = Vec::new();
for prim in 0..self.num_prims() {
let pts = self.prim_points(prim);
@@ -1808,7 +1879,7 @@ impl Detail {
let p = p as usize;
out.push(make(
self.pos.get(p).copied().unwrap_or([0.0; 3]),
- self.color(p),
+ colors.get(p).copied().unwrap_or(DEFAULT_COLOR),
));
}
}
diff --git a/src/geometry.rs b/src/geometry.rs
index b71a60b..fd0bdcf 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1180,16 +1180,17 @@ fn checkpoint_bytes(state: &Detail) -> usize {
2 * (state.num_points() * 96 + state.num_verts() * 8 + state.num_prims() * 8 + 256)
}
-/// One solve's checkpoints, in frame order, and how far apart they are
-/// being kept.
+/// One solve's checkpoints, in frame order, how far apart they are being
+/// kept, and what they hold by [`checkpoint_bytes`], summed.
struct Checkpoints {
every: i32,
kept: Vec<Checkpoint>,
+ bytes: usize,
}
impl Default for Checkpoints {
fn default() -> Self {
- Checkpoints { every: CHECKPOINT_EVERY, kept: Vec::new() }
+ Checkpoints { every: CHECKPOINT_EVERY, kept: Vec::new(), bytes: 0 }
}
}
@@ -1202,17 +1203,35 @@ impl Checkpoints {
/// arriving at the old spacing thinned the start again and again — a
/// scrub is as likely to land there as anywhere, and an even spacing
/// is what bounds the steps from anywhere.
+ ///
+ /// The budget is the SUM of what the kept checkpoints hold, each by its
+ /// own size. Until 2026-10-07 it was a count — the budget over the size
+ /// of the checkpoint just kept — which charged every frame of a growing
+ /// simulation at the size of the latest: on the user's project, whose
+ /// surface grows from 162 points to 57k over 240 frames, it thinned the
+ /// whole history to every fourth frame while holding about 0.4 GB by
+ /// this estimate, and a replay stepped three frames in four again.
+ /// Counted by size, all 240 frames are kept at 1.5 GB.
fn keep(&mut self, at: Checkpoint) {
- let each = checkpoint_bytes(&at.state).max(1);
+ self.keep_within(at, CHECKPOINT_BUDGET, CHECKPOINTS_MAX);
+ }
+
+ /// [`keep`](Self::keep) under a given budget and count, for the tests.
+ fn keep_within(&mut self, at: Checkpoint, budget: usize, max: usize) {
+ let each = checkpoint_bytes(&at.state);
match self.kept.binary_search_by_key(&at.frame, |c| c.frame) {
- Ok(i) => self.kept[i] = at,
+ Ok(i) => {
+ self.bytes -= checkpoint_bytes(&self.kept[i].state);
+ self.kept[i] = at;
+ }
Err(i) => self.kept.insert(i, at),
}
- let room = (CHECKPOINT_BUDGET / each).clamp(2, CHECKPOINTS_MAX);
- while self.kept.len() > room {
+ self.bytes += each;
+ while self.kept.len() > max || (self.bytes > budget && self.kept.len() > 2) {
self.every = self.every.saturating_mul(2);
let every = self.every;
self.kept.retain(|c| c.frame % every == 0);
+ self.bytes = self.kept.iter().map(|c| checkpoint_bytes(&c.state)).sum();
}
}
@@ -1274,6 +1293,11 @@ impl SimCache {
.collect()
}
+ /// What simnet `id`'s checkpoints hold, by [`checkpoint_bytes`].
+ pub fn checkpoint_bytes_held(&self, id: &str) -> usize {
+ self.entries.get(id).map_or(0, |e| e.checkpoints.bytes)
+ }
+
/// The frames simnet `id` has checkpoints at, ascending — relative to
/// its start frame, as the solve counts them.
pub fn checkpoint_frames(&self, id: &str) -> Vec<i32> {
@@ -7741,6 +7765,40 @@ mod simnet_tests {
}
}
+ /// Checkpoints are kept within a budget of what they HOLD, each counted
+ /// at its own size: a history whose states grow fits where charging
+ /// every frame at the size of the latest would have thinned it, and a
+ /// budget too small for it thins the spacing and stays within it.
+ #[test]
+ fn checkpoints_are_budgeted_by_what_each_holds() {
+ // Frame f's state has 10 f points: the history grows as a solve's does.
+ let state = |f: i32| sphere_detail(Vec3::ZERO, 1.0, 2, 5 * f as usize);
+ let at = |f: i32| Checkpoint { frame: f, state: state(f), prev: Detail::default() };
+ let all: usize = (1..=60).map(|f| checkpoint_bytes(&state(f))).sum();
+ let latest = checkpoint_bytes(&state(60));
+ assert!(60 * latest > all * 3 / 2, "the latest times the count overstates the history");
+
+ let mut kept = Checkpoints::default();
+ for f in 1..=60 {
+ kept.keep_within(at(f), all, 1024);
+ }
+ assert_eq!(kept.kept.len(), 60, "every frame fits a budget of what they hold");
+ assert_eq!(kept.bytes, all);
+
+ let mut thinned = Checkpoints::default();
+ for f in 1..=60 {
+ // Offered as a solve offers them: on the spacing in force.
+ if f % thinned.every != 0 {
+ continue;
+ }
+ thinned.keep_within(at(f), all / 3, 1024);
+ assert!(thinned.bytes <= all / 3, "within the budget at frame {f}");
+ assert_eq!(thinned.bytes, thinned.kept.iter().map(|c| checkpoint_bytes(&c.state)).sum::<usize>());
+ }
+ assert!(thinned.every > 1 && thinned.kept.iter().all(|c| c.frame % thinned.every == 0));
+ assert!(thinned.kept.len() > 4, "{} kept", thinned.kept.len());
+ }
+
/// The Visualize node reads columns and a group mask where it read a
/// value by name at every point and searched a list of every point for
/// each one; what it writes is the reference's bit for bit, in every
diff --git a/src/main.rs b/src/main.rs
index e27f8c6..7664c09 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17978,6 +17978,67 @@ mod tests {
}
}
+ /// The edge list is built by bucket, not by one sort of every edge, and
+ /// without the rest of the topology for the wire pass: the same edges in
+ /// the same order either way — on meshes of triangles, quads and
+ /// polygons, open segments, a primitive that names a point twice, and a
+ /// hand-built one naming a point past the end (which takes the sort).
+ #[test]
+ fn unique_edges_match_a_sort_of_every_edge() {
+ let reference = |d: &crate::detail::Detail| -> Vec<[u32; 2]> {
+ let mut all = Vec::new();
+ for prim in 0..d.num_prims() {
+ let pts = d.prim_points(prim);
+ let n = pts.len();
+ if n < 2 {
+ continue;
+ }
+ let span = if n == 2 { 1 } else { n };
+ for i in 0..span {
+ let (a, b) = (pts[i], pts[(i + 1) % n]);
+ if a != b {
+ all.push([a.min(b), a.max(b)]);
+ }
+ }
+ }
+ all.sort_unstable();
+ all.dedup();
+ all
+ };
+ let mut meshes = vec![
+ crate::geometry::sphere_detail(Vec3::ZERO, 1.0, 9, 14),
+ crate::geometry::box_detail(Vec3::ZERO, Vec3::ONE, 0.2),
+ ];
+ // A scramble of triangles, quads, pentagons, segments and a
+ // degenerate polygon over 60 points, numbered out of order.
+ let mut d = crate::detail::Detail::new();
+ for i in 0..60 {
+ d.add_point(Vec3::new(i as f32, (i * 7 % 11) as f32, 0.0));
+ }
+ let mut k = 17u32;
+ for prim in 0..120 {
+ let n = [2, 3, 3, 4, 5][prim % 5];
+ let pts: Vec<u32> = (0..n).map(|_| { k = (k * 31 + 7) % 60; k }).collect();
+ d.add_prim(&pts);
+ }
+ d.add_prim(&[5, 5, 9, 5]);
+ meshes.push(d);
+ let mut past = crate::detail::Detail::new();
+ for i in 0..5 {
+ past.add_point(Vec3::new(i as f32, 0.0, 0.0));
+ }
+ past.add_prim(&[0, 1, 2]);
+ past.add_prim(&[3, 99]);
+ meshes.push(past);
+ for d in &meshes {
+ let want = reference(d);
+ assert_eq!(d.edge_list().into_owned(), want, "built alone");
+ assert!(matches!(d.edge_list(), std::borrow::Cow::Owned(_)), "without building the topology");
+ assert_eq!(d.edges(), want.as_slice(), "built with the topology");
+ assert!(matches!(d.edge_list(), std::borrow::Cow::Borrowed(_)), "and taken from it once built");
+ }
+ }
+
/// 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 b84f942..dc992fb 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1848,14 +1848,22 @@ pub(crate) fn scene_element_overlays(
/// The soup version was what the global Show Wireframe drew until
/// 2026-09-23, while the per-node meta Wireframe drew this one; with the
/// per-node flag retired there is one wireframe, and it is this one.
+///
+/// The edges without the rest of the topology (`Detail::edge_list`) and the
+/// colours as one column: a playing simulation's wireframe is built every
+/// frame, and until 2026-10-07 each built a whole topology and looked the
+/// 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> {
- let mut wires = Vec::new();
- for e in geom.edges() {
+ 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: geom.color(p),
+ color: colors.get(p).copied().unwrap_or(crate::detail::DEFAULT_COLOR),
});
}
}