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

commit6ea4f7482f0c87e161bc0d769af33b8824b09273
parente11d457a2b
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 21:00
test: where a project's simulation spends its time

An ignored test that plays a project's simnet as saved and with each
node of its chain bypassed in turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md   | 17 +++++++++++++++
 src/main.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 88 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5532802..c6d2d4f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1547,6 +1547,23 @@ What still costs is the solver's, not the node's: an edit inside a simnet
 re-solves from the seed, so a change at frame 120 is 120 steps. A
 backward scrub no longer does — the next section.
 
+### Where a simulation's time goes
+
+`sim_profile_on_a_project` (ignored; release, `--ignored --nocapture`,
+with `CCE_SIM_PROJECT` naming a project directory or its state.json and
+`CCE_SIM_FRAMES` how far to play, 60) plays the project's simnet forward
+as saved and again with each node of its chain bypassed in turn, so what
+a node costs is what the solve saves without it. The file is read and
+never written, and the disk cache is off for the run. On the project it
+was written for (2026-09-29; a pull, a Surface detangle with every row on
+and a remesh, 162 points growing to 525): 46 ms a frame as saved, 24
+without the detangle, 6 without the remesh. The remesh is most of it
+twice over: its own passes are some 20 ms at 525 points — the flip pass
+asks a point's valence eight times an edge, each a list built and sorted,
+and the projection's closest-point query is 6 µs — and it splits and
+collapses the same 93 edges at every step of a mesh that has stopped
+moving, so the topology the detangle keeps its lists by is new each step.
+
 ### Simulation checkpoints
 
 A step is not invertible, so going back means going forward from
diff --git a/src/main.rs b/src/main.rs
index 21e581b..bed27a7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11855,6 +11855,77 @@ mod tests {
         }
     }
 
+    /// Where a project's simulation spends its time: `simnet1` of the
+    /// project `CCE_SIM_PROJECT` names played forward to `CCE_SIM_FRAMES`
+    /// (60) as saved, and again with each node of its chain bypassed in
+    /// turn, so what a node costs is what the solve saves without it. The
+    /// file is read and never written. Run in release with `--ignored
+    /// --nocapture`.
+    #[test]
+    #[ignore]
+    fn sim_profile_on_a_project() {
+        let Ok(path) = std::env::var("CCE_SIM_PROJECT") else {
+            println!("CCE_SIM_PROJECT is not set");
+            return;
+        };
+        let frames: i32 = std::env::var("CCE_SIM_FRAMES").ok().and_then(|f| f.parse().ok()).unwrap_or(60);
+        let path = std::path::PathBuf::from(path);
+        let file = if path.is_dir() { path.join("state.json") } else { path };
+        let mut proj: crate::app::Project = serde_json::from_str(&std::fs::read_to_string(&file).expect("reads")).expect("parses");
+        let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
+        proj.sanitize_node_names();
+        proj.migrate_param_refs();
+        crate::app::merge_template_defs(&mut proj.root, &templates);
+        fn simnet(n: &mut FsNode) -> Option<&mut FsNode> {
+            if n.node_type == "simnet" {
+                return Some(n);
+            }
+            n.children.iter_mut().find_map(simnet)
+        }
+        let sim_node = simnet(&mut proj.root).expect("a simnet");
+        // Never the disk: this measures the solve.
+        if let Some(p) = sim_node.params.iter_mut().find(|p| p.name == "Cache") {
+            p.set_text("false");
+        }
+        let sim_name = sim_node.name.clone();
+        let chain: Vec<String> = sim_node
+            .children
+            .iter()
+            .filter(|c| !matches!(c.node_type.as_str(), "input" | "output") && !c.bypassed)
+            .map(|c| c.name.clone())
+            .collect();
+        let mut ways: Vec<Option<String>> = vec![None];
+        ways.extend(chain.into_iter().map(Some));
+        for way in ways {
+            let mut proj = proj.clone();
+            if let Some(name) = &way {
+                simnet(&mut proj.root).unwrap().children.iter_mut().find(|c| &c.name == name).unwrap().bypassed = true;
+            }
+            let root = &proj.root;
+            let node = crate::geometry::find_node_by_name(root, &sim_name).expect("the simnet");
+            let mut cache = crate::geometry::SimCache::default();
+            let mut times = Vec::new();
+            let mut points = Vec::new();
+            for frame in 1..=frames {
+                let mut sim = crate::geometry::EvalSim::new(frame, 1, &mut cache);
+                let mut err = None;
+                let t = std::time::Instant::now();
+                let d = crate::geometry::generate_single_node_geometry_with_errors(root, node, &mut Vec::new(), &mut err, &mut sim);
+                times.push(t.elapsed().as_secs_f64() * 1000.0);
+                points.push(d.map_or(0, |d| d.num_points()));
+            }
+            let mean = times.iter().sum::<f64>() / times.len() as f64;
+            let worst = times.iter().cloned().fold(0.0, f64::max);
+            let at = |f: usize| times.get(f - 1).copied().unwrap_or(0.0);
+            println!(
+                "{:>22}: {mean:7.2} ms a frame, worst {worst:7.2}; frame 2 {:.2}, 10 {:.2}, 30 {:.2}, last {:.2}; points {} -> {}",
+                way.map_or("as saved".to_string(), |n| format!("without {n}")),
+                at(2), at(10), at(30), at(frames as usize),
+                points.first().unwrap(), points.last().unwrap(),
+            );
+        }
+    }
+
     /// What the whole of the Surface method costs where most of a mesh is
     /// in contact: the sphere test's workload with every row on,
     /// at a fifth of an edge a step. The sum is of every position at every