mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
* claim: #3353 * Sheet metal: mirrored and copied sheet bodies stay sheet metal Mirroring a sheet metal body into a new body gave a plain solid: the opposite-hand bracket had no flat pattern, DXF or bend table ("`Body1 (2)` is not a sheet metal body"). Copies from a body pattern or Move with copy lost their sheet model the same way. A copy now takes the sheet model with it (SheetBody::copied). A rigid copy moves the flat frame. A mirror moves the frame with its x axis turned round, so the thickness side stays, and mirrors everything in flat coordinates (x -> -x): base, flange bend lines (run from their far end, with their end extensions, slopes and tears swapped), panel shapes, cut-outs, pockets, bores, letters and base sketch lines. Arcs stay exact and loops keep their sense. A perforation field of a mirrored copy runs its grid from the right, so its holes are the mirror image too. The flat length, bends and K-factor are unchanged, and the copy takes further sheet metal features of its own. Mirror with combine is unchanged: the joined body has no single sheet model yet. Mirroring or patterning flange features is still refused. Sheet models are derived from the timeline, so no design format change. * Format: note the saved from_right of a mirrored sheet's perforation field The new `from_right` flag is saved in the design, so it gets a pending format note. No migration: a field without it runs from the left as before.
This commit is contained in:
@@ -4536,6 +4536,15 @@ fn rotation(o: Vec3, a: Vec3, ang: f64) -> Mat {
|
||||
[[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]]
|
||||
}
|
||||
|
||||
/// A copy of a sheet metal body is sheet metal too: its sheet model goes with it, moved, or
|
||||
/// mirrored into the opposite hand ([`crate::sheet::SheetBody::copied`]), so the copy has its
|
||||
/// own flat pattern, DXF and bend table. A copy by any other transform stays a plain solid.
|
||||
fn copy_sheet(st: &mut ModelState, from: &str, to: &str, m: &crate::Mat) {
|
||||
let Some(sheet) = st.sheets.iter().find(|s| s.body == from).and_then(|s| s.copied(to, m)) else { return };
|
||||
st.sheets.retain(|s| s.body != to);
|
||||
st.sheets.push(sheet);
|
||||
}
|
||||
|
||||
/// Reflection in a plane (column-major).
|
||||
fn mirror_matrix(pl: &Plane) -> Mat {
|
||||
let n = pl.normal();
|
||||
@@ -5509,6 +5518,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let copy = moved_copy(&mb, kernel::transform_matrix(&mb.body, *m)?, k + 1);
|
||||
let name = pattern_copy_name(st, f, output, &mb.name);
|
||||
copy_sheet(st, &mb.name, &name, m);
|
||||
st.bodies.push(ModelBody::traced_new(name, copy, f.id));
|
||||
output += 1;
|
||||
}
|
||||
@@ -5556,6 +5566,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
} else {
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
copy_sheet(st, &mb.name, &name, ©);
|
||||
st.bodies.push(ModelBody::traced_new(name, mirrored, f.id));
|
||||
output += 1;
|
||||
}
|
||||
@@ -5829,6 +5840,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if *copy {
|
||||
let traced = moved_copy(&mb, nb, 1);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
copy_sheet(st, &mb.name, &name, &crate::rigid(t, Vec3::ZERO, axis, ang));
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
output += 1;
|
||||
} else if let Some(slot) = st.bodies.get_mut(i) {
|
||||
|
||||
@@ -611,7 +611,7 @@ pub(super) fn sheet_eval(
|
||||
)));
|
||||
}
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square });
|
||||
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A sheet's perforation field can run its grid from the flat's right edge (`from_right`): the
|
||||
//! field of a mirrored sheet copy, whose holes are the mirror image of its source's. Older designs
|
||||
//! need no transformation: a field without `from_right` runs from the left as before. The last
|
||||
//! release refuses a design saved with this (the newer format number).
|
||||
+105
-3
@@ -541,6 +541,10 @@ pub struct SheetPerforation {
|
||||
pub pitch: f64,
|
||||
pub margin: f64,
|
||||
pub square: bool,
|
||||
/// The grid runs from the flat's right edge instead of its left: the field of a mirrored
|
||||
/// copy, whose holes are the mirror image of its source's.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub from_right: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -1992,8 +1996,11 @@ impl SheetBody {
|
||||
if y > hi.y - clear + 1e-9 {
|
||||
break;
|
||||
}
|
||||
let mut x = lo.x + clear + if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
|
||||
while x <= hi.x - clear + 1e-9 {
|
||||
let shift = if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
|
||||
// Steps across the row from its first hole; a mirrored field counts from the right.
|
||||
let mut step = 0.0;
|
||||
while lo.x + clear + shift + step <= hi.x - clear + 1e-9 {
|
||||
let x = if p.from_right { hi.x - clear - shift - step } else { lo.x + clear + shift + step };
|
||||
let c = Vec2::new(x, y);
|
||||
let (i, j) = idx(x, y);
|
||||
let near = grid.get(j * nx + i).map(Vec::as_slice).unwrap_or(&[]);
|
||||
@@ -2003,7 +2010,7 @@ impl SheetBody {
|
||||
{
|
||||
out.push(c);
|
||||
}
|
||||
x += p.pitch;
|
||||
step += p.pitch;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
@@ -4355,6 +4362,101 @@ impl Letters {
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat point mirrored across the flat frame's y axis (x → −x).
|
||||
fn flip_x(p: Vec2) -> Vec2 {
|
||||
Vec2::new(-p.x, p.y)
|
||||
}
|
||||
|
||||
/// A loop mirrored across the flat frame's y axis, exactly (arcs stay arcs, free-form curves
|
||||
/// follow their control points), and run backwards so it keeps its sense: an outer loop stays
|
||||
/// counter-clockwise, a hole clockwise.
|
||||
fn flip_x_loop(l: &Loop2) -> Loop2 {
|
||||
let segs = l
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
Seg2::Arc { center: flip_x(center), radius, start: std::f64::consts::PI - start, sweep: -sweep }
|
||||
}
|
||||
other => map_controls(&other, flip_x),
|
||||
})
|
||||
.collect();
|
||||
Loop2 { segs }.reversed()
|
||||
}
|
||||
|
||||
fn flip_x_region(r: &Region2) -> Region2 {
|
||||
Region2 { outer: flip_x_loop(&r.outer), holes: r.holes.iter().map(flip_x_loop).collect() }
|
||||
}
|
||||
|
||||
impl SheetBody {
|
||||
/// The sheet model of a copy of this body moved by `m` and named `body`: a rigid motion
|
||||
/// moves its frame; a mirror moves its frame too and mirrors everything in the flat, so the
|
||||
/// copy is the opposite hand, with the same thickness side, bends, K-factor and flat
|
||||
/// length. `None` when `m` is not a rigid motion or a mirror (a scaled copy is no sheet).
|
||||
pub fn copied(&self, body: &str, m: &Mat) -> Option<SheetBody> {
|
||||
let axis = |i: usize| m.get(i).map(|c| Vec3::new(c[0], c[1], c[2]));
|
||||
let (x, y, z) = (axis(0)?, axis(1)?, axis(2)?);
|
||||
let unit = |v: Vec3| (v.dot(v) - 1.0).abs() < 1e-6;
|
||||
let isometry = m.iter().flatten().all(|v| v.is_finite())
|
||||
&& unit(x)
|
||||
&& unit(y)
|
||||
&& unit(z)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6;
|
||||
if !isometry {
|
||||
return None;
|
||||
}
|
||||
let (origin, fx, fy) = (apply_point(m, self.frame.origin), apply_vector(m, self.frame.x), apply_vector(m, self.frame.y));
|
||||
let mut out = self.clone();
|
||||
out.body = body.to_string();
|
||||
if x.cross(y).dot(z) > 0.0 {
|
||||
out.frame = Plane::new(origin, fx, fy)?;
|
||||
} else {
|
||||
// The mirror of flat point (u, v, w) is the point (−u, v, w) of the frame with its x
|
||||
// axis turned round: that frame's normal is the mirrored normal, so the material
|
||||
// stays on the same side of the mirrored base face.
|
||||
out.frame = Plane::new(origin, fx * -1.0, fy)?;
|
||||
out.flip_flat();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Everything in flat coordinates mirrored across the flat's y axis (x → −x).
|
||||
fn flip_flat(&mut self) {
|
||||
self.base = flip_x_region(&self.base);
|
||||
for f in &mut self.flanges {
|
||||
// The bend line runs the other way, from its far end, so the flange still lies on
|
||||
// the same side of it (`n` turned as `d` is): its ends swap.
|
||||
f.p = flip_x(f.p + f.d * f.len);
|
||||
f.d = Vec2::new(f.d.x, -f.d.y);
|
||||
f.n = flip_x(f.n);
|
||||
f.ext = (f.ext.1, f.ext.0);
|
||||
f.slope = (f.slope.1, f.slope.0);
|
||||
f.tear = (f.tear.1, f.tear.0);
|
||||
if let Some(shape) = &mut f.shape {
|
||||
*shape = shape.iter().rev().map(|p| flip_x(*p)).collect();
|
||||
}
|
||||
if let Some(exact) = &mut f.exact {
|
||||
*exact = flip_x_loop(exact);
|
||||
}
|
||||
}
|
||||
for r in self.holes.iter_mut().chain(self.labels.iter_mut()).chain(self.pockets.iter_mut().map(|p| &mut p.region)) {
|
||||
*r = flip_x_region(r);
|
||||
}
|
||||
for b in &mut self.bores {
|
||||
b.base.x = -b.base.x;
|
||||
b.axis.x = -b.axis.x;
|
||||
}
|
||||
for p in &mut self.perforations {
|
||||
p.from_right = !p.from_right;
|
||||
}
|
||||
for (_, a, b) in &mut self.base_curves {
|
||||
(*a, *b) = (flip_x(*b), flip_x(*a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
|
||||
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
|
||||
let (a, b) = (planar_region(a)?, planar_region(b)?);
|
||||
|
||||
@@ -4878,3 +4878,108 @@ fn a_sheet_made_of_sheet_features_and_flat_cuts_still_exports_its_flat_pattern()
|
||||
}
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
/// An L-bracket with a hole off its centre and two flanges, so that its mirror image is the
|
||||
/// opposite hand: a 100 x 60 base on XY with a 10 mm hole at (25, 40), a 20 mm flange on the
|
||||
/// front edge and a 15 mm one on the right edge.
|
||||
fn asymmetric_bracket(s: &mut Session) {
|
||||
run(s, "sheet.manage_rules", json!({"name": "S", "thickness": "2 mm", "bend_radius": "2 mm", "k_factor": 0.4, "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"type": "base"}));
|
||||
run(s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "name": "F1"}));
|
||||
run(s, "sheet.flange", json!({"edges": [[100, 30, 2]], "height": 15, "name": "F2"}));
|
||||
circle_sketch(s, "Hole", json!("XY"), [25.0, 40.0], 10.0);
|
||||
run(s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
}
|
||||
|
||||
/// The points of a flat pattern's loops, `x` scaled by `sx`, sorted, rounded to 1e-6 mm.
|
||||
fn flat_points(v: &Value, sx: f64) -> Vec<(i64, i64)> {
|
||||
let mut out: Vec<(i64, i64)> = v
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|l| l.as_array().cloned().unwrap_or_default())
|
||||
.map(|p| ((p[0].as_f64().unwrap_or(f64::NAN) * sx * 1e6).round() as i64, (p[1].as_f64().unwrap_or(f64::NAN) * 1e6).round() as i64))
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
fn body_volume(s: &mut Session, name: &str) -> f64 {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
m["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == name).and_then(|b| b["volume_mm3"].as_f64()).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirrored_sheet_copy_is_the_opposite_hand_with_the_mirror_image_flat_pattern() {
|
||||
let mut s = Session::default();
|
||||
asymmetric_bracket(&mut s);
|
||||
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
|
||||
let mirrored = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1 (2)"}));
|
||||
// The same blank, the other way round: K-factor flat length, bends and thickness are kept.
|
||||
assert_eq!(mirrored["flat_size_mm"], original["flat_size_mm"], "{mirrored}");
|
||||
assert_eq!(mirrored["thickness"], original["thickness"]);
|
||||
assert_eq!(flat_points(&mirrored["outline"], 1.0), flat_points(&original["outline"], -1.0));
|
||||
assert_eq!(original["cutouts"].as_array().map(Vec::len), Some(1), "{original}");
|
||||
assert_eq!(flat_points(&mirrored["cutouts"], 1.0), flat_points(&original["cutouts"], -1.0));
|
||||
let bends = |f: &Value| -> Vec<(String, i64, i64)> {
|
||||
let mut b: Vec<_> = f["bends"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|b| {
|
||||
(
|
||||
b["direction"].to_string(),
|
||||
(b["allowance"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
|
||||
(b["length"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
b.sort();
|
||||
b
|
||||
};
|
||||
assert_eq!(bends(&mirrored), bends(&original));
|
||||
assert_eq!(bends(&mirrored).len(), 2);
|
||||
// The hole is where the mirror put it, on the copy and in its flat pattern.
|
||||
let in_copy = |p: [f64; 3]| {
|
||||
s.model.state().bodies.iter().find(|b| b.name == "Body1 (2)").is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
|
||||
};
|
||||
assert!(!in_copy([-25.0, 40.0, 1.0]) && in_copy([-35.0, 40.0, 1.0]));
|
||||
let hole = flat_points(&mirrored["cutouts"], 1.0);
|
||||
let cx = hole.iter().map(|p| p.0 as f64).sum::<f64>() / hole.len().max(1) as f64 * 1e-6;
|
||||
assert!((cx + 25.0).abs() < 1e-3, "hole centre x {cx}");
|
||||
// The sheet model is the mirrored body: a flange added to the copy grows it from its own
|
||||
// back edge, and its DXF is that of a sheet.
|
||||
let before = body_volume(&mut s, "Body1 (2)");
|
||||
assert!(rel(before, body_volume(&mut s, "Body1")) < 1e-9);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[-50, 60, 2]], "height": 10, "body": "Body1 (2)"}));
|
||||
let after = body_volume(&mut s, "Body1 (2)");
|
||||
assert!(after > before + 100.0, "{before} -> {after}");
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({"body": "Body1 (2)"}));
|
||||
assert_eq!(dxf["bends"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_or_copying_a_sheet_body_leaves_the_original_sheet_as_it_was() {
|
||||
let mut s = Session::default();
|
||||
asymmetric_bracket(&mut s);
|
||||
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
|
||||
let volume = body_volume(&mut s, "Body1");
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Body1"], "translate": [0, 200, 0], "copy": true}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Body1"], "dir1": [0, 0, 1], "count1": 2, "spacing1": 100}));
|
||||
assert_eq!(run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"})), original);
|
||||
assert!(rel(body_volume(&mut s, "Body1"), volume) < 1e-9);
|
||||
// A moved or patterned copy is the same hand: the same flat pattern, as a sheet of its own.
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names.len(), 4, "{names:?}");
|
||||
for name in names.iter().filter(|n| *n != "Body1" && *n != "Body1 (2)") {
|
||||
let mut copy = run(&mut s, "sheet.flat_pattern", json!({"body": name}));
|
||||
copy["body"] = original["body"].clone();
|
||||
assert_eq!(flat_points(©["outline"], 1.0), flat_points(&original["outline"], 1.0), "{name}");
|
||||
assert_eq!(copy["bends"], original["bends"], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ and selected when the active body is a sheet), and builds parts from a sheet of
|
||||
Bends follow the rule's K-factor, so flat lengths match what a press brake will make. Tests
|
||||
check flat lengths against hand-computed bend allowances.
|
||||
|
||||
A copy of a sheet metal body is sheet metal too. **Mirror** (`solid.mirror` with `bodies`)
|
||||
makes the opposite hand: its own flat pattern, DXF and bend table, the mirror image of the
|
||||
original's flat, with the same thickness side, bends and flat lengths, and holes where the
|
||||
mirror puts them. A copy from **Move** (`copy: true`) or a body pattern keeps the original's
|
||||
flat pattern. Every copy takes further sheet metal features of its own. Mirroring with
|
||||
`combine` joins the two halves into one solid, which has no single sheet model yet; mirroring
|
||||
or patterning flange features themselves is not supported yet.
|
||||
|
||||
An edge flange takes its setback off its own edge only. Where that edge ends at an inside
|
||||
corner of the base (the base runs on past it, like the leg of an L), the rest of the base
|
||||
stays, and the rule's **bend relief** (`relief_width` and `relief_depth`; Relief Width and
|
||||
|
||||
Reference in New Issue
Block a user