feat: close sheet flange corners at any angle (#406)
Edge flanges meeting at a corner of the base were closed only at exactly 90°; at any other angle they stayed open (a V at 72°) and the rule's gap was not used. The flanges of one feature now close at any convex base corner and any bend angle: each panel end is cut square to the sheet on a line in the flat (slanted off 90°, stored as ext plus a new per-end slope) so the edges nearest each other, on the inner faces, run parallel the rule's gap apart about the plane bisecting the inner faces. The construction reads the two flanges' own angles, so it holds for unequal ones as well. At 90° it is the extension the old code gave (reference case 69-sm-tray is unchanged). A corner that can't close (a gap under 0.01 mm, flanges bending opposite ways, ends that would overlap in the flat pattern) stays open, and the flange feature reports a warning that says where and why. A flange of a later feature leaves the corner as it was (reference case 75-sm-bracket-holes: the earlier flange keeps its length). Tested at 72° and 90° against a hand derivation, on a trapezoid, and every 5° from 45° to 135° on a rectangle and a trapezoid (gap exact, one flat region without hairline edges, one valid shell holding the flat material). Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0bb5b7eb09
commit
0154572580
6 files changed
+511
-34
No files matched your search
+1
-1
@@ -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 | 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) | 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 | 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 |
|
||||
|
||||
@@ -430,7 +430,14 @@ pub(super) fn sheet_eval(
|
||||
}
|
||||
let picks = sheet_picks(sh, f, edges, warning);
|
||||
let n0 = sh.flanges.len();
|
||||
sh.add_flanges(&picks, h, a, rr, position, *flip)?;
|
||||
let (_, notes) = sh.add_flanges_noted(&picks, h, a, rr, position, *flip)?;
|
||||
if !notes.is_empty() {
|
||||
let notes = notes.join("; ");
|
||||
*warning = Some(match warning.take() {
|
||||
Some(w) => format!("{w}; {notes}"),
|
||||
None => notes,
|
||||
});
|
||||
}
|
||||
tag_flanges(sh, n0, f.id);
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
|
||||
+422
-31
@@ -273,9 +273,14 @@ pub struct SheetFlange {
|
||||
pub radius: f64,
|
||||
/// Flat length of the panel past the bend zone.
|
||||
pub leg: f64,
|
||||
/// The panel runs on past the bend's ends by these (toward a neighbouring flange).
|
||||
/// The panel runs on past the bend's ends by these (toward a neighbouring flange), measured
|
||||
/// along the bend line; negative values cut the panel back.
|
||||
#[serde(default)]
|
||||
pub ext: (f64, f64),
|
||||
/// How much further each end runs on per unit of panel height: a closed corner between
|
||||
/// 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),
|
||||
/// 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")]
|
||||
@@ -333,6 +338,10 @@ struct Cell {
|
||||
holes: Vec<Vec<Seg2>>,
|
||||
}
|
||||
|
||||
fn is_zero_pair(v: &(f64, f64)) -> bool {
|
||||
v.0 == 0.0 && v.1 == 0.0
|
||||
}
|
||||
|
||||
fn rev_edge(e: EdgeSpec) -> EdgeSpec {
|
||||
match e {
|
||||
EdgeSpec::Line { a, b } => EdgeSpec::Line { a: b, b: a },
|
||||
@@ -417,6 +426,40 @@ fn clip(poly: &[Vec2], o: Vec2, n: Vec2) -> Vec<Vec2> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The smallest gap closed corners are given: closer than this the flanges' ends would touch.
|
||||
const MIN_CORNER_GAP: f64 = 0.01;
|
||||
|
||||
/// Two bends on the base meeting at a corner (see [`SheetBody::corners`]): flanges `a` and `b`,
|
||||
/// which end of each (false = the end at `p`), and the corner point (flat coordinates).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Corner {
|
||||
a: usize,
|
||||
a_end: bool,
|
||||
b: usize,
|
||||
b_end: bool,
|
||||
c: Vec2,
|
||||
}
|
||||
|
||||
/// The corners of a straight-edged region (counter-clockwise).
|
||||
fn corners_of(r: &Region2) -> Vec<Vec2> {
|
||||
dedup(&r.outer.ccw().polyline(1e-3))
|
||||
}
|
||||
|
||||
/// The area two convex counter-clockwise polygons share.
|
||||
fn convex_overlap(a: &[Vec2], b: &[Vec2]) -> f64 {
|
||||
let mut poly = a.to_vec();
|
||||
let m = b.len();
|
||||
for i in 0..m {
|
||||
let (Some(&p), Some(&q)) = (b.get(i), b.get((i + 1) % m)) else { continue };
|
||||
let d = q - p;
|
||||
poly = clip(&poly, p, Vec2::new(-d.y, d.x));
|
||||
if poly.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
signed_area(&poly).abs()
|
||||
}
|
||||
|
||||
impl SheetBody {
|
||||
/// Bend allowance of a flange.
|
||||
pub fn allowance(&self, f: &SheetFlange) -> f64 {
|
||||
@@ -465,7 +508,7 @@ impl SheetBody {
|
||||
let ba = self.allowance(f);
|
||||
let a = f.p + f.n * ba - f.d * f.ext.0;
|
||||
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
|
||||
rect(a, b, b + f.n * f.leg, a + f.n * f.leg)
|
||||
rect(a, b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg), a + f.n * f.leg - f.d * (f.slope.0 * f.leg))
|
||||
}
|
||||
|
||||
/// Flat region of a flange's bend zone.
|
||||
@@ -1185,8 +1228,23 @@ impl SheetBody {
|
||||
}
|
||||
|
||||
/// Edge flanges on several edges at once: every edge is trimmed back first, so flanges on
|
||||
/// adjacent edges stop at each other's bends (the corners stay open).
|
||||
/// adjacent edges stop at each other's bends; the corners between them are then closed with
|
||||
/// the rule's gap (`close_corners`).
|
||||
pub fn add_flanges(&mut self, picks: &[Vec3], height: f64, angle: f64, radius: f64, position: &str, flip: bool) -> Result<Vec<usize>> {
|
||||
self.add_flanges_noted(picks, height, angle, radius, position, flip).map(|(made, _)| made)
|
||||
}
|
||||
|
||||
/// [`SheetBody::add_flanges`], also returning notes on corners that could not be closed
|
||||
/// (they are left open, as the bends leave them).
|
||||
pub fn add_flanges_noted(
|
||||
&mut self,
|
||||
picks: &[Vec3],
|
||||
height: f64,
|
||||
angle: f64,
|
||||
radius: f64,
|
||||
position: &str,
|
||||
flip: bool,
|
||||
) -> Result<(Vec<usize>, Vec<String>)> {
|
||||
if !(angle > 1e-6 && angle <= std::f64::consts::PI - 1e-6) {
|
||||
return Err(DocError::Invalid("the flange angle must be between 0 and 180°".into()));
|
||||
}
|
||||
@@ -1262,43 +1320,146 @@ impl SheetBody {
|
||||
shape: None,
|
||||
tag: String::new(),
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
});
|
||||
made.push(self.flanges.len() - 1);
|
||||
}
|
||||
self.close_corners(&made);
|
||||
Ok(made)
|
||||
let notes = self.close_corners(&made);
|
||||
Ok((made, notes))
|
||||
}
|
||||
|
||||
/// Where two new 90° flanges on the base meet at a corner, run their panels on toward each
|
||||
/// other until the gap between their inner corners is the rule's gap.
|
||||
fn close_corners(&mut self, made: &[usize]) {
|
||||
let ends: Vec<(usize, bool, Vec2)> = made
|
||||
/// Corners where two of the new flanges' bends meet on the base: both bend zones end at the
|
||||
/// corner point. Hems and fold panels are left out, and so is a corner with a flange of an
|
||||
/// earlier feature: that one keeps its length and the new flange ends at its bend, as before.
|
||||
fn corners(&self, made: &[usize]) -> Vec<Corner> {
|
||||
let limit = std::f64::consts::PI - 1e-6;
|
||||
let ends: Vec<(usize, bool, Vec2)> = self
|
||||
.flanges
|
||||
.iter()
|
||||
.filter_map(|&i| self.flanges.get(i).map(|f| (i, f)))
|
||||
.filter(|(_, f)| f.parent.is_none() && (f.angle.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-9)
|
||||
.flat_map(|(i, f)| [(i, false, f.p), (i, true, f.p + f.d * f.len)])
|
||||
.enumerate()
|
||||
.filter(|(_, f)| f.parent.is_none() && f.shape.is_none() && f.angle.abs() < limit)
|
||||
.flat_map(|(i, f)| {
|
||||
let (u0, u1) = self.zone_span(f);
|
||||
[(i, false, f.p + f.d * u0), (i, true, f.p + f.d * u1)]
|
||||
})
|
||||
.collect();
|
||||
for &(i, at_end, c) in &ends {
|
||||
let Some(f) = self.flanges.get(i).cloned() else { continue };
|
||||
let other = ends.iter().find(|&&(j, _, c2)| j != i && c2.dist(c) < 1e-6).and_then(|&(j, _, _)| self.flanges.get(j).cloned());
|
||||
let Some(g) = other else { continue };
|
||||
if g.n.dot(f.n).abs() > 1e-6 || (g.angle > 0.0) != (f.angle > 0.0) {
|
||||
continue;
|
||||
}
|
||||
// The neighbour's setback: its bend starts that far in from the corner of the outer faces.
|
||||
let setback = (g.radius + self.t) * (g.angle.abs() / 2.0).tan();
|
||||
let ext = setback - (self.t + self.gap / std::f64::consts::SQRT_2);
|
||||
if ext <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some(x) = self.flanges.get_mut(i) {
|
||||
if at_end {
|
||||
x.ext.1 = ext;
|
||||
} else {
|
||||
x.ext.0 = ext;
|
||||
let mut out = Vec::new();
|
||||
for (k, &(a, a_end, c)) in ends.iter().enumerate() {
|
||||
for &(b, b_end, c2) in ends.iter().skip(k + 1) {
|
||||
if a != b && c.dist(c2) < 1e-6 && made.contains(&a) && made.contains(&b) {
|
||||
out.push(Corner { a, a_end, b, b_end, c });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Close the corners the new flanges make. Returns a note for every corner left open.
|
||||
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
|
||||
let corners = self.corners(made);
|
||||
let mut notes = Vec::new();
|
||||
let at = |s: &SheetBody, c: Vec2| {
|
||||
let w = apply_point(&s.frame_mat(), v3(c));
|
||||
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
|
||||
};
|
||||
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)));
|
||||
}
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the edges nearest each other (on the inner faces) are `gap` apart along their length. Any
|
||||
/// angles, also unequal ones, at any convex corner of the base.
|
||||
fn close_corner(&mut self, k: &Corner) -> std::result::Result<(), String> {
|
||||
let (Some(fa), Some(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 };
|
||||
// At an inside corner of the base the flanges turn away from each other.
|
||||
if ea.dot(fb.n) > -1e-9 || eb.dot(fa.n) > -1e-9 {
|
||||
return Ok(());
|
||||
}
|
||||
if fa.leg <= 1e-9 || fb.leg <= 1e-9 {
|
||||
// No panel past a bend: the bends meet at the corner, there is nothing to close.
|
||||
return Ok(());
|
||||
}
|
||||
if (fa.angle >= 0.0) != (fb.angle >= 0.0) {
|
||||
return Err("the flanges bend opposite ways".into());
|
||||
}
|
||||
let gap = self.gap;
|
||||
if !(gap >= MIN_CORNER_GAP) {
|
||||
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;
|
||||
// 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
|
||||
// flat: returns u_end at the panel's bend edge and how much further the panel runs on per
|
||||
// unit of height.
|
||||
let end = |f: &SheetFlange, w: &Mat, e: Vec2, sign: f64| -> std::result::Result<(f64, f64), String> {
|
||||
let at = |u: f64, s: f64, z: f64| sign * bisector(apply_point(w, v3(k.c + e * u + f.n * s) + Vec3::Z * z));
|
||||
let c0 = at(0.0, 0.0, 0.0);
|
||||
let (cu, cs, cz) = (at(1.0, 0.0, 0.0) - c0, at(0.0, 1.0, 0.0) - c0, at(0.0, 0.0, 1.0) - c0);
|
||||
if !(cu > 1e-6) || ![c0, cs, cz].iter().all(|x| x.is_finite()) {
|
||||
return Err("the flanges don't meet across the corner".into());
|
||||
}
|
||||
let ba = self.allowance(f);
|
||||
// At 90° the end is square to the bend: keep the panel an exact rectangle.
|
||||
let slope = if (cs / cu).abs() < 1e-12 { 0.0 } else { cs / cu };
|
||||
Ok(((gap / 2.0 - c0 - (cz * self.t).min(0.0) - cs * ba) / cu, slope))
|
||||
};
|
||||
let mut new = [fa.clone(), fb.clone()];
|
||||
for (f, (e, at_end, sign)) in new.iter_mut().zip([(ea, k.a_end, 1.0), (eb, k.b_end, -1.0)]) {
|
||||
let w = if sign > 0.0 { &wa } else { &wb };
|
||||
let (u_end, slope) = end(f, w, e, sign)?;
|
||||
let tc = (k.c - f.p).dot(f.d);
|
||||
if at_end {
|
||||
f.ext.1 = tc - u_end - f.len;
|
||||
f.slope.1 = slope;
|
||||
} else {
|
||||
f.ext.0 = -(tc + u_end);
|
||||
f.slope.0 = slope;
|
||||
}
|
||||
let bottom = f.len + f.ext.0 + f.ext.1;
|
||||
let top = bottom + (f.slope.0 + f.slope.1) * f.leg;
|
||||
if !(bottom > 1e-6 && top > 1e-6) {
|
||||
return Err("the gap would cut a panel away".into());
|
||||
}
|
||||
}
|
||||
// The flat pattern must stay one sheet without overlaps.
|
||||
let [new_a, new_b] = new;
|
||||
let (qa, qb) = (corners_of(&self.panel(&new_a)), corners_of(&self.panel(&new_b)));
|
||||
if convex_overlap(&qa, &qb) > 1e-9
|
||||
|| convex_overlap(&qa, &corners_of(&self.zone(&fb))) > 1e-9
|
||||
|| convex_overlap(&qb, &corners_of(&self.zone(&fa))) > 1e-9
|
||||
{
|
||||
return Err("closed, the flanges would overlap in the flat pattern".into());
|
||||
}
|
||||
if let Some(f) = self.flanges.get_mut(k.a) {
|
||||
*f = new_a;
|
||||
}
|
||||
if let Some(f) = self.flanges.get_mut(k.b) {
|
||||
*f = new_b;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A flat hem: a 180° bend with a tiny radius at the panel edge, `length` to its outer face.
|
||||
@@ -1326,6 +1487,7 @@ impl SheetBody {
|
||||
radius: gap,
|
||||
leg,
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: None,
|
||||
tag: String::new(),
|
||||
});
|
||||
@@ -1414,7 +1576,20 @@ impl SheetBody {
|
||||
n = n * -1.0;
|
||||
}
|
||||
let l = a2.dot(n);
|
||||
let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None, tag: String::new() };
|
||||
let probe = SheetFlange {
|
||||
parent: panel,
|
||||
p: a2,
|
||||
d,
|
||||
len: 1.0,
|
||||
n,
|
||||
angle,
|
||||
radius,
|
||||
leg: 0.0,
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: None,
|
||||
tag: String::new(),
|
||||
};
|
||||
let ba = self.allowance(&probe);
|
||||
let s0 = match position {
|
||||
"centerline" | "center" | "centre" => l - ba / 2.0,
|
||||
@@ -1484,6 +1659,7 @@ impl SheetBody {
|
||||
radius,
|
||||
leg,
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: Some(ccw(moving)),
|
||||
tag: String::new(),
|
||||
});
|
||||
@@ -1625,6 +1801,7 @@ impl SheetBody {
|
||||
radius,
|
||||
leg,
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: Some(ccw(moving)),
|
||||
tag: String::new(),
|
||||
});
|
||||
@@ -1986,6 +2163,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
radius: r,
|
||||
leg: leg.max(0.0),
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: None,
|
||||
tag: String::new(),
|
||||
};
|
||||
@@ -2283,4 +2461,217 @@ mod tests {
|
||||
assert!((vol(&sheet.solid().unwrap()) - exact * 2.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
|
||||
/// 2 mm aluminium, R 1, K 0.5 (folding keeps the volume), flanges 0.5 mm apart at corners.
|
||||
fn alu() -> RuleValues {
|
||||
RuleValues {
|
||||
thickness: 2.0,
|
||||
k_factor: 0.5,
|
||||
bend_radius: 1.0,
|
||||
relief_width: 2.0,
|
||||
relief_depth: 1.0,
|
||||
corner_relief: 8.0,
|
||||
hem_gap: 0.01,
|
||||
gap: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// A 120 x 90 tray: flanges `height` high at `deg` on all four edges (bottom, right, top, left).
|
||||
fn tray(rv: &RuleValues, deg: f64, height: f64) -> (SheetBody, Vec<String>) {
|
||||
let r = 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", rv, Plane::XY, r);
|
||||
let t = rv.thickness;
|
||||
let picks = [Vec3::new(60.0, 0.0, t), Vec3::new(120.0, 45.0, t), Vec3::new(60.0, 90.0, t), Vec3::new(0.0, 45.0, t)];
|
||||
let (_, notes) = s.add_flanges_noted(&picks, height, deg.to_radians(), rv.bend_radius, "inside", false).unwrap();
|
||||
(s, notes)
|
||||
}
|
||||
|
||||
fn flat_area(s: &SheetBody) -> f64 {
|
||||
s.flat_regions().unwrap().iter().map(Region2::area).sum()
|
||||
}
|
||||
|
||||
/// Distance between two parallel 3D lines through a0→a1 and b0→b1 (None if not parallel).
|
||||
fn parallel_gap(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option<f64> {
|
||||
let (u, v) = ((a1 - a0).normalized()?, (b1 - b0).normalized()?);
|
||||
(u.cross(v).len() < 1e-9).then(|| (b0 - a0).cross(u).len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_corners_end_on_the_bisector_with_the_gap() {
|
||||
let (t, r, g, h) = (2.0, 1.0, 0.5, 38.6);
|
||||
for deg in [90.0f64, 72.0] {
|
||||
let (s, notes) = tray(&alu(), deg, h);
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
let th = deg.to_radians();
|
||||
let s0 = (r + t) * (th / 2.0).tan();
|
||||
let ba = th * (r + 0.5 * t);
|
||||
let leg = h - s0;
|
||||
// By hand, for the flange on y = 0 and its mirror image on x = 0: a point of the flange w up
|
||||
// its outer face from the virtual sharp (the original edge), h along the outward normal
|
||||
// (−T ≤ h ≤ 0) is (x, −w·cosθ − h·sinθ, w·sinθ − h·cosθ). Keeping x − y ≥ g/√2 on the
|
||||
// inner face (h = −T) gives the end x(w) = g/√2 + T·sinθ − w·cosθ; the panel spans w = s0 … h.
|
||||
let x_end = |w: f64| g / std::f64::consts::SQRT_2 + t * th.sin() - w * th.cos();
|
||||
let (y0, y1) = (s0 - ba, s0 - ba - leg);
|
||||
let want = [(x_end(s0), y0), (120.0 - x_end(s0), y0), (120.0 - x_end(h), y1), (x_end(h), y1)];
|
||||
let got = corners_of(&s.panel(&s.flanges[0]));
|
||||
assert_eq!(got.len(), 4);
|
||||
for (x, y) in want {
|
||||
assert!(got.iter().any(|p| p.dist(Vec2::new(x, y)) < 1e-9), "{deg}°: ({x}, {y}) not in {got:?}");
|
||||
}
|
||||
if deg == 90.0 {
|
||||
// The rule 90° corners always had: the panel runs on s0 − (T + g/√2) past its bend.
|
||||
assert!((s.flanges[0].ext.0 - (s0 - t - g / std::f64::consts::SQRT_2)).abs() < 1e-12);
|
||||
assert_eq!(s.flanges[0].slope, (0.0, 0.0));
|
||||
} else {
|
||||
assert!((s.flanges[0].slope.0 - th.cos()).abs() < 1e-12, "{:?}", s.flanges[0].slope);
|
||||
}
|
||||
// Folded, the inner edges of the two ends (bottom flange 0, left flange 3) run parallel,
|
||||
// `gap` apart.
|
||||
let (fa, fb) = (s.fold(0), s.fold(3));
|
||||
let pa = |x: f64, y: f64| apply_point(&fa, Vec3::new(x, y, t));
|
||||
let pb = |x: f64, y: f64| apply_point(&fb, Vec3::new(x, y, t));
|
||||
let gap = parallel_gap(pa(x_end(s0), y0), pa(x_end(h), y1), pb(y0, x_end(s0)), pb(y1, x_end(h)));
|
||||
assert!(gap.is_some_and(|d| (d - g).abs() < 1e-9), "{deg}°: {gap:?}");
|
||||
// The flat pattern is one piece and the folded part holds the same material (K = 0.5).
|
||||
let regions = s.flat_regions().unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
let b = s.solid().unwrap();
|
||||
assert!(b.validity().is_empty() && b.shell_count() == 1, "{deg}°: {:?}", b.validity());
|
||||
let v = vol(&b);
|
||||
assert!((v - flat_area(&s) * t).abs() < 1e-3 * v, "{deg}°: {v} vs {}", flat_area(&s) * t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flange_of_a_later_feature_leaves_the_corner_as_it_was() {
|
||||
// The convention of reference case 75-sm-bracket-holes: the earlier flange keeps its length
|
||||
// and the later one ends at its bend. Only flanges made together close toward each other.
|
||||
let rv = alu();
|
||||
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", &rv, 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 (made, 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() && s.corners(&made).is_empty(), "{notes:?}");
|
||||
assert!(s.flanges.iter().all(|f| f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0)));
|
||||
assert!((s.flanges[0].len - 120.0).abs() < 1e-9);
|
||||
assert!(s.solid().is_ok() && s.flat_regions().unwrap().len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_that_are_not_square_close_with_the_gap() {
|
||||
// A trapezoid: two acute and two obtuse corners.
|
||||
let pts = [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 base = Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] };
|
||||
let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, base);
|
||||
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
|
||||
let (made, notes) = s.add_flanges_noted(&picks, 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
let corners = s.corners(&made);
|
||||
assert_eq!(corners.len(), 4);
|
||||
// Each end's inner edge, folded.
|
||||
let edge = |i: usize, at_end: bool| {
|
||||
let f = &s.flanges[i];
|
||||
let ba = s.allowance(f);
|
||||
let (q0, q1) = if at_end {
|
||||
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
|
||||
(b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg))
|
||||
} else {
|
||||
let a = f.p + f.n * ba - f.d * f.ext.0;
|
||||
(a, a + f.n * f.leg - f.d * (f.slope.0 * f.leg))
|
||||
};
|
||||
let w = s.fold(i);
|
||||
(apply_point(&w, Vec3::new(q0.x, q0.y, 2.0)), apply_point(&w, Vec3::new(q1.x, q1.y, 2.0)))
|
||||
};
|
||||
for k in &corners {
|
||||
let ((a0, a1), (b0, b1)) = (edge(k.a, k.a_end), edge(k.b, k.b_end));
|
||||
let gap = parallel_gap(a0, a1, b0, b1);
|
||||
assert!(gap.is_some_and(|d| (d - 0.5).abs() < 1e-9), "{k:?}: {gap:?}");
|
||||
}
|
||||
let b = s.solid().unwrap();
|
||||
assert!(b.validity().is_empty() && b.shell_count() == 1, "{:?}", b.validity());
|
||||
assert!((vol(&b) - flat_area(&s) * 2.0).abs() < 1e-3 * vol(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_that_cannot_close_stay_open_with_a_note() {
|
||||
// A gap of zero would make the ends touch.
|
||||
let mut rv = alu();
|
||||
rv.gap = 0.0;
|
||||
let (s, notes) = tray(&rv, 72.0, 38.6);
|
||||
assert_eq!(notes.len(), 4, "{notes:?}");
|
||||
assert!(notes.iter().all(|n| n.contains("left open") && n.contains("gap")), "{notes:?}");
|
||||
assert!(s.flanges.iter().all(|f| f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0)));
|
||||
assert!(s.solid().is_ok());
|
||||
// Flanges bending opposite ways (the second edge picked on the bottom face).
|
||||
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", &alu(), Plane::XY, base);
|
||||
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(120.0, 45.0, 0.0)];
|
||||
let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
|
||||
assert!(notes.len() == 1 && notes[0].contains("opposite"), "{notes:?}");
|
||||
assert!(s.solid().is_ok());
|
||||
}
|
||||
|
||||
/// Folded, the inner edge of the end of flange `i`'s panel (`at_end`: the end past the far end
|
||||
/// of its bend).
|
||||
fn inner_end_edge(s: &SheetBody, i: usize, at_end: bool) -> (Vec3, Vec3) {
|
||||
let f = &s.flanges[i];
|
||||
let ba = s.allowance(f);
|
||||
let (q0, q1) = if at_end {
|
||||
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
|
||||
(b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg))
|
||||
} else {
|
||||
let a = f.p + f.n * ba - f.d * f.ext.0;
|
||||
(a, a + f.n * f.leg - f.d * (f.slope.0 * f.leg))
|
||||
};
|
||||
let (w, z) = (s.fold(i), if f.angle >= 0.0 { s.t } else { 0.0 });
|
||||
(apply_point(&w, Vec3::new(q0.x, q0.y, z)), apply_point(&w, Vec3::new(q1.x, q1.y, z)))
|
||||
}
|
||||
|
||||
/// At each of `corners` the two panel ends' inner edges run parallel, `gap` apart.
|
||||
fn ends_are_gap_apart(s: &SheetBody, corners: &[Corner], gap: f64, what: &str) {
|
||||
for k in corners {
|
||||
let ((a0, a1), (b0, b1)) = (inner_end_edge(s, k.a, k.a_end), inner_end_edge(s, k.b, k.b_end));
|
||||
let got = parallel_gap(a0, a1, b0, b1);
|
||||
assert!(got.is_some_and(|d| (d - gap).abs() < 1e-9), "{what}: {k:?}: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The flat pattern is one piece without hairline edges, and the folded part is one valid shell
|
||||
/// holding the same material (K = 0.5 keeps the volume).
|
||||
fn holds_the_flat_material(s: &SheetBody, what: &str) {
|
||||
let regions = s.flat_regions().unwrap_or_else(|e| panic!("{what}: no flat pattern: {e}"));
|
||||
assert_eq!(regions.len(), 1, "{what}");
|
||||
let shortest = regions[0].outer.segs.iter().map(Seg2::length).fold(f64::MAX, f64::min);
|
||||
assert!(regions[0].holes.is_empty() && shortest > 1e-3, "{what}: shortest outline edge {shortest:e}");
|
||||
assert_eq!(s.outline().unwrap_or_else(|e| panic!("{what}: no outline: {e}")).len(), 1, "{what}");
|
||||
let b = s.solid().unwrap_or_else(|e| panic!("{what}: no solid: {e}"));
|
||||
assert!(b.validity().is_empty() && b.shell_count() == 1, "{what}: {:?}", b.validity());
|
||||
let v = vol(&b);
|
||||
assert!((v - flat_area(s) * s.t).abs() < 1e-3 * v, "{what}: {v} vs {}", flat_area(s) * s.t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_corners_keep_their_flat_pattern_at_every_angle() {
|
||||
// 45° to 135° in steps of 5° on the tray and on a trapezoid. Which angles lose the flat
|
||||
// pattern to a rounding slip in the planar arrangement is a matter of luck (50°, 60°, 65°,
|
||||
// 75°, 105° and 110° did on the tray), so every one is built.
|
||||
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 tray_picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(120.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(0.0, 45.0, 2.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 trapezoid_picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
|
||||
for (name, pts, picks) in [("tray", tray, tray_picks), ("trapezoid", trapezoid, trapezoid_picks)] {
|
||||
for k in 9..=27 {
|
||||
let deg = k as f64 * 5.0;
|
||||
let what = format!("{name} at {deg}°");
|
||||
let base = Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] };
|
||||
let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, base);
|
||||
let (made, notes) = s.add_flanges_noted(&picks, 38.6, deg.to_radians(), 1.0, "inside", false).unwrap();
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
let corners = s.corners(&made);
|
||||
assert_eq!(corners.len(), 4, "{what}");
|
||||
ends_are_gap_apart(&s, &corners, 0.5, &what);
|
||||
holds_the_flat_material(&s, &what);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("convert_sheet")
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
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); active?: bool; delete?: bool",
|
||||
"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); 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"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
|
||||
@@ -1753,3 +1753,71 @@ fn fold_fixed_point_above_the_sheet_is_not_silently_accepted() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 120 x 90 tray in 2 mm aluminium (R 1, K 0.5): four 38.6 mm flanges at `deg` in one feature.
|
||||
fn alu_tray(s: &mut Session, deg: f64, rule: Value) {
|
||||
let mut r = json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true});
|
||||
if let (Some(r), Some(extra)) = (r.as_object_mut(), rule.as_object()) {
|
||||
r.extend(extra.clone());
|
||||
}
|
||||
run(s, "sheet.manage_rules", r);
|
||||
base(s, 120.0, 90.0);
|
||||
run(s, "sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 38.6, "angle": format!("{deg} deg")}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_corners_reach_the_flat_pattern_and_the_dxf() {
|
||||
use std::f64::consts::SQRT_2;
|
||||
// 60° and 105° are angles whose flat pattern was lost to a rounding slip in the planar arrangement.
|
||||
for deg in [60.0f64, 72.0, 90.0, 105.0] {
|
||||
let mut s = Session::default();
|
||||
alu_tray(&mut s, deg, json!({}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{deg}: {:?}", s.model.results);
|
||||
// The end of the flange on y = 0 at the corner at the origin, by hand (see the doc tests):
|
||||
// x(w) = g/√2 + T·sinθ − w·cosθ at flat y = s0 − BA − (w − s0), for w = s0 and w = 38.6.
|
||||
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 f = flat(&mut s);
|
||||
let outline: Vec<(f64, f64)> = f["outline"][0]
|
||||
.as_array()
|
||||
.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();
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{deg}: {f}");
|
||||
for (x, y) in [(x_end(s0), s0 - ba), (x_end(38.6), s0 - ba - (38.6 - s0))] {
|
||||
assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "{deg}: ({x}, {y}) not in {outline:?}");
|
||||
}
|
||||
// The folded part holds the flat pattern's material (K = 0.5 keeps the volume).
|
||||
let n = outline.len();
|
||||
let area = (0..n).map(|i| outline[i].0 * outline[(i + 1) % n].1 - outline[(i + 1) % n].0 * outline[i].1).sum::<f64>().abs() / 2.0;
|
||||
let v = volume(&mut s);
|
||||
assert!((v - area * 2.0).abs() < 1e-3 * v, "{deg}: {v} vs {}", area * 2.0);
|
||||
// The DXF: the closed outline in straight lines, 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(), n + 4, "{deg}: {n} outline points");
|
||||
// Unfold and refold keep it.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{deg}");
|
||||
// A cut made folded still goes through with the corners closed: a Ø20 hole in the base.
|
||||
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}));
|
||||
let want = PI * 100.0 * 2.0;
|
||||
assert!((v - volume(&mut s) - want).abs() < 1e-3 * want, "{deg}: {v} - {} vs {want}", volume(&mut s));
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1), "{deg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_left_open_are_reported_on_the_flange() {
|
||||
let mut s = Session::default();
|
||||
alu_tray(&mut s, 72.0, json!({"gap": "0 mm"}));
|
||||
let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert_eq!(w.matches("left open").count(), 4, "{w}");
|
||||
// Open corners still fold, unfold and take cuts.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!(volume(&mut s) > 0.0);
|
||||
}
|
||||
@@ -14,6 +14,16 @@ The SHEET METAL tab builds parts from a sheet of constant thickness under a **sh
|
||||
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.
|
||||
|
||||
Where the edge flanges of one feature meet at a corner of the base, their panels are closed
|
||||
toward each other at any bend angle: each end is cut square to the sheet, on a line in the flat
|
||||
chosen so that the edges nearest each other (on the inner faces) run parallel, the rule's
|
||||
**gap** apart, either side of the plane halfway between the flanges. Flanges at 72° get slanted
|
||||
ends in the flat; corners that aren't square close the same way, and at 90° it is the corner
|
||||
the rule always gave. A corner that can't be closed (a gap under 0.01 mm, flanges bending
|
||||
opposite ways, ends that would overlap in the flat pattern) stays open, and the flange feature
|
||||
reports a warning saying where. A flange added by a later feature leaves the corner as it was:
|
||||
the earlier flange keeps its length and the new one ends at its bend.
|
||||
|
||||
For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose
|
||||
`format: "json"` for structured data. It includes the sheet rule, thickness, bend angles,
|
||||
radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use
|
||||
|
||||
Reference in new issue
Block a user