feat: Convert to Sheet Metal takes a solid or a shelled body by its bend edges (#725)
Convert to Sheet Metal now takes a solid or a shelled body by its bend edges (`bends`): the faces reached from the fixed face across those edges become the sheet, keeping the part's envelope, with the flat pattern by the rule's K-factor and radius. Other edges between sheet faces are cut open. The result works with edge flanges, cuts, Unfold/Refold, the flat pattern and DXF. A conversion without `bends` is the plate conversion as before. The optional `bends` field is noted under format 17. Fixes #647. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a513304917
commit
a1cb8d4d36
16 files changed
+1044
-20
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); 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 | 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) 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 |
|
||||
|
||||
@@ -265,7 +265,10 @@ impl FeatureKind {
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
|
||||
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, .. } => pt(face),
|
||||
FeatureKind::SheetConvert { face, bends, .. } => {
|
||||
pt(face);
|
||||
bends.iter_mut().for_each(pt);
|
||||
}
|
||||
FeatureKind::SheetFold { a, b, fixed, .. } => {
|
||||
pt(a);
|
||||
pt(b);
|
||||
|
||||
@@ -782,12 +782,17 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
refold: bool,
|
||||
},
|
||||
/// Convert a plate body (constant thickness from the face at `face`) to sheet metal.
|
||||
/// Convert a body to sheet metal from its fixed face at `face`: a plate of constant
|
||||
/// thickness or, with `bends`, a solid or shelled body whose faces reached from the fixed
|
||||
/// face across those edges become the sheet.
|
||||
SheetConvert {
|
||||
body: String,
|
||||
face: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
rule: Option<String>,
|
||||
/// Points on the sharp edges that become bends.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
bends: Vec<Vec3>,
|
||||
},
|
||||
/// Plastic boss: a round post on a face (draft, root fillet, hole, ribs).
|
||||
Boss {
|
||||
@@ -1460,6 +1465,8 @@ impl FeatureKind {
|
||||
FeatureKind::Thread { face, .. } => vec![face],
|
||||
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
|
||||
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||
// With bend edges the fixed face is found by name (a plate keeps its point).
|
||||
FeatureKind::SheetConvert { face, bends, .. } if !bends.is_empty() => vec![face],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,17 @@ fn replace_sheet_body(mb: &mut ModelBody, body: Body) {
|
||||
*mb = next;
|
||||
}
|
||||
|
||||
/// Put a converted sheet in the place of the body it was made from.
|
||||
fn put_sheet(st: &mut ModelState, sh: sheet::SheetBody) -> Result<()> {
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| b.name == sh.body) {
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| s.body != sh.body);
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let solid = sheet.solid()?;
|
||||
@@ -546,7 +557,34 @@ pub(super) fn sheet_eval(
|
||||
}
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule } => {
|
||||
// With bend edges: a solid or shelled body whose faces become the sheet.
|
||||
FeatureKind::SheetConvert { body, face, rule, bends } if !bends.is_empty() => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
// The bend edges by name first; a name that is gone keeps its point, with a warning.
|
||||
let mut picks = Vec::with_capacity(bends.len());
|
||||
for (i, p) in bends.iter().enumerate() {
|
||||
let name = f.edge_names.get(i).filter(|n| !n.is_empty());
|
||||
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, *p))) {
|
||||
Some((n, Ok(hit))) => {
|
||||
if let Some(note) = hit.note(n) {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
hit.point
|
||||
}
|
||||
Some((n, Err(_))) => {
|
||||
append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point"));
|
||||
*p
|
||||
}
|
||||
None => *p,
|
||||
});
|
||||
}
|
||||
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
|
||||
tag_flanges(&mut sh, 0, f.id);
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule, .. } => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
@@ -565,20 +603,16 @@ pub(super) fn sheet_eval(
|
||||
let area = region.area().abs();
|
||||
let vol = kernel::measure(&mb.body)?.volume;
|
||||
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
|
||||
return Err(DocError::Invalid("not supported yet: converting bodies that are not a plate of even thickness".into()));
|
||||
return Err(DocError::Invalid(
|
||||
"not supported yet: converting bodies that are not a plate of even thickness without bend edges (`bends`)".into(),
|
||||
));
|
||||
}
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let mut rv = doc.rule_values(vals, &r)?;
|
||||
rv.thickness = t;
|
||||
let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() });
|
||||
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| &s.body != body);
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
//! bend (`rim.bend0`, `rim.bend1`). Version 16 designs need no transformation; the version
|
||||
//! stamp makes 0.8.0 (which reads up to 16) ask for an update instead of meeting names it
|
||||
//! can't resolve.
|
||||
//! Convert to Sheet Metal can take bend edges (`bends` in the feature: points on the sharp
|
||||
//! edges of a solid or shelled body that become bends). A conversion without `bends` is the
|
||||
//! plate conversion it always was and saves as before; 0.8.0 would drop the bend edges and
|
||||
//! refuse the body as no plate.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ use solidcraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec};
|
||||
use crate::expr::{self, Kind, Value};
|
||||
use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul};
|
||||
|
||||
#[path = "sheet_convert.rs"]
|
||||
mod convert;
|
||||
pub use convert::{bend_edge_names, convert_body};
|
||||
|
||||
// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
|
||||
// These are work limits, never geometry truncation: a larger input returns a useful error.
|
||||
// Exact lines and arcs count once each (the planar booleans work on them whole); free-form
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Convert to Sheet Metal with bend edges: the faces of a solid, or the walls of a shelled one,
|
||||
//! become one sheet.
|
||||
//!
|
||||
//! The planar faces reached from the fixed face across the bend edges are the sheet's panels,
|
||||
//! all on one skin of it (the one the fixed face is on). A body with a parallel face the same
|
||||
//! small distance behind the fixed face and behind the faces at every bend is a shell: it keeps
|
||||
//! its walls and their thickness. Any other body's faces become the outside of the sheet, with
|
||||
//! the rule's thickness taken into the body. A bend edge stays its bend's virtual sharp: the
|
||||
//! panels on either side are set back from it by (R + T)·tan(θ/2) where the skin is the bend's
|
||||
//! outside and R·tan(θ/2) where it is the inside, so the body keeps its envelope. An edge between
|
||||
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
|
||||
//! gap from the plane halfway between the two.
|
||||
//!
|
||||
//! The result is the model every sheet metal feature builds ([`SheetBody`]): the fixed face is
|
||||
//! the base panel and every other panel a flange on its parent's edge, its outline taken through
|
||||
//! the inverse of its fold. Unfold, the flat pattern, the bend table and cuts then work as on a
|
||||
//! sheet made from flanges.
|
||||
//!
|
||||
//! Not supported yet: curved faces (rolled sheet, a filleted edge as a bend), faces with curved
|
||||
//! edges or holes, loops of bends, two faces that meet along more than one edge.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Vec2, Vec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface, face_surface};
|
||||
|
||||
use super::{RuleValues, SheetBody, SheetFlange, base_flange, polys_cross, signed_area, snap};
|
||||
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
|
||||
|
||||
const MAX_BENDS: usize = 1000;
|
||||
|
||||
fn err<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(DocError::Invalid(msg.into()))
|
||||
}
|
||||
|
||||
fn at(p: Vec3) -> String {
|
||||
format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)
|
||||
}
|
||||
|
||||
/// A planar face of the body: its outward normal and its outline, counter-clockwise about the
|
||||
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`.
|
||||
struct Face {
|
||||
n: Vec3,
|
||||
pts: Vec<Vec3>,
|
||||
across: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
/// A face of the sheet: the panel it hangs off (none for the fixed face), and the bend between
|
||||
/// the two: its angle (positive toward the material, as a flange's) and its setback on this skin.
|
||||
struct Panel {
|
||||
face: Face,
|
||||
parent: Option<usize>,
|
||||
angle: f64,
|
||||
setback: f64,
|
||||
}
|
||||
|
||||
/// Area of a loop in space, positive when it runs counter-clockwise about `n`.
|
||||
fn area_about(pts: &[Vec3], n: Vec3) -> f64 {
|
||||
let Some(&o) = pts.first() else { return 0.0 };
|
||||
pts.windows(2).map(|w| (w[0] - o).cross(w[1] - o).dot(n)).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// Turn a loop's per-edge values round with its corners (edge `i` starts at corner `i`).
|
||||
fn turn<T>(per_edge: &mut [T]) {
|
||||
per_edge.reverse();
|
||||
if !per_edge.is_empty() {
|
||||
per_edge.rotate_left(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Face `f` of the body as a panel outline. Only a planar face bounded by one loop of straight
|
||||
/// edges can be one.
|
||||
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
|
||||
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, f) else {
|
||||
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
|
||||
};
|
||||
// Its edges, each with the face on its other side.
|
||||
let mut edges = Vec::new();
|
||||
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
|
||||
if !faces.iter().any(|g| *g as usize == f) {
|
||||
continue;
|
||||
}
|
||||
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
|
||||
if p.dist(q) <= tol || line.iter().any(|x| x.dist_to_segment(p, q) > tol) {
|
||||
return err("not supported yet: faces with curved edges in a converted sheet");
|
||||
}
|
||||
edges.push((p, q, faces.iter().map(|g| *g as usize).find(|g| *g != f)));
|
||||
}
|
||||
// Chained head to tail from the first one, they close one loop.
|
||||
let Some((start, mut end, across)) = edges.pop() else { return err("a face of the body has no edges") };
|
||||
let (mut pts, mut tags) = (vec![start], vec![across]);
|
||||
while end.dist(start) > tol {
|
||||
let Some(k) = edges.iter().position(|e| e.0.dist(end) <= tol || e.1.dist(end) <= tol) else {
|
||||
return err("the outline of a face of the body does not close");
|
||||
};
|
||||
let (p, q, across) = edges.swap_remove(k);
|
||||
pts.push(end);
|
||||
tags.push(across);
|
||||
end = if p.dist(end) <= tol { q } else { p };
|
||||
}
|
||||
if !edges.is_empty() {
|
||||
return err("not supported yet: faces with holes in a converted sheet (cut them after converting)");
|
||||
}
|
||||
// A straight run along one neighbour is one edge, however the body's edges divide it (a
|
||||
// shell's outer corners are divided at the height of its floor).
|
||||
let m = pts.len();
|
||||
let corner = |i: usize| -> bool {
|
||||
let before = (i + m - 1) % m;
|
||||
let (Some(&a), Some(&p), Some(&b)) = (pts.get(before), pts.get(i), pts.get((i + 1) % m)) else { return true };
|
||||
tags.get(before) != tags.get(i) || p.dist_to_segment(a, b) > tol
|
||||
};
|
||||
let keep: Vec<bool> = (0..m).map(corner).collect();
|
||||
let (mut pts, mut tags): (Vec<Vec3>, Vec<Option<usize>>) = pts.into_iter().zip(tags).zip(keep).filter(|(_, kept)| *kept).map(|(x, _)| x).unzip();
|
||||
if area_about(&pts, n) < 0.0 {
|
||||
pts.reverse();
|
||||
turn(&mut tags);
|
||||
}
|
||||
Ok(Face { n, pts, across: tags })
|
||||
}
|
||||
|
||||
/// The face straight through the material behind planar face `f`, when it is parallel to it,
|
||||
/// and how far behind it lies.
|
||||
fn behind(b: &Body, mesh: &Mesh, f: usize) -> Option<(usize, f64)> {
|
||||
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, f) else { return None };
|
||||
// From the middle of the face's largest triangle.
|
||||
let area = |t: &[Vec3; 3]| (t[1] - t[0]).cross(t[2] - t[0]).len();
|
||||
let tri = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t));
|
||||
let [p, q, r] = tri.max_by(|x, y| area(x).total_cmp(&area(y)))?;
|
||||
let (_, hit) = mesh.raycast((p + q + r) * (1.0 / 3.0), n * -1.0)?;
|
||||
let g = *mesh.tri_face.get(hit)? as usize;
|
||||
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, g) else { return None };
|
||||
(normal.dot(n) < -1.0 + 1e-9).then_some((g, (o - point).dot(n)))
|
||||
}
|
||||
|
||||
/// How far a panel's edge along a rip moves in, so that the panel, cut square to the sheet,
|
||||
/// stays half the gap from the plane halfway between it and the panel `y` across the rip.
|
||||
/// `e` is the edge's direction round panel `x`.
|
||||
fn rip_cut(x: &Face, y: &Face, e: Vec3, t: f64, gap: f64) -> Result<f64> {
|
||||
// Into each panel from the edge, square to the edge.
|
||||
let (wx, wy) = (x.n.cross(e), e.cross(y.n));
|
||||
let mid = (wx - wy).normalized().filter(|m| wx.dot(*m) > 1e-9);
|
||||
let Some(mid) = mid else { return err("two faces of the sheet lie on each other along the edge between them") };
|
||||
// The sheet's other face lies `t` behind the panel: nearer that plane, or further from it.
|
||||
let lean = -x.n.dot(mid) * t;
|
||||
Ok((gap / 2.0 - lean.min(0.0)) / wx.dot(mid))
|
||||
}
|
||||
|
||||
/// A counter-clockwise outline with each edge moved in by its own amount. Moved edges meet
|
||||
/// where their lines cross; two edges in one line that move by different amounts are joined by
|
||||
/// a square step. Returns the corners and, per edge, the edge it was (none for a step).
|
||||
fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return err("a face of the sheet has no outline");
|
||||
}
|
||||
let moved = |i: usize| -> Option<(Vec2, Vec2)> {
|
||||
let (a, b) = (*pts.get(i % n)?, *pts.get((i + 1) % n)?);
|
||||
let d = (b - a).normalized()?;
|
||||
Some((a + Vec2::new(-d.y, d.x) * *by.get(i % n)?, d))
|
||||
};
|
||||
let (mut out, mut from) = (Vec::new(), Vec::new());
|
||||
for i in 0..n {
|
||||
let (Some((p0, d0)), Some((p1, d1))) = (moved(i + n - 1), moved(i)) else { return err("a face of the sheet has an edge without length") };
|
||||
let cross = d0.cross(d1);
|
||||
if cross.abs() > 1e-9 {
|
||||
out.push(p0 + d0 * ((p1 - p0).cross(d1) / cross));
|
||||
} else if d0.dot(d1) > 0.0 {
|
||||
let end = p0 + d0 * (p1 - p0).dot(d0);
|
||||
if end.dist(p1) > 1e-9 {
|
||||
out.push(end);
|
||||
from.push(None);
|
||||
}
|
||||
out.push(p1);
|
||||
} else {
|
||||
return err("the outline of a face of the sheet doubles back on itself");
|
||||
}
|
||||
from.push(Some(i));
|
||||
}
|
||||
// Every edge still runs the way it did, and the outline is still one simple loop.
|
||||
let m = out.len();
|
||||
let kept = (0..m).all(|j| match (from.get(j).copied().flatten().and_then(moved), out.get(j), out.get((j + 1) % m)) {
|
||||
(Some((_, d)), Some(&a), Some(&b)) => (b - a).dot(d) > 1e-9,
|
||||
_ => true,
|
||||
});
|
||||
if !kept || !(signed_area(&out) > 0.0) || polys_cross(&out, &out) {
|
||||
return err("the bends' setbacks and the gaps at the cut edges use up an edge of a face");
|
||||
}
|
||||
Ok((out, from))
|
||||
}
|
||||
|
||||
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
|
||||
/// bend first trimmed to the end of the bend zone.
|
||||
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
|
||||
let ba = sh.flanges.get(i).map_or(0.0, |f| sh.allowance(f));
|
||||
let Some(f) = sh.flanges.get_mut(i) else { return };
|
||||
// Its corners along the bend (u) and past the bend zone (v); those at the zone exactly on it.
|
||||
let uv: Vec<(f64, f64)> =
|
||||
outline.iter().map(|q| ((*q - f.p).dot(f.d), (*q - f.p).dot(f.n) - ba)).map(|(u, v)| (u, if v.abs() < 1e-6 { 0.0 } else { v })).collect();
|
||||
f.leg = uv.iter().fold(0.0, |m, (_, v)| m.max(*v));
|
||||
let level = |h: f64| -> Vec<f64> { uv.iter().filter(|(_, v)| (v - h).abs() < 1e-6).map(|(u, _)| *u).collect() };
|
||||
match (level(0.0).as_slice(), level(f.leg).as_slice()) {
|
||||
// Two corners at the bend and two on a far edge parallel to it: a flange's own panel,
|
||||
// with its run-ons past the bend's ends and its slanted ends.
|
||||
(&[a, b], &[c, d]) if uv.len() == 4 && f.leg > 1e-6 => {
|
||||
let zero = |x: f64| if x.abs() < 1e-12 { 0.0 } else { x };
|
||||
f.ext = (zero(-a.min(b)), zero(a.max(b) - f.len));
|
||||
f.slope = (zero((a.min(b) - c.min(d)) / f.leg), zero((c.max(d) - a.max(b)) / f.leg));
|
||||
}
|
||||
_ => f.shape = Some(uv.iter().map(|(u, v)| f.p + f.d * *u + f.n * (ba + *v)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names of the edges of `body` at the bend points, to find them again after the body changes.
|
||||
/// A point on no edge gets none: the conversion then says so, instead of bending the edge that
|
||||
/// happens to be nearest.
|
||||
pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<String> {
|
||||
let Some(b) = st.body(body) else { return Vec::new() };
|
||||
let (mesh, near) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3));
|
||||
let on = |p: &Vec3| mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near));
|
||||
pts.iter().zip(crate::naming::edge_names_at(b, pts)).map(|(p, name)| if on(p) { name } else { String::new() }).collect()
|
||||
}
|
||||
|
||||
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
|
||||
/// fixed face and `bends` are points on the sharp edges that become bends (the body's
|
||||
/// coordinates, mm). See the module docs.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
|
||||
if bends.is_empty() || bends.len() > MAX_BENDS {
|
||||
return err(format!("pick 1…{MAX_BENDS} bend edges"));
|
||||
}
|
||||
if !(rv.gap >= 0.0 && rv.bend_radius >= 0.0) {
|
||||
return err("the rule's gap and bend radius must not be negative");
|
||||
}
|
||||
let (b, mesh) = (&mb.body, mb.mesh());
|
||||
let size = b.size();
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return err("the body has no size");
|
||||
}
|
||||
// Points closer than `tol` are one corner; a pick lies within `near` of what it picks.
|
||||
let (tol, near) = ((size * 1e-6).max(1e-6), (size * 2e-3).max(1e-3));
|
||||
let Some((_, f0)) = crate::appearance::nearest_face(mb, face).filter(|(d, _)| *d <= near) else {
|
||||
return err(format!("no face of the body at the fixed face point {}", at(face)));
|
||||
};
|
||||
let fixed = outline(b, &mesh, f0, tol).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
|
||||
// The two faces along each bend edge.
|
||||
let mut picks = Vec::with_capacity(bends.len());
|
||||
for p in bends {
|
||||
let dist = |e: &Vec<Vec3>| e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let edge = mesh.edges.iter().zip(&mesh.edge_faces).map(|(e, faces)| (dist(e), faces)).min_by(|x, y| x.0.total_cmp(&y.0));
|
||||
match edge.filter(|(d, _)| *d <= near).map(|(_, faces)| faces.as_slice()) {
|
||||
Some(&[x, y]) if x != y => picks.push((*p, (x as usize, y as usize))),
|
||||
Some(_) => return err(format!("the edge at the bend point {} does not lie between two faces", at(*p))),
|
||||
None => return err(format!("no edge of the body at the bend point {}", at(*p))),
|
||||
}
|
||||
}
|
||||
// A shell: the same small distance of material behind the fixed face and the faces at every
|
||||
// bend. It keeps its thickness, and a bend picked on one skin is that bend on the other too.
|
||||
let thin = behind(b, &mesh, f0).map(|(_, d)| d).filter(|d| *d > 0.0 && d * d * 16.0 <= area_about(&fixed.pts, fixed.n));
|
||||
let wall = |f: usize, d: f64| behind(b, &mesh, f).filter(|(_, e)| (e - d).abs() <= 1e-6 * d.max(1.0)).map(|(g, _)| g);
|
||||
let backs: Vec<Option<(usize, usize)>> = picks.iter().map(|(_, (x, y))| thin.and_then(|d| Some((wall(*x, d)?, wall(*y, d)?)))).collect();
|
||||
let shell = thin.filter(|_| backs.iter().all(Option::is_some));
|
||||
let t = shell.map_or(rv.thickness, |d| (d * 1e9).round() / 1e9);
|
||||
let r = rv.bend_radius;
|
||||
let joined = |x: usize, y: usize| mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)));
|
||||
let mut todo: Vec<(Vec3, Vec<(usize, usize)>)> = Vec::with_capacity(picks.len());
|
||||
for (&(p, pair), back) in picks.iter().zip(&backs) {
|
||||
let other = back.filter(|(x, y)| shell.is_some() && joined(*x, *y));
|
||||
todo.push((p, std::iter::once(pair).chain(other).collect()));
|
||||
}
|
||||
// The panels: the faces reached from the fixed face across the bends, each hanging off the
|
||||
// face it was reached from.
|
||||
let mut panels = BTreeMap::from([(f0, Panel { face: fixed, parent: None, angle: 0.0, setback: 0.0 })]);
|
||||
let mut order = vec![f0];
|
||||
loop {
|
||||
let waiting = todo.len();
|
||||
let mut rest = Vec::new();
|
||||
for (p, pairs) in todo {
|
||||
let reached = pairs.iter().find(|(x, y)| panels.contains_key(x) || panels.contains_key(y));
|
||||
let Some(&(x, y)) = reached else {
|
||||
rest.push((p, pairs));
|
||||
continue;
|
||||
};
|
||||
let (from, to) = if panels.contains_key(&x) { (x, y) } else { (y, x) };
|
||||
if let Some(seen) = panels.get(&to) {
|
||||
// The same bend picked twice is one bend; any other edge between two panels
|
||||
// would close a ring of bends, which no flat sheet folds into.
|
||||
if seen.parent == Some(from) || panels.get(&from).is_some_and(|k| k.parent == Some(to)) {
|
||||
continue;
|
||||
}
|
||||
return err(format!("the bend at {} closes a loop of bends (leave one of its edges unpicked: it is cut open)", at(p)));
|
||||
}
|
||||
let child = outline(b, &mesh, to, tol)
|
||||
.map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?;
|
||||
let Some(parent) = panels.get(&from) else { continue };
|
||||
let theta = parent.face.n.dot(child.n).clamp(-1.0, 1.0).acos();
|
||||
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) {
|
||||
return err(format!("the faces at the bend at {} must meet at an angle between 0 and 180°", at(p)));
|
||||
}
|
||||
// Up: the child rises on the material side of its parent, which is behind its face.
|
||||
let o = parent.face.pts.first().copied().unwrap_or_default();
|
||||
let lift = child.pts.iter().map(|q| (*q - o).dot(parent.face.n)).fold(0.0, |m: f64, h| if h.abs() > m.abs() { h } else { m });
|
||||
let (angle, sharp) = if lift < 0.0 { (theta, r + t) } else { (-theta, r) };
|
||||
panels.insert(to, Panel { face: child, parent: Some(from), angle, setback: sharp * (theta / 2.0).tan() });
|
||||
order.push(to);
|
||||
}
|
||||
todo = rest;
|
||||
if todo.is_empty() || todo.len() == waiting {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((p, _)) = todo.first() {
|
||||
return err(format!("the bend at {} does not connect to the fixed face through the other bends", at(*p)));
|
||||
}
|
||||
// The flat frame: on the fixed face, x along its longest edge, z into the material.
|
||||
let Some(base) = panels.get(&f0) else { return err("fixed face") };
|
||||
let corners = base.face.pts.iter().zip(base.face.pts.iter().cycle().skip(1));
|
||||
let longest = corners.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1)));
|
||||
let frame = longest
|
||||
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
|
||||
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
|
||||
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
|
||||
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
|
||||
// Lay the panels out, parents first: each is its face, seen through the inverse of its
|
||||
// fold, with its edges moved in by a bend's setback or a rip's cut.
|
||||
let mut queue: Vec<(usize, Option<usize>)> = vec![(f0, None)];
|
||||
let mut next = 0;
|
||||
while let Some(&(pf, fl)) = queue.get(next) {
|
||||
next += 1;
|
||||
let Some(panel) = panels.get(&pf) else { continue };
|
||||
let inv = mat_inverse(&sh.world(fl)).ok_or_else(|| DocError::Invalid("sheet panel frame".into()))?;
|
||||
let n = panel.face.pts.len();
|
||||
let (mut pts, mut by) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
||||
for (i, p) in panel.face.pts.iter().enumerate() {
|
||||
let q = apply_point(&inv, *p);
|
||||
if !(q.z.abs() <= tol) {
|
||||
return err(format!("the face at {} does not lie where its bend puts it ({:.6} mm off)", at(*p), q.z));
|
||||
}
|
||||
pts.push(Vec2::new(q.x, q.y));
|
||||
let e = panel.face.pts.get((i + 1) % n).and_then(|b| (*b - *p).normalized()).unwrap_or(Vec3::X);
|
||||
by.push(match panel.face.across.get(i).copied().flatten().and_then(|g| Some((g, panels.get(&g)?))) {
|
||||
Some((g, _)) if panel.parent == Some(g) => panel.setback,
|
||||
Some((_, other)) if other.parent == Some(pf) => other.setback,
|
||||
Some((_, other)) => rip_cut(&panel.face, &other.face, e, t, rv.gap)?,
|
||||
None => 0.0,
|
||||
});
|
||||
}
|
||||
let mut across = panel.face.across.clone();
|
||||
if signed_area(&pts) < 0.0 {
|
||||
pts.reverse();
|
||||
turn(&mut by);
|
||||
turn(&mut across);
|
||||
}
|
||||
let first = panel.face.pts.first().copied().unwrap_or_default();
|
||||
let (trimmed, from) = inset(&pts, &by).map_err(|e| DocError::Invalid(format!("{e} (the face at {})", at(first))))?;
|
||||
match fl {
|
||||
None => sh.base.outer = Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>()),
|
||||
Some(i) => set_panel(&mut sh, i, &trimmed),
|
||||
}
|
||||
// Its children: flanges on the trimmed edges they bend at.
|
||||
let m = trimmed.len();
|
||||
for &g in &order {
|
||||
let Some(child) = panels.get(&g).filter(|c| c.parent == Some(pf)) else { continue };
|
||||
let runs: Vec<usize> =
|
||||
(0..m).filter(|j| from.get(*j).copied().flatten().and_then(|i| across.get(i).copied().flatten()) == Some(g)).collect();
|
||||
let (a, b) = match runs.as_slice() {
|
||||
&[j] => (trimmed.get(j).copied().map(snap), trimmed.get((j + 1) % m).copied().map(snap)),
|
||||
_ => return err("not supported yet: two faces of the sheet that meet along more than one edge"),
|
||||
};
|
||||
let (Some(a), Some(b)) = (a, b) else { continue };
|
||||
let Some(d) = (b - a).normalized() else { return err("a bend edge has no length") };
|
||||
sh.flanges.push(SheetFlange {
|
||||
parent: fl,
|
||||
p: a,
|
||||
d,
|
||||
len: a.dist(b),
|
||||
n: Vec2::new(d.y, -d.x),
|
||||
angle: child.angle,
|
||||
radius: r,
|
||||
leg: 0.0,
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: None,
|
||||
tag: String::new(),
|
||||
});
|
||||
queue.push((g, Some(sh.flanges.len() - 1)));
|
||||
}
|
||||
}
|
||||
Ok(sh)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edges_move_in_by_their_own_amounts_and_step_along_one_line() {
|
||||
let v = Vec2::new;
|
||||
let square = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
|
||||
let (p, from) = inset(&square, &[1.0, 0.0, 2.0, 0.0]).unwrap();
|
||||
assert_eq!(p, vec![v(0.0, 1.0), v(10.0, 1.0), v(10.0, 8.0), v(0.0, 8.0)]);
|
||||
assert_eq!(from, vec![Some(0), Some(1), Some(2), Some(3)]);
|
||||
// Two edges in one line that move by different amounts: a square step between them,
|
||||
// so 5 x 2 goes from the 10 x 10.
|
||||
let split = [v(0.0, 0.0), v(5.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
|
||||
let (p, from) = inset(&split, &[0.0, 2.0, 0.0, 0.0, 0.0]).unwrap();
|
||||
assert_eq!(p, vec![v(0.0, 0.0), v(5.0, 0.0), v(5.0, 2.0), v(10.0, 2.0), v(10.0, 10.0), v(0.0, 10.0)]);
|
||||
assert_eq!(from, vec![Some(0), None, Some(1), Some(2), Some(3), Some(4)]);
|
||||
// A slanted end keeps its line: the corner slides along it (2 up a 45° edge is 2 along).
|
||||
let slanted = [v(0.0, 0.0), v(10.0, 0.0), v(15.0, 5.0), v(0.0, 5.0)];
|
||||
let (p, _) = inset(&slanted, &[2.0, 0.0, 0.0, 0.0]).unwrap();
|
||||
assert!(p[1].dist(v(12.0, 2.0)) < 1e-12 && p[0].dist(v(0.0, 2.0)) < 1e-12, "{p:?}");
|
||||
// Setbacks longer than the face leave nothing of it.
|
||||
assert!(inset(&square, &[6.0, 0.0, 6.0, 0.0]).is_err());
|
||||
assert!(inset(&square, &[f64::NAN, 0.0, 0.0, 0.0]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,12 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(a));
|
||||
}
|
||||
}
|
||||
// New bend edges for a conversion: the names of the body's edges there now.
|
||||
if let (FeatureKind::SheetConvert { bends: a, body, .. }, FeatureKind::SheetConvert { bends: b, body: was, .. }) = (&nf.kind, &f.kind)
|
||||
&& (a != b || body != was)
|
||||
{
|
||||
nf.edge_names = solidcraft_doc::sheet::bend_edge_names(&s.model.state_before(id), body, a);
|
||||
}
|
||||
// Profiles picked by index or point are kept by their curves, as when the feature was made.
|
||||
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
|
||||
super::features::stable_profiles(s, &mut nf.kind)?;
|
||||
|
||||
@@ -394,6 +394,10 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &points))
|
||||
}
|
||||
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)),
|
||||
// A conversion's bend edges are edges of the body it converts.
|
||||
FeatureKind::SheetConvert { body, bends, .. } if !bends.is_empty() => {
|
||||
(Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, bends))
|
||||
}
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let face_names = {
|
||||
|
||||
@@ -34,7 +34,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("convert_sheet")
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
.params(
|
||||
"body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?; \
|
||||
bends?: [[x,y,z] on sharp edges] (a solid or a shelled body: `face` is the fixed face and the flat faces reached from it \
|
||||
across these edges become the sheet; a shell keeps its thickness, a solid takes the rule's inward; \
|
||||
edges between two of those faces that are no bends are cut open with the rule's gap)",
|
||||
),
|
||||
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); \
|
||||
@@ -272,8 +277,17 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face"))?;
|
||||
// With bend edges, a solid or shelled body; without, a plate.
|
||||
let bends = match p.get("bends") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(_) => points(p, "bends", cmd)?,
|
||||
};
|
||||
// Such a sheet's values are its rule's (a plate's thickness is the plate's own).
|
||||
if let Some(key) = RULE_ONLY.iter().find(|k| !bends.is_empty() && p.get(**k).is_some()) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sheet.convert parameter: the sheet metal rule sets it. Pass a rule's name as `rule`")));
|
||||
}
|
||||
let rule = rule_param(s, p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule })
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule, bends })
|
||||
}
|
||||
|
||||
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -599,3 +613,7 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_convert_tests.rs"]
|
||||
mod convert_tests;
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Convert to Sheet Metal with bend edges: solid and shelled bodies.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn bbox(s: &mut Session) -> ([f64; 3], [f64; 3]) {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let b = &m["bodies"][0]["bbox"];
|
||||
let v = |k: &str| [0, 1, 2].map(|i| b[k][i].as_f64().unwrap_or(f64::NAN));
|
||||
(v("min"), v("max"))
|
||||
}
|
||||
|
||||
fn flat(s: &mut Session) -> Value {
|
||||
run(s, "sheet.flat_pattern", json!({}))
|
||||
}
|
||||
|
||||
fn flat_size(s: &mut Session) -> (f64, f64) {
|
||||
let f = flat(s);
|
||||
let v = |i: usize| f["flat_size_mm"][i].as_f64().unwrap_or(f64::NAN);
|
||||
(v(0), v(1))
|
||||
}
|
||||
|
||||
/// Either way round (the flat frame's x runs along the fixed face's longest edge).
|
||||
fn same_size(got: (f64, f64), want: (f64, f64), tol: f64) -> bool {
|
||||
((got.0 - want.0).abs() < tol && (got.1 - want.1).abs() < tol) || ((got.0 - want.1).abs() < tol && (got.1 - want.0).abs() < tol)
|
||||
}
|
||||
|
||||
/// The bend table's rows as (angle in whole degrees, direction, length).
|
||||
fn bend_rows(s: &mut Session) -> Vec<(i64, String, f64)> {
|
||||
let rows = flat(s)["bends"].as_array().cloned().unwrap_or_default();
|
||||
let row = |b: &Value| {
|
||||
(
|
||||
b["angle_deg"].as_f64().unwrap_or(0.0).round() as i64,
|
||||
b["direction"].as_str().unwrap_or("").to_string(),
|
||||
b["length"].as_f64().unwrap_or(0.0),
|
||||
)
|
||||
};
|
||||
rows.iter().map(row).collect()
|
||||
}
|
||||
|
||||
fn rule(s: &mut Session, name: &str, t: f64, k: f64, r: f64, gap: f64) {
|
||||
let mm = |v: f64| format!("{v} mm");
|
||||
run(
|
||||
s,
|
||||
"sheet.manage_rules",
|
||||
json!({"name": name, "thickness": mm(t), "k_factor": format!("{k}"), "bend_radius": mm(r), "gap": mm(gap), "active": true}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bend deduction: twice the outside setback less the allowance.
|
||||
fn deduction(theta: f64, r: f64, t: f64, k: f64) -> f64 {
|
||||
2.0 * (r + t) * (theta / 2.0).tan() - theta * (r + k * t)
|
||||
}
|
||||
|
||||
const BOTTOM_EDGES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [100.0, 30.0, 0.0], [50.0, 60.0, 0.0], [0.0, 30.0, 0.0]];
|
||||
|
||||
/// A 100 x 60 x 30 box shelled to `t` with its top open.
|
||||
fn tray(s: &mut Session, t: f64) {
|
||||
run(s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"}));
|
||||
run(s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": t}));
|
||||
}
|
||||
|
||||
/// The folded tray's volume: base, four 90° bends and four walls cut back at the corners.
|
||||
fn tray_volume(l: f64, w: f64, h: f64, t: f64, r: f64, gap: f64) -> f64 {
|
||||
let s = r + t;
|
||||
let cut = t + gap / SQRT_2;
|
||||
let base = (l - 2.0 * s) * (w - 2.0 * s);
|
||||
let bends = FRAC_PI_2 * (r + t / 2.0) * 2.0 * ((l - 2.0 * s) + (w - 2.0 * s));
|
||||
let walls = (h - s) * 2.0 * ((l - 2.0 * cut) + (w - 2.0 * cut));
|
||||
t * (base + bends + walls)
|
||||
}
|
||||
|
||||
/// A strip `t` thick along the polyline `outer` (its material to the left of the way the line
|
||||
/// runs, its ends cut square), extruded `len` along z as the body `Section`. Returns a point on
|
||||
/// the edge at each corner of the polyline.
|
||||
fn section(s: &mut Session, outer: &[(f64, f64)], t: f64, len: f64) -> Vec<[f64; 3]> {
|
||||
let n = outer.len();
|
||||
let dir = |i: usize| {
|
||||
let (a, b) = (outer[i], outer[i + 1]);
|
||||
let l = (b.0 - a.0).hypot(b.1 - a.1);
|
||||
((b.0 - a.0) / l, (b.1 - a.1) / l)
|
||||
};
|
||||
let shift = |p: (f64, f64), d: (f64, f64)| (p.0 - d.1 * t, p.1 + d.0 * t);
|
||||
let mut inner = vec![shift(outer[0], dir(0))];
|
||||
for (i, &corner) in outer.iter().enumerate().take(n - 1).skip(1) {
|
||||
let (d0, d1) = (dir(i - 1), dir(i));
|
||||
let (p0, p1) = (shift(corner, d0), shift(corner, d1));
|
||||
let along = ((p1.0 - p0.0) * d1.1 - (p1.1 - p0.1) * d1.0) / (d0.0 * d1.1 - d0.1 * d1.0);
|
||||
inner.push((p0.0 + d0.0 * along, p0.1 + d0.1 * along));
|
||||
}
|
||||
inner.push(shift(outer[n - 1], dir(n - 2)));
|
||||
let pts: Vec<[f64; 2]> = outer.iter().chain(inner.iter().rev()).map(|p| [p.0, p.1]).collect();
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Section"}));
|
||||
run(s, "sketch.line", json!({"points": pts, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Section", "distance": len, "body_name": "Section"}));
|
||||
outer[1..n - 1].iter().map(|p| [p.0, p.1, len / 2.0]).collect()
|
||||
}
|
||||
|
||||
/// A base 200 wide with walls 40 long at 72° and returns 15 long at 108° more, 300 long.
|
||||
fn trapezoid_section(s: &mut Session, t: f64) -> Vec<[f64; 3]> {
|
||||
let (c, sn) = (72f64.to_radians().cos(), 72f64.to_radians().sin());
|
||||
let (x, y) = (100.0 + 40.0 * c, 40.0 * sn);
|
||||
section(s, &[(-x + 15.0, y), (-x, y), (-100.0, 0.0), (100.0, 0.0), (x, y), (x - 15.0, y)], t, 300.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shelled_tray_keeps_its_walls_and_unfolds_by_the_k_factor() {
|
||||
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
|
||||
let mut s = Session::default();
|
||||
// The shell's own thickness counts, not the rule's.
|
||||
rule(&mut s, "Test", 3.0, k, r, gap);
|
||||
tray(&mut s, t);
|
||||
let out = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
assert!(out["warning"].is_null(), "{out}");
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
|
||||
// Four 90° bends up, each as long as the base is between its setbacks (R + T at either end).
|
||||
let mut rows = bend_rows(&mut s);
|
||||
rows.sort_by(|a, b| a.2.total_cmp(&b.2));
|
||||
let want = [52.0, 52.0, 92.0, 92.0];
|
||||
assert!(rows.len() == 4 && rows.iter().zip(want).all(|(row, l)| row.0 == 90 && row.1 == "up" && (row.2 - l).abs() < 1e-6), "{rows:?}");
|
||||
let allowance = flat(&mut s)["bends"][0]["allowance"].as_f64().unwrap_or(0.0);
|
||||
assert!((allowance - FRAC_PI_2 * (r + k * t)).abs() < 1e-9, "{allowance}");
|
||||
// The envelope stays: the sharp edges became bends inside it.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
// Flat: the outside lengths less two bend deductions each way.
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
let want = (100.0 + 60.0 - 2.0 * bd, 60.0 + 60.0 - 2.0 * bd);
|
||||
assert!(same_size(flat_size(&mut s), want, 1e-6), "{:?} vs {want:?}", flat_size(&mut s));
|
||||
let v = volume(&mut s);
|
||||
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
|
||||
assert!((v - expect).abs() < 2e-3 * expect, "{v} vs {expect}");
|
||||
// Unfold: flat, one sheet thick, the same outline; refold gives the tray back.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!((hi[2] - lo[2] - t).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
assert!(same_size((hi[0] - lo[0], hi[1] - lo[1]), want, 1e-6), "{lo:?} {hi:?}");
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
// It exports like a sheet made from flanges: an outline and four bend lines up.
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
assert_eq!(dxf["bends"], 4);
|
||||
let lines: Vec<&str> = dxf["dxf"].as_str().unwrap_or_default().lines().collect();
|
||||
assert_eq!(lines.windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count(), 4, "four bend lines");
|
||||
assert_eq!(run(&mut s, "sheet.export_bend_table", json!({"format": "json"}))["bends"], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bends_picked_inside_and_a_fixed_face_inside_give_the_same_sheet() {
|
||||
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
|
||||
let inner = [[50.0, 2.0, 2.0], [98.0, 30.0, 2.0], [50.0, 58.0, 2.0], [2.0, 30.0, 2.0]];
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, gap);
|
||||
tray(&mut s, t);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let (v, f) = (volume(&mut s), flat_size(&mut s));
|
||||
// The inner edges (2 mm in and up) are the same bends.
|
||||
let mut a = Session::default();
|
||||
rule(&mut a, "Test", t, k, r, gap);
|
||||
tray(&mut a, t);
|
||||
run(&mut a, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": inner}));
|
||||
assert!((volume(&mut a) - v).abs() < 1e-6 * v && same_size(flat_size(&mut a), f, 1e-9));
|
||||
// From the floor inside, the sheet's other skin is the fixed one: the bends turn down from it
|
||||
// and the flat is the inside lengths less the inside setbacks, L' + 2H' - 4R + 2BA, which is
|
||||
// the same blank.
|
||||
let mut b = Session::default();
|
||||
rule(&mut b, "Test", t, k, r, gap);
|
||||
tray(&mut b, t);
|
||||
run(&mut b, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 2], "bends": BOTTOM_EDGES}));
|
||||
assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == "down"), "{:?}", bend_rows(&mut b));
|
||||
let ba = FRAC_PI_2 * (r + k * t);
|
||||
let inside = (96.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba, 56.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba);
|
||||
assert!(same_size(flat_size(&mut b), inside, 1e-6) && same_size(inside, f, 1e-6), "{:?} {inside:?} {f:?}", flat_size(&mut b));
|
||||
assert!((volume(&mut b) - v).abs() < 1e-6 * v, "{} vs {v}", volume(&mut b));
|
||||
let (lo, hi) = bbox(&mut b);
|
||||
assert!(lo.iter().all(|x| x.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_solid_block_takes_its_rules_thickness_inward() {
|
||||
let (k, r, gap) = (0.44, 2.0, 0.5);
|
||||
for (name, t) in [(None, 2.0), (Some("Thick"), 3.0)] {
|
||||
// The active rule, or the one the conversion names.
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Thick", 3.0, k, r, gap);
|
||||
rule(&mut s, "Test", 2.0, k, r, gap);
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Block"}));
|
||||
let mut p = json!({"body": "Block", "face": [50, 30, 0], "bends": BOTTOM_EDGES});
|
||||
if let Some(n) = name {
|
||||
p["rule"] = json!(n);
|
||||
}
|
||||
run(&mut s, "sheet.convert", p);
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
|
||||
// The block's faces are the sheet's outside; its top is left out.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd, 120.0 - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
|
||||
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trapezoid_section_unfolds_through_72_and_108_degree_bends() {
|
||||
let (t, k, r) = (2.0, 0.5, 1.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
let bends = trapezoid_section(&mut s, t);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [0, 0, 150], "bends": bends}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 4 && rows.iter().all(|row| row.1 == "up" && (row.2 - 300.0).abs() < 1e-9), "{rows:?}");
|
||||
assert_eq!((rows.iter().filter(|row| row.0 == 72).count(), rows.iter().filter(|row| row.0 == 108).count()), (2, 2), "{rows:?}");
|
||||
let (a72, a108) = (72f64.to_radians(), 108f64.to_radians());
|
||||
let across = 200.0 + 2.0 * 40.0 + 2.0 * 15.0 - 2.0 * deduction(a72, r, t, k) - 2.0 * deduction(a108, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (across, 300.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
// The section keeps its outside faces: the returns' tops stay 40 sin 72° up and the length
|
||||
// stays; the widest point is on the round of the 108° bend (outer radius R + T), whose
|
||||
// centre lies (R + T) / sin 36° from the sharp corner along the corner's bisector.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
let (c72, s72) = (a72.cos(), a72.sin());
|
||||
let rho = r + t;
|
||||
let bisector_x = (-c72 - 1.0) / (-c72 - 1.0).hypot(-s72);
|
||||
let wide = 2.0 * (100.0 + 40.0 * c72 + bisector_x * rho / 36f64.to_radians().sin() + rho);
|
||||
// (The box is measured on the tessellation: the round comes out a chord's sag inside.)
|
||||
assert!((hi[0] - lo[0] - wide).abs() < 2e-3 && (hi[1] - 40.0 * s72).abs() < 1e-6 && (hi[2] - lo[2] - 300.0).abs() < 1e-6, "{lo:?} {hi:?} {wide}");
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
let flat_box = [(hi[0] - lo[0], hi[1] - lo[1]), (hi[0] - lo[0], hi[2] - lo[2])];
|
||||
assert!(flat_box.iter().any(|b| same_size(*b, (across, 300.0), 1e-6)), "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_z_section_bends_up_at_one_edge_and_down_at_the_other() {
|
||||
let (t, k, r) = (2.0, 0.5, 1.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
// Flange 60, web 40, flange 30 on one skin: its first corner is the outside of its bend,
|
||||
// its second the inside.
|
||||
let bends = section(&mut s, &[(0.0, 0.0), (60.0, 0.0), (60.0, 40.0), (90.0, 40.0)], t, 100.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [30, 0, 50], "bends": bends}));
|
||||
let mut directions: Vec<String> = bend_rows(&mut s).into_iter().map(|row| row.1).collect();
|
||||
directions.sort();
|
||||
assert_eq!(directions, ["down", "up"]);
|
||||
// Outside setbacks R + T at the first bend, inside setbacks R at the second, and a 90°
|
||||
// allowance for each: 60 + 40 + 30 - 2 (R + T) - 2 R + π (R + K T) = 128.2832.
|
||||
let across = 130.0 - 2.0 * (r + t) - 2.0 * r + PI * (r + k * t);
|
||||
assert!((across - 128.283185).abs() < 1e-6);
|
||||
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 90.0).abs() < 1e-6 && (hi[1] - 42.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
// Three flat strips and two quarter rounds on the neutral radius R + T/2.
|
||||
let expect = t * 100.0 * ((60.0 - (r + t)) + (40.0 - (r + t) - r) + (30.0 - r) + PI * (r + t / 2.0));
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tapered_tray_converts_with_72_degree_walls_and_slanted_corners() {
|
||||
let (t, k, r, gap) = (2.0, 0.5, 1.0, 0.5);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, gap);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Floor"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [200, 120]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Walls leaning out 18°: 72° bends from the floor.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Floor", "distance": 40, "taper": "18 deg", "body_name": "Tray"}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(hi[0] - lo[0] > 200.0 + 1.0, "the taper widens the top: {lo:?} {hi:?}");
|
||||
run(&mut s, "solid.shell", json!({"faces": [[100, 60, 40]], "thickness": t}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [100, 60, 0], "bends": [[100, 0, 0], [200, 60, 0], [100, 120, 0], [0, 60, 0]]}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 4 && rows.iter().all(|row| row.0 == 72 && row.1 == "up"), "{rows:?}");
|
||||
let slant = 40.0 / 72f64.to_radians().sin();
|
||||
let bd = deduction(72f64.to_radians(), r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (200.0 + 2.0 * slant - 2.0 * bd, 120.0 + 2.0 * slant - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
|
||||
// The outside stays; the rim is cut square to the sheet, as a blank is, so the inside edge
|
||||
// of a wall leaning out 18° stands T sin 18° above the solid's level rim.
|
||||
let (lo2, hi2) = bbox(&mut s);
|
||||
assert!((0..2).all(|i| (lo2[i] - lo[i]).abs() < 1e-6 && (hi2[i] - hi[i]).abs() < 1e-6) && lo2[2].abs() < 1e-6, "{lo2:?} {hi2:?}");
|
||||
assert!((hi2[2] - 40.0 - t * 18f64.to_radians().sin()).abs() < 1e-6, "{hi2:?}");
|
||||
let v = volume(&mut s);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sheet_follows_the_solid_through_parameter_changes_undo_and_reopening() {
|
||||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
run(&mut s, "parameters.add", json!({"name": "L", "expression": "100 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "T", "expression": "2 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": "L", "width": 60, "height": 30, "body_name": "Tray"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": "T"}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let bd = |t: f64| deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
|
||||
// Longer: the right-hand bend edge moved from x = 100 to x = 140 and is found by its name.
|
||||
run(&mut s, "parameters.change", json!({"name": "L", "expression": "140 mm"}));
|
||||
assert!(s.model.results.iter().all(|x| x.error.is_none()), "{:?}", s.model.results.iter().map(|x| (&x.name, &x.error)).collect::<Vec<_>>());
|
||||
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6), "{:?}", flat_size(&mut s));
|
||||
let (_, hi) = bbox(&mut s);
|
||||
assert!((hi[0] - 140.0).abs() < 1e-6, "{hi:?}");
|
||||
// Thicker walls: the sheet takes the shell's new thickness.
|
||||
run(&mut s, "parameters.change", json!({"name": "T", "expression": "3 mm"}));
|
||||
assert!(s.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - 3.0).abs() < 1e-9);
|
||||
let thick = (200.0 - 2.0 * bd(3.0), 120.0 - 2.0 * bd(3.0));
|
||||
assert!(same_size(flat_size(&mut s), thick, 1e-6), "{:?}", flat_size(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(same_size(flat_size(&mut s), thick, 1e-6));
|
||||
// The design file keeps the bend edges, and reopens to the same sheet.
|
||||
let saved = s.doc.to_json();
|
||||
assert!(saved.contains("\"bends\""), "{saved}");
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(same_size(flat_size(&mut reopened), thick, 1e-6));
|
||||
// Bend edges came with format 17: a build from before them asks for an update.
|
||||
let version = serde_json::from_str::<Value>(&saved).unwrap()["format"].as_str().and_then(solidcraft_doc::format::version_of);
|
||||
assert!(version >= Some(17), "{version:?}");
|
||||
}
|
||||
|
||||
/// Designs from before bend edges open unchanged: a plate conversion saves without `bends`, as
|
||||
/// it always did, and a format 16 file with one is the same document and the same sheet.
|
||||
#[test]
|
||||
fn designs_from_before_bend_edges_open_unchanged() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "body_name": "Plate"}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2]}));
|
||||
let mut older: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
assert!(!older.to_string().contains("\"bends\""), "{older}");
|
||||
older["format"] = json!("solidcraft/16");
|
||||
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
|
||||
assert_eq!(doc, *s.doc);
|
||||
let mut reopened = Session::new(doc);
|
||||
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
|
||||
assert!((volume(&mut reopened) - 100.0 * 60.0 * 2.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut reopened), flat(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_edges_changed_in_an_edit_are_the_ones_bent() {
|
||||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
tray(&mut s, t);
|
||||
let made = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
// Only the two long walls: a channel as long as the tray, 60 + 2 x 30 less two deductions
|
||||
// across. (By the names of the first two edges it would be the front and the right wall.)
|
||||
run(&mut s, "timeline.edit", json!({"feature": made["feature"], "set": {"bends": [BOTTOM_EDGES[0], BOTTOM_EDGES[2]]}}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 100.0).abs() < 1e-6), "{rows:?}");
|
||||
let across = 120.0 - 2.0 * deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (100.0, across), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
// Its end walls are no faces of the sheet any more: the channel is open at both ends.
|
||||
let expect = t * 100.0 * ((60.0 - 2.0 * (r + t)) + 2.0 * (30.0 - (r + t)) + PI * (r + t / 2.0));
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flanges_and_cuts_work_on_a_converted_sheet() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
tray(&mut s, 2.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let v0 = volume(&mut s);
|
||||
// A return flange on the front wall's top edge.
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 30]], "height": 10, "angle": 90}));
|
||||
let v1 = volume(&mut s);
|
||||
assert!(v1 > v0 + 500.0, "{v0} {v1}");
|
||||
assert_eq!(bend_rows(&mut s).len(), 5);
|
||||
// A hole cut in the flat stays in the base when the tray is folded again.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "direction": "symmetric"}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let hole = PI * 25.0 * 2.0;
|
||||
assert!((volume(&mut s) - (v1 - hole)).abs() < 2e-3 * v1, "{} vs {}", volume(&mut s), v1 - hole);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversions_that_cannot_work_say_why() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Wide", 2.0, 0.44, 40.0, 0.5);
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
tray(&mut s, 2.0);
|
||||
let err = |s: &mut Session, p: Value| s.execute("sheet.convert", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
// A bend that doesn't reach the fixed face through other bends.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[0, 0, 15]]}));
|
||||
assert!(e.contains("does not connect"), "{e}");
|
||||
// No edge there, and no face there.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[50, 30, 0]]}));
|
||||
assert!(e.contains("no edge"), "{e}");
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, -20], "bends": BOTTOM_EDGES}));
|
||||
assert!(e.contains("no face"), "{e}");
|
||||
// A loop of bends.
|
||||
let mut bends: Vec<[f64; 3]> = BOTTOM_EDGES.to_vec();
|
||||
bends.push([0.0, 0.0, 15.0]);
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": bends}));
|
||||
assert!(e.contains("loop"), "{e}");
|
||||
// Walls shorter than their bends' setbacks (a 40 mm bend radius on 30 mm walls).
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "rule": "Wide"}));
|
||||
assert!(e.contains("setback"), "{e}");
|
||||
// The sheet's values are the rule's; an empty list of bends is not a plate.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "thickness": 3}));
|
||||
assert!(e.contains("the sheet metal rule sets it"), "{e}");
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": []}));
|
||||
assert!(e.contains("1…1000"), "{e}");
|
||||
// Curved faces.
|
||||
let mut c = Session::default();
|
||||
run(&mut c, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
|
||||
let e = err(&mut c, json!({"body": "Rod", "face": [0, 0, 30], "bends": [[10, 0, 30]]}));
|
||||
assert!(e.contains("curved"), "{e}");
|
||||
// Nothing changed.
|
||||
assert!(s.model.state().sheets.is_empty() && c.model.state().sheets.is_empty());
|
||||
}
|
||||
@@ -63,7 +63,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"plastic.boss" | "plastic.snap_fit" | "plastic.rest" => {
|
||||
&[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)]
|
||||
}
|
||||
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
|
||||
"plastic.lip" => &[("face", Pick), ("body", Body)],
|
||||
"sheet.convert" => &[("face", Pick), ("bends", Picks), ("body", Body)],
|
||||
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
|
||||
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
|
||||
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
|
||||
|
||||
@@ -148,7 +148,9 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
),
|
||||
"sheet.unfold" => (Sm::Unfold { refold: false }, vec![SelInput::new("Body (last if none)", BODIES, false)]),
|
||||
"sheet.refold" => (Sm::Unfold { refold: true }, vec![SelInput::new("Body (last if none)", BODIES, false)]),
|
||||
"sheet.convert" => (Sm::Convert { rule: 0 }, vec![SelInput::new("Face", PLANAR_FACES, false)]),
|
||||
"sheet.convert" => {
|
||||
(Sm::Convert { rule: 0 }, vec![SelInput::new("Fixed Face", PLANAR_FACES, false), SelInput::new("Bend Edges", EDGES, true)])
|
||||
}
|
||||
"sheet.manage_rules" => {
|
||||
let active = app.session.doc.sheet.active.clone();
|
||||
let rules = rule_list(app);
|
||||
@@ -586,8 +588,16 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map<String,
|
||||
(if *refold { "sheet.refold" } else { "sheet.unfold" }, p)
|
||||
}
|
||||
Sm::Convert { rule } => {
|
||||
let Some(Sel::Face { body, point, .. }) = items(inputs, 0).first() else { return Err("select the plate's large face first".into()) };
|
||||
let Some(Sel::Face { body, point, .. }) = items(inputs, 0).first() else {
|
||||
return Err("select the fixed face first (a plate's large face)".into());
|
||||
};
|
||||
let mut p = json!({"body": body, "face": pt(*point)});
|
||||
// The sharp edges of a solid or shelled body that become bends (a plate has none).
|
||||
let bends: Vec<Value> =
|
||||
items(inputs, 1).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
|
||||
if !bends.is_empty() {
|
||||
p["bends"] = json!(bends);
|
||||
}
|
||||
if let Some(r) = rule_value(app, *rule) {
|
||||
p["rule"] = json!(r);
|
||||
}
|
||||
@@ -711,7 +721,16 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
|
||||
return Some((Kind::Sheet(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SheetUnfold { body, refold } => (Sm::Unfold { refold: *refold }, body.iter().map(|n| Sel::Body { name: n.clone() }).collect()),
|
||||
FeatureKind::SheetConvert { face, rule, .. } => (Sm::Convert { rule: rule_index(rule) }, face_sel(s, *face).into_iter().collect()),
|
||||
FeatureKind::SheetConvert { face, rule, bends, .. } => {
|
||||
let (_, mut ins) = start(app, "sheet.convert")?;
|
||||
if let Some(i) = ins.first_mut() {
|
||||
i.items = face_sel(s, *face).into_iter().collect();
|
||||
}
|
||||
if let Some(i) = ins.get_mut(1) {
|
||||
i.items = bends.iter().filter_map(|p| edge_sel(s, *p)).collect();
|
||||
}
|
||||
return Some((Kind::Sheet(Sm::Convert { rule: rule_index(rule) }), ins, extra));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let id = match &k {
|
||||
|
||||
@@ -421,3 +421,41 @@ fn edge_flanges_can_have_an_angle_and_a_height_of_their_own() {
|
||||
json!([{"point": pt(point(1)), "angle": "100 deg"}, {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}])
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert takes a fixed face and, for a solid or shelled body, its bend edges; Edit Feature
|
||||
/// brings both back.
|
||||
#[test]
|
||||
fn convert_takes_the_fixed_face_and_the_bend_edges() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"})).unwrap();
|
||||
s.execute("solid.shell", &json!({"faces": [[50, 30, 30]], "thickness": 2})).unwrap();
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
app.start("sheet.convert");
|
||||
let mut d = app.dialog.clone().unwrap();
|
||||
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), ["Fixed Face", "Bend Edges"]);
|
||||
assert!(apply_commands(&app, &d).is_err(), "nothing to convert without a face");
|
||||
// The face alone is the plate conversion: no `bends`.
|
||||
d.inputs[0].items = face_sel(&app.session, Vec3::new(50.0, 30.0, 0.0)).into_iter().collect();
|
||||
assert!(apply_commands(&app, &d).unwrap()[0].1.get("bends").is_none());
|
||||
let edges: Vec<Sel> = [[50.0, 0.0, 0.0], [100.0, 30.0, 0.0], [50.0, 60.0, 0.0], [0.0, 30.0, 0.0]]
|
||||
.iter()
|
||||
.filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2])))
|
||||
.collect();
|
||||
d.inputs[1].items = edges;
|
||||
let c = apply_commands(&app, &d).unwrap();
|
||||
assert_eq!(c[0].0, "sheet.convert");
|
||||
assert_eq!(c[0].1["bends"], json!([[50.0, 0.0, 0.0], [100.0, 30.0, 0.0], [50.0, 60.0, 0.0], [0.0, 30.0, 0.0]]));
|
||||
run_all(&mut app, c);
|
||||
let bends = |app: &mut SolveApp| app.session.execute("sheet.flat_pattern", &json!({})).unwrap()["bends"].as_array().map(Vec::len);
|
||||
assert_eq!(bends(&mut app), Some(4));
|
||||
// Edit Feature: the face and the four edges are back in their rows, and OK rebuilds the sheet.
|
||||
let f = app.session.doc.features.last().unwrap().clone();
|
||||
let e = edit_dialog(&app, f.id, None).unwrap();
|
||||
assert_eq!((e.inputs[0].items.len(), e.inputs[1].items.len()), (1, 4));
|
||||
let c = apply_commands(&app, &e).unwrap();
|
||||
assert_eq!(c[0].0, "timeline.redefine");
|
||||
assert_eq!(c[0].1["params"]["bends"].as_array().map(Vec::len), Some(4));
|
||||
run_all(&mut app, c);
|
||||
assert_eq!(bends(&mut app), Some(4));
|
||||
assert!(app.session.model.results.iter().all(|r| r.error.is_none()), "{:?}", app.session.model.results);
|
||||
}
|
||||
@@ -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, 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, 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
|
||||
|
||||
@@ -7,7 +7,8 @@ The SHEET METAL tab builds parts from a sheet of constant thickness under a **sh
|
||||
sketch, or an edge flange from a part edge.
|
||||
- **Hem** (`sheet.hem`) and **Fold** (`sheet.fold`) along a sketch line.
|
||||
- **Unfold / Refold** (`sheet.unfold`, `sheet.refold`) for working on the flat part.
|
||||
- **Convert** (`sheet.convert`) turns a solid of constant thickness into sheet metal.
|
||||
- **Convert** (`sheet.convert`) turns a plate of constant thickness into sheet metal, or, with
|
||||
bend edges, a solid or a shelled body.
|
||||
- **Create Flat Pattern** (`sheet.flat_pattern`) shows the flat part with its bend lines and a
|
||||
bend table, and exports it as DXF or a bend-table CSV through the save picker.
|
||||
|
||||
@@ -104,6 +105,29 @@ returns on them close over a relieved corner as over a whole one.
|
||||
(The four returns of a 120 x 90 tray in 2 mm sheet with 90° walls 20 high: each point is the
|
||||
middle of a wall's far edge on its inner face, so the returns turn in over the base.)
|
||||
|
||||
**Convert** also takes a solid or a shelled body: pick the face that stays fixed and the sharp
|
||||
edges that become bends (`bends`; Bend Edges in the dialog). The flat faces reached from the
|
||||
fixed face across those edges become the sheet. A shelled body keeps its walls and their
|
||||
thickness; any other solid's faces become the outside of the sheet, with the rule's thickness
|
||||
taken into the body. Each sharp edge stays where the faces of its bend would meet, so the part
|
||||
keeps its envelope and the flat pattern is the outside lengths less a bend deduction per bend,
|
||||
2·(R + T)·tan(θ/2) − θ·(R + K·T). A bend can be picked on either skin of a shell; bends turn
|
||||
either way, follow one another and take any angle (the walls of a tapered tray at 72°). An
|
||||
edge between two faces of the sheet that is no bend is cut open, each side half the rule's
|
||||
**gap** from the plane halfway between the two, like the corners of a tray. The result is an
|
||||
ordinary sheet body: edge flanges, cuts, Unfold, the flat pattern and its exports work on it,
|
||||
and it follows the solid when a parameter changes (the edges and the fixed face are found by
|
||||
their names). Without bend edges Convert expects a plate, as before. Not converted yet: curved
|
||||
faces (rolled sheet, a filleted edge as a bend), faces with holes or curved edges (cut them
|
||||
after converting) and bends that close a ring.
|
||||
|
||||
```json
|
||||
{"command":"solid.box","params":{"length":100,"width":60,"height":30,"body_name":"Tray"}}
|
||||
{"command":"solid.shell","params":{"faces":[[50,30,30]],"thickness":2}}
|
||||
{"command":"sheet.convert","params":{"body":"Tray","face":[50,30,0],
|
||||
"bends":[[50,0,0],[100,30,0],[50,60,0],[0,30,0]]}}
|
||||
```
|
||||
|
||||
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