Sheet metal: tear as a fourth corner relief shape (#809)

A sheet metal rule's corner relief shape can now be tear; saved under format 17 with a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NOWN-DEVandClaude Opus 5.5 authored and GitHub committed 2026-10-13 14:28:28 -04:00
1 parent bfbf5f9432
commit dd084ec9e1
11 files changed
+751 -56

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
+3
View File
@@ -97,6 +97,9 @@
//! A fillet's `type` can be `spline` (the radius along each edge a B-spline: `start`, `knots`,
//! and the control radii after `radius` in `radii`). Other fillets save as before; 0.8.0 would
//! refuse the unknown type.
//! Under the same version, a sheet metal rule's corner relief shape can also be `tear` (no
//! relief: each bend is cut straight from the bends' corner to where its flange's end
//! starts); a rule that says `notch`, `round` or `rectangular` builds what it did.
use serde_json::Value;
+599 -44
View File
@@ -209,7 +209,8 @@ pub struct SheetRule {
pub corner_relief: String,
/// What is cut where two bends meet at a corner of the base: `notch` (nothing more: the
/// bends end square at the corner), `round` or `rectangular` (a relief `corner_relief`
/// across, centred on the corner the two bends share).
/// across, centred on the corner the two bends share), or `tear` (no relief: each bend's
/// end is cut straight from that corner to where its flange's end starts).
#[serde(default = "rule_corner_relief_shape")]
pub corner_relief_shape: String,
/// Inner radius of flat hems.
@@ -286,6 +287,9 @@ pub enum ReliefShape {
/// A square with a corner on the corner of the bends (a kite where the base's corner isn't
/// square: both cuts leave their edge at a right angle).
Rectangular,
/// No relief: both bends run to their corner, and each one's end is cut straight, in the
/// flat, from that corner to where its flange's closed end starts.
Tear,
}
impl ReliefShape {
@@ -294,6 +298,7 @@ impl ReliefShape {
"notch" => Some(ReliefShape::Notch),
"round" => Some(ReliefShape::Round),
"rectangular" => Some(ReliefShape::Rectangular),
"tear" => Some(ReliefShape::Tear),
_ => None,
}
}
@@ -303,6 +308,7 @@ impl ReliefShape {
ReliefShape::Notch => "notch",
ReliefShape::Round => "round",
ReliefShape::Rectangular => "rectangular",
ReliefShape::Tear => "tear",
}
}
}
@@ -349,8 +355,9 @@ impl Document {
relief_width: with_t(&r.relief_width, Kind::Length)?,
relief_depth: with_t(&r.relief_depth, Kind::Length)?,
corner_relief: with_t(&r.corner_relief, Kind::Length)?,
corner_relief_shape: ReliefShape::parse(&r.corner_relief_shape)
.ok_or_else(|| DocError::Invalid(format!("unknown corner relief shape `{}` (notch, round or rectangular)", r.corner_relief_shape)))?,
corner_relief_shape: ReliefShape::parse(&r.corner_relief_shape).ok_or_else(|| {
DocError::Invalid(format!("unknown corner relief shape `{}` (notch, round, rectangular or tear)", r.corner_relief_shape))
})?,
hem_gap: with_t(&r.hem_gap, Kind::Length)?,
gap: with_t(&r.gap, Kind::Length)?,
};
@@ -385,6 +392,11 @@ pub struct SheetFlange {
/// flanges that aren't at 90° ends on a slanted line in the flat.
#[serde(default, skip_serializing_if = "is_zero_pair")]
pub slope: (f64, f64),
/// Torn ends (the rule's `tear` corner relief): there the bend doesn't end square across,
/// but on the straight line, in the flat, from its start line's end to where the panel's
/// end starts.
#[serde(default, skip_serializing_if = "is_false_pair")]
pub tear: (bool, bool),
/// A fold's panel: the part of the sheet past the bend (flat coordinates, counter-clockwise)
/// instead of a rectangle `leg` long.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -462,6 +474,10 @@ fn is_zero_pair(v: &(f64, f64)) -> bool {
v.0 == 0.0 && v.1 == 0.0
}
fn is_false_pair(v: &(bool, bool)) -> bool {
!v.0 && !v.1
}
fn rev_edge(e: EdgeSpec) -> EdgeSpec {
e.reversed()
}
@@ -645,6 +661,29 @@ fn in_triangle(p: Vec2, a: Vec2, b: Vec2, c: Vec2) -> bool {
(x >= -1e-6 && y >= -1e-6 && z >= -1e-6) || (x <= 1e-6 && y <= 1e-6 && z <= 1e-6)
}
/// The least over 0 ≤ φ ≤ `th` (at most π) of `h`, a function of the form
/// a + b·φ + c·cos φ + d·sin φ: at an end, or where its slope b − c·sin φ + d·cos φ vanishes.
fn sinusoid_least(h: &dyn Fn(f64) -> f64, th: f64) -> f64 {
use std::f64::consts::{FRAC_PI_2, PI, TAU};
let (h0, h90, h180, h360) = (h(0.0), h(FRAC_PI_2), h(PI), h(TAU));
let b = (h360 - h0) / TAU;
let a = (h0 + h180 - b * PI) / 2.0;
let (c, d) = (h0 - a, h90 - a - b * FRAC_PI_2);
let mut least = h0.min(h(th));
// c·sin φ − d·cos φ = r·sin(φ − ψ) = b.
let r = c.hypot(d);
if r > 1e-12 && b.abs() <= r {
let (psi, x) = (d.atan2(c), (b / r).asin());
for phi in [psi + x, psi + PI - x] {
let phi = phi.rem_euclid(TAU);
if phi > 0.0 && phi < th {
least = least.min(h(phi));
}
}
}
least
}
/// Two flanges on the far edges of two flanges that meet at a corner of the base (see
/// [`SheetBody::close_return_corners`]): flanges `a` and `b`, which end of each (false = the end
/// at `p`), and where each one's bend ends there (flat coordinates; apart, each on its own parent).
@@ -716,12 +755,36 @@ impl SheetBody {
(-f.ext.0 - f.slope.0 * f.leg, f.len + f.ext.1 + f.slope.1 * f.leg)
}
/// Flat region of a flange's bend zone.
/// Flat region of a flange's bend zone: square across the bend at both ends, but at a torn
/// end its far edge ends where the panel's does.
pub fn zone(&self, f: &SheetFlange) -> Region2 {
let ba = self.allowance(f);
let (u0, u1) = self.zone_span(f);
let (a, b) = (f.p + f.d * u0, f.p + f.d * u1);
rect(a, b, b + f.n * ba, a + f.n * ba)
// The panel's own corners (`panel`), so that the two share the whole edge.
let far_a = if f.tear.0 { f.p + f.n * ba - f.d * f.ext.0 } else { a + f.n * ba };
let far_b = if f.tear.1 { f.p + f.n * ba + f.d * (f.len + f.ext.1) } else { b + f.n * ba };
rect(a, b, far_b, far_a)
}
/// Where a bend zone's far edge (on its panel's side) starts and ends, along the bend from
/// `p`: where its start line does (`zone_span`), or at a torn end where the panel's end starts.
fn zone_ends(&self, f: &SheetFlange) -> (f64, f64) {
let (u0, u1) = self.zone_span(f);
(if f.tear.0 { -f.ext.0 } else { u0 }, if f.tear.1 { f.len + f.ext.1 } else { u1 })
}
/// Folded (in the flat frame, as `fold`), the point of a bend's end (the one at `p`, or the
/// other one when `at_end`) at the turn `phi` of the bend and the height `z` in the sheet. A
/// torn end runs evenly along the bend while the bend turns, from its start line's end to
/// where the panel's end starts.
fn bend_end(&self, f: &SheetFlange, at_end: bool, phi: f64, z: f64) -> Vec3 {
let ((u0, u1), (e0, e1)) = (self.zone_span(f), self.zone_ends(f));
let (from, to) = if at_end { (u1, e1) } else { (u0, e0) };
let u = from + (to - from) * phi / f.angle.abs().max(1e-12);
let parent = f.parent.map_or(IDENTITY, |i| self.fold(i));
let (axis_o, k, turn) = self.bend_axis(f);
apply_point(&mat_mul(&parent, &rot_about(axis_o, k, turn.signum() * phi)), v3(f.p + f.d * u) + Vec3::Z * z)
}
/// The part of a bend along its parent's edge: where a later flange trimmed that edge back
@@ -1390,7 +1453,9 @@ impl SheetBody {
let mut out = Vec::new();
for (i, f) in self.flanges.iter().enumerate() {
let ba = self.allowance(f);
let (u0, u1) = self.zone_span(f);
// The centre line runs between the middles of the bend's two ends (a torn end is askew).
let ((u0, u1), (e0, e1)) = (self.zone_span(f), self.zone_ends(f));
let (u0, u1) = ((u0 + e0) / 2.0, (u1 + e1) / 2.0);
let a = f.p + f.n * (ba / 2.0) + f.d * u0;
for (s0, s1) in clear_of(a, f.d, u1 - u0, &cuts) {
out.push(BendInfo {
@@ -1754,6 +1819,7 @@ impl SheetBody {
tag: String::new(),
ext: (0.0, 0.0),
slope: (0.0, 0.0),
tear: (false, false),
});
made.push(self.flanges.len() - 1);
}
@@ -2179,8 +2245,8 @@ impl SheetBody {
out
}
/// Close the corners the new flanges make, then cut the rule's relief at each. Returns a
/// note for every corner left open or without its relief.
/// Close the corners the new flanges make, then cut the rule's relief at each (or tear it).
/// Returns a note for every corner left open, without its relief or not torn.
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
let corners = self.corners(made);
let mut notes = Vec::new();
@@ -2188,22 +2254,50 @@ impl SheetBody {
let w = apply_point(&s.frame_mat(), v3(c));
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
};
let mut closed = Vec::with_capacity(corners.len());
for k in &corners {
if let Err(why) = self.close_corner(k) {
notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c)));
match self.close_corner(k) {
Ok(()) => closed.push(*k),
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c))),
}
}
// The reliefs come last: they move the bends' ends, which is how the corners were found.
if self.relief != ReliefShape::Notch {
for k in &corners {
if let Err(why) = self.relieve_corner(k, &corners) {
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
match self.relief {
ReliefShape::Notch => {}
// The reliefs come last: they move the bends' ends, which is how the corners were found.
ReliefShape::Round | ReliefShape::Rectangular => {
for k in &corners {
if let Err(why) = self.relieve_corner(k, &corners) {
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
}
}
}
// A tear cuts to the flanges' closed ends, so a corner left open gets none.
ReliefShape::Tear => {
for k in &closed {
if let Err(why) = self.tear_corner(k) {
notes.push(format!("the corner at {} keeps its notch: {why}", at(self, k.c)));
}
}
}
}
notes
}
/// The plane halving the inner faces (toward the bends' axes) of the two flanges at a corner:
/// the signed distance from it of a folded point, positive on flange a's side. `None` where
/// the faces are parallel.
fn halving_plane(&self, k: &Corner) -> Option<impl Fn(Vec3) -> f64 + use<>> {
let (wa, wb) = (self.fold(k.a), self.fold(k.b));
// The inner faces, with normals pointing from the outer face in.
let (z_in, sign) = if self.flanges.get(k.a)?.angle >= 0.0 { (self.t, 1.0) } else { (0.0, -1.0) };
let na = apply_vector(&wa, Vec3::Z) * sign;
let nb = apply_vector(&wb, Vec3::Z) * sign;
let pa = apply_point(&wa, v3(k.c) + Vec3::Z * z_in);
let pb = apply_point(&wb, v3(k.c) + Vec3::Z * z_in);
let m = (nb - na).len();
(m > 1e-6).then_some(move |p: Vec3| (nb.dot(p - pb) - na.dot(p - pa)) / m)
}
/// Run two flanges' panels on toward each other (or cut them back) until their ends meet the
/// plane halfway between the flanges with the rule's gap between them. The ends stay square
/// to the sheet and follow lines in the flat, slanted when the flanges aren't at 90°, so that
@@ -2231,18 +2325,8 @@ impl SheetBody {
return Err(format!("the flange gap {gap:.4} mm is below {MIN_CORNER_GAP} mm"));
}
let (wa, wb) = (self.fold(k.a), self.fold(k.b));
// The inner faces (toward the bends' axes), with normals pointing from the outer face in.
let (z_in, sign) = if fa.angle >= 0.0 { (self.t, 1.0) } else { (0.0, -1.0) };
let na = apply_vector(&wa, Vec3::Z) * sign;
let nb = apply_vector(&wb, Vec3::Z) * sign;
let pa = apply_point(&wa, v3(k.c) + Vec3::Z * z_in);
let pb = apply_point(&wb, v3(k.c) + Vec3::Z * z_in);
let m = (nb - na).len();
if !(m > 1e-6) {
return Err("the flanges are parallel".into());
}
// Signed distance from the plane bisecting the inner faces, positive on flange a's side.
let bisector = |p: Vec3| (nb.dot(p - pb) - na.dot(p - pa)) / m;
let Some(bisector) = self.halving_plane(k) else { return Err("the flanges are parallel".into()) };
// A flange's end: its panel keeps u ≥ u_end(s) (u from the corner along its bend, s from
// the bend's start across it), so that every point of the end face is gap/2 or more from
// the bisecting plane. The distance is affine in (u, s, z), so the end is a line in the
@@ -2296,6 +2380,68 @@ impl SheetBody {
Ok(())
}
/// Tear a closed corner (the rule's `tear`): nothing is cut out of the base, both bends
/// keep running to the corner where their start lines meet, and each one's end is cut
/// straight, in the flat, from that corner to where its panel's closed end starts (`zone`),
/// instead of square across the bend and on along the panel's edge. The panels keep the ends
/// `close_corner` gave them. Folded, such an end is a helix (`ZoneEdge::Curved`): the two
/// bends touch at the corner and open to the rule's gap where the panels start. Refused, with
/// the reason, when the bends would overlap in the flat pattern, or when folded a bend's end
/// would cross the plane square to the base that halves the corner (into the other bend) or
/// come nearer the plane halving the flanges' inner faces than a straight taper from where
/// the corner lies to the half gap the panels keep; the corner then stays as it was closed.
fn tear_corner(&mut self, k: &Corner) -> std::result::Result<(), String> {
let (Some(mut fa), Some(mut fb)) = (self.flanges.get(k.a).cloned(), self.flanges.get(k.b).cloned()) else {
return Err("a flange is missing".into());
};
let ea = if k.a_end { fa.d * -1.0 } else { fa.d };
let eb = if k.b_end { fb.d * -1.0 } else { fb.d };
// As for the closing: flanges at an inside corner of the base turn away from each other,
// and a bend without a panel has no end to be cut to.
if ea.dot(fb.n) > -1e-9 || eb.dot(fa.n) > -1e-9 || fa.leg <= 1e-9 || fb.leg <= 1e-9 {
return Ok(());
}
for (f, at_end) in [(&mut fa, k.a_end), (&mut fb, k.b_end)] {
if at_end {
f.tear.1 = true;
} else {
f.tear.0 = true;
}
}
let (za, zb) = (corners_of(&self.zone(&fa)), corners_of(&self.zone(&fb)));
if pieces_overlap(&za, &zb) || pieces_overlap(&za, &corners_of(&self.panel(&fb))) || pieces_overlap(&zb, &corners_of(&self.panel(&fa))) {
return Err("a tear would make the bends overlap in the flat pattern".into());
}
let (Some(half), Some(bisector)) = (v3(ea - eb).normalized(), self.halving_plane(k)) else {
return Err("the flanges are parallel".into());
};
let (c, half_gap) = (v3(k.c), self.gap / 2.0);
for (f, at_end, sign) in [(&fa, k.a_end, 1.0), (&fb, k.b_end, -1.0)] {
// On a torn end a signed distance is a + b·φ + c·cos φ + d·sin φ in the turn φ, on
// either face of the sheet: its least less a taper from `from` to `to` is exact.
let th = f.angle.abs();
let least = |g: &dyn Fn(Vec3) -> f64, from: f64, to: f64| {
let over = |z: f64| sinusoid_least(&|phi: f64| g(self.bend_end(f, at_end, phi, z)) - (from + (to - from) * phi / th), th);
over(0.0).min(over(self.t))
};
if least(&|p: Vec3| sign * (p - c).dot(half), 0.0, 0.0) < -1e-9 {
return Err("a tear would run a bend into the other one".into());
}
let own = |p: Vec3| sign * bisector(p);
let start = own(self.bend_end(f, at_end, 0.0, 0.0)).min(own(self.bend_end(f, at_end, 0.0, self.t))).min(half_gap);
if least(&own, start, half_gap) < -1e-9 {
return Err("a tear would bring the bends nearer each other than the flanges' ends are".into());
}
}
if let Some(f) = self.flanges.get_mut(k.a) {
*f = fa;
}
if let Some(f) = self.flanges.get_mut(k.b) {
*f = fb;
}
Ok(())
}
/// Cut the rule's relief where two bends meet at a corner of the base, `relief_size / 2`
/// from the corner along both edges: a round about the corner through the angle the base
/// fills there, or the square with that side (a kite where the corner isn't square, its
@@ -2340,7 +2486,7 @@ impl SheetBody {
// The relief from the edge that ends at the corner to the one that starts there, and
// how far from the corner it reaches.
let (relief, reach) = match self.relief {
ReliefShape::Notch => return Ok(()),
ReliefShape::Notch | ReliefShape::Tear => return Ok(()),
ReliefShape::Round => (vec![Seg2::Arc { center: c, radius: r, start: da.angle(), sweep: -alpha }], r),
ReliefShape::Rectangular => {
let x = snap(c + (da + db) * (r / (1.0 + da.dot(db))));
@@ -2400,6 +2546,7 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
tag: String::new(),
});
@@ -2509,6 +2656,7 @@ impl SheetBody {
leg: 0.0,
ext: (0.0, 0.0),
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
tag: String::new(),
};
@@ -2582,6 +2730,7 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
slope: (0.0, 0.0),
tear: (false, false),
shape: Some(ccw(moving)),
tag: String::new(),
});
@@ -2724,6 +2873,7 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
slope: (0.0, 0.0),
tear: (false, false),
shape: Some(ccw(moving)),
tag: String::new(),
});
@@ -2946,7 +3096,7 @@ impl SheetBody {
/// The flat end of a bend (where the bend stops along its flange's edge: an annular sector
/// square to the bend axis) that a point lies on: `<flange>:rim.bend0` at its start,
/// `rim.bend1` at its end.
/// `rim.bend1` at its end. A torn end is a helix on the bend, no flat face.
fn bend_side_at(&self, p: Vec3, tol: f64) -> Option<String> {
if self.flat {
return None;
@@ -2966,9 +3116,9 @@ impl SheetBody {
continue;
}
let (u0, u1) = self.zone_span(f);
let side = if (u - u0).abs() <= tol {
let side = if (u - u0).abs() <= tol && !f.tear.0 {
"bend0"
} else if (u - u1).abs() <= tol {
} else if (u - u1).abs() <= tol && !f.tear.1 {
"bend1"
} else {
continue;
@@ -2982,6 +3132,9 @@ impl SheetBody {
/// along the bend axis, its origin on the axis, its x across the sheet.
fn bend_side(&self, i: usize, end: bool) -> Option<Plane> {
let f = self.flanges.get(i)?;
if if end { f.tear.1 } else { f.tear.0 } {
return None;
}
let (world, o, d, _, _) = self.bend_frame(f)?;
let (u0, u1) = self.zone_span(f);
let (u, out) = if end { (u1, d) } else { (u0, d * -1.0) };
@@ -3446,6 +3599,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
leg: leg.max(0.0),
ext: (0.0, 0.0),
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
tag: String::new(),
};
@@ -4314,7 +4468,7 @@ mod tests {
fn relief_area(shape: ReliefShape, size: f64, alpha: f64, ba: f64) -> f64 {
let r = size / 2.0;
match shape {
ReliefShape::Notch => 0.0,
ReliefShape::Notch | ReliefShape::Tear => 0.0,
ReliefShape::Round => r * r * alpha / 2.0 + 2.0 * r * ba,
ReliefShape::Rectangular => r * r * (alpha / 2.0).tan() + 2.0 * r * ba,
}
@@ -4328,15 +4482,17 @@ mod tests {
assert_eq!(SheetRule::steel().corner_relief_shape, "notch");
let (doc, vals) = (Document::default(), std::collections::BTreeMap::new());
assert_eq!(doc.rule_values(&vals, &old).unwrap().corner_relief_shape, ReliefShape::Notch);
for (text, shape) in [("round", ReliefShape::Round), (" Rectangular ", ReliefShape::Rectangular), ("NOTCH", ReliefShape::Notch)] {
let shapes =
[("round", ReliefShape::Round), (" Rectangular ", ReliefShape::Rectangular), ("NOTCH", ReliefShape::Notch), ("Tear", ReliefShape::Tear)];
for (text, shape) in shapes {
let rule = SheetRule { corner_relief_shape: text.into(), ..old.clone() };
assert_eq!(doc.rule_values(&vals, &rule).unwrap().corner_relief_shape, shape);
assert_eq!(ReliefShape::parse(shape.name()), Some(shape));
}
// No other name is a shape.
for text in ["square", "circle", ""] {
for text in ["square", "circle", "rip", ""] {
let err = doc.rule_values(&vals, &SheetRule { corner_relief_shape: text.into(), ..old.clone() }).unwrap_err().to_string();
assert!(err.contains(&format!("unknown corner relief shape `{text}` (notch, round or rectangular)")), "{err}");
assert!(err.contains(&format!("unknown corner relief shape `{text}` (notch, round, rectangular or tear)")), "{err}");
}
}
@@ -4669,14 +4825,14 @@ mod tests {
#[test]
fn a_bend_relief_at_one_end_leaves_the_corner_at_the_other_as_it_was() {
// Flanges on the bar's top and right edges in one call: their bends meet at (157, 27),
// where the corner is closed and relieved by the rule's shape, and the top one ends
// beside the leg at (20, 27), where it gets the bend relief. Against the bar without
// where the corner is closed and relieved or torn by the rule's shape, and the top one
// ends beside the leg at (20, 27), where it gets the bend relief. Against the bar without
// the leg, the same corner and all, the flat pattern gains the leg from the bend line
// up, 20 × (60 − 27), less the relief, 2 × (1 + BA + 12 + 2), and loses 20 of the top
// flange's length, 20 × (BA + 12).
let (ba, panel) = (std::f64::consts::PI, 12.0);
let picks = [Vec3::new(90.0, 30.0, 2.0), Vec3::new(160.0, 15.0, 2.0)];
for shape in [ReliefShape::Notch, ReliefShape::Round, ReliefShape::Rectangular] {
for shape in [ReliefShape::Notch, ReliefShape::Round, ReliefShape::Rectangular, ReliefShape::Tear] {
let what = format!("{shape:?}");
let rv = RuleValues { corner_relief_shape: shape, ..alu() };
let bar = rect(Vec2::new(0.0, 0.0), Vec2::new(160.0, 0.0), Vec2::new(160.0, 30.0), Vec2::new(0.0, 30.0));
@@ -4686,8 +4842,14 @@ mod tests {
let mut s = bar_with_leg(&rv, 30.0);
let (made, notes) = s.add_flanges_noted(&picks, 15.0, std::f64::consts::FRAC_PI_2, 1.0, "inside", false).unwrap();
assert!(notes.is_empty() && made.len() == 2, "{what}: {notes:?}");
// The corner's relief reaches 4 along both bends (8 across); the notch none.
let cut = if shape == ReliefShape::Notch { 0.0 } else { 4.0 };
// The corner's relief reaches 4 along both bends (8 across); the notch none. A tear
// moves the bend's end at the corner out by grow = sin 90° − 0.5/√2 on the flange's
// side, so its centre line is half of that longer.
let cut = match shape {
ReliefShape::Notch => 0.0,
ReliefShape::Tear => -(1.0 - 0.5 / std::f64::consts::SQRT_2) / 2.0,
ReliefShape::Round | ReliefShape::Rectangular => 4.0,
};
let lengths: Vec<f64> = s.bends().iter().map(|b| b.length).collect();
assert!((lengths[0] - (137.0 - cut)).abs() < 1e-9 && (lengths[1] - (27.0 - cut)).abs() < 1e-9, "{what}: {lengths:?}");
let got = corners_of(&s.base);
@@ -4896,6 +5058,7 @@ mod tests {
p: Vec2,
d: Vec2,
span: (f64, f64),
ends: (f64, f64),
radius: f64,
angle: f64,
t: f64,
@@ -4913,6 +5076,7 @@ mod tests {
p: f.p,
d: f.d,
span: s.zone_span(f),
ends: s.zone_ends(f),
radius: f.radius,
angle: f.angle,
t: s.t,
@@ -4936,7 +5100,10 @@ mod tests {
let down = Vec3::Z * -1.0;
let (rho, turn) = (perp.len(), perp.dot(k.cross(down)).atan2(perp.dot(down)));
let u = (Vec2::new(q.x, q.y) - self.p).dot(self.d);
let in_bend = [rho - self.radius, self.radius + self.t - rho, turn * rho, (self.angle - turn) * rho, u - self.span.0, self.span.1 - u]
// A torn end runs along the bend as the bend turns, from the start line's end to the panel's.
let (span, ends, x) = (self.span, self.ends, turn / self.angle);
let (u0, u1) = (span.0 + (ends.0 - span.0) * x, span.1 + (ends.1 - span.1) * x);
let in_bend = [rho - self.radius, self.radius + self.t - rho, turn * rho, (self.angle - turn) * rho, u - u0, u1 - u]
.into_iter()
.fold(f64::INFINITY, f64::min);
let depth = in_panel.max(in_bend);
@@ -4954,13 +5121,14 @@ mod tests {
let f = &s.flanges[i];
let ba = s.allowance(f);
let mut samples = Vec::new();
// The bend's 12 mm nearest the corner.
let (u0, u1) = s.zone_span(f);
// The bend's 12 mm nearest the corner, from its end (which a tear leaves askew).
let ((u0, u1), (e0, e1)) = (s.zone_span(f), s.zone_ends(f));
for iu in 0..48 {
let du = 0.02 + 0.25 * iu as f64;
let u = if at_end { u1 - du } else { u0 + du };
for is in 0..24 {
samples.push((Piece::Zone(i), f.p + f.d * u + f.n * (ba * (is as f64 + 0.5) / 24.0)));
let x = (is as f64 + 0.5) / 24.0;
let u = if at_end { u1 + (e1 - u1) * x - du } else { u0 + (e0 - u0) * x + du };
samples.push((Piece::Zone(i), f.p + f.d * u + f.n * (ba * x)));
}
}
// The panel's 25 mm nearest its end, over its whole height.
@@ -5776,4 +5944,391 @@ mod tests {
holds_the_flat_material(&s, &what);
}
}
/// `alu()` with torn corners.
fn torn() -> RuleValues {
RuleValues { corner_relief_shape: ReliefShape::Tear, ..alu() }
}
/// The corners of the flat pattern's outline.
fn outline_points(s: &SheetBody) -> Vec<Vec2> {
s.flat_regions().unwrap().iter().flat_map(|r| r.outer.segs.iter().map(Seg2::start)).collect()
}
/// A tear leaves the base and the flanges as the closing made them (`notch`): it only marks
/// the bend ends at the corners as torn. `torn`: how many of the ends are.
fn tears_nothing_but_the_bend_ends(s: &SheetBody, notch: &SheetBody, torn: usize, what: &str) {
assert_eq!(s.base, notch.base, "{what}");
assert_eq!(s.flanges.iter().map(|f| f.tear.0 as usize + f.tear.1 as usize).sum::<usize>(), torn, "{what}");
let whole: Vec<SheetFlange> = s.flanges.iter().map(|f| SheetFlange { tear: (false, false), ..f.clone() }).collect();
assert_eq!(whole, notch.flanges, "{what}");
}
#[test]
fn a_tear_cuts_each_bend_from_the_corner_to_its_flanges_end() {
use std::f64::consts::SQRT_2;
// The tray under `alu()`: T = 2, R = 1, gap g = 0.5, flanges h = 38.6 high. The bends on y = 0
// and x = 0 start on the lines y = s0 and x = s0, s0 = (R + T)·tan(θ/2), which meet at
// c = (s0, s0), and are BA = θ·(R + T/2) wide. The flange on y = 0 is closed to
// x_end(w) = g/√2 + T·sinθ − w·cosθ (`closed_corners_end_on_the_bisector_with_the_gap`): w = s0
// where its panel starts, at flat y = s0 − BA, and w = h at its far edge. A tear cuts from c
// straight to (x_end(s0), s0 − BA), where the panel's end goes on; the notch's step at
// (s0, s0 − BA) is gone. The flange on x = 0 is the mirror image in x = y.
let (t, r, g, h) = (2.0, 1.0, 0.5, 38.6);
for deg in [90.0f64, 72.0] {
let what = format!("{deg}°");
let (notch, _) = tray(&alu(), deg, h);
let (s, notes) = tray(&torn(), deg, h);
assert!(notes.is_empty(), "{what}: {notes:?}");
tears_nothing_but_the_bend_ends(&s, &notch, 8, &what);
let th = deg.to_radians();
let (s0, ba) = ((r + t) * (th / 2.0).tan(), th * (r + 0.5 * t));
let x_end = |w: f64| g / SQRT_2 + t * th.sin() - w * th.cos();
let (y0, y1) = (s0 - ba, s0 - ba - (h - s0));
// The bend on y = 0: its start line between the corners, its far edge as long as the panel's.
let zone = corners_of(&s.zone(&s.flanges[0]));
assert_eq!(zone.len(), 4, "{what}");
for (x, y) in [(s0, s0), (120.0 - s0, s0), (120.0 - x_end(s0), y0), (x_end(s0), y0)] {
assert!(zone.iter().any(|p| p.dist(Vec2::new(x, y)) < 1e-9), "{what}: ({x}, {y}) not in {zone:?}");
}
// The outline at the corner: the panel's end, the cut, the corner of the bends, and on.
let (cut, stepped) = (outline_points(&s), outline_points(&notch));
let at = |pts: &[Vec2], x: f64, y: f64| pts.iter().any(|p| p.dist(Vec2::new(x, y)) < 1e-9);
for (x, y) in [(x_end(h), y1), (x_end(s0), y0), (s0, s0)] {
assert!(at(&cut, x, y) && at(&cut, y, x), "{what}: ({x}, {y}) not in the outline");
}
assert!(at(&stepped, s0, y0) && at(&stepped, y0, s0) && !at(&cut, s0, y0) && !at(&cut, y0, s0), "{what}");
// Each of the eight bend ends gains the triangle between its square end and the cut.
let grow = s0 - x_end(s0);
let gained = flat_area(&s) - flat_area(&notch);
assert!(grow > 0.0 && (gained - 8.0 * ba * grow / 2.0).abs() < 1e-6, "{what}: {gained}");
// The bend table: the centre line runs between the middles of the bend's two ends.
let bend = &s.bends()[0];
let (start, end) = (Vec2::new(s0 - grow / 2.0, s0 - ba / 2.0), Vec2::new(120.0 - s0 + grow / 2.0, s0 - ba / 2.0));
assert!(
(bend.length - (120.0 - 2.0 * s0 + grow)).abs() < 1e-9 && bend.start.dist(start) < 1e-9 && bend.end.dist(end) < 1e-9,
"{what}: {bend:?}"
);
// Folded, the panels' ends are the gap apart as they were. A point of a bend's inner face
// is R·sin φ out from the bend's start line at the turn φ, and the torn end is there
// grow·φ/θ past the corner along the bend, so the two ends at this corner (flange 0's
// and its mirror image, flange 3's) are √2·(R·sin φ − grow·φ/θ) apart: they touch at the
// corner and are the gap apart where the panels start (grow = R·sinθ − g/√2).
let corners = s.corners(&[0, 1, 2, 3]);
assert_eq!(corners.len(), 4, "{what}");
ends_are_gap_apart(&s, &corners, g, &what);
let (c, far) = (Vec2::new(s0, s0), Vec2::new(x_end(s0), y0));
for i in 0..=64 {
let x = i as f64 / 64.0;
let q = c + (far - c) * x;
let apart = s.map3(Piece::Zone(0), q, t).dist(s.map3(Piece::Zone(3), Vec2::new(q.y, q.x), t));
let want = SQRT_2 * (r * (th * x).sin() - grow * x);
assert!((apart - want).abs() < 1e-9 && apart > g * x - 1e-9, "{what}: {x}: {apart} vs {want}");
assert!(s.bend_end(&s.flanges[0], false, th * x, t).dist(s.map3(Piece::Zone(0), q, t)) < 1e-9, "{what}: {x}");
}
for k in &corners {
flanges_keep_clear(&s, k, &what);
}
holds_the_flat_material(&s, &what);
let unfolded = vol(&s.flat_solid().unwrap());
assert!((unfolded - flat_area(&s) * t).abs() < 1e-3 * unfolded, "{what}: {unfolded}");
}
}
#[test]
fn a_tear_follows_corners_that_are_not_square() {
// The trapezoid of the relief tests with 72° flanges: the base fills α = atan(90/20) = 77.47°
// at its lower corners and β = 180° − α at the upper ones. Seen from above, a point u along a
// bend from the corner of the bends and v out from the bend's start line is
// u·sin(α/2) + v·cos(α/2) from the plane that halves the corner, which also halves the inner
// faces of two flanges at one angle. A panel's inner face starts v = R·sinθ out and keeps
// g/2 from that plane, so its end starts at u = (g/2 − R·sinθ·cos(α/2)) / sin(α/2): less
// than nothing, the panel runs on past the corner, and the tear cuts from the corner to there.
let (r, g) = (1.0, 0.5);
let th = 72f64.to_radians();
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
let (alpha, beta) = (90f64.atan2(20.0), std::f64::consts::PI - 90f64.atan2(20.0));
let u_end = |a: f64| (g / 2.0 - r * th.sin() * (a / 2.0).cos()) / (a / 2.0).sin();
let (notch, _) = trapezoid(&alu(), 72.0);
let (s, notes) = trapezoid(&torn(), 72.0);
assert!(notes.is_empty(), "{notes:?}");
tears_nothing_but_the_bend_ends(&s, &notch, 8, "trapezoid");
let (cut, stepped) = (outline_points(&s), outline_points(&notch));
let at = |pts: &[Vec2], q: Vec2| pts.iter().any(|p| p.dist(q) < 1e-8);
// The bend on y = 0 (flange 0) between the lower corners, and the one on y = 90 (flange 2)
// between the upper ones: the corner of the bends, where the cut ends, and the notch's step.
let lower = (Vec2::new(s0 / (alpha / 2.0).tan(), s0), Vec2::new(u_end(alpha), -ba));
let upper = (Vec2::new(20.0 + s0 / (beta / 2.0).tan(), 90.0 - s0), Vec2::new(u_end(beta), ba));
for (i, (c, to)) in [(0, lower), (2, upper)] {
let zone = corners_of(&s.zone(&s.flanges[i]));
let mirror = |q: Vec2| Vec2::new(120.0 - q.x, q.y);
for q in [c, c + to, mirror(c), mirror(c + to)] {
assert!(at(&zone, q) && at(&cut, q), "flange {i}: {q:?} not in {zone:?}");
}
let step = Vec2::new(c.x, c.y + to.y);
assert!(at(&stepped, step) && !at(&cut, step), "flange {i}");
}
// Each bend end gains the triangle between its square end and the cut: four ends at α, four at β.
let (gained, want) = (flat_area(&s) - flat_area(&notch), -2.0 * ba * (u_end(alpha) + u_end(beta)));
assert!((gained - want).abs() < 1e-6, "{gained} vs {want}");
let corners = s.corners(&[0, 1, 2, 3]);
assert_eq!(corners.len(), 4);
ends_are_gap_apart(&s, &corners, g, "trapezoid");
for k in &corners {
flanges_keep_clear(&s, k, "trapezoid");
}
holds_the_flat_material(&s, "trapezoid");
}
#[test]
fn a_cut_out_over_torn_bend_ends_folds() {
use std::f64::consts::{FRAC_PI_2, PI};
// A round cut-out of radius 2 about the corner (s0, s0) of the tray's bends takes the two
// torn bend ends with it. Of its disc the flat pattern holds all but the wedge between the
// two cuts, which leave the corner atan(grow/BA) off the lines square to the bends.
for deg in [90.0f64, 72.0] {
let what = format!("{deg}°");
let th = deg.to_radians();
let (s0, ba, grow) = (3.0 * (th / 2.0).tan(), th * 2.0, th.sin() - 0.25 * 2f64.sqrt());
let (whole, _) = tray(&torn(), deg, 38.6);
let mut s = whole.clone();
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(s0, s0), 2.0), holes: vec![] });
let (removed, want) = (flat_area(&whole) - flat_area(&s), 4.0 * (PI - (FRAC_PI_2 - 2.0 * (grow / ba).atan()) / 2.0));
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
let body = s.solid().unwrap_or_else(|e| panic!("{what}: no solid: {e}"));
assert!(body.validity().is_empty() && body.shell_count() == 1, "{what}: {:?}", body.validity());
let lost = vol(&whole.solid().unwrap()) - vol(&body);
assert!((lost - want * 2.0).abs() < 0.05, "{what}: {lost} vs {}", want * 2.0);
}
}
#[test]
fn a_tear_holds_at_every_angle() {
use std::f64::consts::{FRAC_PI_2, PI};
// Every 5° from 45° to 135° on the tray and on the trapezoid: all four corners are torn, the
// flanges are the ones the notch gives, and each of the eight bend ends gains the triangle
// between its square end and the cut, BA·(−u_end)/2 with u_end as in
// `a_tear_follows_corners_that_are_not_square`. The flanges' ends stay the gap apart, no
// bend runs into the other flange, and the folded part holds the flat pattern's material.
let alpha = 90f64.atan2(20.0);
for step in 9..=27 {
let deg = step as f64 * 5.0;
let th = deg.to_radians();
let u_end = |a: f64| (0.25 - th.sin() * (a / 2.0).cos()) / (a / 2.0).sin();
for (name, (s, notes), (notch, _), angles) in [
("tray", tray(&torn(), deg, 38.6), tray(&alu(), deg, 38.6), [FRAC_PI_2; 4]),
("trapezoid", trapezoid(&torn(), deg), trapezoid(&alu(), deg), [alpha, alpha, PI - alpha, PI - alpha]),
] {
let what = format!("a tear on the {name} at {deg}°");
assert!(notes.is_empty(), "{what}: {notes:?}");
tears_nothing_but_the_bend_ends(&s, &notch, 8, &what);
let want: f64 = angles.iter().map(|a| -(th * 2.0) * u_end(*a)).sum();
let gained = flat_area(&s) - flat_area(&notch);
assert!((gained - want).abs() < 1e-6, "{what}: {gained} vs {want}");
let corners = s.corners(&[0, 1, 2, 3]);
assert_eq!(corners.len(), 4, "{what}");
ends_are_gap_apart(&s, &corners, 0.5, &what);
for k in &corners {
flanges_keep_clear(&s, k, &what);
}
holds_the_flat_material(&s, &what);
}
}
}
#[test]
fn flanges_of_unequal_angle_are_torn_where_their_bends_stay_apart() {
// The tray with flanges at 60°, 90°, 105° and 120°, each its own height. Three corners are
// torn: each bend's start line runs between the corners of the bend lines, and its far edge
// to the panel's end of `hand_panel` where the end is torn. At the fourth corner the flanges
// at 120° and 60° lean 60° apart, and the plane halving them leans with them: a cut to the
// ends it gives would run one bend across the corner into the other, so that corner keeps
// its notch, with a note.
let rv = torn();
let heights = [38.6, 30.0, 45.0, 25.0];
let (notch, _) = tray_each(&alu(), MIXED, heights);
let (s, notes) = tray_each(&rv, MIXED, heights);
let s0 = MIXED.map(|a| 3.0 * (a.to_radians() / 2.0).tan());
let ba = MIXED.map(|a| a.to_radians() * 2.0);
let refused = format!("the corner at ({:.3}, {:.3}, 0.000) keeps its notch: a tear would run a bend into the other one", s0[3], s0[0]);
assert_eq!(notes, [refused]);
tears_nothing_but_the_bend_ends(&s, &notch, 6, "four angles");
assert_eq!(s.flanges.iter().map(|f| f.tear).collect::<Vec<_>>(), [(false, true), (true, true), (true, true), (true, false)]);
// The corners of the bend lines, counter-clockwise from the one at the origin, and the way
// out of the base across each bend.
let c = [Vec2::new(s0[3], s0[0]), Vec2::new(120.0 - s0[1], s0[0]), Vec2::new(120.0 - s0[1], 90.0 - s0[2]), Vec2::new(s0[3], 90.0 - s0[2])];
let out = [Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0)];
let mut gain = 0.0;
for i in 0..4 {
let (f, panel) = (&s.flanges[i], hand_panel(&rv, MIXED, heights, i));
let (a, b) = (c[i], c[(i + 1) % 4]);
let (square_a, square_b) = (a + out[i] * ba[i], b + out[i] * ba[i]);
let (far_a, far_b) = (if f.tear.0 { panel[0] } else { square_a }, if f.tear.1 { panel[3] } else { square_b });
let zone = corners_of(&s.zone(f));
assert_eq!(zone.len(), 4, "flange {i}");
for q in [a, b, far_b, far_a] {
assert!(zone.iter().any(|p| p.dist(q) < 1e-9), "flange {i}: {q:?} not in {zone:?}");
}
// A torn end gains the triangle between the bend's square end and the cut.
let along = (b - a).normalized().unwrap();
gain += ba[i] / 2.0 * ((square_a - far_a).dot(along) + (far_b - square_b).dot(along));
}
let (area, want) = (flat_area(&s), hand_flat_area(&rv, MIXED, heights) + gain);
assert!(gain > 0.0 && (area - want).abs() < 1e-6, "flat area {area} vs {want}");
let corners = s.corners(&[0, 1, 2, 3]);
assert_eq!(corners.len(), 4);
ends_are_gap_apart(&s, &corners, rv.gap, "four angles");
for k in &corners {
flanges_keep_clear(&s, k, "four angles");
}
holds_the_flat_material(&s, "four angles");
}
#[test]
fn every_pair_of_angles_is_torn_or_keeps_its_notch() {
// The corners of `every_pair_of_angles_closes_at_any_base_corner` under the tear. Flanges at
// one angle are always torn. Where the angles differ, the plane that halves the flanges
// leans, and where their ends start moves with it: the corner is torn while both bends' ends
// stay on their own side of the corner and open toward the flanges' ends, and else keeps
// its notch with a note. That is a cautious rule: at the 60° and the 90° corner it tears
// angles up to 45° apart, at the 120° corner 27 of the 49 pairs. Torn, the flanges are the
// closed ones, no bend lies in the other flange, and the part is one valid shell.
let mut torn_pairs = Vec::new();
for alpha in [60.0f64, 90.0, 120.0] {
let u = Vec2::new(alpha.to_radians().cos(), alpha.to_radians().sin());
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 0.0) + u * 90.0, u * 90.0];
let mut count = 0;
for a in SWEEP {
for b in SWEEP {
let what = format!("corner {alpha}°, flanges {a}° and {b}°");
let each = [
EdgeFlange { pick: Vec3::new(60.0, 0.0, 2.0), height: 38.6, angle: a.to_radians() },
EdgeFlange { pick: Vec3::new(u.x * 45.0, u.y * 45.0, 2.0), height: 38.6, angle: b.to_radians() },
];
let base = || Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] };
let (notch, _) = flanges_each(base(), &alu(), &each);
let (s, notes) = flanges_each(base(), &torn(), &each);
if !notes.is_empty() {
assert!(notes.len() == 1 && notes[0].contains(") keeps its notch: a tear would"), "{what}: {notes:?}");
assert!(a != b && s.flanges == notch.flanges, "{what}: {notes:?}");
continue;
}
count += 1;
tears_nothing_but_the_bend_ends(&s, &notch, 2, &what);
let corners = s.corners(&[0, 1]);
assert_eq!(corners.len(), 1, "{what}");
ends_are_gap_apart(&s, &corners, 0.5, &what);
flanges_keep_clear(&s, &corners[0], &what);
holds_the_flat_material(&s, &what);
}
}
torn_pairs.push(count);
}
assert_eq!(torn_pairs, [37, 37, 27]);
}
#[test]
fn returns_close_at_torn_corners_as_at_whole_ones() {
use std::f64::consts::{FRAC_PI_2, PI};
// Like a relief, a tear leaves the walls' closed ends as they are, so the returns on them
// meet as at a whole corner: every check of `tray_with_level_returns_closes_every_corner`
// holds, the flanges are the ones the notch gives, and the walls' bends are torn as without
// returns (the returns' own bends are not).
let tray = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0)];
let trapezoid = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)];
let alpha = 90f64.atan2(20.0);
let cases = [
("tray", tray, [FRAC_PI_2; 4], 90.0f64),
("tray", tray, [FRAC_PI_2; 4], 110.0),
("trapezoid", trapezoid, [alpha, alpha, PI - alpha, PI - alpha], 110.0),
];
for (name, pts, angles, deg) in cases {
let what = format!("a tear on the {name}, walls at {deg}°");
let notch = tray_with_level_returns_closes_every_corner(&alu(), &pts, deg, &what);
let s = tray_with_level_returns_closes_every_corner(&torn(), &pts, deg, &what);
tears_nothing_but_the_bend_ends(&s, &notch, 8, &what);
assert!(s.flanges.iter().all(|f| f.tear == (f.parent.is_none(), f.parent.is_none())), "{what}");
let th = deg.to_radians();
let want: f64 = angles.iter().map(|a| -(th * 2.0) * (0.25 - th.sin() * (a / 2.0).cos()) / (a / 2.0).sin()).sum();
let gained = flat_area(&s) - flat_area(&notch);
assert!((gained - want).abs() < 1e-6, "{what}: {gained} vs {want}");
returns_keep_out_of_the_walls(&s, &what);
}
}
#[test]
fn a_corner_left_open_or_made_by_two_features_is_not_torn() {
// A tear cuts to the flanges' closed ends. With a gap of zero the corners stay open: the
// notes say so, nothing is torn, and the tear adds no note of its own.
let (s, notes) = tray(&RuleValues { gap: 0.0, ..torn() }, 72.0, 38.6);
assert_eq!(notes.len(), 4, "{notes:?}");
assert!(notes.iter().all(|n| n.contains("left open") && n.contains("gap")), "{notes:?}");
assert_eq!(s.flanges, tray(&RuleValues { gap: 0.0, ..alu() }, 72.0, 38.6).0.flanges);
assert!(s.solid().is_ok());
// Like the closing, the tear belongs to the corners between the flanges of one feature.
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
let mut s = base_flange("Body1", "AL2", &torn(), Plane::XY, base);
s.add_flanges(&[Vec3::new(60.0, 0.0, 2.0)], 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
let (_, notes) = s.add_flanges_noted(&[Vec3::new(120.0, 45.0, 2.0)], 25.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
assert!(notes.is_empty(), "{notes:?}");
assert!(s.flanges.iter().all(|f| f.tear == (false, false)));
assert!(s.solid().is_ok() && s.flat_regions().unwrap().len() == 1);
}
#[test]
fn a_torn_bend_end_is_no_flat_face() {
use std::f64::consts::{FRAC_PI_2, SQRT_2};
// A bend's flat ends are faces a sketch can sit on (`rim.bend0`, `rim.bend1`). The bar's top
// flange (see `a_bend_relief_at_one_end_leaves_the_corner_at_the_other_as_it_was`) turns
// about the axis y = 27, z = 3 between the radii 1 and 3. With the notch its bend ends on the
// planes x = 157, at the corner, and x = 20, beside the leg; the middle of each end is 2 from
// the axis, halfway round. Torn, the end at the corner is a helix that leaves its plane at
// the corner of the bends: no flat face is there, the point has no name and the name gives
// no plane. The end beside the leg is the face it was.
let picks = [Vec3::new(90.0, 30.0, 2.0), Vec3::new(160.0, 15.0, 2.0)];
let mid = |x: f64| Vec3::new(x, 27.0 + SQRT_2, 3.0 - SQRT_2);
let mut notch = bar_with_leg(&alu(), 30.0);
notch.add_flanges_noted(&picks, 15.0, FRAC_PI_2, 1.0, "inside", false).unwrap();
let mut s = bar_with_leg(&torn(), 30.0);
s.add_flanges_noted(&picks, 15.0, FRAC_PI_2, 1.0, "inside", false).unwrap();
let (corner, at_corner) = notch.panel_face_at(mid(157.0), 1e-6).unwrap();
let (leg, at_leg) = notch.panel_face_at(mid(20.0), 1e-6).unwrap();
assert!(corner.starts_with("#0:rim.bend") && leg.starts_with("#0:rim.bend") && corner != leg, "{corner} {leg}");
assert!(at_corner.normal().dist(Vec3::X) < 1e-12 && at_leg.normal().dist(Vec3::X * -1.0) < 1e-12, "{at_corner:?} {at_leg:?}");
assert!(s.panel_face_at(mid(157.0), 1e-6).is_none() && s.panel_face(&corner).is_none(), "{corner}");
assert_eq!(s.panel_face_at(mid(20.0), 1e-6), Some((leg.clone(), at_leg)));
assert_eq!(s.panel_face(&leg), Some(at_leg));
}
#[test]
fn a_tear_that_cannot_be_made_leaves_the_notch_with_a_note() {
// Flanges at very different angles on the two edges at the origin of a parallelogram (as in
// `every_pair_of_angles_is_torn_or_keeps_its_notch`). The bend lines lie s0 = 3·tan(θ/2) in
// from their edges and meet at ((s0_b + s0_a·cos α) / sin α, s0_a), where the note points.
// Each of the three reasons leaves the part the notch gives.
let cases = [
(90.0f64, 20.0f64, 140.0f64, "a tear would make the bends overlap in the flat pattern"),
(90.0, 60.0, 120.0, "a tear would run a bend into the other one"),
(120.0, 105.0, 60.0, "a tear would bring the bends nearer each other than the flanges' ends are"),
];
for (alpha, a, b, why) in cases {
let what = format!("corner {alpha}°, flanges {a}° and {b}°");
let alpha = alpha.to_radians();
let u = Vec2::new(alpha.cos(), alpha.sin());
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 0.0) + u * 90.0, u * 90.0];
let each = [
EdgeFlange { pick: Vec3::new(60.0, 0.0, 2.0), height: 38.6, angle: a.to_radians() },
EdgeFlange { pick: Vec3::new(u.x * 45.0, u.y * 45.0, 2.0), height: 38.6, angle: b.to_radians() },
];
let base = || Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] };
let (notch, none) = flanges_each(base(), &alu(), &each);
let (s, notes) = flanges_each(base(), &torn(), &each);
let (s0_a, s0_b) = (3.0 * (a.to_radians() / 2.0).tan(), 3.0 * (b.to_radians() / 2.0).tan());
let c = Vec2::new((s0_b + s0_a * alpha.cos()) / alpha.sin(), s0_a);
assert!(none.is_empty(), "{what}: {none:?}");
assert_eq!(notes, [format!("the corner at ({:.3}, {:.3}, 0.000) keeps its notch: {why}", c.x, c.y)], "{what}");
assert_eq!((&s.base, &s.flanges), (&notch.base, &notch.flanges), "{what}");
holds_the_flat_material(&s, &what);
}
}
}
+1
View File
@@ -380,6 +380,7 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
slope: (0.0, 0.0),
shape: None,
tag: String::new(),
tear: (false, false),
});
queue.push((g, Some(sh.flanges.len() - 1)));
}
+3 -2
View File
@@ -43,7 +43,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \
corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \
corner_relief_shape?: notch|round|rectangular|tear (what is cut where two bends meet at a corner of the base: round and rectangular a relief \
`corner_relief` across; tear none, each bend's end is cut straight from the corner to its flange's end; default notch); \
active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
@@ -323,7 +324,7 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) {
let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse);
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into();
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round, rectangular or tear"))?.name().into();
}
let (vals, _) = s.doc.param_values();
let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?;
+72 -2
View File
@@ -2478,6 +2478,76 @@ fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() {
}
}
#[test]
fn a_tear_of_the_rule_reaches_the_part_the_flat_pattern_and_the_dxf() {
use std::f64::consts::SQRT_2;
// The tray at 90° and 72° (see the doc tests): its bends meet at (s0, s0), s0 = 3·tan(θ/2), and
// are BA = 2θ wide in the flat. The flange on y = 0 is closed to x_end(w) = g/√2 + T·sinθ − w·cosθ.
// Torn, its bend's end is cut from (s0, s0) straight to (x_end(s0), s0 − BA), where the flange's
// end starts: grow = s0 − x_end(s0) = sinθ − 0.5/√2 past the corner, instead of square across the
// bend and back along the flange's edge (the notch's step).
for deg in [90.0f64, 72.0] {
let th = deg.to_radians();
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
let x_end = |w: f64| 0.5 / SQRT_2 + 2.0 * th.sin() - w * th.cos();
let grow = s0 - x_end(s0);
assert!((grow - (th.sin() - 0.5 / SQRT_2)).abs() < 1e-12);
let points = |f: &Value| -> Vec<(f64, f64)> {
let line = f["outline"][0].as_array();
line.map(|l| l.iter().map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN))).collect()).unwrap_or_default()
};
let mut notch = Session::default();
alu_tray(&mut notch, deg, json!({}));
let (v0, stepped) = (volume(&mut notch), points(&flat(&mut notch)));
run(&mut notch, "sheet.unfold", json!({}));
let flat0 = volume(&mut notch);
let mut s = Session::default();
alu_tray(&mut s, deg, json!({"corner_relief_shape": "tear"}));
assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{deg}: {:?}", s.model.results);
let listed = run(&mut s, "sheet.rules", json!({}));
assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], "tear");
assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], "tear");
// The flat pattern, at the corner at the origin: the flange's end, the cut, the corner of the
// bends, and the same on the flange on x = 0. The step is gone: a corner less at each of the
// eight bend ends.
let f = flat(&mut s);
let outline = points(&f);
let at = |pts: &[(f64, f64)], x: f64, y: f64| pts.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{deg}: {f}");
for (x, y) in [(x_end(38.6), s0 - ba - (38.6 - s0)), (x_end(s0), s0 - ba), (s0, s0)] {
assert!(at(&outline, x, y) && at(&outline, y, x), "{deg}: ({x}, {y}) not in {outline:?}");
}
assert!(at(&stepped, s0, s0 - ba) && !at(&outline, s0, s0 - ba) && !at(&outline, s0 - ba, s0), "{deg}");
assert_eq!(outline.len() + 8, stepped.len(), "{deg}");
// The bend table: every centre line runs between the middles of its bend's two ends.
let lengths: Vec<f64> =
f["bends"].as_array().map(|b| b.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default();
for (got, edge) in lengths.iter().zip([120.0, 90.0, 120.0, 90.0]) {
assert!(lengths.len() == 4 && (got - (edge - 2.0 * s0 + grow)).abs() < 1e-6, "{deg}: {lengths:?}");
}
// The folded part gains what the flat pattern gains: the triangle BA·grow/2 at each of the
// eight bend ends (K = 0.5 keeps the volume of a bend).
let (v, want) = (volume(&mut s), 8.0 * ba * grow / 2.0 * 2.0);
assert!((v - v0 - want).abs() < 0.05, "{deg}: {v} - {v0} vs {want}");
// The DXF: the outline in straight lines, each cut one of them, and four bend lines.
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{deg}");
assert!(dxf.contains(&format!("{:.6}\n20\n{:.6}\n", x_end(s0), s0 - ba)), "{deg}");
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - flat0 - want).abs() < 1e-6, "{deg}: unfolded {} - {flat0} vs {want}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{deg}");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true}));
assert!((v - volume(&mut s) - PI * 100.0 * 2.0).abs() < 0.5, "{deg}: {v} - {}", volume(&mut s));
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6, "{deg}");
}
}
#[test]
fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
let mut s = Session::default();
@@ -2485,10 +2555,10 @@ fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
let shape = |s: &mut Session| run(s, "sheet.rules", json!({}))["rules"][0]["rule"]["corner_relief_shape"].clone();
let v0 = volume(&mut s);
assert_eq!(shape(&mut s), "notch");
// Only the three names are shapes; a refused one changes nothing.
// Only the four names are shapes; a refused one changes nothing.
for wrong in [json!("star"), json!("square"), json!(""), json!(2)] {
let e = s.execute("sheet.manage_rules", &json!({"name": "AL2", "corner_relief_shape": wrong})).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("`corner_relief_shape` must be notch, round or rectangular"), "{e}");
assert!(e.contains("`corner_relief_shape` must be notch, round, rectangular or tear"), "{e}");
}
assert!(shape(&mut s) == "notch" && volume(&mut s) == v0);
// Changing the rule changes the parts made with it; other settings leave the shape alone.
+2 -2
View File
@@ -33,8 +33,8 @@ const RULE_FIELDS: [(&str, &str); 8] = [
("gap", "Flange Gap"),
];
/// The corner relief shapes the manager offers (the rule's `corner_relief_shape`) and their labels.
const RELIEF_SHAPES: [&str; 3] = ["notch", "round", "rectangular"];
const RELIEF_SHAPE_LABELS: [&str; 3] = ["Notch", "Round", "Rectangular"];
const RELIEF_SHAPES: [&str; 4] = ["notch", "round", "rectangular", "tear"];
const RELIEF_SHAPE_LABELS: [&str; 4] = ["Notch", "Round", "Rectangular", "Tear"];
#[derive(Clone, Debug)]
pub enum Sm {
@@ -350,6 +350,15 @@ fn rules_dialog_picks_the_corner_relief_shape() {
h.step(&json!({"start": "sheet.manage_rules"})).unwrap();
h.frames(3);
assert_eq!(shown(&h.app), Some(1), "the dialog shows the rule's shape");
// The tear: no relief in the base, both ends of all four bends torn.
h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap();
h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Tear"}})).unwrap();
assert_eq!(shown(&h.app), Some(3));
h.step(&json!({"widget": "OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
assert_eq!(h.app.session.doc.sheet_rule(None).corner_relief_shape, "tear");
let torn = h.app.session.model.state().sheets.last().map(|s| s.flanges.iter().filter(|f| f.tear == (true, true)).count());
assert_eq!((rounds(&h.app), torn), (Some(0), Some(4)));
}
#[test]
@@ -1,6 +1,6 @@
[
{
"note": "Sheet Metal Rules: the Corner Relief Shape row sets the rule's corner_relief_shape, and the tray made with the rule follows. The bend on the 120 mm edge is 120 - 2*s0 long (s0 = 3*tan 36 deg) and 8 mm shorter with a relief 8 mm across at both its ends."
"note": "Sheet Metal Rules: the Corner Relief Shape row sets the rule's corner_relief_shape, and the tray made with the rule follows. The bend on the 120 mm edge is 120 - 2*s0 long (s0 = 3*tan 36 deg) and 8 mm shorter with a relief 8 mm across at both its ends. Torn, its centre line is sin 72 deg - 0.5/sqrt 2 longer: each end runs half that past the corner, toward its flange's closed end."
},
{
"call": "ui.set",
@@ -213,5 +213,53 @@
},
{
"shot": "s51_rectangular_reliefs"
},
{
"start": "sheet.manage_rules"
},
{
"click": {
"handle": "dialog:Corner Relief Shape"
}
},
{
"click": {
"handle": "dialog:Corner Relief Shape:Tear"
}
},
{
"widget": "OK"
},
{
"expect": {
"dialog": null,
"errors": 0,
"query": {
"command": "sheet.rules",
"path": [
"rules",
0,
"rule",
"corner_relief_shape"
],
"value": "tear"
}
}
},
{
"expect": {
"query": {
"command": "sheet.flat_pattern",
"path": [
"bends",
0,
"length"
],
"value": 116.238247958
}
}
},
{
"shot": "s51_torn_corners"
}
]
+1 -1
View File
@@ -58,7 +58,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
The default `cache: false` omits the portable thumbnail too.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 17, including face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
Current designs use format 17, including face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
links, persistent
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
association profile references, durable sketch face anchors, periodic fit splines and versioned
+11 -3
View File
@@ -34,7 +34,15 @@ gets the bend relief as above. An edge that cut-outs have taken whole is refused
Where two bends of one edge flange feature meet at a corner of the base, the rule's **corner
relief shape** (`corner_relief_shape`; Corner Relief Shape in Sheet Metal Rules) says what is
cut there. `notch`, the default, cuts nothing more: the bends end square at the corner. `round`
cut there. `notch`, the default, cuts nothing more: the bends end square at the corner, which
leaves a step in the flat pattern between each bend's end and its flange's closed end. `tear`
cuts no relief either, and no step: both bends run to the corner where the two bend lines
meet, and each bend's end is cut straight, in the flat pattern, from that corner to where its
flange's closed end starts. Folded, the two bends touch at the corner and open to the rule's
gap where the flanges start; the bend table lists a torn bend between the middles of its two
ends. A tear whose cuts would cross in the flat pattern, or that would run one bend into the
other folded (flanges at very different angles), is left out with a warning and the corner
keeps its notch. `round`
and `rectangular` cut a relief the rule's **corner relief** across, centred on the corner where
the two bend lines meet: out of the base a round with half that size as its radius, or a square
with half that size as its side, and both bends stop where it begins, cut straight across. The
@@ -94,8 +102,8 @@ with the flange it stands on; where the flange beside that one leans in over it,
cut back until it keeps as clear of that flange as the end of the flange it stands on does.
Returns that can't be closed stay open with a warning, as on the base. Returns of separate
features don't close toward each other either: make the returns round a corner in one feature.
A corner relief changes none of this: it leaves the flanges' closed ends as they are, so the
returns on them close over a relieved corner as over a whole one.
A corner relief or a tear changes none of this: it leaves the flanges' closed ends as they
are, so the returns on them close over a relieved or torn corner as over a whole one.
```json
{"command":"sheet.flange","params":{"edges":[[60,2,20],[118,45,20],[60,88,20],[2,45,20]],