From 20aa3ed4e1b0ccc5a0848afc55a8bcec3d3bf82e Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 05:15:25 +0000 Subject: [PATCH] Oracle: seam-normalised topology. When body count, volume and area pass but the raw counts differ, Fusion's faces/edges/vertices are normalised from its face, edge and vertex lists as SolveCraft's merged counts are (closed-surface patches split by seams count once, seam edges dropped, two-edge vertices joined) and compared exactly, with both raw counts in the note (56 and 61 pass) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- apps/solvecraft-cli/src/main.rs | 1 + apps/solvecraft-cli/src/oracle.rs | 45 ++++- apps/solvecraft-cli/src/seams.rs | 282 ++++++++++++++++++++++++++++++ docs/oracle.md | 51 +++++- xtask/src/main.rs | 2 +- 5 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 apps/solvecraft-cli/src/seams.rs diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index a09bd359..f964ccc5 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -16,6 +16,7 @@ mod oracle; mod recipe; +mod seams; use std::process::ExitCode; diff --git a/apps/solvecraft-cli/src/oracle.rs b/apps/solvecraft-cli/src/oracle.rs index 3f276c37..be8391d6 100644 --- a/apps/solvecraft-cli/src/oracle.rs +++ b/apps/solvecraft-cli/src/oracle.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use crate::{read_json, recipe}; +use crate::{read_json, recipe, seams}; /// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4). pub const REL_TOL: f64 = 1e-3; @@ -48,13 +48,47 @@ fn check_case(dir: &str) -> Value { // Cases where Fusion's part is itself an approximation: compare size loosely, not topology. let approx = APPROXIMATE.iter().find(|(c, _, _)| *c == name); let tol = approx.map(|(_, t, _)| *t).unwrap_or(REL_TOL); + let within = |k: &str, rel: f64| match (got["total"][k].as_f64(), want["total"][k].as_f64()) { + (Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0), + _ => false, + }; + let size_ok = got["body_count"] == want["body_count"] && within("volume_mm3", tol) && within("area_mm2", tol); num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0); num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), tol); num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), tol); + let mut seam_note = None; if approx.is_none() { - num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0); - num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0); - num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0); + let exact = ["faces", "edges", "vertices"].iter().all(|k| got["total"][k].as_f64().is_some() && got["total"][k] == want["total"][k]); + let fusion_seams = want["bodies"].as_array().and_then(|bs| { + bs.iter().map(seams::normalised).try_fold((0, 0, 0), |a, n| n.map(|n| (a.0 + n.0, a.1 + n.1, a.2 + n.2))) + }); + match fusion_seams { + // Size matches and only the topology differs: compare with Fusion's seam splits merged. + Some((f, e, v)) if size_ok && !exact => { + let raw: Vec = ["faces", "edges", "vertices"] + .iter() + .map(|k| { + let ours: i64 = got["bodies"].as_array().into_iter().flatten().filter_map(|b| b["kernel"][k].as_i64()).sum(); + format!("{k} {ours}/{}", want["total"][k]) + }) + .collect(); + seam_note = Some(format!( + "topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolveCraft/Fusion: {})", + want["total"]["faces"], + want["total"]["edges"], + want["total"]["vertices"], + raw.join(", ") + )); + num("faces (seam-normalised)", got["total"]["faces"].as_f64(), Some(f as f64), 0.0); + num("edges (seam-normalised)", got["total"]["edges"].as_f64(), Some(e as f64), 0.0); + num("vertices (seam-normalised)", got["total"]["vertices"].as_f64(), Some(v as f64), 0.0); + } + _ => { + num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0); + num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0); + num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0); + } + } } // Our STEP export of the rebuilt part: well formed, and read back with no warnings and the // same volume. @@ -65,6 +99,9 @@ fn check_case(dir: &str) -> Value { if let Some((_, _, why)) = approx { out["note"] = json!(why); } + if let Some(n) = seam_note { + out["note"] = json!(n); + } let effective: Vec = recipe["features"] .as_array() .into_iter() diff --git a/apps/solvecraft-cli/src/seams.rs b/apps/solvecraft-cli/src/seams.rs new file mode 100644 index 00000000..f5d63899 --- /dev/null +++ b/apps/solvecraft-cli/src/seams.rs @@ -0,0 +1,282 @@ +//! Seam-normalised topology of Fusion's measurements. +//! +//! Fusion splits a closed surface along seams: a full cylinder can be two half faces, and a +//! closed curve crossing a seam becomes two edges with a vertex on the seam. SolveCraft's merged +//! counts already count each closed surface patch once and ignore seam edges and vertices, so +//! the oracle normalises Fusion's side the same way from its face, edge and vertex lists: +//! - faces cut from one periodic surface (cylinder, cone, sphere, torus) and separated only by +//! seam edges count once; +//! - a seam edge lies on that surface and on no other face's surface: it is dropped; +//! - a vertex left with exactly two edges is no corner (a solid's corners have three or more): +//! the two edges join, and a curve that closes on itself keeps one vertex. + +use std::collections::HashMap; + +use serde_json::Value; + +type V3 = [f64; 3]; + +const TOL: f64 = 1e-4; + +fn v3(v: &Value) -> Option { + let a = v.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]) +} +fn sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn add(a: V3, b: V3) -> V3 { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn mul(a: V3, s: f64) -> V3 { + [a[0] * s, a[1] * s, a[2] * s] +} +fn dot(a: V3, b: V3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn cross(a: V3, b: V3) -> V3 { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} +fn len(a: V3) -> f64 { + dot(a, a).sqrt() +} +fn unit(a: V3) -> Option { + let l = len(a); + (l > 1e-12).then(|| mul(a, 1.0 / l)) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Surf { + Plane { p: V3, n: V3 }, + Cylinder { o: V3, a: V3, r: f64 }, + Cone { o: V3, a: V3, r: f64, tan: f64 }, + Sphere { c: V3, r: f64 }, + Torus { c: V3, a: V3, big: f64, small: f64 }, + Other, +} + +impl Surf { + fn of(f: &Value) -> Surf { + let g = |k: &str| v3(&f[k]); + let n = |k: &str| f[k].as_f64(); + let s = match f["type"].as_str().unwrap_or_default() { + "plane" => g("centroid").zip(g("plane_normal_outward").and_then(unit)).map(|(p, n)| Surf::Plane { p, n }), + "cylinder" => (|| Some(Surf::Cylinder { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")? }))(), + "cone" => (|| Some(Surf::Cone { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")?, tan: n("half_angle_deg")?.to_radians().tan() }))(), + "sphere" => (|| Some(Surf::Sphere { c: g("center")?, r: n("radius_mm")? }))(), + "torus" => (|| Some(Surf::Torus { c: g("center")?, a: unit(g("axis")?)?, big: n("major_radius_mm")?, small: n("minor_radius_mm")? }))(), + _ => None, + }; + s.unwrap_or(Surf::Other) + } + + fn periodic(&self) -> bool { + matches!(self, Surf::Cylinder { .. } | Surf::Cone { .. } | Surf::Sphere { .. } | Surf::Torus { .. }) + } + + /// Whether the point lies on the surface; `None` when that can't be told (free-form faces). + fn contains(&self, p: V3) -> Option { + let radial = |o: V3, a: V3| { + let d = sub(p, o); + let t = dot(d, a); + (t, len(sub(d, mul(a, t)))) + }; + Some(match *self { + Surf::Plane { p: q, n } => dot(sub(p, q), n).abs() < TOL, + Surf::Cylinder { o, a, r } => (radial(o, a).1 - r).abs() < TOL, + Surf::Cone { o, a, r, tan } => { + let (t, rho) = radial(o, a); + (rho - (r + t * tan)).abs() < TOL || (rho - (r - t * tan)).abs() < TOL + } + Surf::Sphere { c, r } => (len(sub(p, c)) - r).abs() < TOL, + Surf::Torus { c, a, big, small } => { + let (t, rho) = radial(c, a); + ((rho - big).hypot(t) - small).abs() < TOL + } + Surf::Other => return None, + }) + } + + fn same(&self, o: &Surf) -> bool { + let close = |a: f64, b: f64| (a - b).abs() < TOL; + let pt = |a: V3, b: V3| len(sub(a, b)) < TOL; + let par = |a: V3, b: V3| len(cross(a, b)) < TOL; + // Same axis line: parallel, and either origin on the other's axis. + let axis = |o1: V3, a1: V3, o2: V3, a2: V3| par(a1, a2) && len(cross(sub(o2, o1), a1)) < TOL; + match (*self, *o) { + (Surf::Cylinder { o: o1, a: a1, r: r1 }, Surf::Cylinder { o: o2, a: a2, r: r2 }) => axis(o1, a1, o2, a2) && close(r1, r2), + (Surf::Cone { o: o1, a: a1, r: r1, tan: t1 }, Surf::Cone { .. }) => { + // Same cone: the other's frame circle lies on this one, same opening angle. + let Surf::Cone { o: o2, a: a2, r: r2, tan: t2 } = *o else { return false }; + axis(o1, a1, o2, a2) && close(t1, t2) && { + let t = dot(sub(o2, o1), a1) * dot(a1, a2).signum(); + close(r2, r1 + t * t1) || close(r2, r1 - t * t1) + } + } + (Surf::Sphere { c: c1, r: r1 }, Surf::Sphere { c: c2, r: r2 }) => pt(c1, c2) && close(r1, r2), + (Surf::Torus { c: c1, a: a1, big: b1, small: s1 }, Surf::Torus { c: c2, a: a2, big: b2, small: s2 }) => { + pt(c1, c2) && par(a1, a2) && close(b1, b2) && close(s1, s2) + } + _ => false, + } + } +} + +/// Points along an edge from Fusion's edge list. +fn edge_points(e: &Value) -> Vec { + let (s, t) = (v3(&e["start"]), v3(&e["end"])); + let mut pts: Vec = s.into_iter().chain(t).collect(); + let (c, r, a) = (v3(&e["center"]), e["radius_mm"].as_f64(), v3(&e["axis"]).and_then(unit)); + match (e["type"].as_str().unwrap_or_default(), s, t) { + ("line", Some(s), Some(t)) => pts.push(mul(add(s, t), 0.5)), + ("arc", Some(s), Some(t)) => { + if let (Some(c), Some(r)) = (c, r) + && let Some(m) = unit(add(sub(s, c), sub(t, c))) + { + let long = e["length_mm"].as_f64().is_some_and(|l| l > std::f64::consts::PI * r); + pts.push(add(c, mul(m, if long { -r } else { r }))); + } + } + ("circle", Some(s), _) => { + if let (Some(c), Some(a)) = (c, a) { + let d = sub(s, c); + for th in [2.0 * std::f64::consts::FRAC_PI_3, 4.0 * std::f64::consts::FRAC_PI_3] { + pts.push(add(c, add(mul(d, th.cos()), mul(cross(a, d), th.sin())))); + } + } + } + _ => {} + } + pts +} + +fn key(p: V3) -> [i64; 3] { + p.map(|x| (x * 1e4).round() as i64) +} + +fn find(p: &mut [usize], mut i: usize) -> usize { + while let Some(&q) = p.get(i) { + if q == i { + break; + } + i = q; + } + i +} + +/// Faces, edges and vertices of one Fusion body after merging seam splits; `None` when the +/// lists are missing. +pub fn normalised(body: &Value) -> Option<(usize, usize, usize)> { + let faces = body["face_list"].as_array()?; + let edges = body["edge_list"].as_array()?; + let surfs: Vec = faces.iter().map(Surf::of).collect(); + // Group faces cut from one periodic surface. + let mut group: Vec = (0..surfs.len()).collect(); + for i in 0..surfs.len() { + for j in 0..i { + if surfs[i].periodic() && surfs[i].same(&surfs[j]) { + group[i] = group[j]; + break; + } + } + } + let size = |g: usize| group.iter().filter(|x| **x == g).count(); + // Seam edges: on a split group's surface and on no other face's surface. + let mut seams_in: HashMap = HashMap::new(); + let mut seam = vec![false; edges.len()]; + for (ei, e) in edges.iter().enumerate() { + let pts = edge_points(e); + if pts.is_empty() { + continue; + } + let on = |s: &Surf| pts.iter().map(|p| s.contains(*p)).try_fold(true, |acc, x| Some(acc && x?)); + let carriers: Vec> = surfs.iter().map(on).collect(); + if carriers.contains(&None) { + continue; + } + let groups: Vec = (0..surfs.len()).filter(|f| carriers[*f] == Some(true)).map(|f| group[f]).collect(); + if let Some(&g) = groups.first() + && groups.iter().all(|x| *x == g) + && surfs[g].periodic() + && size(g) > 1 + { + seam[ei] = true; + *seams_in.entry(g).or_insert(0) += 1; + } + } + let mut face_count = 0; + for g in 0..surfs.len() { + let k = size(g); + if k > 0 { + face_count += k.saturating_sub(seams_in.get(&g).copied().unwrap_or(0)).max(1); + } + } + // Vertex degrees over the remaining edges; a closed edge counts once at its vertex. + let mut ends: Vec<(usize, Option<[i64; 3]>, Option<[i64; 3]>)> = Vec::new(); + for (ei, e) in edges.iter().enumerate() { + if seam[ei] { + continue; + } + let (s, t) = (v3(&e["start"]).map(key), v3(&e["end"]).map(key)); + let t = if t == s { None } else { t }; + ends.push((ends.len(), s, t)); + } + let mut deg: HashMap<[i64; 3], Vec> = HashMap::new(); + for (i, s, t) in &ends { + for v in [s, t].into_iter().flatten() { + deg.entry(*v).or_default().push(*i); + } + } + let mut ep: Vec = (0..ends.len()).collect(); + let mut merged: Vec<[i64; 3]> = Vec::new(); + for (v, es) in ° { + if let [i, j] = es[..] + && i != j + { + let (ri, rj) = (find(&mut ep, i), find(&mut ep, j)); + if let Some(x) = ep.get_mut(ri) { + *x = rj; + } + merged.push(*v); + } + } + let mut real: HashMap = HashMap::new(); + for (i, s, t) in &ends { + let r = find(&mut ep, *i); + let has_real = [s, t].into_iter().flatten().any(|v| !merged.contains(v)) || (s.is_none() && t.is_none()); + *real.entry(r).or_insert(false) |= has_real; + } + let closed = real.values().filter(|r| !**r).count(); + let vertices = deg.keys().filter(|v| !merged.contains(v)).count() + closed; + Some((face_count, real.len(), vertices)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A cylinder (r 5, height 10) split by Fusion into two halves along two seams. + #[test] + fn split_cylinder_counts_once() { + let cyl = json!({"type": "cylinder", "axis_origin": [0, 0, 0], "axis": [0, 0, 1], "radius_mm": 5.0}); + let plane = |z: f64, n: f64| json!({"type": "plane", "centroid": [0, 0, z], "plane_normal_outward": [0, 0, n]}); + let arc = |z: f64, s: [f64; 3], t: [f64; 3]| { + json!({"type": "arc", "start": s, "end": t, "center": [0, 0, z], "radius_mm": 5.0, "axis": [0, 0, 1], "length_mm": 15.707963}) + }; + let line = |x: f64| json!({"type": "line", "start": [x, 0, 0], "end": [x, 0, 10]}); + let body = json!({ + "face_list": [cyl.clone(), cyl, plane(0.0, -1.0), plane(10.0, 1.0)], + "edge_list": [line(5.0), line(-5.0), arc(0.0, [5.0, 0.0, 0.0], [-5.0, 0.0, 0.0]), arc(0.0, [-5.0, 0.0, 0.0], [5.0, 0.0, 0.0]), arc(10.0, [5.0, 0.0, 10.0], [-5.0, 0.0, 10.0]), arc(10.0, [-5.0, 0.0, 10.0], [5.0, 0.0, 10.0])], + }); + assert_eq!(normalised(&body), Some((3, 2, 2))); + } + + /// A closed curve split into two edges at a seam vertex joins again (Fusion part 61's keyway). + #[test] + fn degree_two_vertices_join() { + let n = |s: [f64; 3], t: [f64; 3]| json!({"type": "nurbs", "start": s, "end": t}); + let body = json!({"face_list": [], "edge_list": [n([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), n([1.0, 0.0, 0.0], [0.0, 0.0, 0.0])]}); + assert_eq!(normalised(&body), Some((0, 1, 1))); + } +} diff --git a/docs/oracle.md b/docs/oracle.md index d5d935fe..f56fcd68 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -1,8 +1,8 @@ # Fusion oracle -Generated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolveCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact). +Generated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolveCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact). SolveCraft counts each closed-surface patch once and ignores seam edges and vertices; when body count, volume and area pass but the raw counts differ, Fusion's counts are normalised the same way from its face, edge and vertex lists (faces cut from one cylinder, cone, sphere or torus and separated only by seams count once; an edge on that surface and no other is a seam and is dropped; a vertex left with two edges joins them) and compared exactly; the note gives both sides' raw counts. -**29 / 29 cases pass** (0 need features not implemented yet). +**72 / 76 cases pass** (0 need features not implemented yet). | Case | Result | Notes | |---|---|---| @@ -35,3 +35,50 @@ Generated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe a | 27-sketch-trapezoid | pass | | | 28-blind-pocket-fillets | pass | | | 29-two-sided-and-partial-revolve | pass | | +| 30-fillet-after-shell | pass | | +| 31-fillet-l-all-edges | pass | | +| 32-boss-plate-fillets | pass | | +| 33-fillet-tangent-chain | pass | | +| 34-chamfer-then-fillet | pass | | +| 35-fillet-curved-curved | FAIL | step 9 (FusionFilletEdgesCommand): not supported yet: only straight edges can be blended | +| 36-corner-blend-two-radii | pass | | +| 37-pyramid-apex-blend | pass | | +| 38-coincident-cylinders | pass | | +| 39-tee-pipe | pass | | +| 40-box-sphere-torus | FAIL | step 18 (FusionCombineCommand): boolean Cut: kernel internal error: boolean: This shell is not oriented and closed.; the operation failed: not supported yet: coincident faces on curved bodies | +| 41-tube-radial-holes | pass | | +| 42-overlapping-patterns | FAIL | faces (seam-normalised): got 41.0 want 43.0; edges (seam-normalised): got 102.0 want 106.0; vertices (seam-normalised): got 78.0 want 74.0; topology compared seam-normalised: Fusion's 43/106/74 faces/edges/vertices become 43/106/74 with closed-surface patches counted once and seam edges and vertices dropped (raw SolveCraft/Fusion: faces 76/43, edges 222/106, vertices 148/74) | +| 43-cut-splits-body | pass | | +| 44-shell-l-bracket | pass | | +| 45-shell-boss-plate | pass | | +| 46-shell-two-open-faces | pass | | +| 47-draft-curved-pocket | pass | | +| 48-pulley | pass | | +| 49-helical-sweep | pass | Fusion sweeps along a spline fitted through helix points; SolveCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all | +| 50-sweep-tight-bend | FAIL | area_mm2: got 1660.3701512704645 want 2153.937024; faces: got 6.0 want 5.0; edges: got 5.0 want 6.0; vertices: got 5.0 want 4.0 | +| 51-loft-rect-circle-offset | pass | | +| 52-circular-pattern-fillet | pass | | +| 53-path-pattern | pass | | +| 54-mirror-body-fillets | pass | | +| 55-tapped-hole-cosmetic | pass | | +| 56-holes-on-curved-face | pass | topology compared seam-normalised: Fusion's 7/10/10 faces/edges/vertices become 7/8/8 with closed-surface patches counted once and seam edges and vertices dropped (raw SolveCraft/Fusion: faces 16/7, edges 32/10, vertices 19/10) | +| 57-hole-pattern-sketch-points | pass | | +| 58-phone-stand | pass | | +| 59-enclosure-base | pass | | +| 60-pipe-flange | pass | | +| 61-shaft-keyway | pass | topology compared seam-normalised: Fusion's 14/22/18 faces/edges/vertices become 14/20/16 with closed-surface patches counted once and seam edges and vertices dropped (raw SolveCraft/Fusion: faces 24/14, edges 54/22, vertices 34/18) | +| 62-heat-sink | pass | | +| 63-hinge-knuckle | pass | known Fusion difference: Gap replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolveCraft's through-all goes truly through) | +| 64-sketch-tangent-arc-chain | pass | | +| 65-sketch-gear-tooth | pass | | +| 66-sketch-linkage | pass | | +| 67-sm-base-flange | pass | | +| 68-sm-edge-flange | pass | | +| 69-sm-tray | pass | | +| 70-sm-flange-45 | pass | | +| 71-sm-custom-rule | pass | | +| 72-sm-contour-flange | pass | | +| 73-sm-hem | pass | | +| 74-sm-unfold-refold | pass | | +| 75-sm-bracket-holes | pass | | +| 76-sm-thickness-variant | pass | | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7b33570c..68f761d5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -139,7 +139,7 @@ fn cmd_oracle() -> Result<(), String> { let pass = list.iter().filter(|r| r["pass"].as_bool().unwrap_or(false)).count(); let skipped = list.iter().filter(|r| r["skipped"].as_bool().unwrap_or(false)).count(); let mut md = format!( - "# Fusion oracle\n\nGenerated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolveCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact).\n\n**{pass} / {} cases pass** ({skipped} need features not implemented yet).\n\n| Case | Result | Notes |\n|---|---|---|\n", + "# Fusion oracle\n\nGenerated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolveCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact). SolveCraft counts each closed-surface patch once and ignores seam edges and vertices; when body count, volume and area pass but the raw counts differ, Fusion's counts are normalised the same way from its face, edge and vertex lists (faces cut from one cylinder, cone, sphere or torus and separated only by seams count once; an edge on that surface and no other is a seam and is dropped; a vertex left with two edges joins them) and compared exactly; the note gives both sides' raw counts.\n\n**{pass} / {} cases pass** ({skipped} need features not implemented yet).\n\n| Case | Result | Notes |\n|---|---|---|\n", list.len() ); for r in &list {