diff --git a/ROADMAP.md b/ROADMAP.md index 504b6e71..0ecfa01a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 0422e883..22167a93 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -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); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index c3ac3716..e54f98ef 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, + pub height: Option, +} + +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(&self, s: S) -> std::result::Result { + 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: D) -> std::result::Result { + 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, + #[serde(default)] + height: Option, + } + 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>(self, seq: A) -> std::result::Result { + Vec3::deserialize(SeqAccessDeserializer::new(seq)).map(FlangeEdge::at) + } + fn visit_map>(self, map: A) -> std::result::Result { + 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, + edges: Vec, 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); diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index b4be6867..78e31286 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -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 = edges.iter().map(|e| e.point).collect(); + let each: Vec = 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() { diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index f9309c9e..b8270056 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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)); diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 88b5dc3a..cb64abfb 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -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, Vec)> { - 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 = 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, Vec)> { + 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) { + 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) { + 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 = (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, + 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 { + 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 { + 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 = (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); + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a8636c4e..9cac5c97 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -322,6 +322,14 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { 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::>(); + 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)?; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 3adfe75f..8ebae7f9 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 = { diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 57b2bbe0..53ffc7d2 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -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> { .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> { + 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> { + 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> { 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 { ) } "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()); diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index af4f54db..26297aa8 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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 { + 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> { + // 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::().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 = 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::(); + 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 = + 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::>()); + // 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()); +} diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 40ebc902..d7532600 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -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 { 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 { + 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 { + 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) -> Result, 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 Option<(Kind, Vec { if let Some(b) = body { diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 8d83e428..d2212184 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -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 = [[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 = 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"}]) + ); +} diff --git a/crates/ui-egui/tests/scenarios/s49_sheet_metal_flange_per_edge.json b/crates/ui-egui/tests/scenarios/s49_sheet_metal_flange_per_edge.json new file mode 100644 index 00000000..91392978 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s49_sheet_metal_flange_per_edge.json @@ -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" + } +] diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index fb01530e..8d73337b 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -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