graphic design tool
git clone https://git.lucas.co/cce-designer.git
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