feat: edge flanges with their own angle and height per edge (#447)
One edge flange feature gave all its flanges one height and one angle. A
part whose flanges lean differently took one feature per angle, and
flanges of separate features don't close toward each other, so its
corners stayed open.
- sheet.flange (edge): an entry of `edges` is a point [x, y, z] as before,
or {point, angle?, height?} whose angle and height (expressions, checked
like the feature's) replace the feature's for that edge. Bad input is
refused with the edge named. Before, the object form was read for its
point and the rest was ignored without a word.
- The feature stores its edges as FlangeEdge. An edge without values of
its own is written as the plain point it always was, so designs without
them are read and written as before; an unknown key in an edge is
refused, not dropped.
- The edges' values are feature inputs of their own (Edge angle, Edge
height, after the feature's), so parameters, renames and
parameters.change reach them. timeline.edit names the edges again when
it changes them, so each value stays with its edge.
- SheetBody::add_flanges_each trims every edge by its own setback and
adds the flanges. The corners between them close as between equal
flanges: the closing already works from the two flanges it joins (each
one's fold and leg), so the flat pattern follows by hand from the
angles, the heights and the rule (the tests derive it).
- The Flange dialog shows a row per picked edge with an angle and a
height box (empty: the dialog's value) once more than one edge is
picked, and brings them back on Edit Feature.
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0dee9deec1
commit
fb4c1d74b9
14 files changed
+1039
-38
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 | 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 | 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 |
|
||||
|
||||
@@ -263,7 +263,8 @@ impl FeatureKind {
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
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::SheetFold { a, b, fixed, .. } => {
|
||||
pt(a);
|
||||
|
||||
@@ -269,6 +269,73 @@ impl EmbossFace {
|
||||
}
|
||||
}
|
||||
|
||||
/// One edge of an edge flange feature: a point on the sheet's edge and, where given, this
|
||||
/// edge's own angle and height (expressions) in place of the feature's. In a design file an
|
||||
/// edge with neither is the point `[x, y, z]`, as every edge was before; one with its own
|
||||
/// values is `{"point": [x, y, z], "angle": "…", "height": "…"}`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FlangeEdge {
|
||||
pub point: Vec3,
|
||||
pub angle: Option<String>,
|
||||
pub height: Option<String>,
|
||||
}
|
||||
|
||||
impl FlangeEdge {
|
||||
/// An edge that takes the feature's angle and height.
|
||||
pub fn at(point: Vec3) -> Self {
|
||||
Self { point, angle: None, height: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FlangeEdge {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
if self.angle.is_none() && self.height.is_none() {
|
||||
return self.point.serialize(s);
|
||||
}
|
||||
let mut m = s.serialize_map(None)?;
|
||||
m.serialize_entry("point", &self.point)?;
|
||||
if let Some(a) = &self.angle {
|
||||
m.serialize_entry("angle", a)?;
|
||||
}
|
||||
if let Some(h) = &self.height {
|
||||
m.serialize_entry("height", h)?;
|
||||
}
|
||||
m.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FlangeEdge {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
|
||||
/// The object form; a key this build doesn't know is refused, not dropped.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Own {
|
||||
point: Vec3,
|
||||
#[serde(default)]
|
||||
angle: Option<String>,
|
||||
#[serde(default)]
|
||||
height: Option<String>,
|
||||
}
|
||||
struct Edge;
|
||||
impl<'de> serde::de::Visitor<'de> for Edge {
|
||||
type Value = FlangeEdge;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a point [x, y, z] or an edge {point, angle?, height?}")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, seq: A) -> std::result::Result<FlangeEdge, A::Error> {
|
||||
Vec3::deserialize(SeqAccessDeserializer::new(seq)).map(FlangeEdge::at)
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> std::result::Result<FlangeEdge, A::Error> {
|
||||
let o = Own::deserialize(MapAccessDeserializer::new(map))?;
|
||||
Ok(FlangeEdge { point: o.point, angle: o.angle, height: o.height })
|
||||
}
|
||||
}
|
||||
d.deserialize_any(Edge)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -659,9 +726,10 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reverse: bool,
|
||||
},
|
||||
/// Edge flanges on sheet edges (points on the top or bottom edges).
|
||||
/// Edge flanges on sheet edges (points on the top or bottom edges). `height` and `angle`
|
||||
/// hold for every edge that doesn't carry its own (see [`FlangeEdge`]).
|
||||
SheetFlange {
|
||||
edges: Vec<Vec3>,
|
||||
edges: Vec<FlangeEdge>,
|
||||
height: String,
|
||||
angle: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -1521,9 +1589,12 @@ impl FeatureKind {
|
||||
v.extend([length, draft, thickness].into_iter().flatten().map(String::as_str));
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(distance),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
|
||||
v.extend([height.as_str(), angle]);
|
||||
v.extend(radius.iter().map(String::as_str));
|
||||
for e in edges {
|
||||
v.extend([&e.angle, &e.height].into_iter().flatten().map(String::as_str));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
v.push(angle);
|
||||
|
||||
@@ -415,8 +415,21 @@ pub(super) fn sheet_eval(
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let i = sheet_for(st, body, edges.first().map(|e| e.point))?;
|
||||
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
|
||||
// Each edge's own height and angle, else the feature's.
|
||||
let mut own = Vec::with_capacity(edges.len());
|
||||
for e in edges {
|
||||
let height = match &e.height {
|
||||
Some(x) => val(vals, x, Kind::Length)?,
|
||||
None => h,
|
||||
};
|
||||
let angle = match &e.angle {
|
||||
Some(x) => val(vals, x, Kind::Angle)?,
|
||||
None => a,
|
||||
};
|
||||
own.push((height, angle));
|
||||
}
|
||||
let rr = match radius {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
@@ -428,9 +441,14 @@ pub(super) fn sheet_eval(
|
||||
if sh.flat {
|
||||
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
|
||||
}
|
||||
let picks = sheet_picks(sh, f, edges, warning);
|
||||
let points: Vec<Vec3> = edges.iter().map(|e| e.point).collect();
|
||||
let each: Vec<sheet::EdgeFlange> = sheet_picks(sh, f, &points, warning)
|
||||
.into_iter()
|
||||
.zip(own)
|
||||
.map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle })
|
||||
.collect();
|
||||
let n0 = sh.flanges.len();
|
||||
let (_, notes) = sh.add_flanges_noted(&picks, h, a, rr, position, *flip)?;
|
||||
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?;
|
||||
if !notes.is_empty() {
|
||||
let notes = notes.join("; ");
|
||||
*warning = Some(match warning.take() {
|
||||
|
||||
@@ -223,12 +223,21 @@ impl FeatureKind {
|
||||
v.push(("Catch length", catch_length, L));
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
|
||||
v.push(("Height", height, L));
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(r) = radius {
|
||||
v.push(("Bend radius", r, L));
|
||||
}
|
||||
// The edges' own values come last, so the inputs above keep their names.
|
||||
for e in edges {
|
||||
if let Some(a) = &mut e.angle {
|
||||
v.push(("Edge angle", a, A));
|
||||
}
|
||||
if let Some(h) = &mut e.height {
|
||||
v.push(("Edge height", h, L));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
|
||||
+305
-20
@@ -298,6 +298,15 @@ pub struct SheetFlange {
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
/// One edge flange to make: a pick on the sheet's edge, and that flange's height (to the outer
|
||||
/// face, from the virtual sharp) and angle (radians).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct EdgeFlange {
|
||||
pub pick: Vec3,
|
||||
pub height: f64,
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
/// A sheet metal body (see the module docs).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct SheetBody {
|
||||
@@ -1368,38 +1377,54 @@ impl SheetBody {
|
||||
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()));
|
||||
}
|
||||
if picks.is_empty() || picks.len() > 1000 {
|
||||
let each: Vec<EdgeFlange> = picks.iter().map(|&pick| EdgeFlange { pick, height, angle }).collect();
|
||||
self.add_flanges_each(&each, radius, position, flip)
|
||||
}
|
||||
|
||||
/// Edge flanges that each have their own height and angle, in one go: every edge is trimmed
|
||||
/// back by its own setback first, then the flanges are added and the corners between them
|
||||
/// are closed. The closing works from the two flanges it joins (each one's own fold and
|
||||
/// leg), so unequal flanges close like equal ones.
|
||||
pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec<usize>, Vec<String>)> {
|
||||
if edges.is_empty() || edges.len() > 1000 {
|
||||
return Err(DocError::Invalid("pick 1…1000 edges".into()));
|
||||
}
|
||||
let sharp = (radius + self.t) * (angle / 2.0).tan();
|
||||
let setback = match position {
|
||||
"inside" | "" => sharp,
|
||||
"outside" => 0.0,
|
||||
"middle" | "mid" => sharp / 2.0,
|
||||
other => return Err(DocError::Invalid(format!("unknown bend position `{other}` (inside, outside or middle)"))),
|
||||
};
|
||||
let leg = height - sharp;
|
||||
if leg < -1e-9 {
|
||||
return Err(DocError::Invalid(format!("the flange height must be at least {sharp:.3} mm for this bend")));
|
||||
// With several edges a message says which one it is about.
|
||||
let on = |k: usize| if edges.len() > 1 { format!("edge {}: ", k + 1) } else { String::new() };
|
||||
// Each edge's setback and leg (the panel past the bend) follow its own angle and height.
|
||||
let mut sizes = Vec::with_capacity(edges.len());
|
||||
for (k, e) in edges.iter().enumerate() {
|
||||
if !(e.angle > 1e-6 && e.angle <= std::f64::consts::PI - 1e-6) {
|
||||
return Err(DocError::Invalid(format!("{}the flange angle must be between 0 and 180°", on(k))));
|
||||
}
|
||||
let sharp = (radius + self.t) * (e.angle / 2.0).tan();
|
||||
let setback = match position {
|
||||
"inside" | "" => sharp,
|
||||
"outside" => 0.0,
|
||||
"middle" | "mid" => sharp / 2.0,
|
||||
other => return Err(DocError::Invalid(format!("unknown bend position `{other}` (inside, outside or middle)"))),
|
||||
};
|
||||
let leg = e.height - sharp;
|
||||
if !(leg >= -1e-9) {
|
||||
return Err(DocError::Invalid(format!("{}the flange height must be at least {sharp:.3} mm for this bend", on(k))));
|
||||
}
|
||||
sizes.push((setback, leg));
|
||||
}
|
||||
// Find every edge before anything moves.
|
||||
let mut found = Vec::new();
|
||||
for p in picks {
|
||||
let (panel, a, b, top, dist) = self.edge_at(*p).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
|
||||
for (e, &(setback, leg)) in edges.iter().zip(&sizes) {
|
||||
let (panel, a, b, top, dist) = self.edge_at(e.pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
|
||||
if dist > self.t * 2.0 + 1e-3 {
|
||||
return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into()));
|
||||
}
|
||||
let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?;
|
||||
found.push((panel, a, b, d, top));
|
||||
found.push((panel, a, b, d, top, setback, leg, e.angle));
|
||||
}
|
||||
for (panel, a, b, _, _) in &found {
|
||||
self.trim(*panel, *a, *b, setback)?;
|
||||
for (panel, a, b, _, _, setback, _, _) in &found {
|
||||
self.trim(*panel, *a, *b, *setback)?;
|
||||
}
|
||||
let mut made = Vec::new();
|
||||
for (panel, a, b, d, top) in found {
|
||||
for (panel, a, b, d, top, setback, leg, angle) in found {
|
||||
let outward = Vec2::new(d.y, -d.x);
|
||||
let line_o = a - outward * setback;
|
||||
// The trimmed edge: along d, on the moved line.
|
||||
@@ -2895,4 +2920,264 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge flanges on `base`, each with its own height and angle, added in one call. Returns the
|
||||
/// sheet and the notes on corners left open.
|
||||
fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec<String>) {
|
||||
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, base);
|
||||
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false).unwrap().1;
|
||||
(s, notes)
|
||||
}
|
||||
|
||||
/// The 120 x 90 tray's four flanges (bottom, right, top, left), each with its own angle
|
||||
/// (degrees) and height.
|
||||
fn tray_each(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4]) -> (SheetBody, Vec<String>) {
|
||||
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 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 each: Vec<EdgeFlange> = (0..4).map(|i| EdgeFlange { pick: picks[i], height: heights[i], angle: angles[i].to_radians() }).collect();
|
||||
flanges_each(base, rv, &each)
|
||||
}
|
||||
|
||||
/// The material of a base flange bent up, for asking whether a folded point lies in it. Worked
|
||||
/// out from the fold on its own (not through `map3`): the panel through the inverse of its fold,
|
||||
/// the bend from the radius and turn about the bend's axis.
|
||||
struct Material {
|
||||
back: Mat,
|
||||
panel: Vec<Vec2>,
|
||||
axis: (Vec3, Vec3),
|
||||
p: Vec2,
|
||||
d: Vec2,
|
||||
span: (f64, f64),
|
||||
radius: f64,
|
||||
angle: f64,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
fn of(s: &SheetBody, i: usize) -> Material {
|
||||
let f = &s.flanges[i];
|
||||
assert!(f.parent.is_none() && f.angle > 0.0);
|
||||
let (o, k, _) = s.bend_axis(f);
|
||||
Material {
|
||||
back: mat_inverse(&s.fold(i)).unwrap(),
|
||||
panel: corners_of(&s.panel(f)),
|
||||
axis: (o, k),
|
||||
p: f.p,
|
||||
d: f.d,
|
||||
span: s.zone_span(f),
|
||||
radius: f.radius,
|
||||
angle: f.angle,
|
||||
t: s.t,
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep (mm) `q` lies inside the flange's panel or bend; None when it is outside both.
|
||||
fn depth(&self, q: Vec3) -> Option<f64> {
|
||||
let flat = apply_point(&self.back, q);
|
||||
let m = self.panel.len();
|
||||
let sides = (0..m).map(|j| {
|
||||
let (a, b) = (self.panel[j], self.panel[(j + 1) % m]);
|
||||
(b - a).cross(Vec2::new(flat.x, flat.y) - a) / (b - a).len()
|
||||
});
|
||||
let in_panel = sides.fold(flat.z.min(self.t - flat.z), f64::min);
|
||||
// The bend turns the sheet under its axis toward the flange: a point's distance from
|
||||
// the axis is its radius, its turn from straight below the axis its place across the bend.
|
||||
let (o, k) = self.axis;
|
||||
let rel = q - o;
|
||||
let perp = rel - k * rel.dot(k);
|
||||
let down = Vec3::Z * -1.0;
|
||||
let (rho, turn) = (perp.len(), perp.dot(k.cross(down)).atan2(perp.dot(down)));
|
||||
let u = (Vec2::new(q.x, q.y) - self.p).dot(self.d);
|
||||
let in_bend = [rho - self.radius, self.radius + self.t - rho, turn * rho, (self.angle - turn) * rho, u - self.span.0, self.span.1 - u]
|
||||
.into_iter()
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let depth = in_panel.max(in_bend);
|
||||
(depth > 0.0).then_some(depth)
|
||||
}
|
||||
}
|
||||
|
||||
/// Folded, the two flanges at corner `k` keep out of each other: no point of one's bend or
|
||||
/// panel near the corner (sampled every 0.5 mm or finer, at three heights through the sheet)
|
||||
/// lies in the other's. Each sample is also found inside its own flange, which checks
|
||||
/// [`Material`] against the fold that places the solid's faces.
|
||||
fn flanges_keep_clear(s: &SheetBody, k: &Corner, what: &str) {
|
||||
let (ma, mb) = (Material::of(s, k.a), Material::of(s, k.b));
|
||||
for (i, at_end, own, other) in [(k.a, k.a_end, &ma, &mb), (k.b, k.b_end, &mb, &ma)] {
|
||||
let f = &s.flanges[i];
|
||||
let ba = s.allowance(f);
|
||||
let mut samples = Vec::new();
|
||||
// The bend's 12 mm nearest the corner.
|
||||
let (u0, u1) = s.zone_span(f);
|
||||
for iu in 0..48 {
|
||||
let du = 0.02 + 0.25 * iu as f64;
|
||||
let u = if at_end { u1 - du } else { u0 + du };
|
||||
for is in 0..24 {
|
||||
samples.push((Piece::Zone(i), f.p + f.d * u + f.n * (ba * (is as f64 + 0.5) / 24.0)));
|
||||
}
|
||||
}
|
||||
// The panel's 25 mm nearest its end, over its whole height.
|
||||
let (q0, q1, inward) = 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), f.d * -1.0)
|
||||
} 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), f.d)
|
||||
};
|
||||
let rows = (f.leg / 0.5).ceil().max(1.0) as usize;
|
||||
for ir in 0..rows {
|
||||
let on_end = q0 + (q1 - q0) * ((ir as f64 + 0.5) / rows as f64);
|
||||
for iu in 0..50 {
|
||||
samples.push((Piece::Panel(Some(i)), on_end + inward * (0.02 + 0.5 * iu as f64)));
|
||||
}
|
||||
}
|
||||
for (piece, flat) in samples {
|
||||
for z in [0.1 * s.t, 0.5 * s.t, 0.9 * s.t] {
|
||||
let q = s.map3(piece, flat, z);
|
||||
assert!(own.depth(q).is_some(), "{what}: flange {i}: its own point {flat:?} at {z} ({piece:?}) is not found in it");
|
||||
let d = other.depth(q);
|
||||
assert!(
|
||||
d.is_none_or(|d| d < 1e-6),
|
||||
"{what}: flange {i}'s {piece:?} at {flat:?}, height {z}, lies {d:?} mm inside the other flange"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel of flange `i` of the tray by hand, in the flat pattern. Take a flange at θ on the
|
||||
/// edge y = 0 and its neighbour at φ on x = 0, T the thickness. A point of the first one's inner
|
||||
/// face, w up its outer face from the virtual sharp, is (x, −w·cosθ + T·sinθ, w·sinθ + T·cosθ);
|
||||
/// the inner faces are the planes y·sinθ + z·cosθ = T and x·sinφ + z·cosφ = T, and the plane
|
||||
/// halving them passes through the corner with normal (sinφ, −sinθ, cosφ − cosθ), of length
|
||||
/// √(2 − 2·cosθ·cosφ). Keeping the inner face gap/2 from it gives the panel's end
|
||||
/// x(w) = ((g/2)·√(2 − 2·cosθ·cosφ) + T·(1 − cosθ·cosφ) − w·sinθ·cosφ) / sinφ for w = s0 … h, at
|
||||
/// flat y = s0 − BA − (w − s0). Each flange is taken in its own frame: the origin at the start
|
||||
/// corner of its edge, u along the edge, v into the base; flange i − 1 is at its start and
|
||||
/// flange i + 1 at its end.
|
||||
fn hand_panel(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4], i: usize) -> Vec<Vec2> {
|
||||
let (t, r, g) = (rv.thickness, rv.bend_radius, rv.gap);
|
||||
let (o, d, v, len) = [
|
||||
(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), 120.0),
|
||||
(Vec2::new(120.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0), 90.0),
|
||||
(Vec2::new(120.0, 90.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0), 120.0),
|
||||
(Vec2::new(0.0, 90.0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0), 90.0),
|
||||
][i];
|
||||
let th = angles[i].to_radians();
|
||||
let (s0, ba) = ((r + t) * (th / 2.0).tan(), th * (r + rv.k_factor * t));
|
||||
let x_end = |phi: f64, w: f64| {
|
||||
let c = th.cos() * phi.cos();
|
||||
((g / 2.0) * (2.0 - 2.0 * c).sqrt() + t * (1.0 - c) - w * th.sin() * phi.cos()) / phi.sin()
|
||||
};
|
||||
let (prev, next) = (angles[(i + 3) % 4].to_radians(), angles[(i + 1) % 4].to_radians());
|
||||
let flat = |u: f64, w: f64| o + d * u + v * (s0 - ba - (w - s0));
|
||||
let h = heights[i];
|
||||
vec![flat(x_end(prev, s0), s0), flat(x_end(prev, h), h), flat(len - x_end(next, h), h), flat(len - x_end(next, s0), s0)]
|
||||
}
|
||||
|
||||
/// The tray's flat area by hand: the base inside the four bend start lines, every bend zone
|
||||
/// (the base's trimmed edge by the bend allowance) and every panel of [`hand_panel`]. With
|
||||
/// s0 = (R + T)·tan(θ/2) a flange's setback and BA = θ·(R + K·T) its bend allowance, the base
|
||||
/// is (120 − s0_left − s0_right) by (90 − s0_bottom − s0_top).
|
||||
fn hand_flat_area(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4]) -> f64 {
|
||||
let s0 = angles.map(|a| (rv.bend_radius + rv.thickness) * (a.to_radians() / 2.0).tan());
|
||||
let ba = angles.map(|a| a.to_radians() * (rv.bend_radius + rv.k_factor * rv.thickness));
|
||||
let (w, h) = (120.0 - s0[3] - s0[1], 90.0 - s0[0] - s0[2]);
|
||||
let panels: f64 = (0..4).map(|i| signed_area(&hand_panel(rv, angles, heights, i)).abs()).sum();
|
||||
w * h + (ba[0] + ba[2]) * w + (ba[1] + ba[3]) * h + panels
|
||||
}
|
||||
|
||||
/// 60°, 90°, 105° and 120° on the tray's bottom, right, top and left edges.
|
||||
const MIXED: [f64; 4] = [60.0, 90.0, 105.0, 120.0];
|
||||
|
||||
#[test]
|
||||
fn flanges_with_their_own_angle_and_height_close_at_their_corners() {
|
||||
// Four angles on a 120 x 90 tray, then four heights as well, in one call: every panel ends
|
||||
// where the hand derivation says, whatever its neighbour's angle and height.
|
||||
let rv = alu();
|
||||
for (heights, what) in [([38.6; 4], "one height"), ([38.6, 30.0, 45.0, 25.0], "four heights")] {
|
||||
let (s, notes) = tray_each(&rv, MIXED, heights);
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
for i in 0..4 {
|
||||
let (f, th) = (&s.flanges[i], MIXED[i].to_radians());
|
||||
assert!((f.angle - th).abs() < 1e-12, "{what}: flange {i}");
|
||||
assert!((f.leg - (heights[i] - (rv.bend_radius + rv.thickness) * (th / 2.0).tan())).abs() < 1e-9, "{what}: flange {i}");
|
||||
let got = corners_of(&s.panel(f));
|
||||
assert_eq!(got.len(), 4, "{what}: flange {i}");
|
||||
for q in hand_panel(&rv, MIXED, heights, i) {
|
||||
assert!(got.iter().any(|p| p.dist(q) < 1e-9), "{what}: flange {i}: {q:?} not in {got:?}");
|
||||
}
|
||||
}
|
||||
// The flat pattern is the base, the bend zones and those panels, nothing more or less;
|
||||
// folded it holds the same material (`holds_the_flat_material`), so the volume is this
|
||||
// area times the thickness.
|
||||
let (area, want) = (flat_area(&s), hand_flat_area(&rv, MIXED, heights));
|
||||
assert!((area - want).abs() < 1e-6, "{what}: flat area {area} vs {want}");
|
||||
let corners = s.corners(&[0, 1, 2, 3]);
|
||||
assert_eq!(corners.len(), 4, "{what}");
|
||||
ends_are_gap_apart(&s, &corners, rv.gap, what);
|
||||
for k in &corners {
|
||||
flanges_keep_clear(&s, k, what);
|
||||
}
|
||||
holds_the_flat_material(&s, what);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trapezoid_with_unequal_angles_and_heights_closes_at_its_acute_and_obtuse_corners() {
|
||||
// 77.5° and 102.5° base corners; flanges at 60°, 105°, 75° and 120°, each its own height.
|
||||
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 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 (angles, heights) = ([60.0f64, 105.0, 75.0, 120.0], [38.6, 30.0, 45.0, 25.0]);
|
||||
let each: Vec<EdgeFlange> = (0..4).map(|i| EdgeFlange { pick: picks[i], height: heights[i], angle: angles[i].to_radians() }).collect();
|
||||
let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }, &alu(), &each);
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
for i in 0..4 {
|
||||
let (f, th) = (&s.flanges[i], angles[i].to_radians());
|
||||
assert!((f.angle - th).abs() < 1e-12 && (f.leg - (heights[i] - 3.0 * (th / 2.0).tan())).abs() < 1e-9, "flange {i}: {f:?}");
|
||||
}
|
||||
let corners = s.corners(&[0, 1, 2, 3]);
|
||||
assert_eq!(corners.len(), 4);
|
||||
ends_are_gap_apart(&s, &corners, 0.5, "trapezoid");
|
||||
for k in &corners {
|
||||
flanges_keep_clear(&s, k, "trapezoid");
|
||||
}
|
||||
holds_the_flat_material(&s, "trapezoid");
|
||||
}
|
||||
|
||||
/// The angles of the sweep below.
|
||||
const SWEEP: [f64; 7] = [45.0, 60.0, 75.0, 90.0, 105.0, 120.0, 135.0];
|
||||
|
||||
#[test]
|
||||
fn every_pair_of_angles_closes_at_any_base_corner() {
|
||||
// A parallelogram base (120 and 90 long) with a corner of 60°, 90° or 120° at the origin;
|
||||
// flanges 38.6 high on its two edges there, in one call, at every pair of the angles: 147
|
||||
// corners, each closed with the gap, the flanges clear of each other, the part one valid
|
||||
// shell holding the flat pattern's material.
|
||||
let rv = alu();
|
||||
let mut cases = 0;
|
||||
for alpha in [60.0f64, 90.0, 120.0] {
|
||||
let u = Vec2::new(alpha.to_radians().cos(), alpha.to_radians().sin());
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 0.0) + u * 90.0, u * 90.0];
|
||||
for a in SWEEP {
|
||||
for b in SWEEP {
|
||||
let what = format!("corner {alpha}°, flanges {a}° and {b}°");
|
||||
let each = [
|
||||
EdgeFlange { pick: Vec3::new(60.0, 0.0, 2.0), height: 38.6, angle: a.to_radians() },
|
||||
EdgeFlange { pick: Vec3::new(u.x * 45.0, u.y * 45.0, 2.0), height: 38.6, angle: b.to_radians() },
|
||||
];
|
||||
let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }, &rv, &each);
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
assert!((s.flanges[0].angle - a.to_radians()).abs() < 1e-12 && (s.flanges[1].angle - b.to_radians()).abs() < 1e-12, "{what}");
|
||||
let corners = s.corners(&[0, 1]);
|
||||
assert_eq!(corners.len(), 1, "{what}");
|
||||
ends_are_gap_apart(&s, &corners, rv.gap, &what);
|
||||
flanges_keep_clear(&s, &corners[0], &what);
|
||||
holds_the_flat_material(&s, &what);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(cases, 147);
|
||||
}
|
||||
}
|
||||
@@ -322,6 +322,14 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nf.edge_refs = solidcraft_doc::edge_refs(&before, body, a);
|
||||
nf.edge_names = solidcraft_doc::edge_names_for(&before, body, a);
|
||||
}
|
||||
// New or reordered flange edges: their names now, so each edge's own angle and height stay
|
||||
// with the edge they were given for.
|
||||
if let (FeatureKind::SheetFlange { edges: a, .. }, FeatureKind::SheetFlange { edges: b, .. }) = (&nf.kind, &f.kind) {
|
||||
let points = |e: &[solidcraft_doc::FlangeEdge]| e.iter().map(|x| x.point).collect::<Vec<_>>();
|
||||
if points(a) != points(b) {
|
||||
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(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)?;
|
||||
|
||||
@@ -387,9 +387,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
(solidcraft_doc::edge_refs(&st, body, edges), solidcraft_doc::edge_names_for(&st, body, edges))
|
||||
}
|
||||
// Sheet metal edges are named by the sheet model.
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
|
||||
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges))
|
||||
FeatureKind::SheetFlange { edges, .. } => {
|
||||
let points: Vec<_> = edges.iter().map(|e| e.point).collect();
|
||||
(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)),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let face_names = {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! the flat pattern (outline, bend lines, bend table) and its DXF export.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FeatureKind;
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::sheet::{SheetBody, SheetRule};
|
||||
use solidcraft_doc::{FeatureKind, FlangeEdge};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -15,7 +15,8 @@ use crate::{EngineError, Result, Session};
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
|
||||
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
|
||||
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?} for an edge with its own angle and height] + \
|
||||
height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body)",
|
||||
),
|
||||
CommandSpec::new("sheet.hem", "Hem", hem)
|
||||
@@ -61,6 +62,39 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `edges` of an edge flange: points `[x, y, z]` on sheet edges, or `{point, angle?, height?}`
|
||||
/// for an edge with its own angle and height (expressions, checked like the feature's).
|
||||
fn flange_edges(s: &Session, p: &Value, cmd: &str) -> Result<Vec<FlangeEdge>> {
|
||||
const KEYS: [&str; 3] = ["point", "angle", "height"];
|
||||
let list = "`edges` must list points [x, y, z] on sheet edges, or {point, angle?, height?}";
|
||||
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, list))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "`edges` must list 1…1000 points"));
|
||||
}
|
||||
let mut edges = Vec::with_capacity(a.len());
|
||||
for (i, v) in a.iter().enumerate() {
|
||||
// A bare point, as before: [x, y, z] or {x, y, z}.
|
||||
let Some(o) = v.as_object().filter(|o| KEYS.iter().any(|k| o.contains_key(*k))) else {
|
||||
edges.push(FlangeEdge::at(vec3(v).ok_or_else(|| bad(cmd, format!("edges[{i}]: {list}")))?));
|
||||
continue;
|
||||
};
|
||||
if let Some(k) = o.keys().find(|k| !KEYS.contains(&k.as_str())) {
|
||||
return Err(bad(cmd, format!("edges[{i}]: unknown key `{k}` (an edge takes point, angle and height)")));
|
||||
}
|
||||
let point = o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("edges[{i}]: `point` must be [x, y, z]")))?;
|
||||
let own = |k: &str, kind: Kind| -> Result<Option<String>> {
|
||||
if o.get(k).is_none_or(Value::is_null) {
|
||||
return Ok(None);
|
||||
}
|
||||
let e = expr(v, k).ok_or_else(|| bad(cmd, format!("edges[{i}]: `{k}` must be a number or an expression")))?;
|
||||
check_expr(s, &e, kind, cmd, &format!("edges[{i}].{k}"))?;
|
||||
Ok(Some(e))
|
||||
};
|
||||
edges.push(FlangeEdge { point, angle: own("angle", Kind::Angle)?, height: own("height", Kind::Length)? });
|
||||
}
|
||||
Ok(edges)
|
||||
}
|
||||
|
||||
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
match str_(p, "rule") {
|
||||
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
|
||||
@@ -106,7 +140,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
"edge" => {
|
||||
let edges = points(p, "edges", cmd)?;
|
||||
let edges = flange_edges(s, p, cmd)?;
|
||||
let height = req_expr(cmd, p, "height")?;
|
||||
check_expr(s, &height, Kind::Length, cmd, "height")?;
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "90 deg".into());
|
||||
|
||||
@@ -1865,3 +1865,302 @@ fn corners_left_open_are_reported_on_the_flange() {
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!(volume(&mut s) > 0.0);
|
||||
}
|
||||
|
||||
/// 2 mm aluminium (R 1, K 0.5, gap 0.5) and a 120 x 90 base flange.
|
||||
fn alu_base(s: &mut Session) {
|
||||
run(
|
||||
s,
|
||||
"sheet.manage_rules",
|
||||
json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}),
|
||||
);
|
||||
base(s, 120.0, 90.0);
|
||||
}
|
||||
|
||||
/// The base's four edges (bottom, right, top, left) in one feature: 75° and 38.6 high from the
|
||||
/// feature, then 90°, 105° at 30 high and 120° at 25 high from the edges themselves.
|
||||
fn four_angles_in_one_feature() -> Session {
|
||||
let mut s = Session::default();
|
||||
alu_base(&mut s);
|
||||
let edges = json!([
|
||||
[60, 0, 2],
|
||||
{"point": [120, 45, 2], "angle": "90 deg"},
|
||||
{"point": [60, 90, 2], "angle": 105, "height": "30 mm"},
|
||||
{"point": {"x": 0, "y": 45, "z": 2}, "angle": "120 deg", "height": 25}
|
||||
]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "75 deg"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The flat outline's points.
|
||||
fn outline_points(f: &Value) -> Vec<(f64, f64)> {
|
||||
let loops = f["outline"].as_array().cloned().unwrap_or_default();
|
||||
loops
|
||||
.iter()
|
||||
.flat_map(|l| l.as_array().cloned().unwrap_or_default())
|
||||
.map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Two point lists are the same set of points, to 1e-6.
|
||||
fn same_points(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
|
||||
a.len() == b.len() && a.iter().all(|p| b.iter().any(|q| (p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6))
|
||||
}
|
||||
|
||||
fn bend_angles(s: &mut Session) -> Vec<f64> {
|
||||
flat(s)["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["angle_deg"].as_f64().unwrap_or(f64::NAN).round()).collect()
|
||||
}
|
||||
|
||||
fn no_errors_or_warnings(s: &Session) -> bool {
|
||||
s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none())
|
||||
}
|
||||
|
||||
/// The flanges of [`four_angles_in_one_feature`] (bottom, right, top, left): angles in degrees
|
||||
/// and heights.
|
||||
const FOUR_ANGLES: [f64; 4] = [75.0, 90.0, 105.0, 120.0];
|
||||
const FOUR_HEIGHTS: [f64; 4] = [38.6, 38.6, 30.0, 25.0];
|
||||
|
||||
/// That tray's panels in the flat pattern by hand (T 2, R 1, K 0.5, gap g 0.5; the derivation
|
||||
/// is with the doc tests). Folded, a flange at θ whose neighbour at a corner is at φ ends
|
||||
/// x(w) = ((g/2)·√(2 − 2·cosθ·cosφ) + T·(1 − cosθ·cosφ) − w·sinθ·cosφ) / sinφ along its edge from
|
||||
/// that corner, w up its outer face from the virtual sharp. In the flat that point lies
|
||||
/// s0 − BA − (w − s0) into the base from the edge, with s0 = (R + T)·tan(θ/2) the setback and
|
||||
/// BA = θ·(R + K·T) the bend allowance; the panel spans w = s0 … the flange's height.
|
||||
fn hand_panels() -> Vec<Vec<(f64, f64)>> {
|
||||
// Each edge's start corner, its direction, the direction into the base, and its length.
|
||||
let frames: [((f64, f64), (f64, f64), (f64, f64), f64); 4] = [
|
||||
((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 120.0),
|
||||
((120.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 90.0),
|
||||
((120.0, 90.0), (-1.0, 0.0), (0.0, -1.0), 120.0),
|
||||
((0.0, 90.0), (0.0, -1.0), (1.0, 0.0), 90.0),
|
||||
];
|
||||
(0..4)
|
||||
.map(|i| {
|
||||
let (o, d, v, len) = frames[i];
|
||||
let (th, h) = (FOUR_ANGLES[i].to_radians(), FOUR_HEIGHTS[i]);
|
||||
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
|
||||
let x_end = |phi: f64, w: f64| {
|
||||
let c = th.cos() * phi.cos();
|
||||
(0.25 * (2.0 - 2.0 * c).sqrt() + 2.0 * (1.0 - c) - w * th.sin() * phi.cos()) / phi.sin()
|
||||
};
|
||||
let (prev, next) = (FOUR_ANGLES[(i + 3) % 4].to_radians(), FOUR_ANGLES[(i + 1) % 4].to_radians());
|
||||
let at = |u: f64, w: f64| {
|
||||
let into = s0 - ba - (w - s0);
|
||||
(o.0 + d.0 * u + v.0 * into, o.1 + d.1 * u + v.1 * into)
|
||||
};
|
||||
vec![at(x_end(prev, s0), s0), at(x_end(prev, h), h), at(len - x_end(next, h), h), at(len - x_end(next, s0), s0)]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A polygon's area.
|
||||
fn polygon_area(p: &[(f64, f64)]) -> f64 {
|
||||
let n = p.len();
|
||||
(0..n).map(|i| p[i].0 * p[(i + 1) % n].1 - p[(i + 1) % n].0 * p[i].1).sum::<f64>().abs() / 2.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flanges_take_their_own_angle_and_height_per_edge() {
|
||||
let mut one = four_angles_in_one_feature();
|
||||
assert!(no_errors_or_warnings(&one), "{:?}", one.model.results);
|
||||
let f1 = flat(&mut one);
|
||||
// The bends: each edge's own angle, as long as the base's edge between its neighbours' bend
|
||||
// start lines (each s0 in from the original edge).
|
||||
assert_eq!(bend_angles(&mut one), FOUR_ANGLES);
|
||||
let s0 = FOUR_ANGLES.map(|a| 3.0 * (a.to_radians() / 2.0).tan());
|
||||
let ba = FOUR_ANGLES.map(|a| a.to_radians() * 2.0);
|
||||
let (w, h) = (120.0 - s0[3] - s0[1], 90.0 - s0[0] - s0[2]);
|
||||
let lengths: Vec<f64> = f1["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect();
|
||||
assert_eq!(lengths.len(), 4, "{f1}");
|
||||
for (got, want) in lengths.iter().zip([w, h, w, h]) {
|
||||
assert!((got - want).abs() < 1e-6, "{lengths:?} vs {w} and {h}");
|
||||
}
|
||||
// The flat pattern is one outline through every hand-derived panel corner.
|
||||
let (outline, panels) = (outline_points(&f1), hand_panels());
|
||||
assert_eq!(f1["outline"].as_array().map(Vec::len), Some(1), "{f1}");
|
||||
for (i, panel) in panels.iter().enumerate() {
|
||||
for &(x, y) in panel {
|
||||
assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "flange {i}: ({x}, {y}) not in {outline:?}");
|
||||
}
|
||||
}
|
||||
// It holds the base, the four bend zones and the four panels, nothing more or less, and its
|
||||
// size is the panels' extent.
|
||||
let area = w * h + (ba[0] + ba[2]) * w + (ba[1] + ba[3]) * h + panels.iter().map(|p| polygon_area(p)).sum::<f64>();
|
||||
assert!(rel(polygon_area(&outline), area) < 1e-9, "{} vs {area}", polygon_area(&outline));
|
||||
let all = panels.concat();
|
||||
let span = |k: fn(&(f64, f64)) -> f64| all.iter().map(k).fold(f64::MIN, f64::max) - all.iter().map(k).fold(f64::MAX, f64::min);
|
||||
for (k, want) in [span(|p| p.0), span(|p| p.1), 2.0].into_iter().enumerate() {
|
||||
assert!((f1["flat_size_mm"][k].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{}: {want}", f1["flat_size_mm"]);
|
||||
}
|
||||
// Folded, the part holds that material (K = 0.5 keeps the volume).
|
||||
let v1 = volume(&mut one);
|
||||
assert!(rel(v1, area * 2.0) < 1e-3, "{v1} vs {}", area * 2.0);
|
||||
// The DXF: the outline in straight lines, and four bend lines.
|
||||
let dxf = run(&mut one, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{} outline points", outline.len());
|
||||
// Unfold and refold keep it.
|
||||
run(&mut one, "sheet.unfold", json!({}));
|
||||
assert!(same_points(&outline_points(&flat(&mut one)), &outline_points(&f1)));
|
||||
run(&mut one, "sheet.refold", json!({}));
|
||||
assert!(rel(volume(&mut one), v1) < 1e-9);
|
||||
// Saved, an edge with its own values is an object and one without is the point it always was.
|
||||
let text = one.doc.to_json();
|
||||
let saved: Value = serde_json::from_str(&text).unwrap();
|
||||
let flange = saved["features"]
|
||||
.as_array()
|
||||
.and_then(|f| f.iter().find(|f| f["type"] == "sheet_flange" && f["edges"].as_array().is_some_and(|e| e.len() == 4)));
|
||||
assert_eq!(
|
||||
flange.map(|f| f["edges"].clone()),
|
||||
Some(json!([
|
||||
[60.0, 0.0, 2.0],
|
||||
{"point": [120.0, 45.0, 2.0], "angle": "90 deg"},
|
||||
{"point": [60.0, 90.0, 2.0], "angle": "105", "height": "30 mm"},
|
||||
{"point": [0.0, 45.0, 2.0], "angle": "120 deg", "height": "25"}
|
||||
])),
|
||||
"{saved}"
|
||||
);
|
||||
// Reopened, it is the same part.
|
||||
let mut again = Session::new(solidcraft_doc::Document::from_json(&text).unwrap());
|
||||
assert!(no_errors_or_warnings(&again), "{:?}", again.model.results);
|
||||
assert!(rel(volume(&mut again), v1) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_edge_values_are_expressions_and_feature_inputs() {
|
||||
let build = |angle: &str, height: &str| {
|
||||
let mut s = Session::default();
|
||||
alu_base(&mut s);
|
||||
run(&mut s, "parameters.add", json!({"name": "wall_angle", "expression": "105 deg"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "wall_height", "expression": "30 mm"}));
|
||||
let edges = json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "90 deg"}, {"point": [60, 90, 2], "angle": angle, "height": height}]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "75 deg"}));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
s
|
||||
};
|
||||
let part = |s: &mut Session| (volume(s), outline_points(&flat(s)));
|
||||
let mut s = build("wall_angle", "wall_height");
|
||||
// The feature's inputs: its own, then each edge's, in the order of the edges.
|
||||
let labels: Vec<String> =
|
||||
s.doc.features.last().map(|f| f.kind.inputs().into_iter().map(|(l, e, _)| format!("{l}={e}")).collect()).unwrap_or_default();
|
||||
assert_eq!(labels, ["Height=38.6", "Angle=75 deg", "Edge angle=90 deg", "Edge angle=wall_angle", "Edge height=wall_height"]);
|
||||
// Changing the user parameters moves that flange.
|
||||
run(&mut s, "parameters.change", json!({"name": "wall_angle", "expression": "110 deg"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "wall_height", "expression": "34 mm"}));
|
||||
let (v, outline) = part(&mut build("110 deg", "34 mm"));
|
||||
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
|
||||
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
|
||||
// So does the feature's own input for an edge, by its parameter name (the last two).
|
||||
let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default();
|
||||
assert_eq!(names.len(), 5, "{names:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": names[3], "expression": "100 deg"}));
|
||||
run(&mut s, "parameters.change", json!({"name": names[4], "expression": "36 mm"}));
|
||||
let (v, outline) = part(&mut build("100 deg", "36 mm"));
|
||||
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
|
||||
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_per_edge_input_is_refused_and_plain_edges_stay_plain() {
|
||||
let mut s = Session::default();
|
||||
alu_base(&mut s);
|
||||
let err = |s: &mut Session, edges: Value| {
|
||||
s.execute("sheet.flange", &json!({"edges": edges, "height": 38.6, "angle": "90 deg"})).err().map(|e| e.to_string()).unwrap_or_default()
|
||||
};
|
||||
// The wrong kind of value, or no expression at all: the edge and the key are named.
|
||||
let e = err(&mut s, json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "10 mm"}]));
|
||||
assert!(e.contains("edges[1].angle"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "height": "30 deg"}]));
|
||||
assert!(e.contains("edges[0].height"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "angle": "nope"}]));
|
||||
assert!(e.contains("edges[0].angle"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "angle": [1, 2]}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("`angle` must be a number or an expression"), "{e}");
|
||||
// An unknown key, a point that is none, an angle without `point`, something that is no edge.
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "twist": 3}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("unknown key `twist`"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0], "angle": "60 deg"}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("`point` must be [x, y, z]"), "{e}");
|
||||
let e = err(&mut s, json!([{"x": 60, "y": 0, "z": 2, "angle": "60 deg"}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("unknown key"), "{e}");
|
||||
let e = err(&mut s, json!([{"angle": "60 deg"}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("`point` must be [x, y, z]"), "{e}");
|
||||
let e = err(&mut s, json!(["edge"]));
|
||||
assert!(e.contains("edges[0]"), "{e}");
|
||||
let e = err(&mut s, json!([]));
|
||||
assert!(e.contains("1…1000"), "{e}");
|
||||
// Evaluated: an angle outside 0…180° and a height under the bend's, each naming its edge.
|
||||
let e = err(&mut s, json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "200 deg"}]));
|
||||
assert!(e.contains("edge 2") && e.contains("angle must be between 0 and 180"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "height": "2 mm"}, [120, 45, 2]]));
|
||||
assert!(e.contains("edge 1") && e.contains("height must be at least"), "{e}");
|
||||
// None of it added a feature.
|
||||
assert_eq!(s.doc.features.len(), 2, "{:?}", s.doc.features.iter().map(|f| f.name.clone()).collect::<Vec<_>>());
|
||||
// Plain points, `{x, y, z}` points and `{point}` without values are saved as the points they were.
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[60, 0, 2], {"x": 120, "y": 45, "z": 2}, {"point": [60, 90, 2]}], "height": 20, "angle": "90 deg"}));
|
||||
let edges = serde_json::to_value(s.doc.features.last().map(|f| f.kind.clone())).unwrap()["edges"].clone();
|
||||
assert_eq!(edges, json!([[60.0, 0.0, 2.0], [120.0, 45.0, 2.0], [60.0, 90.0, 2.0]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_flange_keeps_each_edges_own_values_with_its_edge() {
|
||||
let mut s = four_angles_in_one_feature();
|
||||
let id = s.doc.features.last().map(|f| f.id).unwrap_or(0);
|
||||
// Give the first edge 80° and take the 90° off the second.
|
||||
let edges = json!([
|
||||
{"point": [60, 0, 2], "angle": "80 deg"},
|
||||
[120, 45, 2],
|
||||
{"point": [60, 90, 2], "angle": "105 deg", "height": "30 mm"},
|
||||
{"point": [0, 45, 2], "angle": "120 deg", "height": "25 mm"}
|
||||
]);
|
||||
run(&mut s, "timeline.edit", json!({"feature": id, "set": {"edges": edges}}));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
assert_eq!(bend_angles(&mut s), [80.0, 75.0, 105.0, 120.0]);
|
||||
let kind = |s: &Session| serde_json::to_value(s.doc.features.last().map(|f| f.kind.clone())).unwrap();
|
||||
assert_eq!(kind(&s)["edges"][1], json!([120.0, 45.0, 2.0]), "no value of its own: a plain point again");
|
||||
let (v, outline) = (volume(&mut s), outline_points(&flat(&mut s)));
|
||||
// The same edges in another order are the same part: each value follows its edge.
|
||||
let edges = json!([
|
||||
{"point": [0, 45, 2], "angle": "120 deg", "height": "25 mm"},
|
||||
{"point": [60, 90, 2], "angle": "105 deg", "height": "30 mm"},
|
||||
{"point": [60, 0, 2], "angle": "80 deg"},
|
||||
[120, 45, 2]
|
||||
]);
|
||||
run(&mut s, "timeline.edit", json!({"feature": id, "set": {"edges": edges}}));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
assert_eq!(bend_angles(&mut s), [120.0, 105.0, 80.0, 75.0]);
|
||||
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
|
||||
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
|
||||
// A bad edit is refused and leaves the feature as it was.
|
||||
let before = kind(&s);
|
||||
assert!(s.execute("timeline.edit", &json!({"feature": id, "set": {"edges": [{"point": [60, 0, 2], "angle": "80 deg", "colour": 1}]}})).is_err());
|
||||
assert!(s.execute("timeline.edit", &json!({"feature": id, "set": {"edges": [{"point": [60, 0, 2], "angle": "250 deg"}]}})).is_err());
|
||||
assert_eq!(kind(&s), before);
|
||||
assert_eq!(bend_angles(&mut s), [120.0, 105.0, 80.0, 75.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designs_without_per_edge_values_are_read_and_written_as_before() {
|
||||
let mut s = Session::default();
|
||||
alu_base(&mut s);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 38.6, "angle": "72 deg"}));
|
||||
let v = volume(&mut s);
|
||||
let text = s.doc.to_json();
|
||||
let mut design: Value = serde_json::from_str(&text).unwrap();
|
||||
let flange = design["features"].as_array().and_then(|f| f.iter().position(|f| f["type"] == "sheet_flange")).unwrap();
|
||||
assert_eq!(design["features"][flange]["edges"], json!([[60.0, 0.0, 2.0], [120.0, 45.0, 2.0], [60.0, 90.0, 2.0], [0.0, 45.0, 2.0]]));
|
||||
let mut same = Session::new(solidcraft_doc::Document::from_json(&text).unwrap());
|
||||
assert!(rel(volume(&mut same), v) < 1e-12);
|
||||
// A design file whose last edge carries its own angle is the part the command makes.
|
||||
design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "angle": "100 deg"});
|
||||
let mut opened = Session::new(solidcraft_doc::Document::from_json(&design.to_string()).unwrap());
|
||||
assert!(no_errors_or_warnings(&opened), "{:?}", opened.model.results);
|
||||
let mut made = Session::default();
|
||||
alu_base(&mut made);
|
||||
let edges = json!([[60, 0, 2], [120, 45, 2], [60, 90, 2], {"point": [0, 45, 2], "angle": "100 deg"}]);
|
||||
run(&mut made, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "72 deg"}));
|
||||
let (vo, vm) = (volume(&mut opened), volume(&mut made));
|
||||
assert!(rel(vo, vm) < 1e-9 && rel(vo, v) > 1e-4, "{vo} vs {vm} vs {v}");
|
||||
// A key this build doesn't know is refused, not dropped.
|
||||
design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "twist": "3 deg"});
|
||||
assert!(solidcraft_doc::Document::from_json(&design.to_string()).is_err());
|
||||
}
|
||||
@@ -46,6 +46,9 @@ pub enum Sm {
|
||||
distance: String,
|
||||
reverse: bool,
|
||||
rule: usize,
|
||||
/// Edge flanges: the edges with an angle and/or a height of their own (the picked point,
|
||||
/// angle, height; an empty text means the dialog's), from the rows per edge.
|
||||
own: Vec<(Vec3, String, String)>,
|
||||
},
|
||||
Hem {
|
||||
length: String,
|
||||
@@ -125,6 +128,7 @@ fn new_flange(ty: usize) -> Sm {
|
||||
distance: "20 mm".into(),
|
||||
reverse: false,
|
||||
rule: 0,
|
||||
own: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +192,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
let t = Tokens::get();
|
||||
let mut enter = false;
|
||||
match k {
|
||||
Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule } => {
|
||||
Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule, own } => {
|
||||
*ty = flange_type(inputs);
|
||||
row_label(ui, "Type");
|
||||
ui.label(FLANGE_TYPES.get(*ty).copied().unwrap_or(""));
|
||||
@@ -209,6 +213,34 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
crate::params_dialog::complete(ui, &r, radius);
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.end_row();
|
||||
// With several edges, a row for each: an angle and a height of its own, left
|
||||
// empty for the values above (shown as the hints).
|
||||
let points = edge_point_list(inputs);
|
||||
if points.len() > 1 || own.iter().any(|o| !(o.1.trim().is_empty() && o.2.trim().is_empty())) {
|
||||
row_label(ui, "Per Edge");
|
||||
ui.label(RichText::new("angle, height").weak()).on_hover_text("An edge's own angle and height. Empty: the values above.");
|
||||
ui.end_row();
|
||||
let w = ((ui.spacing().text_edit_width - ui.spacing().item_spacing.x) / 2.0).max(40.0);
|
||||
for (n, p) in points.iter().enumerate() {
|
||||
let slot = own_slot(own, *p);
|
||||
let Some((_, a, h)) = own.get_mut(slot) else { continue };
|
||||
row_label(ui, &format!("Edge {}", n + 1));
|
||||
ui.horizontal(|ui| {
|
||||
for (text, hint, what, id) in
|
||||
[(a, angle.as_str(), "angle", "sm_edge_angle"), (h, height.as_str(), "height", "sm_edge_height")]
|
||||
{
|
||||
let r = ui
|
||||
.add(egui::TextEdit::singleline(text).hint_text(hint).desired_width(w).id_salt((id, n)))
|
||||
.on_hover_text(format!("The {what} of the edge picked at ({:.1}, {:.1}, {:.1})", p.x, p.y, p.z));
|
||||
// For scenarios and the control channel: `dialog:Edge 2 angle`.
|
||||
crate::scenario::publish_handle(&format!("dialog:Edge {} {what}", n + 1), r.rect.center());
|
||||
crate::params_dialog::complete(ui, &r, text);
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
row_label(ui, "Distance");
|
||||
@@ -419,14 +451,48 @@ fn edge_points(inputs: &[SelInput]) -> Vec<Value> {
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The picked edges' points.
|
||||
fn edge_point_list(inputs: &[SelInput]) -> Vec<Vec3> {
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(*point) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// Where the own angle and height of the edge picked at `p` are kept (an empty entry on first use).
|
||||
fn own_slot(own: &mut Vec<(Vec3, String, String)>, p: Vec3) -> usize {
|
||||
own.iter().position(|o| o.0.dist(p) < 1e-9).unwrap_or_else(|| {
|
||||
own.push((p, String::new(), String::new()));
|
||||
own.len() - 1
|
||||
})
|
||||
}
|
||||
|
||||
/// The edges for `sheet.flange`: a plain point, or `{point, angle?, height?}` for an edge with
|
||||
/// an angle and/or a height of its own.
|
||||
fn flange_edges(inputs: &[SelInput], own: &[(Vec3, String, String)]) -> Vec<Value> {
|
||||
edge_point_list(inputs)
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let (a, h) = own.iter().find(|o| o.0.dist(p) < 1e-9).map(|o| (o.1.trim(), o.2.trim())).unwrap_or(("", ""));
|
||||
if a.is_empty() && h.is_empty() {
|
||||
return pt(p);
|
||||
}
|
||||
let mut e = json!({"point": pt(p)});
|
||||
for (k, v) in [("angle", a), ("height", h)] {
|
||||
if !v.is_empty() {
|
||||
e[k] = json!(v);
|
||||
}
|
||||
}
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The commands the dialog's OK runs (`extra`: parameters kept from the feature being edited).
|
||||
pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
|
||||
let s = &app.session;
|
||||
let (cmd, mut p): (&str, Value) = match k {
|
||||
Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, .. } => {
|
||||
Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, own, .. } => {
|
||||
let mut p = match flange_type(inputs) {
|
||||
1 => {
|
||||
let mut p = json!({"type": "edge", "edges": edge_points(inputs), "height": height, "angle": angle,
|
||||
let mut p = json!({"type": "edge", "edges": flange_edges(inputs, own), "height": height, "angle": angle,
|
||||
"position": POSITIONS.get(*position).copied().unwrap_or("inside")});
|
||||
if !radius.trim().is_empty() {
|
||||
p["radius"] = json!(radius);
|
||||
@@ -572,6 +638,16 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
|
||||
if let Some(b) = body {
|
||||
extra.insert("body".into(), json!(b));
|
||||
}
|
||||
// The edges, and the angle and height some have of their own (kept by the point the
|
||||
// edge's selection carries).
|
||||
let (mut picked, mut own) = (Vec::new(), Vec::new());
|
||||
for e in edges {
|
||||
let Some(sel) = edge_sel(s, e.point) else { continue };
|
||||
if let (Sel::Edge { point, .. }, true) = (&sel, e.angle.is_some() || e.height.is_some()) {
|
||||
own.push((*point, e.angle.clone().unwrap_or_default(), e.height.clone().unwrap_or_default()));
|
||||
}
|
||||
picked.push(sel);
|
||||
}
|
||||
let f = Sm::Flange {
|
||||
ty: 1,
|
||||
height: height.clone(),
|
||||
@@ -582,8 +658,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
|
||||
distance: "20 mm".into(),
|
||||
reverse: false,
|
||||
rule: 0,
|
||||
own,
|
||||
};
|
||||
(f, edges.iter().filter_map(|p| edge_sel(s, *p)).collect())
|
||||
(f, picked)
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
if let Some(b) = body {
|
||||
|
||||
@@ -318,3 +318,65 @@ fn pocket_depth_labels_follow_inches_without_changing_flat_data() {
|
||||
assert_eq!(super::pocket_label(&app, &json!({"side":"top","depth_mm":25.4*0.1234})).as_deref(), Some("Top pocket: 0.1234 in deep"));
|
||||
assert!(super::pocket_label(&app, &json!({"side":"top","depth_mm":-1})).is_none());
|
||||
}
|
||||
|
||||
/// Edges with an angle and/or a height of their own: the texts of the dialog's rows per edge
|
||||
/// become `{point, angle?, height?}` edges while the others stay plain points; Edit Feature
|
||||
/// brings them back and rebuilds the same flanges.
|
||||
#[test]
|
||||
fn edge_flanges_can_have_an_angle_and_a_height_of_their_own() {
|
||||
let mut app = plate();
|
||||
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
let edges: Vec<Sel> = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]]
|
||||
.iter()
|
||||
.filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2])))
|
||||
.collect();
|
||||
assert_eq!(edges.len(), 4);
|
||||
let point = |i: usize| if let Sel::Edge { point, .. } = &edges[i] { *point } else { Vec3::ZERO };
|
||||
app.session.selection = edges.clone();
|
||||
app.start("sheet.flange");
|
||||
let mut d = app.dialog.clone().unwrap();
|
||||
if let Kind::Sheet(Sm::Flange { height, angle, own, .. }) = &mut d.kind {
|
||||
*height = "20 mm".into();
|
||||
*angle = "75 deg".into();
|
||||
// The second edge: its own angle; the fourth: both; the third: texts left blank.
|
||||
own.push((point(1), "100 deg".into(), String::new()));
|
||||
own.push((point(3), "90 deg".into(), "15 mm".into()));
|
||||
own.push((point(2), " ".into(), String::new()));
|
||||
}
|
||||
let c = apply_commands(&app, &d).unwrap();
|
||||
assert_eq!(
|
||||
c[0].1["edges"],
|
||||
json!([pt(point(0)), {"point": pt(point(1)), "angle": "100 deg"}, pt(point(2)), {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}]),
|
||||
"no text or a blank one: a plain point"
|
||||
);
|
||||
run_all(&mut app, c);
|
||||
let bends = app.session.execute("sheet.flat_pattern", &json!({})).unwrap()["bends"].clone();
|
||||
let angles: Vec<f64> = bends.as_array().unwrap().iter().map(|b| b["angle_deg"].as_f64().unwrap().round()).collect();
|
||||
assert_eq!(angles, [75.0, 100.0, 75.0, 90.0], "{bends}");
|
||||
assert!(app.session.model.results.iter().all(|r| r.error.is_none()), "{:?}", app.session.model.results);
|
||||
// Edit Feature: the edges and their own values come back, and OK rebuilds the same part.
|
||||
let f = app.session.doc.features.last().unwrap().clone();
|
||||
let mut e = edit_dialog(&app, f.id, None).unwrap();
|
||||
assert_eq!(e.inputs[0].items.len(), 4);
|
||||
let Kind::Sheet(Sm::Flange { own, .. }) = &e.kind else { panic!("not a flange dialog") };
|
||||
assert_eq!(own.len(), 2, "{own:?}");
|
||||
assert!(own.iter().any(|o| o.0.dist(point(1)) < 1e-9 && o.1 == "100 deg" && o.2.is_empty()), "{own:?}");
|
||||
assert!(own.iter().any(|o| o.0.dist(point(3)) < 1e-9 && o.1 == "90 deg" && o.2 == "15 mm"), "{own:?}");
|
||||
let volume = |app: &mut SolveApp| app.session.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let before = volume(&mut app);
|
||||
let c = apply_commands(&app, &e).unwrap();
|
||||
assert_eq!(c[0].0, "timeline.redefine");
|
||||
assert_eq!(c[0].1["params"]["edges"][1], json!({"point": pt(point(1)), "angle": "100 deg"}));
|
||||
assert_eq!(c[0].1["params"]["edges"][3]["height"], "15 mm");
|
||||
run_all(&mut app, c);
|
||||
let after = volume(&mut app);
|
||||
assert!((after - before).abs() < 1e-6 * before, "{after} vs {before}");
|
||||
// Taking edges out of the selection leaves each value with its edge.
|
||||
e.inputs[0].items.remove(2);
|
||||
e.inputs[0].items.remove(0);
|
||||
let c = apply_commands(&app, &e).unwrap();
|
||||
assert_eq!(
|
||||
c[0].1["params"]["edges"],
|
||||
json!([{"point": pt(point(1)), "angle": "100 deg"}, {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
[
|
||||
{
|
||||
"note": "Two edge flanges in one feature; the second gets an angle of its own in its row of the Flange dialog. Default rule: T 2.5, R 2.5, gap 2.5; height 10. By hand: s0 = (R + T)·tan(θ/2) is 5 at 90° and 2.886751 at 60°, so the base keeps 57.113249 x 35 = 1998.963703 mm². Each bend holds len·θ·(R + T/2): 57.113249·(π/2)·3.75 + 35·(π/3)·3.75 = 473.869483. The 90° panel ends x(w) = ((g/2)·√2 + T − w·cos 60°) / sin 60° from the corner, 2.041241 at w = 5 and −0.845510 at w = 10: 5·(60 − 0.597866) = 297.010671. The 60° panel ends (g/2)·√2 + T = 4.267767 from the corner and is 7.113249 high: 254.172259. Volume: 2.5·3024.016116 = 7560.040 mm³."
|
||||
},
|
||||
{
|
||||
"call": "ui.set",
|
||||
"params": {
|
||||
"tab": "SHEET METAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.create"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"plane": "XY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.rectangle.two_point"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"sketch": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"sketch": [
|
||||
60,
|
||||
40
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"start": "sketch.finish"
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "iso"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sheet.flange"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
30,
|
||||
20,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bodies": 1,
|
||||
"volume": 6000
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sheet.flange"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
30,
|
||||
0,
|
||||
2.5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
60,
|
||||
20,
|
||||
2.5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "dialog:Edge 2 angle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"text": "60 deg"
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bodies": 1,
|
||||
"errors": 0,
|
||||
"dialog": null,
|
||||
"volume": [
|
||||
7560.04,
|
||||
0.0001
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"shot": "s49_flange_per_edge"
|
||||
}
|
||||
]
|
||||
@@ -24,6 +24,24 @@ opposite ways, ends that would overlap in the flat pattern) stays open, and the
|
||||
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.
|
||||
|
||||
The flanges of one edge flange feature can each have an angle and a height of their own. An
|
||||
entry of `edges` is a point `[x, y, z]`, which takes the feature's `height` and `angle`, or an
|
||||
object `{"point": [x, y, z], "angle": "105 deg", "height": "30 mm"}` whose `angle` and `height`
|
||||
(either one, or both) replace them for that edge. They are expressions like the feature's own,
|
||||
so parameters work, and each is a feature input of its own (`Edge angle`, `Edge height`) after
|
||||
`Height` and `Angle`. The corners between such flanges close like those between equal ones:
|
||||
the ends run toward each other the rule's gap apart, either side of the plane halfway between
|
||||
the two flanges, and a taller flange's end keeps that line up to its own tip. This is how to
|
||||
close a corner between flanges at different angles, because flanges of separate features
|
||||
don't close toward each other. In the Flange dialog, picking more than one edge shows a row
|
||||
per edge with an angle and a height box; an empty box takes the value above.
|
||||
|
||||
```json
|
||||
{"command":"sheet.flange","params":{"edges":[[60,0,2],{"point":[120,45,2],"angle":"90 deg"},
|
||||
{"point":[60,90,2],"angle":"105 deg","height":"30 mm"},{"point":[0,45,2],"angle":"120 deg","height":"25 mm"}],
|
||||
"height":38.6,"angle":"75 deg"}}
|
||||
```
|
||||
|
||||
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