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

commit123e08abb2875357b96daedcbcd9128bbda766bf
parent12c2b3c944
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 15:57
test: the detangle node's settings tried on a project

An ignored test that reads a project named by the environment, never
writes it, and plays its simnet forward with the detangle node set each
way in turn: what crossed, when, and what a frame cost.

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

 CLAUDE.md   | 16 +++++++++++++
 src/main.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 91 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9566c81..98623ea 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1329,6 +1329,22 @@ every look of the hold, was repeating one spatial search.
   it was measured on, 149 ms became 27; the first three changes alone took
   186 to 149.
 
+**On a project** (`detangle_on_a_project`, ignored; release, `--ignored
+--nocapture`, with `CCE_DETANGLE_PROJECT` naming a project directory or its
+state.json, `CCE_DETANGLE_FRAMES` how far to play, 240, and
+`CCE_DETANGLE_OUT` a directory for each way's last frame as an OBJ). The
+file is read and never written: the detangle node inside its simnet is set
+each way in turn in memory and the simnet played forward a frame at a
+time on one cache, as playback does. It is how the node was first run on
+something that was not a test's fixture — a 162-point icosphere with one
+point pulled through its own far wall, relaxed behind it: as saved
+(Points) the first crossing is at frame 20 and 35 edges are through a
+triangle at frame 240, the pulled point a spike out of the far side;
+Surface alone 71, later (frame 81); with Fold Contact 9; with everything
+on none at any frame, the far wall carried out ahead of the point as a
+tent, at 5.1 ms a frame against 0.5. Every row mattered there: the edges
+alone and the folds alone each left crossings.
+
 `detangle::self_intersections` is the MEASURE: every edge passing through a
 triangle (`spatial::segment_crosses_triangle`, tolerance relative to the
 lengths, so scale does not change the answer), no thickness and no rings.
diff --git a/src/main.rs b/src/main.rs
index 072cdcf..37db8e7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10548,6 +10548,81 @@ mod tests {
         }
     }
 
+    /// The Detangle node's settings tried on a PROJECT: the file named by
+    /// `CCE_DETANGLE_PROJECT` (a project directory or its state.json), read
+    /// and never written, its first simnet played forward a frame at a
+    /// time to `CCE_DETANGLE_FRAMES` (240) with the detangle node inside it
+    /// set each way in turn. Says what crossed, when, and what a frame
+    /// cost. Run in release with `--ignored --nocapture`.
+    #[test]
+    #[ignore]
+    fn detangle_on_a_project() {
+        let Ok(path) = std::env::var("CCE_DETANGLE_PROJECT") else {
+            println!("CCE_DETANGLE_PROJECT is not set");
+            return;
+        };
+        let frames: i32 = std::env::var("CCE_DETANGLE_FRAMES").ok().and_then(|f| f.parse().ok()).unwrap_or(240);
+        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 find<'a>(n: &'a mut FsNode, ty: &str) -> Option<&'a mut FsNode> {
+            if n.node_type == ty {
+                return Some(n);
+            }
+            n.children.iter_mut().find_map(|c| find(c, ty))
+        }
+        let saved: Vec<(String, String)> = find(&mut proj.root, "detangle").expect("a detangle node").params.iter().map(|p| (p.name.clone(), p.text().to_string())).collect();
+        println!("as loaded: {saved:?}");
+        let ways: [(&str, &[(&str, &str)]); 6] = [
+            ("off (thickness 0)", &[("Method", "Points"), ("Thickness", "0.00")]),
+            ("points", &[("Method", "Points")]),
+            ("surface", &[("Method", "Surface"), ("Edge Contact", "false"), ("Fold Contact", "false")]),
+            ("surface, edges", &[("Method", "Surface"), ("Edge Contact", "true"), ("Fold Contact", "false")]),
+            ("surface, folds", &[("Method", "Surface"), ("Edge Contact", "false"), ("Fold Contact", "true")]),
+            ("all", &[("Method", "Surface"), ("Edge Contact", "true"), ("Fold Contact", "true")]),
+        ];
+        let out = std::env::var("CCE_DETANGLE_OUT").ok();
+        for (name, settings) in ways {
+            let node = find(&mut proj.root, "detangle").unwrap();
+            for (param, text) in saved.iter().map(|(a, b)| (a.as_str(), b.as_str())).chain(settings.iter().copied()) {
+                node.params.iter_mut().find(|p| p.name == param).expect("the template's row").set_text(text);
+            }
+            let root = &proj.root;
+            let simnet = crate::geometry::find_node_by_name(root, "simnet1").expect("simnet1");
+            let mut cache = crate::geometry::SimCache::default();
+            let (mut worst, mut first, mut last, mut spent, mut moved) = (0, None, 0, std::time::Duration::ZERO, 0.0f32);
+            let mut began: Option<Detail> = None;
+            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, simnet, &mut Vec::new(), &mut err, &mut sim).expect("solves");
+                spent += t.elapsed();
+                assert!(err.is_none(), "{err:?}");
+                let crossings = crate::detangle::self_intersections(&d).crossings;
+                if crossings > 0 && first.is_none() {
+                    first = Some(frame);
+                }
+                (worst, last) = (worst.max(crossings), crossings);
+                let began = began.get_or_insert_with(|| d.clone());
+                moved = (0..d.num_points()).map(|p| (d.pos(p) - began.pos(p)).length()).fold(0.0, f32::max);
+                if let (Some(out), true) = (&out, frame == frames) {
+                    let file = std::path::PathBuf::from(out).join(format!("{}.obj", name.replace([' ', ',', '(', ')'], "_")));
+                    crate::export::write(&d, &file, crate::export::Format::from_path(&file), 1.0).expect("writes");
+                }
+            }
+            println!(
+                "{name:>18}: first crossing at frame {first:?}, worst {worst}, at frame {frames} {last}; {:.2} ms a frame; the furthest point went {moved:.3}",
+                spent.as_secs_f64() * 1000.0 / frames as f64
+            );
+        }
+    }
+
     /// 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