fix(sheet): flat pattern reports cut-outs exactly as circles, lines and arcs (#542) (#552)

sheet.flat_pattern reports every cut-out exactly in a new cutout_shapes list, parallel to cutouts: a round hole as a circle, other cut-outs as their lines, arcs and (for other curves) polylines. Pockets get the same. The DXF writer and the JSON share one circle test. Un-ignores the #542 regression test. Fixes #542.

Co-authored-by: jonaswre <[email protected]>
This commit is contained in:
Jonas Wrede authored and GitHub committed 2026-10-13 04:18:37 -04:00
1 parent 2aff1c5303
commit 9c94e1b96d
3 files changed
+74 -18

No files matched your search

+46 -13
View File
@@ -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<SheetBody> {
}
}
/// 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<Value> = 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::<Vec<_>>()}),
})
.collect();
json!({"segments": segments})
}
fn flat_json(sh: &SheetBody) -> Result<Value> {
let regions = sh.flat_regions()?;
let loops: Vec<Vec<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
@@ -360,6 +395,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
let pt = |v: &Vec2| json!([v.x, v.y]);
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
let bends: Vec<Value> = sh
.bends()
.iter()
@@ -381,11 +417,12 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
};
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
"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<String> {
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 {
+25 -5
View File
@@ -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]
+3
View File
@@ -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.