feat: corner reliefs at sheet flange corners, chosen by the rule (#488)
Sheet Metal Rules get a Corner Relief Shape (notch, round or rectangular), sized by the rule's corner relief, cut where two bends meet at a corner of the base. Notch, the default, keeps existing designs as they were. File format 16. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c2a9c71436
commit
d6af554c6d
11 files changed
+830
-15
No files matched your search
+1
-1
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature | in progress | 36 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular) chosen by the rule | 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 |
|
||||
|
||||
@@ -51,13 +51,17 @@
|
||||
//! anchors can name a sheet metal panel side (`sheet:<body>:<panel>:<side>`). Version 14
|
||||
//! designs need no transformation; the version stamp lets older readers request an update
|
||||
//! instead of encountering an unknown certificate or link kind.
|
||||
//! - 16: sheet metal rules can choose a corner relief shape (`corner_relief_shape`: notch,
|
||||
//! round or rectangular). Version 15 designs need no transformation (an absent shape is the
|
||||
//! notch they always had); the version stamp lets older readers request an update instead of
|
||||
//! silently building notches where a design asks for another shape.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 15;
|
||||
pub const FORMAT: u32 = 16;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -74,7 +78,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 14] = [
|
||||
const JSON_STEPS: [JsonStep; 15] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -89,6 +93,7 @@ const JSON_STEPS: [JsonStep; 14] = [
|
||||
|_| Ok(()),
|
||||
v13_to_v14,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
|
||||
+422
-4
@@ -157,6 +157,11 @@ pub struct SheetRule {
|
||||
pub relief_depth: String,
|
||||
#[serde(default = "rule_corner_relief")]
|
||||
pub corner_relief: String,
|
||||
/// What is cut where two bends meet at a corner of the base: `notch` (nothing more: the
|
||||
/// bends end square at the corner), `round` or `rectangular` (a relief `corner_relief`
|
||||
/// across, centred on the corner the two bends share).
|
||||
#[serde(default = "rule_corner_relief_shape")]
|
||||
pub corner_relief_shape: String,
|
||||
/// Inner radius of flat hems.
|
||||
#[serde(default = "rule_hem_gap")]
|
||||
pub hem_gap: String,
|
||||
@@ -178,6 +183,9 @@ fn rule_relief_depth() -> String {
|
||||
fn rule_corner_relief() -> String {
|
||||
"Thickness * 4.0".into()
|
||||
}
|
||||
fn rule_corner_relief_shape() -> String {
|
||||
ReliefShape::Notch.name().into()
|
||||
}
|
||||
fn rule_hem_gap() -> String {
|
||||
"0.01 mm".into()
|
||||
}
|
||||
@@ -193,6 +201,7 @@ impl SheetRule {
|
||||
relief_width: rule_relief_width(),
|
||||
relief_depth: rule_relief_depth(),
|
||||
corner_relief: rule_corner_relief(),
|
||||
corner_relief_shape: rule_corner_relief_shape(),
|
||||
hem_gap: rule_hem_gap(),
|
||||
gap: rule_gap(),
|
||||
}
|
||||
@@ -214,6 +223,40 @@ impl SheetSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape of the relief cut where two bends meet at a corner of the base (a rule's
|
||||
/// `corner_relief_shape`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReliefShape {
|
||||
/// Nothing is cut: the bends end square at the corner.
|
||||
#[default]
|
||||
Notch,
|
||||
/// A round about the corner of the bends, through the angle the base fills there.
|
||||
Round,
|
||||
/// A square with a corner on the corner of the bends (a kite where the base's corner isn't
|
||||
/// square: both cuts leave their edge at a right angle).
|
||||
Rectangular,
|
||||
}
|
||||
|
||||
impl ReliefShape {
|
||||
pub fn parse(s: &str) -> Option<ReliefShape> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"notch" => Some(ReliefShape::Notch),
|
||||
"round" => Some(ReliefShape::Round),
|
||||
"rectangular" => Some(ReliefShape::Rectangular),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ReliefShape::Notch => "notch",
|
||||
ReliefShape::Round => "round",
|
||||
ReliefShape::Rectangular => "rectangular",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rule's values (mm, unit-less K).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct RuleValues {
|
||||
@@ -223,6 +266,7 @@ pub struct RuleValues {
|
||||
pub relief_width: f64,
|
||||
pub relief_depth: f64,
|
||||
pub corner_relief: f64,
|
||||
pub corner_relief_shape: ReliefShape,
|
||||
pub hem_gap: f64,
|
||||
pub gap: f64,
|
||||
}
|
||||
@@ -255,6 +299,8 @@ impl Document {
|
||||
relief_width: with_t(&r.relief_width, Kind::Length)?,
|
||||
relief_depth: with_t(&r.relief_depth, Kind::Length)?,
|
||||
corner_relief: with_t(&r.corner_relief, Kind::Length)?,
|
||||
corner_relief_shape: ReliefShape::parse(&r.corner_relief_shape)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown corner relief shape `{}` (notch, round or rectangular)", r.corner_relief_shape)))?,
|
||||
hem_gap: with_t(&r.hem_gap, Kind::Length)?,
|
||||
gap: with_t(&r.gap, Kind::Length)?,
|
||||
};
|
||||
@@ -328,6 +374,10 @@ pub struct SheetBody {
|
||||
pub flat: bool,
|
||||
/// Gap between flanges that meet at a corner.
|
||||
pub gap: f64,
|
||||
/// The relief cut where two bends meet at a corner of the base, and how far across it is
|
||||
/// (the rule's `corner_relief_shape` and `corner_relief`).
|
||||
pub relief: ReliefShape,
|
||||
pub relief_size: f64,
|
||||
/// The base sketch's lines (id, ends in flat coordinates), naming the base panel's edges.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub base_curves: Vec<(String, Vec2, Vec2)>,
|
||||
@@ -479,6 +529,9 @@ fn clip(poly: &[Vec2], o: Vec2, n: Vec2) -> Vec<Vec2> {
|
||||
/// The smallest gap closed corners are given: closer than this the flanges' ends would touch.
|
||||
const MIN_CORNER_GAP: f64 = 0.01;
|
||||
|
||||
/// The smallest corner relief (across) that is cut: a smaller one would leave hairline edges.
|
||||
const MIN_CORNER_RELIEF: f64 = 0.01;
|
||||
|
||||
/// Two bends on the base meeting at a corner (see [`SheetBody::corners`]): flanges `a` and `b`,
|
||||
/// which end of each (false = the end at `p`), and the corner point (flat coordinates).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -570,7 +623,8 @@ impl SheetBody {
|
||||
}
|
||||
|
||||
/// The part of a bend along its parent's edge: where a later flange trimmed that edge back
|
||||
/// (a corner), the bend stops and the flange panel runs on alone (a corner relief).
|
||||
/// (a corner) or a corner relief cut into it, the bend stops and the flange panel runs on
|
||||
/// alone.
|
||||
pub fn zone_span(&self, f: &SheetFlange) -> (f64, f64) {
|
||||
if f.parent.is_some() {
|
||||
return (0.0, f.len);
|
||||
@@ -1361,13 +1415,13 @@ impl SheetBody {
|
||||
|
||||
/// Edge flanges on several edges at once: every edge is trimmed back first, so flanges on
|
||||
/// adjacent edges stop at each other's bends; the corners between them are then closed with
|
||||
/// the rule's gap (`close_corners`).
|
||||
/// the rule's gap and given the rule's relief (`close_corners`).
|
||||
pub fn add_flanges(&mut self, picks: &[Vec3], height: f64, angle: f64, radius: f64, position: &str, flip: bool) -> Result<Vec<usize>> {
|
||||
self.add_flanges_noted(picks, height, angle, radius, position, flip).map(|(made, _)| made)
|
||||
}
|
||||
|
||||
/// [`SheetBody::add_flanges`], also returning notes on corners that could not be closed
|
||||
/// (they are left open, as the bends leave them).
|
||||
/// (they are left open, as the bends leave them) or relieved (they get no relief).
|
||||
pub fn add_flanges_noted(
|
||||
&mut self,
|
||||
picks: &[Vec3],
|
||||
@@ -1502,7 +1556,8 @@ impl SheetBody {
|
||||
out
|
||||
}
|
||||
|
||||
/// Close the corners the new flanges make. Returns a note for every corner left open.
|
||||
/// Close the corners the new flanges make, then cut the rule's relief at each. Returns a
|
||||
/// note for every corner left open or without its relief.
|
||||
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
|
||||
let corners = self.corners(made);
|
||||
let mut notes = Vec::new();
|
||||
@@ -1515,6 +1570,14 @@ impl SheetBody {
|
||||
notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c)));
|
||||
}
|
||||
}
|
||||
// The reliefs come last: they move the bends' ends, which is how the corners were found.
|
||||
if self.relief != ReliefShape::Notch {
|
||||
for k in &corners {
|
||||
if let Err(why) = self.relieve_corner(k, &corners) {
|
||||
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
@@ -1610,6 +1673,84 @@ impl SheetBody {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cut the rule's relief where two bends meet at a corner of the base, `relief_size / 2`
|
||||
/// from the corner along both edges: a round about the corner through the angle the base
|
||||
/// fills there, or the square with that side (a kite where the corner isn't square, its
|
||||
/// cuts square to the two edges). The base's edges then start that far from the corner, so
|
||||
/// both bends stop there, cut straight across (`zone_span`): their ends stay exact arcs,
|
||||
/// the round runs on into them tangentially, and the bend table gets the shorter bends.
|
||||
/// The flange panels keep the ends `close_corner` gave them. A relief too small to cut, one
|
||||
/// that would leave nothing of an edge it starts on (with the relief at that edge's other
|
||||
/// end, where there is one), or one reaching as far from the corner as another edge of the
|
||||
/// base is, is refused with the reason.
|
||||
fn relieve_corner(&mut self, k: &Corner, corners: &[Corner]) -> std::result::Result<(), String> {
|
||||
let size = self.relief_size;
|
||||
if !(size >= MIN_CORNER_RELIEF) {
|
||||
return Err(format!("the relief size {size:.4} mm is below {MIN_CORNER_RELIEF} mm"));
|
||||
}
|
||||
let r = size / 2.0;
|
||||
let segs = self.base.outer.ccw().segs;
|
||||
let n = segs.len();
|
||||
// The base's edges at the corner: `i` ends there and the next one starts there.
|
||||
let at_corner =
|
||||
|i: usize| segs.get(i).is_some_and(|s| s.end().dist(k.c) < 1e-6) && segs.get((i + 1) % n).is_some_and(|s| s.start().dist(k.c) < 1e-6);
|
||||
let Some(i) = (0..n).find(|&i| at_corner(i)) else { return Err("the bends don't meet at a corner of the base".into()) };
|
||||
let j = (i + 1) % n;
|
||||
let (Some(&Seg2::Line { a: pa, b: c }), Some(&Seg2::Line { b: pb, .. })) = (segs.get(i), segs.get(j)) else {
|
||||
return Err("the base's edges at the corner are not straight".into());
|
||||
};
|
||||
let (Some(da), Some(db)) = ((pa - c).normalized(), (pb - c).normalized()) else {
|
||||
return Err("an edge of the base at the corner has no length".into());
|
||||
};
|
||||
// The angle the base fills at the corner.
|
||||
let alpha = db.cross(da).atan2(db.dot(da));
|
||||
if !(alpha > 1e-6 && alpha < std::f64::consts::PI - 1e-6) {
|
||||
// An inside corner of the base: the flanges turn away from each other.
|
||||
return Ok(());
|
||||
}
|
||||
// Both bends must keep some length. An edge whose far end is another of the corners has
|
||||
// to hold a relief there too, so the reliefs at its two ends fit or are left out together.
|
||||
let takes = |far: Vec2| if corners.iter().any(|q| q.c.dist(far) < 1e-6) { 2.0 * r } else { r };
|
||||
if takes(pa) >= pa.dist(c) - 1e-6 || takes(pb) >= pb.dist(c) - 1e-6 {
|
||||
return Err(format!("a relief {size:.3} mm across would take a whole bend away"));
|
||||
}
|
||||
// The relief from the edge that ends at the corner to the one that starts there, and
|
||||
// how far from the corner it reaches.
|
||||
let (relief, reach) = match self.relief {
|
||||
ReliefShape::Notch => return Ok(()),
|
||||
ReliefShape::Round => (vec![Seg2::Arc { center: c, radius: r, start: da.angle(), sweep: -alpha }], r),
|
||||
ReliefShape::Rectangular => {
|
||||
let x = snap(c + (da + db) * (r / (1.0 + da.dot(db))));
|
||||
(vec![Seg2::Line { a: snap(c + da * r), b: x }, Seg2::Line { a: x, b: snap(c + db * r) }], x.dist(c))
|
||||
}
|
||||
};
|
||||
let (Some(start), Some(end)) = (relief.first().map(Seg2::start), relief.last().map(Seg2::end)) else { return Ok(()) };
|
||||
// The rest of the outline must stay clear of it.
|
||||
let clear = segs.iter().enumerate().filter(|(e, _)| *e != i && *e != j).all(|(_, s)| {
|
||||
s.polyline(1e-3).windows(2).all(|w| {
|
||||
let d = w[1] - w[0];
|
||||
let t = if d.len2() > 1e-30 { ((c - w[0]).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
c.dist(w[0] + d * t) > reach + 1e-6
|
||||
})
|
||||
});
|
||||
if !clear {
|
||||
return Err(format!("a relief {size:.3} mm across reaches {reach:.3} mm from the corner, where another edge of the base is nearer"));
|
||||
}
|
||||
let mut outer = Vec::with_capacity(n + relief.len());
|
||||
for (e, s) in segs.iter().enumerate() {
|
||||
if e == i {
|
||||
outer.push(Seg2::Line { a: pa, b: start });
|
||||
outer.extend(relief.iter().copied());
|
||||
} else if e == j {
|
||||
outer.push(Seg2::Line { a: end, b: pb });
|
||||
} else {
|
||||
outer.push(*s);
|
||||
}
|
||||
}
|
||||
self.base = Region2 { outer: Loop2 { segs: outer }, holes: self.base.holes.clone() };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A flat hem: a 180° bend with a tiny radius at the panel edge, `length` to its outer face.
|
||||
pub fn add_hem(&mut self, pick: Vec3, length: f64, gap: f64, flip: bool) -> Result<usize> {
|
||||
let (panel, a, b, top, dist) = self.edge_at(pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
|
||||
@@ -2294,6 +2435,8 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
|
||||
pockets: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
relief: rv.corner_relief_shape,
|
||||
relief_size: rv.corner_relief,
|
||||
base_curves: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -2346,6 +2489,8 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
pockets: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
relief: rv.corner_relief_shape,
|
||||
relief_size: rv.corner_relief,
|
||||
base_curves: Vec::new(),
|
||||
};
|
||||
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
|
||||
@@ -2461,6 +2606,7 @@ mod tests {
|
||||
relief_width: 2.5,
|
||||
relief_depth: 1.25,
|
||||
corner_relief: 10.0,
|
||||
corner_relief_shape: ReliefShape::Notch,
|
||||
hem_gap: 0.01,
|
||||
gap: 2.5,
|
||||
}
|
||||
@@ -2827,6 +2973,7 @@ mod tests {
|
||||
relief_width: 2.0,
|
||||
relief_depth: 1.0,
|
||||
corner_relief: 8.0,
|
||||
corner_relief_shape: ReliefShape::Notch,
|
||||
hem_gap: 0.01,
|
||||
gap: 2.0,
|
||||
};
|
||||
@@ -2908,6 +3055,7 @@ mod tests {
|
||||
relief_width: 2.0,
|
||||
relief_depth: 1.0,
|
||||
corner_relief: 8.0,
|
||||
corner_relief_shape: ReliefShape::Notch,
|
||||
hem_gap: 0.01,
|
||||
gap: 0.5,
|
||||
}
|
||||
@@ -3087,6 +3235,248 @@ mod tests {
|
||||
assert!((v - flat_area(s) * s.t).abs() < 1e-3 * v, "{what}: {v} vs {}", flat_area(s) * s.t);
|
||||
}
|
||||
|
||||
/// The flat area a corner relief `size` across takes where the base fills the angle `alpha`
|
||||
/// and both bends are `ba` wide: of the base a sector of radius size/2 (round) or the kite
|
||||
/// with that side, square to both edges (rectangular), and size/2 of each bend's length.
|
||||
fn relief_area(shape: ReliefShape, size: f64, alpha: f64, ba: f64) -> f64 {
|
||||
let r = size / 2.0;
|
||||
match shape {
|
||||
ReliefShape::Notch => 0.0,
|
||||
ReliefShape::Round => r * r * alpha / 2.0 + 2.0 * r * ba,
|
||||
ReliefShape::Rectangular => r * r * (alpha / 2.0).tan() + 2.0 * r * ba,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rule_names_its_corner_relief_shape() {
|
||||
// A rule saved before the shape existed reads as the notch it always had.
|
||||
let old: SheetRule = serde_json::from_str(r#"{"name":"Old","thickness":"2 mm","k_factor":"0.5","bend_radius":"1 mm"}"#).unwrap();
|
||||
assert_eq!(old.corner_relief_shape, "notch");
|
||||
assert_eq!(SheetRule::steel().corner_relief_shape, "notch");
|
||||
let (doc, vals) = (Document::default(), std::collections::BTreeMap::new());
|
||||
assert_eq!(doc.rule_values(&vals, &old).unwrap().corner_relief_shape, ReliefShape::Notch);
|
||||
for (text, shape) in [("round", ReliefShape::Round), (" Rectangular ", ReliefShape::Rectangular), ("NOTCH", ReliefShape::Notch)] {
|
||||
let rule = SheetRule { corner_relief_shape: text.into(), ..old.clone() };
|
||||
assert_eq!(doc.rule_values(&vals, &rule).unwrap().corner_relief_shape, shape);
|
||||
assert_eq!(ReliefShape::parse(shape.name()), Some(shape));
|
||||
}
|
||||
// No other name is a shape.
|
||||
for text in ["square", "circle", ""] {
|
||||
let err = doc.rule_values(&vals, &SheetRule { corner_relief_shape: text.into(), ..old.clone() }).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("unknown corner relief shape `{text}` (notch, round or rectangular)")), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_reliefs_cut_the_base_corner_and_stop_the_bends() {
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
|
||||
// The tray: the bends on x = 0 and y = 0 meet at c = (s0, s0), s0 = (R + T)·tan(θ/2) in from
|
||||
// the sheet's corner. A relief 8 mm across reaches 4 mm from c along both edges of the base.
|
||||
for deg in [72.0f64, 90.0] {
|
||||
let th = deg.to_radians();
|
||||
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
|
||||
let len = 120.0 - 2.0 * s0;
|
||||
let c = Vec2::new(s0, s0);
|
||||
let (notch, _) = tray(&alu(), deg, 38.6);
|
||||
assert_eq!(notch.base.outer.segs.len(), 4, "{deg}°: the notch cuts nothing");
|
||||
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
|
||||
let what = format!("{shape:?} at {deg}°");
|
||||
let (s, notes) = tray(&RuleValues { corner_relief_shape: shape, ..alu() }, deg, 38.6);
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
let segs = &s.base.outer.segs;
|
||||
if shape == ReliefShape::Round {
|
||||
// A quarter round of radius 4 about c, from the edge x = s0 round to the edge y = s0.
|
||||
assert_eq!((segs.len(), segs.iter().filter(|g| matches!(g, Seg2::Arc { .. })).count()), (8, 4), "{what}");
|
||||
let found = segs.iter().any(|g| {
|
||||
matches!(g, Seg2::Arc { center, radius, .. } if center.dist(c) < 1e-8 && (radius - 4.0).abs() < 1e-12)
|
||||
&& g.start().dist(c + Vec2::new(0.0, 4.0)) < 1e-9
|
||||
&& g.point_at(0.5).dist(c + Vec2::from_angle(FRAC_PI_4) * 4.0) < 1e-9
|
||||
&& g.end().dist(c + Vec2::new(4.0, 0.0)) < 1e-9
|
||||
});
|
||||
assert!(found, "{what}: {segs:?}");
|
||||
} else {
|
||||
// A 4 mm square: in from the edge x = s0 to (s0 + 4, s0 + 4), out to the edge y = s0.
|
||||
let line =
|
||||
|a: Vec2, b: Vec2| segs.iter().any(|g| matches!(g, Seg2::Line { .. }) && g.start().dist(a) < 1e-9 && g.end().dist(b) < 1e-9);
|
||||
assert_eq!(segs.len(), 12, "{what}");
|
||||
assert!(
|
||||
line(c + Vec2::new(0.0, 4.0), c + Vec2::new(4.0, 4.0)) && line(c + Vec2::new(4.0, 4.0), c + Vec2::new(4.0, 0.0)),
|
||||
"{what}: {segs:?}"
|
||||
);
|
||||
}
|
||||
// The bend on y = 0 stops 4 mm from the corners at both its ends, and the bend table follows.
|
||||
let (u0, u1) = s.zone_span(&s.flanges[0]);
|
||||
assert!((u0 - 4.0).abs() < 1e-9 && (u1 - (len - 4.0)).abs() < 1e-9, "{what}: {u0} {u1}");
|
||||
let bend = &s.bends()[0];
|
||||
let (start, end) = (Vec2::new(s0 + 4.0, s0 - ba / 2.0), Vec2::new(120.0 - s0 - 4.0, s0 - ba / 2.0));
|
||||
assert!((bend.length - (len - 8.0)).abs() < 1e-9 && bend.start.dist(start) < 1e-9 && bend.end.dist(end) < 1e-9, "{what}: {bend:?}");
|
||||
// Only the base changes: the flanges are the ones the closing left.
|
||||
assert_eq!(s.flanges, notch.flanges, "{what}");
|
||||
// The flat pattern loses, at each of its four corners, the relief in the base and 4 mm of two bends.
|
||||
let (removed, want) = (flat_area(¬ch) - flat_area(&s), 4.0 * relief_area(shape, 8.0, FRAC_PI_2, ba));
|
||||
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
|
||||
holds_the_flat_material(&s, &what);
|
||||
let unfolded = vol(&s.flat_solid().unwrap());
|
||||
assert!((unfolded - flat_area(&s) * 2.0).abs() < 1e-3 * unfolded, "{what}: {unfolded}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trapezoid of the closing tests with four flanges at `deg`, under rule `rv`.
|
||||
fn trapezoid(rv: &RuleValues, deg: f64) -> (SheetBody, Vec<String>) {
|
||||
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 mut s = base_flange("Body1", "AL2", rv, Plane::XY, Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] });
|
||||
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 (_, notes) = s.add_flanges_noted(&picks, 38.6, deg.to_radians(), rv.bend_radius, "inside", false).unwrap();
|
||||
(s, notes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_reliefs_follow_corners_that_are_not_square() {
|
||||
use std::f64::consts::PI;
|
||||
// The base fills α = atan(90/20) = 77.47° at the two lower corners of the trapezoid and 180° − α
|
||||
// at the upper ones. With every edge trimmed by s0, the bends on y = 0 and on the left edge meet
|
||||
// at c = (s0/tan(α/2), s0), and the left edge leaves c in the direction (cos α, sin α).
|
||||
let th = 72f64.to_radians();
|
||||
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
|
||||
let alpha = 90f64.atan2(20.0);
|
||||
let c = Vec2::new(s0 / (alpha / 2.0).tan(), s0);
|
||||
let (along, up) = (Vec2::new(1.0, 0.0), Vec2::from_angle(alpha));
|
||||
let (notch, _) = trapezoid(&alu(), 72.0);
|
||||
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
|
||||
let what = format!("{shape:?}");
|
||||
let (s, notes) = trapezoid(&RuleValues { corner_relief_shape: shape, ..alu() }, 72.0);
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
let segs = &s.base.outer.segs;
|
||||
if shape == ReliefShape::Round {
|
||||
// An arc of radius 4 about c through the angle α, tangent to both bends' straight ends.
|
||||
let found = segs.iter().any(|g| {
|
||||
matches!(g, Seg2::Arc { center, radius, sweep, .. } if center.dist(c) < 1e-8 && (radius - 4.0).abs() < 1e-12 && (sweep + alpha).abs() < 1e-9)
|
||||
&& g.start().dist(c + up * 4.0) < 1e-8
|
||||
&& g.end().dist(c + along * 4.0) < 1e-8
|
||||
&& g.start_tangent().dot(up).abs() < 1e-9
|
||||
&& g.end_tangent().dot(along).abs() < 1e-9
|
||||
});
|
||||
assert!(found, "{what}: {segs:?}");
|
||||
} else {
|
||||
// A kite: both cuts square to their edge, meeting 4/cos(α/2) from c on the bisector.
|
||||
let x = c + Vec2::from_angle(alpha / 2.0) * (4.0 / (alpha / 2.0).cos());
|
||||
let line =
|
||||
|a: Vec2, b: Vec2| segs.iter().any(|g| matches!(g, Seg2::Line { .. }) && g.start().dist(a) < 1e-8 && g.end().dist(b) < 1e-8);
|
||||
assert!(line(c + up * 4.0, x) && line(x, c + along * 4.0), "{what}: {segs:?}");
|
||||
assert!((x - (c + up * 4.0)).dot(up).abs() < 1e-9 && (x - (c + along * 4.0)).dot(along).abs() < 1e-9);
|
||||
}
|
||||
// Two corners of α and two of 180° − α, eight bend ends.
|
||||
let want = 2.0 * relief_area(shape, 8.0, alpha, ba) + 2.0 * relief_area(shape, 8.0, PI - alpha, ba);
|
||||
let removed = flat_area(¬ch) - flat_area(&s);
|
||||
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
|
||||
assert_eq!(s.flanges, notch.flanges, "{what}");
|
||||
holds_the_flat_material(&s, &what);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_reliefs_hold_at_every_angle_and_size() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
// The round relief runs into the bends' straight ends tangentially, a contact the planar
|
||||
// booleans have to keep as one point. So every 5° from 45° to 135° is built on the tray and on
|
||||
// the trapezoid with both shapes, and on the tray reliefs from 0.5 to 20 mm across.
|
||||
let alpha = 90f64.atan2(20.0);
|
||||
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
|
||||
for k in 9..=27 {
|
||||
let deg = k as f64 * 5.0;
|
||||
let ba = deg.to_radians() * 2.0;
|
||||
let rv = RuleValues { corner_relief_shape: shape, ..alu() };
|
||||
for (name, (s, notes), (notch, _), corners) in [
|
||||
("tray", tray(&rv, deg, 38.6), tray(&alu(), deg, 38.6), [FRAC_PI_2; 4]),
|
||||
("trapezoid", trapezoid(&rv, deg), trapezoid(&alu(), deg), [alpha, alpha, PI - alpha, PI - alpha]),
|
||||
] {
|
||||
let what = format!("{shape:?} on the {name} at {deg}°");
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
let want: f64 = corners.iter().map(|a| relief_area(shape, 8.0, *a, ba)).sum();
|
||||
let removed = flat_area(¬ch) - flat_area(&s);
|
||||
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
|
||||
holds_the_flat_material(&s, &what);
|
||||
}
|
||||
}
|
||||
for size in [0.5, 1.0, 3.0, 5.0, 12.0, 20.0] {
|
||||
for deg in [72.0f64, 90.0] {
|
||||
let what = format!("{shape:?} {size} mm across at {deg}°");
|
||||
let (s, notes) = tray(&RuleValues { corner_relief_shape: shape, corner_relief: size, ..alu() }, deg, 38.6);
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
let want = 4.0 * relief_area(shape, size, FRAC_PI_2, deg.to_radians() * 2.0);
|
||||
let removed = flat_area(&tray(&alu(), deg, 38.6).0) - flat_area(&s);
|
||||
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
|
||||
holds_the_flat_material(&s, &what);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relief_that_does_not_fit_is_left_out_with_a_note() {
|
||||
let closed = tray(&alu(), 72.0, 38.6).0;
|
||||
// 200 mm across reaches 100 mm along edges of which the shorter is 90 − 2·s0 = 85.6 mm long.
|
||||
// 100 mm across reaches 50 mm: that fits the edge once but not from both its ends, and all four
|
||||
// corners are left out together. Nothing, or less than nothing, is too small to cut.
|
||||
let sizes = [(200.0, "whole bend"), (100.0, "whole bend"), (0.0, "below 0.01 mm"), (-8.0, "below 0.01 mm"), (f64::NAN, "below 0.01 mm")];
|
||||
for (size, why) in sizes {
|
||||
let (s, notes) = tray(&RuleValues { corner_relief_shape: ReliefShape::Round, corner_relief: size, ..alu() }, 72.0, 38.6);
|
||||
assert_eq!(notes.len(), 4, "{size}: {notes:?}");
|
||||
assert!(notes.iter().all(|n| n.starts_with("no corner relief at (") && n.contains(why)), "{size}: {notes:?}");
|
||||
// The part is the one the notch gives, corners closed.
|
||||
assert_eq!((&s.base, &s.flanges), (&closed.base, &closed.flanges), "{size}");
|
||||
}
|
||||
// A 120 × 12 strip with flanges on its lower and left edges, a relief 16 mm across. Its 8 mm fit
|
||||
// on both edges at the corner (the left one is 12 − s0 = 9.82 long), but the far corner of the
|
||||
// square is 8·√2 = 11.3 mm from the corner of the bends, and the strip's upper edge is only 9.82
|
||||
// from it: no relief may reach as far from its corner as another edge is. The round one reaches
|
||||
// 8 mm and fits.
|
||||
let strip = |shape: ReliefShape| {
|
||||
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 12.0), Vec2::new(0.0, 12.0));
|
||||
let mut s = base_flange("Body1", "AL2", &RuleValues { corner_relief_shape: shape, corner_relief: 16.0, ..alu() }, Plane::XY, base);
|
||||
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(0.0, 6.0, 2.0)];
|
||||
let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
|
||||
(s, notes)
|
||||
};
|
||||
let (s, notes) = strip(ReliefShape::Rectangular);
|
||||
assert!(notes.len() == 1 && notes[0].contains("another edge of the base"), "{notes:?}");
|
||||
assert!(s.base.outer.segs.len() == 4 && s.solid().is_ok());
|
||||
let (s, notes) = strip(ReliefShape::Round);
|
||||
assert!(notes.is_empty() && s.base.outer.segs.iter().filter(|g| matches!(g, Seg2::Arc { .. })).count() == 1, "{notes:?}");
|
||||
holds_the_flat_material(&s, "the strip");
|
||||
// A right triangle with flanges on its two legs: the base's legs are 100 − 2·s0 = 95.6 long and
|
||||
// its long edge passes (100 − 2·s0)/√2 = 67.6 mm from the corner of the bends. A round relief of
|
||||
// radius 75 would cut through that edge; one of radius 65 stays inside.
|
||||
let triangle = |size: f64| {
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
|
||||
let rv = RuleValues { corner_relief_shape: ReliefShape::Round, corner_relief: size, ..alu() };
|
||||
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] });
|
||||
let picks = [Vec3::new(50.0, 0.0, 2.0), Vec3::new(0.0, 50.0, 2.0)];
|
||||
let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
|
||||
(s, notes)
|
||||
};
|
||||
let (s, notes) = triangle(150.0);
|
||||
assert!(notes.len() == 1 && notes[0].contains("another edge of the base"), "{notes:?}");
|
||||
assert!(s.base.outer.segs.len() == 3 && s.solid().is_ok());
|
||||
let (s, notes) = triangle(130.0);
|
||||
assert!(notes.is_empty() && s.base.outer.segs.len() == 4, "{notes:?}");
|
||||
holds_the_flat_material(&s, "the triangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corner_with_a_flange_of_an_earlier_feature_gets_no_relief() {
|
||||
// Like the closing, the relief belongs to the corners between the flanges of one feature.
|
||||
let rv = RuleValues { corner_relief_shape: ReliefShape::Round, ..alu() };
|
||||
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
|
||||
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, base);
|
||||
s.add_flanges(&[Vec3::new(60.0, 0.0, 2.0)], 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
|
||||
let (_, notes) = s.add_flanges_noted(&[Vec3::new(120.0, 45.0, 2.0)], 25.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
assert!(s.base.outer.segs.iter().all(|g| matches!(g, Seg2::Line { .. })));
|
||||
assert!(s.solid().is_ok() && s.flat_regions().unwrap().len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_corners_keep_their_flat_pattern_at_every_angle() {
|
||||
// 45° to 135° in steps of 5° on the tray and on a trapezoid. Which angles lose the flat
|
||||
@@ -3371,4 +3761,32 @@ mod tests {
|
||||
}
|
||||
assert_eq!(cases, 147);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_reliefs_are_cut_between_flanges_of_unequal_angle_and_height() {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
// The relief is cut in the base, so flanges with their own angle and height get the same
|
||||
// one: 4 mm into the base at each of its four corners, and every bend 4 mm shorter at both
|
||||
// ends, each over its own width BA = θ·(R + K·T).
|
||||
let heights = [38.6, 30.0, 45.0, 25.0];
|
||||
let (notch, _) = tray_each(&alu(), MIXED, heights);
|
||||
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
|
||||
let what = format!("{shape:?}");
|
||||
let (s, notes) = tray_each(&RuleValues { corner_relief_shape: shape, ..alu() }, MIXED, heights);
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
assert_eq!(s.flanges, notch.flanges, "{what}");
|
||||
// The bend on y = 0 runs between the setbacks of its neighbours at 120° and 90°:
|
||||
// 120 − 3·tan 60° − 3·tan 45° long, less the two reliefs.
|
||||
let (bends, before) = (s.bends(), notch.bends());
|
||||
assert_eq!((bends.len(), before.len()), (4, 4), "{what}");
|
||||
assert!((bends[0].length - (120.0 - 3.0 * 60f64.to_radians().tan() - 3.0 - 8.0)).abs() < 1e-9, "{what}: {}", bends[0].length);
|
||||
for (b, b0) in bends.iter().zip(&before) {
|
||||
assert!((b.length - (b0.length - 8.0)).abs() < 1e-9, "{what}: {} vs {}", b.length, b0.length);
|
||||
}
|
||||
let in_the_bends: f64 = MIXED.iter().map(|deg| 8.0 * deg.to_radians() * 2.0).sum();
|
||||
let (removed, want) = (flat_area(¬ch) - flat_area(&s), 4.0 * relief_area(shape, 8.0, FRAC_PI_2, 0.0) + in_the_bends);
|
||||
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
|
||||
holds_the_flat_material(&s, &what);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \
|
||||
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); active?: bool; delete?: bool",
|
||||
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \
|
||||
corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \
|
||||
active?: bool; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
@@ -269,6 +271,10 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*slot = e;
|
||||
}
|
||||
}
|
||||
if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) {
|
||||
let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse);
|
||||
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into();
|
||||
}
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let doc = s.doc_mut();
|
||||
|
||||
@@ -2060,6 +2060,103 @@ fn closed_corners_reach_the_flat_pattern_and_the_dxf() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() {
|
||||
use solidcraft_geom::Vec2;
|
||||
// The tray at 72°: its bends meet at the corners of the base, s0 = 3·tan 36° in from the sheet's
|
||||
// edges, and are BA = 2θ wide in the flat. A relief 8 mm across reaches r = 4 mm along both edges
|
||||
// and takes, at each corner, r²·π/4 (round) or r² (rectangular) of the base and r of two bends.
|
||||
let th = 72f64.to_radians();
|
||||
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
|
||||
let corners = [(s0, s0), (120.0 - s0, s0), (120.0 - s0, 90.0 - s0), (s0, 90.0 - s0)];
|
||||
let export = |s: &mut Session| run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
let mut notch = Session::default();
|
||||
alu_tray(&mut notch, 72.0, json!({"corner_relief": "8 mm"}));
|
||||
let v0 = volume(&mut notch);
|
||||
assert_eq!(export(&mut notch).matches("0\nARC\n").count(), 0);
|
||||
run(&mut notch, "sheet.unfold", json!({}));
|
||||
let flat0 = volume(&mut notch);
|
||||
for (shape, of_the_base) in [("round", 4.0 * PI), ("rectangular", 16.0)] {
|
||||
let mut s = Session::default();
|
||||
alu_tray(&mut s, 72.0, json!({"corner_relief_shape": shape, "corner_relief": "8 mm"}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{shape}: {:?}", s.model.results);
|
||||
let listed = run(&mut s, "sheet.rules", json!({}));
|
||||
assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], shape);
|
||||
assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], shape);
|
||||
// The folded part loses what the flat pattern loses (K = 0.5 keeps the volume of a bend).
|
||||
let (v, want) = (volume(&mut s), 4.0 * (of_the_base + 8.0 * ba) * 2.0);
|
||||
assert!((v0 - v - want).abs() < 0.1, "{shape}: {v0} - {v} vs {want}");
|
||||
// The bend table: every bend is 8 mm shorter.
|
||||
let f = flat(&mut s);
|
||||
let lengths: Vec<f64> =
|
||||
f["bends"].as_array().map(|b| b.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default();
|
||||
for (got, edge) in lengths.iter().zip([120.0, 90.0, 120.0, 90.0]) {
|
||||
assert!(lengths.len() == 4 && (got - (edge - 2.0 * s0 - 8.0)).abs() < 1e-6, "{shape}: {lengths:?}");
|
||||
}
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{shape}");
|
||||
// The DXF: the round reliefs are arcs of radius 4 about the four corners, the square ones lines.
|
||||
let dxf = export(&mut s);
|
||||
let arcs: Vec<(Vec2, f64)> = solidcraft_io::read_dxf(&dxf)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some((*c, a.dist(*c))) } else { None })
|
||||
.collect();
|
||||
if shape == "round" {
|
||||
assert_eq!(arcs.len(), 4, "{arcs:?}");
|
||||
for (x, y) in corners {
|
||||
assert!(arcs.iter().any(|(c, r)| c.dist(Vec2::new(x, y)) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}");
|
||||
}
|
||||
} else {
|
||||
assert!(arcs.is_empty() && dxf.contains(&format!("{:.6}\n20\n{:.6}\n", s0 + 4.0, s0 + 4.0)), "{arcs:?}");
|
||||
}
|
||||
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((flat0 - volume(&mut s) - want).abs() < 0.1, "{shape}: unfolded {flat0} - {} vs {want}", volume(&mut s));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{shape}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true}));
|
||||
assert!((v - volume(&mut s) - PI * 100.0 * 2.0).abs() < 0.5, "{shape}: {v} - {}", volume(&mut s));
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6, "{shape}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
|
||||
let mut s = Session::default();
|
||||
alu_tray(&mut s, 72.0, json!({}));
|
||||
let shape = |s: &mut Session| run(s, "sheet.rules", json!({}))["rules"][0]["rule"]["corner_relief_shape"].clone();
|
||||
let v0 = volume(&mut s);
|
||||
assert_eq!(shape(&mut s), "notch");
|
||||
// Only the three names are shapes; a refused one changes nothing.
|
||||
for wrong in [json!("star"), json!("square"), json!(""), json!(2)] {
|
||||
let e = s.execute("sheet.manage_rules", &json!({"name": "AL2", "corner_relief_shape": wrong})).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(e.contains("`corner_relief_shape` must be notch, round or rectangular"), "{e}");
|
||||
}
|
||||
assert!(shape(&mut s) == "notch" && volume(&mut s) == v0);
|
||||
// Changing the rule changes the parts made with it; other settings leave the shape alone.
|
||||
let r = run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "Round"}));
|
||||
assert_eq!((&r["rule"]["corner_relief_shape"], &r["values"]["corner_relief_shape"]), (&json!("round"), &json!("round")));
|
||||
let round = volume(&mut s);
|
||||
assert!(round < v0 - 200.0, "{v0} -> {round}");
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "hem_gap": "0.02 mm"}));
|
||||
assert!(shape(&mut s) == "round" && volume(&mut s) == round);
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": null, "corner_relief": "6 mm"}));
|
||||
assert!(shape(&mut s) == "round" && volume(&mut s) > round);
|
||||
for _ in 0..3 {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
assert!(shape(&mut s) == "notch" && volume(&mut s) == v0);
|
||||
// A relief longer than the bends is left out and reported on the flange; the part is the notched one.
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "rectangular", "corner_relief": "200 mm"}));
|
||||
let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert_eq!(w.matches("no corner relief at (").count(), 4, "{w}");
|
||||
assert!(w.contains("would take a whole bend away") && volume(&mut s) == v0, "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_left_open_are_reported_on_the_flange() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -32,6 +32,9 @@ const RULE_FIELDS: [(&str, &str); 8] = [
|
||||
("hem_gap", "Hem Gap"),
|
||||
("gap", "Flange Gap"),
|
||||
];
|
||||
/// The corner relief shapes the manager offers (the rule's `corner_relief_shape`) and their labels.
|
||||
const RELIEF_SHAPES: [&str; 3] = ["notch", "round", "rectangular"];
|
||||
const RELIEF_SHAPE_LABELS: [&str; 3] = ["Notch", "Round", "Rectangular"];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Sm {
|
||||
@@ -73,6 +76,8 @@ pub enum Sm {
|
||||
pick: usize,
|
||||
name: String,
|
||||
values: Vec<String>,
|
||||
/// The corner relief shape (index into `RELIEF_SHAPES`).
|
||||
relief: usize,
|
||||
active: bool,
|
||||
loaded: Option<usize>,
|
||||
},
|
||||
@@ -148,7 +153,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let active = app.session.doc.sheet.active.clone();
|
||||
let rules = rule_list(app);
|
||||
let pick = active.and_then(|a| rules.iter().position(|r| r.name == a)).unwrap_or(0);
|
||||
(Sm::Rules { pick, name: String::new(), values: Vec::new(), active: true, loaded: None }, vec![])
|
||||
(Sm::Rules { pick, name: String::new(), values: Vec::new(), relief: 0, active: true, loaded: None }, vec![])
|
||||
}
|
||||
"sheet.flat_pattern" => (Sm::FlatPattern { data: None, key: None, status: None }, vec![SelInput::new("Body (last if none)", BODIES, false)]),
|
||||
_ => return None,
|
||||
@@ -291,7 +296,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
}
|
||||
Sm::Unfold { .. } => {}
|
||||
Sm::Convert { rule } => rule_combo(app, ui, "sm_conv_rule", rule),
|
||||
Sm::Rules { pick, name, values, active, loaded } => {
|
||||
Sm::Rules { pick, name, values, relief, active, loaded } => {
|
||||
let rules = rule_list(app);
|
||||
let mut names: Vec<String> = rules.iter().map(|r| r.name.clone()).collect();
|
||||
names.push("New rule…".into());
|
||||
@@ -312,6 +317,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
base.hem_gap.clone(),
|
||||
base.gap.clone(),
|
||||
];
|
||||
*relief = RELIEF_SHAPES.iter().position(|s| s.eq_ignore_ascii_case(base.corner_relief_shape.trim())).unwrap_or(0);
|
||||
*active = app.session.doc.sheet_rule(None).name == base.name || *pick >= rules.len();
|
||||
if *pick >= rules.len() {
|
||||
*name = format!("Rule{}", rules.len() + 1);
|
||||
@@ -323,7 +329,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
}
|
||||
for ((_, label), v) in RULE_FIELDS.iter().zip(values.iter_mut()) {
|
||||
for ((key, label), v) in RULE_FIELDS.iter().zip(values.iter_mut()) {
|
||||
if *key == "corner_relief" {
|
||||
// The shape goes with its size, the row below.
|
||||
row_label(ui, "Corner Relief Shape");
|
||||
combo(ui, "sm_relief_shape", &RELIEF_SHAPE_LABELS, relief);
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, label);
|
||||
enter |= field(ui, v);
|
||||
ui.end_row();
|
||||
@@ -581,7 +593,7 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map<String,
|
||||
}
|
||||
("sheet.convert", p)
|
||||
}
|
||||
Sm::Rules { pick, name, values, active, .. } => {
|
||||
Sm::Rules { pick, name, values, relief, active, .. } => {
|
||||
let rules = rule_list(app);
|
||||
let n = match rules.get(*pick) {
|
||||
Some(r) => r.name.clone(),
|
||||
@@ -590,7 +602,7 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map<String,
|
||||
if n.is_empty() {
|
||||
return Err("give the rule a name".into());
|
||||
}
|
||||
let mut p = json!({"name": n, "active": active});
|
||||
let mut p = json!({"name": n, "active": active, "corner_relief_shape": RELIEF_SHAPES.get(*relief).copied().unwrap_or("notch")});
|
||||
for ((k, _), v) in RULE_FIELDS.iter().zip(values) {
|
||||
if !v.trim().is_empty() {
|
||||
p[*k] = json!(v);
|
||||
|
||||
@@ -311,6 +311,47 @@ fn bend_table_picker_writes_the_current_body() {
|
||||
assert_eq!(app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// The rule manager's shape selector: picked in the dialog, the shape reaches the rule and the part
|
||||
/// made with it, and the dialog shows it the next time.
|
||||
#[test]
|
||||
fn rules_dialog_picks_the_corner_relief_shape() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app
|
||||
.run(
|
||||
"sheet.manage_rules",
|
||||
json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}),
|
||||
)
|
||||
.unwrap();
|
||||
h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [120, 90]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
h.app.run("sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 20, "angle": "72 deg"})).unwrap();
|
||||
// The rounds in the base of the tray, and the shape the open dialog shows.
|
||||
let rounds = |app: &SolveApp| {
|
||||
let state = app.session.model.state();
|
||||
state.sheets.last().map(|s| s.base.outer.segs.iter().filter(|g| matches!(g, solidcraft_engine::geom::Seg2::Arc { .. })).count())
|
||||
};
|
||||
let shown = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) {
|
||||
Some(Kind::Sheet(Sm::Rules { relief, .. })) => Some(*relief),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(rounds(&h.app), Some(0));
|
||||
h.step(&json!({"start": "sheet.manage_rules"})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(shown(&h.app), Some(0), "a rule starts with the notch");
|
||||
h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap();
|
||||
h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Round"}})).unwrap();
|
||||
assert_eq!(shown(&h.app), Some(1));
|
||||
h.step(&json!({"widget": "OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
assert_eq!(h.app.session.doc.sheet_rule(None).corner_relief_shape, "round");
|
||||
assert_eq!(rounds(&h.app), Some(4), "one round at each corner of the tray");
|
||||
h.step(&json!({"start": "sheet.manage_rules"})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(shown(&h.app), Some(1), "the dialog shows the rule's shape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pocket_depth_labels_follow_inches_without_changing_flat_data() {
|
||||
let mut app = plate();
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
[
|
||||
{
|
||||
"note": "Sheet Metal Rules: the Corner Relief Shape row sets the rule's corner_relief_shape, and the tray made with the rule follows. The bend on the 120 mm edge is 120 - 2*s0 long (s0 = 3*tan 36 deg) and 8 mm shorter with a relief 8 mm across at both its ends."
|
||||
},
|
||||
{
|
||||
"call": "ui.set",
|
||||
"params": {
|
||||
"tab": "SHEET METAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sheet.manage_rules",
|
||||
"params": {
|
||||
"name": "Sheet 2 mm",
|
||||
"thickness": "2 mm",
|
||||
"k_factor": 0.5,
|
||||
"bend_radius": "1 mm",
|
||||
"gap": "0.5 mm",
|
||||
"corner_relief": "8 mm",
|
||||
"active": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "Base"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"p1": [
|
||||
120,
|
||||
90
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sheet.flange",
|
||||
"params": {
|
||||
"sketch": "Base"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sheet.flange",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
60,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
120,
|
||||
45,
|
||||
2
|
||||
],
|
||||
[
|
||||
60,
|
||||
90,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
45,
|
||||
2
|
||||
]
|
||||
],
|
||||
"height": 20,
|
||||
"angle": "72 deg"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "iso"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bodies": 1,
|
||||
"errors": 0,
|
||||
"query": {
|
||||
"command": "sheet.flat_pattern",
|
||||
"path": [
|
||||
"bends",
|
||||
0,
|
||||
"length"
|
||||
],
|
||||
"value": 115.640744832
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sheet.manage_rules"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "dialog:Corner Relief Shape"
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "dialog:Corner Relief Shape:Round"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widget": "OK"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"dialog": null,
|
||||
"errors": 0,
|
||||
"query": {
|
||||
"command": "sheet.rules",
|
||||
"path": [
|
||||
"rules",
|
||||
0,
|
||||
"rule",
|
||||
"corner_relief_shape"
|
||||
],
|
||||
"value": "round"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"query": {
|
||||
"command": "sheet.flat_pattern",
|
||||
"path": [
|
||||
"bends",
|
||||
0,
|
||||
"length"
|
||||
],
|
||||
"value": 107.640744832
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"shot": "s51_round_reliefs"
|
||||
},
|
||||
{
|
||||
"start": "sheet.manage_rules"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "dialog:Corner Relief Shape"
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "dialog:Corner Relief Shape:Rectangular"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widget": "OK"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"dialog": null,
|
||||
"errors": 0,
|
||||
"query": {
|
||||
"command": "sheet.rules",
|
||||
"path": [
|
||||
"rules",
|
||||
0,
|
||||
"rule",
|
||||
"corner_relief_shape"
|
||||
],
|
||||
"value": "rectangular"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"query": {
|
||||
"command": "sheet.flat_pattern",
|
||||
"path": [
|
||||
"bends",
|
||||
0,
|
||||
"length"
|
||||
],
|
||||
"value": 107.640744832
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"shot": "s51_rectangular_reliefs"
|
||||
}
|
||||
]
|
||||
@@ -51,7 +51,7 @@ keeps the current design. Previews cannot be edited or measured. This experiment
|
||||
requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches.
|
||||
The repository's `docs/design-container.md` describes limits and encoding details.
|
||||
|
||||
Current designs use format 15, including sketch face anchors on cylinder caps, sketch axis
|
||||
Current designs use format 16, including sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
links, persistent
|
||||
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
|
||||
association profile references, durable sketch face anchors, periodic fit splines and versioned
|
||||
|
||||
@@ -14,6 +14,25 @@ The SHEET METAL tab builds parts from a sheet of constant thickness under a **sh
|
||||
Bends follow the rule's K-factor, so flat lengths match what a press brake will make. Tests
|
||||
check flat lengths against hand-computed bend allowances.
|
||||
|
||||
Where two bends of one edge flange feature meet at a corner of the base, the rule's **corner
|
||||
relief shape** (`corner_relief_shape`; Corner Relief Shape in Sheet Metal Rules) says what is
|
||||
cut there. `notch`, the default, cuts nothing more: the bends end square at the corner. `round`
|
||||
and `rectangular` cut a relief the rule's **corner relief** across, centred on the corner where
|
||||
the two bend lines meet: out of the base a round with half that size as its radius, or a square
|
||||
with half that size as its side, and both bends stop where it begins, cut straight across. The
|
||||
flange panels keep their ends. The relief is in the folded part, the flat pattern, the bend
|
||||
table (the bends are shorter) and the DXF (a round relief is an arc). At a corner that isn't
|
||||
square the round follows the angle of the base and the square becomes a kite, its two cuts
|
||||
square to the edges. Changing the shape in a rule changes the parts made with that rule. A
|
||||
relief that doesn't fit (under 0.01 mm across, leaving nothing of a bend, or reaching as far
|
||||
from its corner as another edge of the base is) is left out, and the flange feature reports a
|
||||
warning saying where.
|
||||
|
||||
```json
|
||||
{"command":"sheet.manage_rules","params":{"name":"Sheet 2 mm","thickness":"2 mm","k_factor":0.5,
|
||||
"bend_radius":"1 mm","corner_relief_shape":"round","corner_relief":"8 mm","active":true}}
|
||||
```
|
||||
|
||||
Where the edge flanges of one feature meet at a corner of the base, their panels are closed
|
||||
toward each other at any bend angle: each end is cut square to the sheet, on a line in the flat
|
||||
chosen so that the edges nearest each other (on the inner faces) run parallel, the rule's
|
||||
|
||||
@@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract;
|
||||
`io::write_design_container` is the fallible container writer.
|
||||
|
||||
Packages have `container.json` with `{"format":"solidcraft-container/1"}` and
|
||||
`document.json` with the versioned parametric document (currently `solidcraft/15`).
|
||||
`document.json` with the versioned parametric document (currently `solidcraft/16`).
|
||||
The independent versions let document migrations evolve without changing blob encodings.
|
||||
Existing serialized document fields are retained automatically, without a second copy
|
||||
of the document schema or a deep clone of embedded payloads.
|
||||
|
||||
Reference in new issue
Block a user