Sheet metal: a junction cut trimmed away is clipped to the material left, a name follows the surviving stretch (#5927, #5928)

This commit is contained in:
Lychee
2026-11-07 03:56:46 -05:00
parent ec759da230
commit 04f31e0ade
6 changed files with 284 additions and 14 deletions
+67 -2
View File
@@ -452,6 +452,56 @@ fn slit_loop(lp: &mut Loop2, at: Vec2, tip: Vec2) -> bool {
true
}
/// Clip a junction cut to the material that remains when the ends it was computed from were cut
/// away: walk the junction line outward from the bend line and end the cut where that line meets the
/// outer loop. Returns the two ends to slit between, boundary end first, or None when the line has no
/// material outward of the bend line: that cut has nothing left to part, an empty stretch of the
/// line rather than a failed lookup.
fn clip_junction_slit(lp: &Loop2, at: Vec2, tip: Vec2) -> Option<(Vec2, Vec2)> {
let d = tip - at;
let len = d.len();
if len <= 1e-9 {
return None;
}
let u = d / len;
// Where the junction line crosses the boundary, nearest the bend line first. Free-form segments
// follow the 0.001 mm chords the planar booleans use; lines keep their exact ends.
let mut hits: Vec<(f64, Vec2)> = Vec::new();
for seg in &lp.segs {
let pts = seg.polyline(1e-3);
for w in pts.windows(2) {
let (a, b) = (w[0], w[1]);
let e = b - a;
let den = u.cross(e);
if den.abs() <= 1e-12 {
continue;
}
let t = (a - at).cross(e) / den;
let s = (at - a).cross(u) / e.cross(u);
if t < -1e-9 || !(-1e-9..=1.0 + 1e-9).contains(&s) {
continue;
}
hits.push((t.max(0.0), at + u * t));
}
}
hits.sort_by(|a, b| a.0.total_cmp(&b.0));
hits.dedup_by(|a, b| (a.0 - b.0).abs() <= 1e-7);
if lp.contains(at) {
// The bend line is in material: the cut runs from it out to the nearest boundary crossing.
return hits.first().map(|(_, p)| (*p, at));
}
// The bend line itself was cut away: the cut spans the stretch of material the line still
// crosses, both ends on the boundary.
let (pairs, _) = hits.as_chunks::<2>();
for [enter, leave] in pairs {
let mid = at + u * ((enter.0 + leave.0) / 2.0);
if lp.contains(mid) {
return Some((enter.1, leave.1));
}
}
None
}
/// Split the loop's segment that runs through `p` into two there, so `p` is a vertex of the loop.
/// A `p` already at a vertex splits nothing. None when no segment runs through `p`.
fn split_loop_at(lp: &mut Loop2, p: Vec2) -> Option<usize> {
@@ -2650,8 +2700,23 @@ impl SheetBody {
// unchanged: the slit is a zero-width line, so it adds no area and no kernel boolean sees
// a self-touching loop.
for (at, tip) in self.junction_slits() {
for r in &mut regions {
if slit_loop(&mut r.outer, at, tip) {
if regions.iter_mut().any(|r| slit_loop(&mut r.outer, at, tip)) {
continue;
}
// Neither computed end is a vertex of the material that is left: clip the cut to what
// remains, so a required junction cut is never dropped (a notch through the far edge,
// for one). A cut with no material outward of the bend line parts nothing, which
// `clip_junction_slit` says by returning none: an empty stretch of the line, not a
// failed lookup.
let holder = regions.iter().position(|r| r.outer.contains(at));
let order: Vec<usize> = match holder {
Some(h) => std::iter::once(h).chain((0..regions.len()).filter(|&i| i != h)).collect(),
None => (0..regions.len()).collect(),
};
for i in order {
if let Some((anchor, inner)) = clip_junction_slit(&regions[i].outer, at, tip)
&& slit_loop(&mut regions[i].outer, anchor, inner)
{
break;
}
}
+60 -10
View File
@@ -1767,6 +1767,17 @@ fn reorder_features(
Ok((next, ordered, broken, Some(after)))
}
/// The edge and stretch (`from`, `to`) a `sheet.flange` segment input's key names:
/// `sheet.flange.edge<edge>.stretch<from bits>.<to bits>.<field>`.
fn segment_span(key: &str) -> Option<(usize, f64, f64)> {
let rest = key.strip_prefix("sheet.flange.edge")?;
let mut parts = rest.split('.');
let edge: usize = parts.next()?.parse().ok()?;
let from = f64::from_bits(parts.next()?.strip_prefix("stretch")?.parse::<u64>().ok()?);
let to = f64::from_bits(parts.next()?.parse::<u64>().ok()?);
Some((edge, from, to))
}
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
if let FeatureKind::Fillet { style, .. } = kind {
@@ -1962,28 +1973,67 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
// old index. Constructor-assigned names for new inputs cannot reuse the old dimensions.
// A key names the input itself where its label doesn't (a segment's stretch, a fillet's
// control); it is matched first, so inserting or reordering stretches moves no name onto
// another stretch. An input whose key changed (a stretch's fractions edited) still keeps the
// name of the input with the same label and kind, as it did before keys were unique.
let old_labels: Vec<String> = old.kind.inputs().into_iter().map(|(label, _, _)| label.to_string()).collect();
let new_labels: Vec<String> = new.kind.inputs().into_iter().map(|(label, _, _)| label.to_string()).collect();
let mut old_inputs: Vec<(String, String, solidcraft_doc::expr::Kind, String)> = input_keys(&old.kind)
// another stretch. An input whose key changed (a stretch's fractions edited) is matched to the
// material it still is, below: the value it still has first, then the span it still covers.
let new_inputs = new.kind.inputs();
let new_labels: Vec<String> = new_inputs.iter().map(|(label, _, _)| label.to_string()).collect();
let mut old_inputs: Vec<(String, String, solidcraft_doc::expr::Kind, String, String)> = input_keys(&old.kind)
.into_iter()
.zip(old_labels)
.zip(old.kind.inputs())
.zip(&old.param_names)
.map(|(((key, kind), label), name)| (key, label, kind, name.clone()))
.map(|(((key, kind), (label, expression, _)), name)| (key, label.to_string(), kind, name.clone(), expression))
.collect();
let keys = input_keys(&new.kind);
let mut param_names: Vec<String> = vec![String::new(); keys.len()];
for (i, (key, kind)) in keys.iter().enumerate() {
if let Some(k) = old_inputs.iter().position(|(old_key, _, old_kind, _)| old_key == key && old_kind == kind) {
if let Some(k) = old_inputs.iter().position(|(old_key, _, old_kind, _, _)| old_key == key && old_kind == kind) {
param_names[i] = old_inputs.remove(k).3.clone();
}
}
for (i, (_, kind)) in keys.iter().enumerate() {
// A name follows the material it was given to, not the order of the list. When a segment input's
// key misses (a stretch deleted or merged, so the survivor's fractions are new), it is matched to
// the old input of the same label and kind that carries the same expression first: the survivor
// still has the value it had. Only then does the greatest span overlap on the same edge carry a
// name. A deleted stretch's name never lands on a stretch whose value came from elsewhere
// (#5927, #5928).
for (i, (key, kind)) in keys.iter().enumerate() {
if !param_names[i].is_empty() {
continue;
}
if let Some(k) = old_inputs.iter().position(|(_, old_label, old_kind, _)| *old_label == new_labels[i] && old_kind == kind) {
if let Some((edge, from, to)) = segment_span(key) {
let mut best: Option<(usize, bool, f64)> = None;
for (k, (old_key, old_label, old_kind, _, old_expression)) in old_inputs.iter().enumerate() {
if *old_label != new_labels[i] || old_kind != kind {
continue;
}
let Some((old_edge, old_from, old_to)) = segment_span(old_key) else { continue };
if old_edge != edge {
continue;
}
let same_value = *old_expression == new_inputs[i].1;
let overlap = (old_to.min(to) - old_from.max(from)).max(0.0);
if !same_value && overlap <= 1e-9 {
continue;
}
let better = match best {
None => true,
Some((_, best_value, best_overlap)) => (same_value && !best_value) || (same_value == best_value && overlap > best_overlap),
};
if better {
best = Some((k, same_value, overlap));
}
}
if let Some((k, _, _)) = best {
param_names[i] = old_inputs.remove(k).3.clone();
}
// No old input of this edge carries the stretch's value or overlaps it: the
// constructor's own name stays, rather than another stretch's.
continue;
}
if let Some(k) = old_inputs
.iter()
.position(|(old_key, old_label, old_kind, _, _)| *old_label == new_labels[i] && old_kind == kind && segment_span(old_key).is_none())
{
param_names[i] = old_inputs.remove(k).3.clone();
}
}
+91
View File
@@ -1794,3 +1794,94 @@ fn redefining_a_segmented_flange_keeps_a_named_height_on_its_stretch() {
let relief_index = inputs.iter().position(|(label, _, _)| *label == "Corner relief").unwrap();
assert_eq!(f.param_names[relief_index], "Relief");
}
/// A name follows the material it was given to, not the order of the input list: deleting one of two
/// named stretches keeps the survivor's own name, drops the deleted stretch's, and an expression that
/// reads the survivor's name still reads its height. A deleted stretch's name never lands on a
/// stretch whose value came from elsewhere (#5927, #5928).
#[test]
fn deleting_a_stretch_keeps_the_surviving_stretches_own_name() {
fn param(s: &mut Session, name: &str) -> Value {
let list = run(s, "parameters.list", json!({}));
list["parameters"].as_array().and_then(|a| a.iter().find(|p| p["name"] == name)).cloned().unwrap_or(Value::Null)
}
fn has(s: &mut Session, name: &str) -> bool {
param(s, name)["value"].as_f64().is_some()
}
fn input_row(s: &mut Session, feature: &str, input: &str) -> Value {
let list = run(s, "parameters.list", json!({}));
list["parameters"].as_array().and_then(|a| a.iter().find(|p| p["feature"] == feature && p["input"] == input)).cloned().unwrap_or(Value::Null)
}
// The name on each "Segment height" input of `Lip`, in the walk's order.
fn segment_names(s: &mut Session) -> Vec<(String, String)> {
let f = s.doc.find_feature("Lip").unwrap();
f.kind
.inputs()
.iter()
.enumerate()
.filter(|(_, (label, _, _))| *label == "Segment height")
.map(|(i, (_, expression, _))| (f.param_names[i].clone(), expression.clone()))
.collect()
}
// A 200 × 100 base, a two-stretch flange on its y = 0 edge with each height named, and a flange on
// the opposite edge whose height reads one of those names.
fn built(heights: [&str; 2], spans: [f64; 2], reader: &str) -> Session {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name":"AL2","thickness":"2 mm","k_factor":0.5,"bend_radius":"1 mm","active":true}));
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[200,100]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch":"Base"}));
run(
&mut s,
"sheet.flange",
json!({"type":"edge","edges":[{"point":[100,0,2],"segments":[
{"from":0,"to":spans[0],"height":heights[0]},
{"from":spans[0],"to":1,"height":heights[1]}]}],
"height":"20 mm","angle":"90 deg","name":"Lip"}),
);
let names: Vec<String> = segment_names(&mut s).into_iter().map(|(n, _)| n).collect();
assert_eq!(names.len(), 2, "{names:?}");
run(&mut s, "parameters.rename", json!({"name": names[0], "new_name": "Short"}));
run(&mut s, "parameters.rename", json!({"name": names[1], "new_name": "Tall"}));
run(&mut s, "sheet.flange", json!({"edges":[[100,100,2]],"height":reader,"angle":"90 deg","name":"Reader"}));
s
}
let redefine = |s: &mut Session, segments: Value| {
run(
s,
"timeline.redefine",
json!({"feature":"Lip","command":"sheet.flange","params":{
"type":"edge","edges":[{"point":[100,0,2],"segments":segments}],
"height":"20 mm","angle":"90 deg"}}),
);
};
// Deleting the first stretch: the survivor is the 15 mm stretch, and keeps its own name.
let mut s = built(["20 mm", "15 mm"], [0.5, 1.0], "Tall");
assert_eq!(param(&mut s, "Short")["value"].as_f64(), Some(20.0));
assert_eq!(param(&mut s, "Tall")["value"].as_f64(), Some(15.0));
redefine(&mut s, json!([{"from":0,"to":1,"height":"15 mm"}]));
assert_eq!(segment_names(&mut s), [("Tall".to_string(), "15 mm".to_string())], "the survivor keeps its own name");
assert!(has(&mut s, "Tall") && !has(&mut s, "Short"), "the deleted stretch's name is gone: {:?}", run(&mut s, "parameters.list", json!({})));
let reader = input_row(&mut s, "Reader", "Height");
assert_eq!(reader["expression"], "Tall");
assert_eq!(reader["value"].as_f64(), Some(15.0), "an expression reading Tall still reads the survivor's height: {reader}");
assert!(reader["error"].is_null(), "{reader}");
// Deleting the second stretch: the survivor is the 20 mm stretch, and keeps its own name.
let mut s = built(["20 mm", "15 mm"], [0.5, 1.0], "Short");
redefine(&mut s, json!([{"from":0,"to":1,"height":"20 mm"}]));
assert_eq!(segment_names(&mut s), [("Short".to_string(), "20 mm".to_string())], "the survivor keeps its own name");
assert!(has(&mut s, "Short") && !has(&mut s, "Tall"), "{:?}", run(&mut s, "parameters.list", json!({})));
let reader = input_row(&mut s, "Reader", "Height");
assert_eq!(reader["expression"], "Short");
assert_eq!(reader["value"].as_f64(), Some(20.0), "an expression reading Short still reads the survivor's height: {reader}");
// A merge whose surviving value came from the first stretch: it keeps the first stretch's name
// even though the second stretch's span is the larger part of the merged stretch.
let mut s = built(["20 mm", "15 mm"], [0.25, 1.0], "Short");
redefine(&mut s, json!([{"from":0,"to":1,"height":"20 mm"}]));
assert_eq!(segment_names(&mut s), [("Short".to_string(), "20 mm".to_string())], "the merged stretch keeps the name of the value it carries");
assert!(has(&mut s, "Short") && !has(&mut s, "Tall"), "{:?}", run(&mut s, "parameters.list", json!({})));
let reader = input_row(&mut s, "Reader", "Height");
assert_eq!(reader["value"].as_f64(), Some(20.0), "{reader}");
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
+52
View File
@@ -738,3 +738,55 @@ fn segments_on_opposite_sides_cut_the_blank_at_the_junction() {
let regions = same.model.state().sheets[0].flat_regions().unwrap_or_default();
assert!(!regions.iter().any(|r| cut(&r.outer, from, to) && cut(&r.outer, to, from)), "a same-side junction stays joined");
}
/// A notch through a junction's far edge takes both ends the junction cut was computed from off the
/// blank: the far edge at x = 50 is gone, and the bend-line end lies inside the material. The cut is
/// clipped to the material that remains, from the bend line out to the notch's edge, and written in
/// the flat loop and the DXF. Dropped, the blank stayed joined where the folded part separates
/// (FR-7 #5932).
#[test]
fn a_notch_through_a_junctions_far_edge_still_parts_the_blank() {
let mut s = plate_in(sheet_of_2mm(), &[]);
run(
&mut s,
"sheet.flange",
json!({"type":"edge","edges":[{"point":[50,0,2],"segments":[
{"from":0,"to":0.5,"angle":"90 deg","height":"20 mm","flip":false},
{"from":0.5,"to":1,"angle":"90 deg","height":"20 mm","flip":true}]}],
"height":"20 mm","angle":"90 deg"}),
);
// The notch: x 49…51, y -18…-10, in the flat frame. The flange's far edge is at y -17.14, so
// the notch takes it and the material between it and y -10 away at the junction.
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Notch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[49,-18],"p1":[51,-10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":"Notch","through_all":true,"operation":"cut","direction":"symmetric"}));
// The bend line, and where the material left ends outward of it: the notch's edge.
let bend = Vec2::new(50.0, 3.0);
let left = Vec2::new(50.0, -10.0);
let cut = |lp: &solidcore_record::Loop2, a: Vec2, b: Vec2| {
lp.segs.iter().any(|s| matches!(s, Seg2::Line { a: p, b: q } if p.dist(a) < 1e-6 && q.dist(b) < 1e-6))
};
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
assert!(
regions.iter().any(|r| cut(&r.outer, bend, left) && cut(&r.outer, left, bend)),
"the blank is not cut from the bend line to the notch's edge: {:?}",
regions.iter().map(|r| r.outer.segs.len()).collect::<Vec<_>>()
);
let outline: Vec<(Vec2, Vec2)> = entities(&export(&mut s, json!({})))
.iter()
.filter(|e| e.0 == "LINE" && e.1.iter().any(|(c, v)| *c == 8 && v == "OUTLINE"))
.map(|e| (point(e, 10), point(e, 11)))
.collect();
let near = |p: Vec2, q: Vec2| p.dist(q) < 1e-6;
let both_ways = |lines: &[(Vec2, Vec2)], a: Vec2, b: Vec2| {
lines.iter().any(|&(p, q)| near(p, a) && near(q, b)) && lines.iter().any(|&(p, q)| near(p, b) && near(q, a))
};
assert!(both_ways(&outline, bend, left), "the DXF has no clipped junction cut: {outline:?}");
// The cut leaves the part one piece, and refolding then unfolding builds it.
run(&mut s, "sheet.refold", json!({}));
run(&mut s, "sheet.unfold", json!({}));
let errors: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
assert!(errors.is_empty(), "unfold: {errors:?}");
}
+4 -1
View File
@@ -205,7 +205,10 @@ stretch's side outright: `true` bends it the other way and `false` the same way
an override rather than a turn relative to it. No bend relief is cut where two segments meet, and
where the two stretches bend to opposite sides the blank is cut along their junction, from the bend
line out to the shorter stretch's far edge, in the flat pattern and the DXF: a blank still joined
there cannot fold one stretch up and the next down, while same-side stretches stay one piece.
there cannot fold one stretch up and the next down, while same-side stretches stay one piece. Where
a cut-out took that far edge away, the cut is clipped to the material that is left: it runs from the
bend line to the nearest boundary the junction line meets (a notch's edge, for one), so the blank is
parted as far as its remaining material allows.
`then` is refused on a split edge: bend each segment's far edge in its own
feature. Stretches that meet within 1e-9 (the junction tolerance, `SEGMENT_JOIN_EPS`) are one
partition: the junction is snapped shut whatever the two stretches' angle, height or side are, so
+10 -1
View File
@@ -118,7 +118,11 @@ falls back to the edge's own value, then the feature's.
two bends butt along the junction line. Only the outermost segments take part in corner closing
and end relief. Where the two stretches bend to opposite sides the blank is cut along their
junction, from the bend line out to the shorter stretch's far edge, in the flat pattern and the
DXF: a blank joined there cannot fold, one stretch up and the next down. Same-side stretches stay
DXF: a blank joined there cannot fold, one stretch up and the next down. Where a cut-out took that
far edge away, the cut is clipped to the material that is left: it runs from the bend line to the
nearest boundary the junction line meets (a notch's edge, for one), so the blank is parted as far
as its remaining material allows; a junction with no material left outward of the bend line has
nothing to part and is left alone. Same-side stretches stay
one piece. A junction the command accepts within `SEGMENT_JOIN_EPS` is snapped shut, and a
stretch narrower than that tolerance is no bend of its own: the run before it takes that stretch
(its angle and height do not build), so the bends still tile the edge with nothing left uncovered,
@@ -269,6 +273,11 @@ which edge is "the long edge" on a near-square part) before building.
the only stretch left makes the whole edge. The dialog never submits a list with a gap.
- **Per-edge versus per-segment:** `angle` and `height` at the edge entry are that edge's values; a
segment's own values override them; the feature's values are the last fallback.
- **A named stretch keeps its name with its material:** redefining a split edge after a stretch is
deleted or merged keeps the surviving stretch's own model parameter name (the name on the value it
still has; failing that, the name whose span it still covers), and the deleted stretch's name goes
away. Expressions that read the survivor's name still read its height; a deleted stretch's name
never lands on a stretch whose value came from elsewhere.
- **`flip` is per segment:** it sets that bend's side outright: `true` bends it the other way and
`false` the same way as the feature's `flip`, an override rather than a turn relative to it.
- **`then` is refused with `segments`:** bend each segment's far edge in its own feature. The