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

commit8112c63590b73ca7fe3bd396bc4e76bf05f7d9c6
parent47fda5f090
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 00:44
perf(spreadsheet): fill the table with columns of values, and write the disk cache only when a frame is solved

geometry_to_spreadsheet_columns copies attribute values into cce-ui's
SheetColumns; the widget writes only the cells it paints. And a Cache-on
simnet wrote its whole state to disk on every evaluation, cache hits
included, several times a frame; it now writes when it steps. A refresh
of the table on a selected simnet at 11k points: about 9 ms to 1.1.

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

 CLAUDE.md       |  33 +++++++++++----
 src/app.rs      | 126 +++++++++++++++++++++++++-------------------------------
 src/geometry.rs |  47 ++++++++++++++++++++-
 src/main.rs     |  31 +++-----------
 4 files changed, 133 insertions(+), 104 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c34412f..c6282a6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2280,7 +2280,13 @@ selection left it showing the values from before. A refresh keeps the
 spreadsheet's scroll and sort. On the project this was measured on, a scrub
 back over sixty frames from frame 120 went from a mean of 15 ms a frame
 to 1.3, and from frame 240 from 67 to 4. The disk cache (`Cache` on the
-simnet) is unchanged and still holds the one latest frame.
+simnet) still holds one frame: the last one SOLVED. It is written only
+when an evaluation steps (since 2026-10-07): until then every evaluation
+wrote it, a resume from memory included, and since the scene, the
+spreadsheet and the pull arrows each evaluate the simnet every frame, a
+Cache-on simulation wrote its whole state to disk several times a frame
+even while replaying frames solved long before — 4 ms of each of those
+evaluations at 11k points. `a_cached_simnet_writes_to_disk_only_when_it_solves`.
 
 ### Selected spreadsheet rows are marked in the scene
 
@@ -2309,7 +2315,7 @@ wears and at Group Marker Size.
 `selected_spreadsheet_rows_are_marked_in_the_scene` drives it by pointer.
 
 **The point groups are the spreadsheet's first columns** after the
-point's number (`geometry_to_spreadsheet_data`, since 2026-09-29):
+point's number (`geometry_to_spreadsheet_columns`, since 2026-09-29):
 `group:<name>`, 1 for a member and 0 for the rest. They were `g:` columns
 after every attribute, the thirteenth column of a sphere's table and off
 the right of any pane, and blank for a point outside the group — so a
@@ -3417,12 +3423,23 @@ over the start. With Repeat off, landing on the far end stops there.
 What a replayed frame costs past the solve (measured 2026-10-06 on the
 user's project, 8k points, markers, two visualizers and the spreadsheet
 on): the spreadsheet's refill and the point markers were most of it, and
-both got cheaper without changing what they show — the table looks each
-column up once and formats through `app::fmt4` (`{:.4}` to the
-character, `fmt4_is_format_4`), and `points_vertices` works the marker
-sphere out once and moves it to each point. A replayed frame went from
-140 ms to 24 there. What is left grows with the mesh: the visualizers'
-copy of the scene, and the table.
+both got cheaper without changing what they show — `points_vertices`
+works the marker sphere out once and moves it to each point (and then
+the markers were instanced, below). A replayed frame went from 140 ms to
+24 there. What is left grows with the mesh: the visualizers' copy of the
+scene.
+
+**The spreadsheet is columns of values** (2026-10-07, cce-ui's
+`SheetColumn` and `set_spreadsheet_columns`): `geometry_to_spreadsheet_columns`
+copies each attribute's values into a typed column (`Int` for the point
+number, the groups and int attributes; `Float` at four decimals for the
+rest; a detail attribute repeated down its column), and the widget writes
+the cells it PAINTS — thirty rows, not ten thousand — and sorts by value.
+Until then every cell of every row was a `String` formatted on every
+refill, which a playing simulation does every frame. With the disk-cache
+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).
 
 **The markers are instanced** (the same day, cce-ui's
 `SceneDraw::instances`): every kind — Show Point Markers, Show Vertex
diff --git a/src/app.rs b/src/app.rs
index 4f02a03..9487cb3 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1267,38 +1267,6 @@ pub fn strip_meta_children(root: &mut FsNode) {
 /// lowercased, as Houdini names its nodes (`sphere1`, `camera1`), since a
 /// path convention with exceptions is two conventions. Empty comes back as
 /// `node`, since a node with no name has no path at all.
-/// One attribute value as spreadsheet cells: a cell a component, `-` for
-/// each when there is none.
-fn push_cells(row: &mut Vec<String>, value: Option<crate::detail::AttribValue>, components: usize) {
-    match value {
-        Some(crate::detail::AttribValue::Float(f)) => row.push(fmt4(f)),
-        Some(crate::detail::AttribValue::Int(i)) => row.push(i.to_string()),
-        Some(crate::detail::AttribValue::Float2(a)) => row.extend(a.iter().map(|v| fmt4(*v))),
-        Some(crate::detail::AttribValue::Float3(a)) => row.extend(a.iter().map(|v| fmt4(*v))),
-        Some(crate::detail::AttribValue::Float4(a)) => row.extend(a.iter().map(|v| fmt4(*v))),
-        None => row.extend(std::iter::repeat("-".to_string()).take(components)),
-    }
-}
-
-/// `format!("{:.4}", x)`, character for character, several times faster:
-/// an f32 times ten thousand is exact in an f64 (24 bits of mantissa and
-/// 14), so rounding it half to even is rounding the exact decimal value,
-/// which is what the formatter does. What does not fit an integer goes to
-/// the formatter. `fmt4_is_format_4` holds the two equal.
-pub(crate) fn fmt4(x: f32) -> String {
-    if !x.is_finite() || x.abs() >= 1.0e14 {
-        return format!("{:.4}", x);
-    }
-    use std::fmt::Write;
-    let y = (x.abs() as f64 * 10000.0).round_ties_even() as u64;
-    let mut s = String::with_capacity(12);
-    if x.is_sign_negative() {
-        s.push('-');
-    }
-    let _ = write!(s, "{}.{:04}", y / 10000, y % 10000);
-    s
-}
-
 /// The playbar's cache strip, frame by frame over `start..=end` and run
 /// together: a frame is CACHED when every simnet in the tree has it in hand
 /// (at or before its start it shows its seed, which every simnet has), STALE
@@ -7736,7 +7704,7 @@ impl State {
     }
 
 
-pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<Vec<String>>) {
+pub(crate) fn geometry_to_spreadsheet_columns(geom: &Detail) -> (Vec<String>, Vec<cce_ui::widget::SheetColumn>) {
     // 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.
@@ -7815,37 +7783,59 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
     }
 
-    // Each column's store looked up once, not once a row: a playing
-    // simulation refills the table every frame, and at ten thousand points
-    // the lookups and `format!` were most of a frame (see `fmt4`).
-    let point_cols: Vec<(Option<&crate::detail::AttribData>, usize)> =
-        attribs.iter().map(|(name, ty)| (geom.points().get(name), ty.components())).collect();
-    let detail_cells: Vec<String> = {
-        let mut cells = Vec::new();
-        for (name, ty) in &detail {
-            push_cells(&mut cells, geom.detail().value(name, 0), ty.components());
+    // The table as COLUMNS of values (cce-ui's `SheetColumn`): the widget
+    // writes the cells it paints, so a refill is a copy of the values. A
+    // playing simulation refills the table every frame, and until
+    // 2026-10-07 every cell of every row was formatted into a String here
+    // for a pane that shows thirty rows — at ten thousand points most of
+    // what the table cost a frame.
+    use cce_ui::widget::SheetColumn;
+    let n = geom.num_points();
+    let float = |values: Vec<f32>| SheetColumn::Float { values, decimals: 4 };
+    // A value's components, a column each, `rows` long.
+    let value_columns = |value: crate::detail::AttribValue, rows: usize| -> Vec<SheetColumn> {
+        use crate::detail::AttribValue as V;
+        match value {
+            V::Float(f) => vec![float(vec![f; rows])],
+            V::Int(i) => vec![SheetColumn::Int(vec![i as i64; rows])],
+            V::Float2(a) => a.iter().map(|&v| float(vec![v; rows])).collect(),
+            V::Float3(a) => a.iter().map(|&v| float(vec![v; rows])).collect(),
+            V::Float4(a) => a.iter().map(|&v| float(vec![v; rows])).collect(),
         }
-        cells
     };
-    let width = headers.len();
-    let mut rows = Vec::with_capacity(geom.num_points());
-    for p in 0..geom.num_points() {
-        let pos = geom.positions()[p];
-        let col = geom.color(p);
-        let mut row = Vec::with_capacity(width);
-        row.push(p.to_string());
-        row.extend(groups.iter().map(|g| if geom.points().in_group(g, p) { "1" } else { "0" }.to_string()));
-        row.extend([fmt4(pos[0]), fmt4(pos[1]), fmt4(pos[2]), fmt4(col[0]), fmt4(col[1]), fmt4(col[2])]);
-        // A column covers its whole class, so there is no "this element
-        // does not have it" case left to render as a dash.
-        for (data, components) in &point_cols {
-            push_cells(&mut row, data.and_then(|d| d.get(p)), *components);
-        }
-        row.extend(detail_cells.iter().cloned());
-        rows.push(row);
-    }
-
-    (headers, rows)
+    let mut columns = Vec::with_capacity(headers.len());
+    columns.push(SheetColumn::Int((0..n as i64).collect()));
+    for g in &groups {
+        columns.push(SheetColumn::Int((0..n).map(|p| geom.points().in_group(g, p) as i64).collect()));
+    }
+    for k in 0..3 {
+        columns.push(float(geom.positions().iter().map(|p| p[k]).collect()));
+    }
+    let colors: Vec<[f32; 3]> = (0..n).map(|p| geom.color(p)).collect();
+    for k in 0..3 {
+        columns.push(float(colors.iter().map(|c| c[k]).collect()));
+    }
+    // A column covers its whole class, so there is no "this element does not
+    // have it" case left to render as a dash.
+    for (name, _) in &attribs {
+        use crate::detail::AttribData as D;
+        match geom.points().get(name) {
+            Some(D::Float(v)) => columns.push(float(v.clone())),
+            Some(D::Int(v)) => columns.push(SheetColumn::Int(v.iter().map(|&i| i as i64).collect())),
+            Some(D::Float2(v)) => columns.extend((0..2).map(|k| float(v.iter().map(|c| c[k]).collect()))),
+            Some(D::Float3(v)) => columns.extend((0..3).map(|k| float(v.iter().map(|c| c[k]).collect()))),
+            Some(D::Float4(v)) => columns.extend((0..4).map(|k| float(v.iter().map(|c| c[k]).collect()))),
+            None => {}
+        }
+    }
+    for (name, ty) in &detail {
+        match geom.detail().value(name, 0) {
+            Some(value) => columns.extend(value_columns(value, n)),
+            None => columns.extend((0..ty.components()).map(|_| SheetColumn::Text(vec!["-".to_string(); n]))),
+        }
+    }
+
+    (headers, columns)
 }
 
     pub fn update_active_camera_rotation(&mut self, d_yaw: f32, d_pitch: f32) -> bool {
@@ -8200,7 +8190,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let mut spreadsheet_update = None;
         if self.show_spreadsheet && !cache_hit {
             let mut headers = Vec::new();
-            let mut rows = Vec::new();
+            let mut columns = Vec::new();
             // Where each row's point is: a row is a point, by index.
             let mut points: Vec<[f32; 3]> = Vec::new();
 
@@ -8208,13 +8198,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 let mut ocl_error = None;
                 let mut sim = crate::geometry::EvalSim::new(sim_frame, sim_start, &mut sim_cache);
                 if let Some(geom) = crate::geometry::node_geometry_as_shown(&self.fs_root, node, &mut ocl_error, &mut sim) {
-                    let (h, r) = Self::geometry_to_spreadsheet_data(&geom);
-                    headers = h;
-                    rows = r;
+                    (headers, columns) = Self::geometry_to_spreadsheet_columns(&geom);
                     points = geom.positions().to_vec();
                 }
             }
-            spreadsheet_update = Some((headers, rows, points));
+            spreadsheet_update = Some((headers, columns, points));
         }
 
         // Selected-Group viewport markers: while the selection is a Group
@@ -8248,14 +8236,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         // `selected_node` is not read past here.
         self.sim_cache = sim_cache;
 
-        if let Some((headers, rows, points)) = spreadsheet_update {
+        if let Some((headers, columns, points)) = spreadsheet_update {
             // The selection is of rows by index, which are points of ONE
             // node's output: it stands across a frame or an edit, and goes
             // when the table becomes another node's.
             if current_name != self.last_spreadsheet_node_name {
                 self.spreadsheet_mut().set_selected_rows(&[]);
             }
-            self.spreadsheet_mut().set_spreadsheet_data(headers, rows);
+            self.spreadsheet_mut().set_spreadsheet_columns(headers, columns);
             self.spreadsheet_points = points;
             self.rebuild_row_markers();
             self.last_spreadsheet_node_name = current_name;
diff --git a/src/geometry.rs b/src/geometry.rs
index 67d16cf..4c15f02 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1360,6 +1360,7 @@ 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).
+#[cfg(test)]
 fn sim_solve_key(simnet: &FsNode, seed: &Detail) -> u64 {
     sim_solve_key_of(chain_hash(simnet), seed)
 }
@@ -7411,6 +7412,7 @@ pub fn resolve_simnet_geometry_with_errors(
     // speed control.
     let dt = 1.0 / substeps as f32;
 
+    let resumed_at = done;
     while done < due {
         for _ in 0..substeps {
             // The step boundary, and the contract that makes a chain
@@ -7457,7 +7459,14 @@ pub fn resolve_simnet_geometry_with_errors(
         }
     }
 
-    if caching && due > 0 {
+    // Written when this call SOLVED something — a frame the disk cannot
+    // already hold. Until 2026-10-07 it was written on every evaluation,
+    // a resume from memory included: the scene, the spreadsheet and the
+    // pull arrows each evaluate the simnet every frame, so a Cache-on
+    // simulation wrote its whole state to disk several times a frame,
+    // even replaying frames solved long before — at ten thousand points
+    // most of what the spreadsheet's refresh cost.
+    if caching && due > 0 && done > resumed_at {
         write_sim_cache(&target.id, key, due, &state, &prev_frame);
     }
     sim.cache.entries.insert(
@@ -10169,6 +10178,42 @@ mod simnet_tests {
         assert!((min_x(&fed) - (min_x(&shown) - 1.0)).abs() < 1e-4, "the feedback is one pull short of the display");
     }
 
+    /// A Cache-on simnet writes its state to disk when it SOLVES a frame,
+    /// and not when it is evaluated again at a frame it holds — which the
+    /// scene, the spreadsheet and the pull arrows each do every frame, and
+    /// which until 2026-10-07 wrote the whole state to disk each time.
+    #[test]
+    fn a_cached_simnet_writes_to_disk_only_when_it_solves() {
+        let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("radius", "0.5")], vec![]);
+        let inner_input = node("id-in", "input1", "input", vec![], vec![]);
+        let pull = node("id-pull", "pull1", "attribute", vec![
+            param("input", "input1"), param("operation", "Modify"), param("attribute_name", "Pos"),
+            param("value", "1.00:0.00:0.00"), param("combine", "Add"), param("group", ""),
+        ], vec![]);
+        let inner_output = node("id-out", "output1", "output", vec![param("input", "pull1")], vec![]);
+        let sim_node = node("id-sim-disk-writes", "Simnet 1", "simnet",
+            vec![param("input", "Sphere 1"), param("cache", "true")], vec![inner_input, pull, inner_output]);
+        let root = node("id-root", "root", "node", vec![], vec![sphere, sim_node]);
+        let simnet = &root.children[1];
+        let path = sim_cache_path(&simnet.id).expect("a cache path");
+        let _ = std::fs::remove_file(&path);
+        let mut cache = SimCache::default();
+        let mut at = |frame: i32| {
+            let mut sim = EvalSim::new(frame, 1, &mut cache);
+            resolve_simnet_geometry_with_errors(&root, simnet, &mut Vec::new(), &mut None, &mut sim).expect("solves");
+        };
+        at(5);
+        assert!(path.exists(), "solving wrote the file");
+        std::fs::remove_file(&path).unwrap();
+        at(5);
+        at(3);
+        at(5);
+        assert!(!path.exists(), "frames already solved are not written again");
+        at(6);
+        assert!(path.exists(), "a frame solved is");
+        let _ = std::fs::remove_file(&path);
+    }
+
     /// A Cache-on simnet resumed from DISK exactly at the frame asked for
     /// runs no step, so the feedback the interior view and the pull arrows
     /// read has to come from the file: until 2026-09-28 it was the seed for
diff --git a/src/main.rs b/src/main.rs
index e254ee9..397e5f8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6685,7 +6685,11 @@ mod tests {
         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);
+        let (headers, columns) = State::geometry_to_spreadsheet_columns(&geom);
+        // The cells as the table writes them when it paints them.
+        let rows: Vec<Vec<String>> = (0..geom.num_points()).map(|r| columns.iter().map(|c| c.cell(r)).collect()).collect();
+        assert_eq!(columns.len(), headers.len(), "a column a header");
+        assert!(columns.iter().all(|c| c.len() == geom.num_points()), "a cell a row");
         assert_eq!(
             headers,
             vec![
@@ -18195,31 +18199,6 @@ mod tests {
         }
     }
 
-    /// The spreadsheet's fast float formatter writes what `{:.4}` writes:
-    /// over every exponent, the exact ties (multiples of 1/2^k that land
-    /// on a fifth decimal of 5), negative zero and what rounds to it, and
-    /// the values too large for the integer path.
-    #[test]
-    fn fmt4_is_format_4() {
-        let mut check = |x: f32| assert_eq!(crate::app::fmt4(x), format!("{:.4}", x), "{x:e} ({:#x})", x.to_bits());
-        for x in [0.0, -0.0, 0.00005, -0.00005, 0.03125, -0.03125, 0.15625, 1.00005, 2.5e-5, 1.0e13, 9.9e13, 1.0e14, 3.0e38, f32::MIN_POSITIVE, 1.0e-45, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
-            check(x);
-        }
-        // Ties: k/2^n with a 5 in the fifth decimal.
-        for n in 1..=24 {
-            for k in 0..2000u32 {
-                check(k as f32 / (1u32 << n) as f32);
-                check(-(k as f32) / (1u32 << n) as f32);
-            }
-        }
-        // And a sweep through the bit patterns.
-        let mut bits = 0u32;
-        while bits < u32::MAX - 997 {
-            check(f32::from_bits(bits));
-            bits += 997;
-        }
-    }
-
     /// 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