diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 74ba8a09..a3225871 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -37,7 +37,7 @@ const USAGE: &str = "usage: solvecraft-cli recipe solvecraft-cli oracle ... [--json] solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE] - solvecraft-cli step-corpus ... [--json] + solvecraft-cli step-corpus ... [--json] [--iges] solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT] solvecraft-cli licences solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico diff --git a/apps/solvecraft-cli/src/oracle.rs b/apps/solvecraft-cli/src/oracle.rs index e26c85d6..7d30bd87 100644 --- a/apps/solvecraft-cli/src/oracle.rs +++ b/apps/solvecraft-cli/src/oracle.rs @@ -170,9 +170,10 @@ const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"]; /// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements: /// body count, volume and area (relative 1e-3) and the faces read from the file. -fn check_step_case(dir: &str) -> Value { +/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json. +fn check_step_case(dir: &str, iges: bool) -> Value { let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default(); - let sp = format!("{dir}/part.step"); + let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" }); let want = match read_json(&format!("{dir}/measure.json")) { Ok(m) => m, Err(e) => return json!({"case": name, "pass": false, "error": e}), @@ -191,7 +192,14 @@ fn check_step_case(dir: &str) -> Value { // Faces read from the file (the kernel may split a face that wraps around a closed surface). let file_faces = std::fs::read(&sp) .ok() - .and_then(|b| solvecraft_engine::kernel::step_import_shared(&String::from_utf8_lossy(&b)).ok()) + .and_then(|b| { + let text = String::from_utf8_lossy(&b); + if iges { + solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new) + } else { + solvecraft_engine::kernel::step_import_shared(&text).ok() + } + }) .map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::() as f64); let mut checks = Vec::new(); let mut pass = true; @@ -221,7 +229,8 @@ pub fn step_corpus(args: &[String]) -> Result<(), String> { if dirs.is_empty() { return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into()); } - let results: Vec = dirs.iter().map(|d| check_step_case(d)).collect(); + let iges = args.iter().any(|a| a == "--iges"); + let results: Vec = dirs.iter().map(|d| check_step_case(d, iges)).collect(); report(args, &results); let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count(); if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) } diff --git a/crates/kernel/src/iges_in/tests.rs b/crates/kernel/src/iges_in/tests.rs index 03edfc81..cf3e2594 100644 --- a/crates/kernel/src/iges_in/tests.rs +++ b/crates/kernel/src/iges_in/tests.rs @@ -141,3 +141,18 @@ fn trimmed_surfaces_sew_into_a_solid() { let m = measure(&imp.bodies[0].body).unwrap(); assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area); } + +#[test] +fn a_drill_point_round_trips_through_iges() { + // A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the + // apex and straight back). + use solvecraft_geom::{Loop2, Plane, Region2, Vec2}; + let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap(); + let profile = + Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] }; + let hole = crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap(); + let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap(); + let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0); + assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3); + round_trip(&drilled); +} diff --git a/crates/kernel/src/step_in/split.rs b/crates/kernel/src/step_in/split.rs index a72d2520..c11756eb 100644 --- a/crates/kernel/src/step_in/split.rs +++ b/crates/kernel/src/step_in/split.rs @@ -299,6 +299,10 @@ fn plan(face: &mt::Face) -> R> { _ => chains.push((seam, vec![e])), } } + // A cone's tip: the seam runs from the ring to the apex and straight back. + if let [(true, sm), (false, ring)] = chains.as_slice() { + return apex_plan(face, &s, (d, mid, period), sm, ring, &wires, outer_idx).map(Some); + } let [(true, s1), (false, r1), (true, s2), (false, r2)] = chains.as_slice() else { return Err(format!("unsupported seam loop layout ({} runs)", chains.len())); }; @@ -358,6 +362,72 @@ fn plan(face: &mt::Face) -> R> { Ok(Some(Plan { cuts: c1.into_iter().chain(c2).collect(), faces: (fa, fb) })) } +/// Split a face closing at an apex (loop: seam out to the apex, seam back, the ring) along the +/// iso-line opposite the seam, from the ring to the apex. +fn apex_plan( + face: &mt::Face, + s: &mt::Surface, + (d, mid, period): (Dir, f64, f64), + sm: &[mt::Edge], + ring: &[mt::Edge], + wires: &[mt::Wire], + outer_idx: usize, +) -> R { + let h = sm.len() / 2; + if !sm.len().is_multiple_of(2) || h == 0 { + return Err("seam edges are not used back and forth".into()); + } + let (out, back) = (sm.get(..h).ok_or("seam")?, sm.get(h..).ok_or("seam")?); + if out.iter().map(mt::Edge::id).collect::>() != back.iter().rev().map(mt::Edge::id).collect::>() { + return Err("seam edges are not used back and forth".into()); + } + let apex = out.last().ok_or("seam")?.back().clone(); + let (k, t, q) = crossing(s, d, ring, mid, period)?; + let seam_line: Vec = out.iter().flat_map(|e| samples(&e.oriented_curve(), 16).into_iter().map(|x| x.1)).collect(); + let iso = iso_curve(s, d, mid, q, apex.point(), &seam_line)?; + let (ra, rb, c, vq) = split_chain(ring, k, t, q)?; + let e = mt::Edge::new_unchecked(&vq, &apex, iso); + // Face A: out to the apex, down the split line, the ring's second part; face B the rest. + let mut wa: Vec = out.to_vec(); + wa.push(e.inverse()); + wa.extend(rb.iter().cloned()); + let mut wb: Vec = vec![e]; + wb.extend(back.iter().cloned()); + wb.extend(ra); + let (wire_a, wire_b): (mt::Wire, mt::Wire) = (wa.into(), wb.into()); + if !wire_a.is_closed() || !wire_b.is_closed() { + return Err("split loops do not close".into()); + } + let side_a = rb + .iter() + .flat_map(|e| samples(&e.oriented_curve(), 4)) + .filter_map(|(_, p)| uv_of(s, p).map(|uv| coord(d, uv))) + .find(|w| (w - mid).abs() > period * 1e-3) + .map(|w| w < mid) + .ok_or("cannot place the split halves")?; + let (mut ha, mut hb) = (vec![wire_a], vec![wire_b]); + for (i, w) in wires.iter().enumerate() { + if i == outer_idx { + continue; + } + let ws: Vec = w.edge_iter().flat_map(|e| samples(&e.curve(), 4)).filter_map(|(_, p)| uv_of(s, p).map(|uv| coord(d, uv))).collect(); + if ws.iter().all(|x| (*x < mid) == side_a) { + ha.push(w.clone()); + } else if ws.iter().all(|x| (*x < mid) != side_a) { + hb.push(w.clone()); + } else { + return Err("a hole crosses the split line".into()); + } + } + let mut fa = mt::Face::new_unchecked(ha, s.clone()); + let mut fb = mt::Face::new_unchecked(hb, s.clone()); + if !face.orientation() { + fa.invert(); + fb.invert(); + } + Ok(Plan { cuts: c.into_iter().collect(), faces: (fa, fb) }) +} + /// Replace cut edges in a face's loops. fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face { let touches = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).any(|e| cuts.iter().any(|(c, _)| c.id() == e.id())); diff --git a/docs/iges-import.md b/docs/iges-import.md new file mode 100644 index 00000000..b3ded677 --- /dev/null +++ b/docs/iges-import.md @@ -0,0 +1,84 @@ +# IGES import (Fusion corpus) + +Generated by `cargo xtask step-corpus --iges`. Each case is a reference part exported from Fusion as IGES 5.3 (kept locally in `plan/fusion/oracle/`, never committed). SolveCraft opens `part.igs` exactly as File → Open does and compares with Fusion's measurements of the part it exported: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file. How IGES is read: see [STEP import](step-import.md#iges). 50-sweep-tight-bend is written by Fusion as five loose, partly degenerate surfaces (seams running into slits, a ring given twice); Fusion cannot read it back as a solid either. + +**75 / 76 files pass.** + +| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes | +|---|---|---|---|---|---|---|---| +| 01-box | pass | Body1 | 24000.000 / 24000.000 | 5200.000 / 5200.000 | 6 / 6 | 35 | | +| 02-cylinder | pass | Body1 | 35339.346 / 35342.917 | 6125.844 / 6126.106 | 3 / 3 | 399 | | +| 03-box-through-hole | pass | Body1 | 45738.459 / 45738.053 | 9327.794 / 9327.788 | 7 / 7 | 292 | | +| 04-revolve-vase | pass | Body1 | 112488.577 / 112499.479 | 13156.092 / 13156.615 | 5 / 5 | 1515 | | +| 05-taper-extrude | pass | Body1 | 61814.826 / 61814.826 | 9676.915 / 9676.915 | 6 / 6 | 28 | | +| 06-symmetric-extrude | pass | Body1 | 30000.000 / 30000.000 | 6200.000 / 6200.000 | 6 / 6 | 33 | | +| 07-fillet-all-edges | pass | Body1 | 23340.463 / 23340.849 | 4726.195 / 4726.265 | 26 / 26 | 1285 | | +| 08-chamfer | pass | Body1 | 23730.667 / 23730.667 | 5029.352 / 5029.352 | 10 / 10 | 64 | | +| 09-shell | pass | Body1 | 13632.000 / 13632.000 | 13992.000 / 13992.000 | 11 / 11 | 76 | | +| 10-rect-pattern-holes | pass | Body1 | 29803.844 / 29803.540 | 14652.460 / 14652.389 | 18 / 18 | 1988 | | +| 11-circular-pattern-flange | pass | Body1 | 66758.418 / 66758.844 | 19320.582 / 19320.795 | 10 / 10 | 2360 | | +| 12-mirror-feature | pass | Body1 | 21392.312 / 21392.920 | 6530.923 / 6530.973 | 10 / 10 | 753 | | +| 13-combine-join | pass | Body1 | 45569.249 / 45571.680 | 8634.154 / 8634.336 | 9 / 9 | 383 | | +| 14-combine-cut | pass | Body1 | 42932.838 / 42931.417 | 9706.788 / 9706.858 | 7 / 7 | 1401 | | +| 15-combine-intersect | pass | Body1 | 54451.677 / 54454.273 | 7382.501 / 7382.743 | 7 / 7 | 2559 | solid 'Body1': face wrapping around a closed surface kept whole: unsupported seam loop layout (8 runs) | +| 16-sweep | pass | Body1 | 2584.300 / 2584.724 | 1779.617 / 1779.698 | 5 / 5 | 1957 | | +| 17-loft | pass | Body1 | 35120.291 / 35125.498 | 6495.290 / 6495.000 | 6 / 6 | 601 | | +| 18-hole-features | pass | Body1 | 33890.962 / 33890.710 | 8568.687 / 8568.696 | 13 / 13 | 1552 | | +| 19-l-bracket-constrained | pass | Body1 | 14720.000 / 14720.000 | 5472.000 / 5472.000 | 8 / 8 | 39 | | +| 20-slot-constrained | pass | Body1 | 2965.385 / 2965.487 | 1774.641 / 1774.690 | 6 / 6 | 88 | | +| 21-draft | pass | Body1 | 54574.737 / 54574.737 | 8781.998 / 8781.998 | 6 / 6 | 38 | | +| 22-split-body | pass | Body1, Body2 | 75390.604 / 75398.224 | 12954.438 / 12955.175 | 6 / 6 | 802 | | +| 23-bracket-multifeature | pass | Body1 | 59331.547 / 59331.129 | 18290.759 / 18290.737 | 19 / 19 | 1441 | | +| 24-user-parameters | pass | Body1 | 34822.114 / 34821.903 | 8114.166 / 8114.159 | 7 / 7 | 437 | | +| 25-sketch-triangle | pass | Body1 | 4032.155 / 4032.155 | 2274.099 / 2274.099 | 5 / 5 | 51 | | +| 26-sketch-belt | pass | Body1 | 11326.049 / 11326.170 | 5637.345 / 5637.404 | 7 / 7 | 599 | | +| 27-sketch-trapezoid | pass | Body1 | 5342.307 / 5342.257 | 3066.958 / 3066.946 | 8 / 8 | 832 | | +| 28-blind-pocket-fillets | pass | Body1 | 84299.377 / 84299.249 | 16279.553 / 16279.572 | 23 / 23 | 2087 | | +| 29-two-sided-and-partial-revolve | pass | Body1 | 25731.359 / 25731.307 | 6312.589 / 6312.602 | 12 / 12 | 229 | | +| 30-fillet-after-shell | pass | Body1 | 21745.916 / 21745.876 | 14968.283 / 14968.332 | 27 / 27 | 1298 | | +| 31-fillet-l-all-edges | pass | Body1 | 14321.380 / 14321.626 | 5023.977 / 5024.045 | 38 / 38 | 2673 | | +| 32-boss-plate-fillets | pass | Body1 | 33590.273 / 33590.350 | 9929.855 / 9929.864 | 10 / 10 | 3878 | | +| 33-fillet-tangent-chain | pass | Body1 | 8236.989 / 8237.122 | 2793.535 / 2793.569 | 14 / 14 | 1687 | | +| 34-chamfer-then-fillet | pass | Body1 | 22892.359 / 22892.421 | 4753.672 / 4753.688 | 18 / 18 | 577 | | +| 35-fillet-curved-curved | pass | Body1 | 63169.296 / 63171.443 | 10269.098 / 10269.348 | 6 / 6 | 9098 | solid 'Body1': face wrapping around a closed surface kept whole: split line does not reach the rings (×2); solid 'Body1': face wrapping around a closed surface kept whole: unsupported seam loop layout (8 runs) | +| 36-corner-blend-two-radii | pass | Body1 | 57799.380 / 57800.240 | 8585.423 / 8585.524 | 18 / 18 | 3015 | | +| 37-pyramid-apex-blend | pass | Body1 | 15465.384 / 15465.575 | 3914.545 / 3914.581 | 18 / 18 | 5090 | | +| 38-coincident-cylinders | pass | Body1 | 15706.154 / 15707.963 | 3769.748 / 3769.911 | 3 / 3 | 354 | | +| 39-tee-pipe | pass | Body1 | 24095.711 / 24096.237 | 16709.770 / 16710.048 | 7 / 7 | 2800 | solid 'Body1': face wrapping around a closed surface kept whole: unsupported seam loop layout (8 runs) (×2) | +| 40-box-sphere-torus | pass | Body1 | 36287.765 / 36287.433 | 10696.188 / 10696.165 | 10 / 10 | 6187 | | +| 41-tube-radial-holes | pass | Body1 | 20243.087 / 20243.981 | 14873.680 / 14873.773 | 22 / 22 | 9763 | | +| 42-overlapping-patterns | pass | Body1 | 38488.942 / 38487.064 | 21687.167 / 21686.603 | 43 / 43 | 5322 | a solid's shell does not close; solid 'Body1': imported as an open body (the shell is not closed) | +| 43-cut-splits-body | pass | Body1, Body2 | 28000.000 / 28000.000 | 7200.000 / 7200.000 | 12 / 12 | 128 | | +| 44-shell-l-bracket | pass | Body1 | 8384.000 / 8384.000 | 8784.000 / 8784.000 | 15 / 15 | 132 | | +| 45-shell-boss-plate | pass | Body1 | 13676.813 / 13676.602 | 14156.507 / 14156.602 | 15 / 15 | 785 | | +| 46-shell-two-open-faces | pass | Body1 | 16044.000 / 16044.000 | 11398.000 / 11398.000 | 10 / 10 | 141 | | +| 47-draft-curved-pocket | pass | Body1 | 111388.066 / 111387.712 | 17700.333 / 17700.361 | 11 / 11 | 290 | | +| 48-pulley | pass | Body1 | 168508.132 / 168510.841 | 23718.191 / 23718.644 | 12 / 12 | 4041 | | +| 49-helical-sweep | pass | Body1 | 4740.421 / 4738.099 | 4768.066 / 4767.652 | 3 / 3 | 33507 | | +| 50-sweep-tight-bend | FAIL | Body1 | 1621.826 / 3758.443 | – / – | 4 / 5 | 813 | the surfaces do not close into a solid: imported as an open body; solid 'Body1': face #231: face boundary is not a closed loop; solid 'Body1': imported as an open body (the shell is not closed) | +| 51-loft-rect-circle-offset | pass | Body1 | 61129.309 / 61133.116 | 9412.484 / 9411.655 | 6 / 6 | 532 | | +| 52-circular-pattern-fillet | pass | Body1 | 47206.121 / 47206.850 | 14141.995 / 14142.171 | 21 / 21 | 5990 | | +| 53-path-pattern | pass | Body1 | 35010.576 / 35010.398 | 16463.935 / 16463.894 | 13 / 13 | 1431 | | +| 54-mirror-body-fillets | pass | Body1 | 22780.189 / 22780.089 | 6972.821 / 6972.832 | 12 / 12 | 861 | | +| 55-tapped-hole-cosmetic | pass | Body1 | 17542.374 / 17542.314 | 4761.564 / 4761.596 | 8 / 8 | 543 | | +| 56-holes-on-curved-face | pass | Body1 | 77940.974 / 77942.388 | 10592.891 / 10593.139 | 7 / 7 | 3231 | solid 'Body1': face wrapping around a closed surface kept whole: a hole crosses the split line | +| 57-hole-pattern-sketch-points | pass | Body1 | 36767.089 / 36766.712 | 11818.157 / 11818.153 | 24 / 24 | 2099 | | +| 58-phone-stand | pass | Body1 | 74559.445 / 74559.177 | 29148.741 / 29148.785 | 19 / 19 | 181 | | +| 59-enclosure-base | pass | Body1 | 34309.026 / 34309.486 | 33795.404 / 33795.610 | 39 / 39 | 2120 | solid 'Body1': 2 face(s) could not be meshed and are not shown; solid 'Body1': face wrapping around a closed surface kept whole: cannot cut a ring edge at the split line (×4) | +| 60-pipe-flange | pass | Body1 | 323660.822 / 323660.979 | 56984.283 / 56984.564 | 16 / 16 | 4410 | | +| 61-shaft-keyway | pass | Body1 | 47521.369 / 47525.946 | 9676.777 / 9677.096 | 14 / 14 | 3019 | solid 'Body1': face wrapping around a closed surface kept whole: a hole crosses the split line | +| 62-heat-sink | pass | Body1 | 43125.000 / 43125.000 | 45725.000 / 45725.000 | 66 / 66 | 278 | | +| 63-hinge-knuckle | pass | Body1 | 4783.060 / 4783.117 | 4110.247 / 4110.287 | 14 / 14 | 974 | solid 'Body1': face wrapping around a closed surface kept whole: a hole crosses the split line | +| 64-sketch-tangent-arc-chain | pass | Body1 | 4800.000 / 4800.000 | 3843.701 / 3843.712 | 9 / 9 | 113 | | +| 65-sketch-gear-tooth | pass | Body1 | 3987.829 / 3988.084 | 1945.416 / 1945.485 | 9 / 9 | 119 | | +| 66-sketch-linkage | pass | Body1 | 8045.432 / 8045.386 | 4308.169 / 4308.175 | 10 / 10 | 1662 | | +| 67-sm-base-flange | pass | Body1 | 15000.000 / 15000.000 | 12800.000 / 12800.000 | 6 / 6 | 31 | | +| 68-sm-edge-flange | pass | Body1 | 18972.511 / 18972.622 | 16057.510 / 16057.550 | 14 / 14 | 182 | | +| 69-sm-tray | pass | Body1 | 29665.924 / 29666.234 | 24979.972 / 24980.086 | 46 / 46 | 568 | | +| 70-sm-flange-45 | pass | Body1 | 18450.720 / 18450.777 | 15629.618 / 15629.637 | 14 / 14 | 112 | | +| 71-sm-custom-rule | pass | Body1 | 10053.662 / 10053.717 | 13896.235 / 13896.299 | 22 / 22 | 277 | | +| 72-sm-contour-flange | pass | Body1 | 12097.511 / 12097.622 | 10411.960 / 10412.002 | 22 / 22 | 264 | | +| 73-sm-hem | pass | Body1 | 21334.436 / 21334.723 | 17994.374 / 17994.473 | 22 / 22 | 317 | | +| 74-sm-unfold-refold | pass | Body1 | 15800.138 / 15800.304 | 13610.263 / 13610.326 | 32 / 32 | 286 | | +| 75-sm-bracket-holes | pass | Body1 | 13915.905 / 13916.012 | 12035.926 / 12035.962 | 25 / 25 | 558 | | +| 76-sm-thickness-variant | pass | Body1 | 22520.416 / 22520.575 | 16064.080 / 16064.128 | 14 / 14 | 139 | | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0a24dc80..f871a552 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -23,9 +23,10 @@ commands: xtask/data/fusion-catalog.tsv from plan/fusion/menu-tree.json) oracle replay plan/fusion/oracle/*/recipe.json and compare with measure.json; writes docs/oracle.md - step-corpus [DIR] + step-corpus [--iges] [DIR] import every DIR/*/part.step (default plan/fusion/oracle) and compare - with measure.json; writes docs/step-import.md + with measure.json; writes docs/step-import.md (--iges: part.igs, + docs/iges-import.md) book [--no-build] generate docs/book's command reference and screenshots, then mdbook build into target/book @@ -44,7 +45,7 @@ fn main() -> ExitCode { Some("assets") => assets::run(&root()), Some("parity") => parity::run(&root(), rest.contains(&"--refresh")), Some("oracle") => cmd_oracle(), - Some("step-corpus") => cmd_step_corpus(rest.first().copied()), + Some("step-corpus") => cmd_step_corpus(rest.iter().find(|a| !a.starts_with("--")).copied(), rest.contains(&"--iges")), Some("book") => book::run(&root(), !rest.contains(&"--no-build")), Some("licences") => licences::run(&root(), rest.contains(&"--check")), Some("ci") => cmd_ci(), @@ -216,20 +217,24 @@ fn cmd_wasm() -> Result<(), String> { /// Import every oracle STEP file (exported by Fusion) and compare with Fusion's measurements; /// write docs/step-import.md. -fn cmd_step_corpus(dir: Option<&str>) -> Result<(), String> { +fn cmd_step_corpus(dir: Option<&str>, iges: bool) -> Result<(), String> { let dir = dir.map(PathBuf::from).unwrap_or_else(|| root().join("plan/fusion/oracle")); + let file = if iges { "part.igs" } else { "part.step" }; let mut cases: Vec = std::fs::read_dir(&dir) .map_err(|e| format!("{}: {e} (the oracle data is local, in plan/fusion/oracle)", dir.display()))? .flatten() .map(|e| e.path()) - .filter(|p| p.join("part.step").is_file() && p.join("measure.json").is_file()) + .filter(|p| p.join(file).is_file() && p.join("measure.json").is_file()) .collect(); cases.sort(); if cases.is_empty() { - return Err(format!("no cases with part.step and measure.json in {}", dir.display())); + return Err(format!("no cases with {file} and measure.json in {}", dir.display())); } let mut cmd = cargo(); cmd.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--", "step-corpus", "--json"]); + if iges { + cmd.arg("--iges"); + } for c in &cases { cmd.arg(c); } @@ -241,8 +246,19 @@ fn cmd_step_corpus(dir: Option<&str>) -> Result<(), String> { let check = |r: &serde_json::Value, name: &str| r["checks"].as_array().and_then(|c| c.iter().find(|c| c["check"] == name).cloned()).unwrap_or_default(); let num = |v: &serde_json::Value| v.as_f64().map(|x| format!("{x:.3}")).unwrap_or_else(|| "–".into()); - let mut md = format!( - "# STEP import\n\n\ + let mut md = if iges { + format!( + "# IGES import (Fusion corpus)\n\nGenerated by `cargo xtask step-corpus --iges`. Each case is a reference part exported from Fusion as IGES 5.3 \ +(kept locally in `plan/fusion/oracle/`, never committed). SolveCraft opens `part.igs` exactly as File → Open does and compares with Fusion's \ +measurements of the part it exported: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file. \ +How IGES is read: see [STEP import](step-import.md#iges). 50-sweep-tight-bend is written by Fusion as five loose, partly degenerate surfaces \ +(seams running into slits, a ring given twice); Fusion cannot read it back as a solid either.\n\n**{pass} / {} files pass.**\n\n\ +| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes |\n|---|---|---|---|---|---|---|---|\n", + list.len() + ) + } else { + format!( + "# STEP import\n\n\ SolveCraft reads STEP (ISO 10303-21; AP203, AP214 and AP242 geometry) with its own reader in `crates/kernel/src/step_in` \ (written from the public standards; no third-party STEP code). A STEP file becomes an **Import** base feature on the \ timeline: it holds the file's B-rep (the STEP text is kept in the design, so the design reopens without the file) and \ @@ -270,8 +286,9 @@ units; colours (314 and the predefined numbers); body names (name properties, el offset surfaces, curves on surfaces given only in parameter space. Export (`file.export` to `.igs`) writes each body as \ a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour.\n\n\ ## Fusion corpus\n\nGenerated by `cargo xtask step-corpus`. Each case is a part exported from Fusion as STEP (AP214; kept locally in `plan/fusion/oracle/`, never committed). SolveCraft opens `part.step` exactly as File → Open does (an Import base feature) and compares with Fusion's own measurements: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file.\n\n**{pass} / {} files pass.**\n\n| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes |\n|---|---|---|---|---|---|---|---|\n", - list.len() - ); + list.len() + ) + }; for r in &list { let status = if r["pass"].as_bool().unwrap_or(false) { "pass" } else { "FAIL" }; let (v, a, f) = (check(r, "volume_mm3"), check(r, "area_mm2"), check(r, "faces")); @@ -294,9 +311,10 @@ a manifold solid B-rep on the same surfaces a STEP export uses, with its name an notes.join("; ") ); } - std::fs::write(root().join("docs/step-import.md"), md).map_err(|e| format!("docs/step-import.md: {e}"))?; - println!("step-corpus: {pass}/{} files pass → docs/step-import.md", list.len()); - if pass == list.len() { Ok(()) } else { Err(format!("{} STEP file(s) failed", list.len() - pass)) } + let out_md = if iges { "docs/iges-import.md" } else { "docs/step-import.md" }; + std::fs::write(root().join(out_md), md).map_err(|e| format!("{out_md}: {e}"))?; + println!("step-corpus: {pass}/{} files pass → {out_md}", list.len()); + if pass == list.len() { Ok(()) } else { Err(format!("{} file(s) failed", list.len() - pass)) } } fn cmd_ci() -> Result<(), String> {