diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 944e187b..1d9e799e 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -46,7 +46,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SHEET METAL", "CREATE") .icon("flat") .noundo() - .params("body? → flat size, outline loops and cut-outs (flat coordinates), bend lines and the bend table"), + .params( + "body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table", + ), CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf) .noundo() .params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"), @@ -348,6 +350,39 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result { } } +/// The centre and radius of a loop that is one whole circle (often two semicircles). +fn loop_circle(lp: &solidcraft_geom::Loop2) -> Option<(Vec2, f64)> { + use solidcraft_geom::Seg2; + let Some(Seg2::Arc { center, radius, sweep, .. }) = lp.segs.first() else { return None }; + let total: f64 = lp.segs.iter().filter_map(|s| if let Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum(); + let circular = lp.segs.iter().all( + |s| matches!(s, Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r - *radius).abs() < 1e-9 && a.signum() == sweep.signum()), + ); + (circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((*center, *radius)) +} + +/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other +/// curves as point lists at the flat pattern's 0.001 mm tolerance). +fn loop_shape(lp: &solidcraft_geom::Loop2) -> Value { + use solidcraft_geom::Seg2; + let pt = |v: Vec2| json!([v.x, v.y]); + if let Some((center, radius)) = loop_circle(lp) { + return json!({"circle": {"center": pt(center), "radius": radius}}); + } + let segments: Vec = lp + .segs + .iter() + .map(|seg| match *seg { + Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}), + Seg2::Arc { center, radius, start, sweep } => { + json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}}) + } + ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::>()}), + }) + .collect(); + json!({"segments": segments}) +} + fn flat_json(sh: &SheetBody) -> Result { let regions = sh.flat_regions()?; let loops: Vec> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect(); @@ -360,6 +395,7 @@ fn flat_json(sh: &SheetBody) -> Result { let pt = |v: &Vec2| json!([v.x, v.y]); let outline: Vec = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect(); let holes: Vec = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect(); + let hole_shapes: Vec = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect(); let bends: Vec = sh .bends() .iter() @@ -381,11 +417,12 @@ fn flat_json(sh: &SheetBody) -> Result { }; json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::>(), "cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::>()).collect::>(), + "cutout_shapes": p.region.holes.iter().map(loop_shape).collect::>(), "side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1}) }) .collect(); Ok( - json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}), + json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}), ) } @@ -459,17 +496,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { for (h, layer) in loops { // A circle is often represented by two semicircles. Keep the whole analytic // boundary as one CIRCLE rather than approximating outer boundaries with chords. - if let Some(solidcraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() { - let total: f64 = h.segs.iter().filter_map(|s| if let solidcraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum(); - let circular = h.segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum())); - if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 { - w(0, "CIRCLE"); - w(8, &layer); - w(10, &f(center.x)); - w(20, &f(center.y)); - w(40, &f(*radius)); - continue; - } + if let Some((center, radius)) = loop_circle(h) { + w(0, "CIRCLE"); + w(8, &layer); + w(10, &f(center.x)); + w(20, &f(center.y)); + w(40, &f(radius)); + continue; } for seg in &h.segs { match *seg { diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 32eb9d9f..a67636fc 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2523,11 +2523,10 @@ fn a_box_cut_straight_through_a_flat_sheet_is_a_cutout_like_the_extrude_cut() { } #[test] -#[ignore = "fails until #542 is fixed"] fn flat_pattern_reports_a_round_hole_as_a_circle() { // The DXF export already writes this hole as a CIRCLE; the JSON flat pattern should say - // "circle, centre (50, 30), radius 1.6" too instead of a sampled polyline. The exact key - // names are open: any object in the cutout that has a `center` and a `radius` counts. + // "circle, centre (50, 30), radius 1.6" too, not only a sampled polyline: any object in the + // cut-out's shape that has a `center` and a `radius` counts. fn circles(v: &Value, out: &mut Vec<([f64; 2], f64)>) { match v { Value::Object(m) => { @@ -2552,13 +2551,34 @@ fn flat_pattern_reports_a_round_hole_as_a_circle() { let f = flat(&mut s); let cutouts = f["cutouts"].as_array().cloned().unwrap_or_default(); assert_eq!(cutouts.len(), 1); + // The point list stays for existing readers; `cutout_shapes` carries the exact shape. + assert!(cutouts[0].as_array().is_some_and(|p| p.len() > 8)); + let shapes = f["cutout_shapes"].as_array().cloned().unwrap_or_default(); + assert_eq!(shapes.len(), 1); let mut found = Vec::new(); - circles(&cutouts[0], &mut found); - let shown: String = cutouts[0].to_string().chars().take(120).collect(); + circles(&shapes[0], &mut found); + let shown: String = shapes[0].to_string().chars().take(120).collect(); assert!( found.iter().any(|(c, r)| (c[0] - 50.0).abs() < 1e-9 && (c[1] - 30.0).abs() < 1e-9 && (r - 1.6).abs() < 1e-9), "the round hole is not reported as a circle: {shown} ..." ); + // A slot is its two lines and two half-circle arcs, exactly. + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"})); + run(&mut s, "sketch.slot.overall", json!({"p0": [10, 10], "p1": [30, 10], "width": 4})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"})); + let f = flat(&mut s); + let shapes = f["cutout_shapes"].as_array().cloned().unwrap_or_default(); + assert_eq!(shapes.len(), f["cutouts"].as_array().map_or(0, Vec::len)); + let slot = shapes.iter().find(|s| s.get("segments").is_some()).cloned().unwrap_or_default(); + let segments = slot["segments"].as_array().cloned().unwrap_or_default(); + assert_eq!(segments.iter().filter(|s| s.get("line").is_some()).count(), 2, "{slot}"); + let arcs: Vec<&Value> = segments.iter().filter_map(|s| s.get("arc")).collect(); + assert_eq!(arcs.len(), 2, "{slot}"); + assert!( + arcs.iter() + .all(|a| (a["radius"].as_f64().unwrap_or(0.0) - 2.0).abs() < 1e-9 && (a["sweep_deg"].as_f64().unwrap_or(0.0).abs() - 180.0).abs() < 1e-6) + ); } #[test] diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 85442911..d833f597 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -80,6 +80,9 @@ Base-flange profiles keep their interior holes. Through cuts that cross a sheet notches in the flat outline; overlapping and repeated cuts remove their combined area once. The flat preview, DXF and unfolded solid use the same material boundaries. Lines and circular arcs remain exact; other profile curves use a 0.001 mm chord tolerance. +Flat-pattern JSON lists each cut-out twice, in the same order: as points (`cutouts`), and +exactly (`cutout_shapes`): a round hole as `{"circle": {"center", "radius"}}`, any other as its +`line`, `arc` (centre, radius, start and sweep in degrees) and `polyline` segments. Cuts can leave separate material pieces, including the centre of a ring cut. These pieces remain one logical sheet group through Unfold / Refold and export as separate flat outlines.