Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions assets/locales/pending/sheet-contour-flange-options.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"en": {
"sheet.flange.side_1": "Side 1",
"sheet.flange.side_2": "Side 2"
},
"ja": {
"sheet.flange.side_1": "側 1",
"sheet.flange.side_2": "側 2"
},
"zh": {
"sheet.flange.side_1": "侧 1",
"sheet.flange.side_2": "侧 2"
},
"de": {
"sheet.flange.side_1": "Seite 1",
"sheet.flange.side_2": "Seite 2"
},
"ko": {
"sheet.flange.side_1": "측면 1",
"sheet.flange.side_2": "측면 2"
},
"ar": {
"sheet.flange.side_1": "الجانب 1",
"sheet.flange.side_2": "الجانب 2"
},
"he": {
"sheet.flange.side_1": "צד 1",
"sheet.flange.side_2": "צד 2"
},
"vi": {
"sheet.flange.side_1": "Mặt 1",
"sheet.flange.side_2": "Mặt 2"
}
}
9 changes: 9 additions & 0 deletions crates/doc/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1447,7 +1447,16 @@ pub enum FeatureKind {
#[walk(length = "Distance")]
distance: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Bend radius")]
radius: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
rule: Option<String>,
/// center | side1 | side2 (where the contour lies across the sheet thickness).
#[serde(default, skip_serializing_if = "Option::is_none")]
orientation: Option<String>,
/// Apply the contour distance on both sides of the sketch plane (twice the total width).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
symmetric: bool,
/// Material on the other side of the chain.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
Expand Down
15 changes: 12 additions & 3 deletions crates/doc/src/eval_more.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,15 +612,23 @@ pub(super) fn sheet_eval(
}
Ok(())
}
FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => {
FeatureKind::SheetContour { sketch, curves, distance, radius, rule, orientation, symmetric, flip, reverse } => {
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
let segs = chain_segs(&ss, &ss.chain_order(curves)?)?;
let w = val(vals, distance, Kind::Length)?;
if !(w > 0.0) {
return Err(DocError::Invalid("the contour flange distance must be positive".into()));
}
let r = doc.sheet_rule(rule.as_deref());
let rv = doc.rule_values(vals, &r)?;
let mut rv = doc.rule_values(vals, &r)?;
if let Some(radius) = radius {
rv.bend_radius = val(vals, radius, Kind::Length)?;
if !(rv.bend_radius >= 0.0 && rv.bend_radius < 1e4) {
return Err(DocError::Invalid("the contour flange bend radius must be non-negative".into()));
}
}
// `reverse` (Other side) mirrors the sketch, taking the width to the other side of
// its plane, with an orientation saved or not.
let plane = if *reverse { Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 } } else { ss.plane };
let segs: Vec<Seg2> = if *reverse {
segs.iter()
Expand All @@ -634,9 +642,10 @@ pub(super) fn sheet_eval(
} else {
segs
};
let orientation = orientation.as_deref().unwrap_or("side1");
// Mirroring the sketch swaps the material side too.
let name = new_name(st, f, 0);
let sh = sheet::contour_flange_path(&name, &r.name, &rv, &plane, &segs, w, *flip != *reverse)?;
let sh = sheet::contour_flange_path(&name, &r.name, &rv, &plane, &segs, w, orientation, *symmetric, *flip != *reverse)?;
let solid = sh.solid()?;
st.bodies.push(ModelBody::new(name, solid, f.id));
st.sheets.push(sh);
Expand Down
3 changes: 3 additions & 0 deletions crates/doc/src/format/pending/sheet-contour-flange-options.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! Contour flanges may save a bend-radius override, width orientation and symmetric width mode.
//! Older designs keep their rule radius and one-sided width on Side 1; the last release refuses
//! the next format number rather than dropping these new settings.
120 changes: 109 additions & 11 deletions crates/doc/src/sheet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6897,14 +6897,24 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
}
}

/// A sheet from an open chain of lines (contour flange): `pts` in `plane`, the material on the
/// right of the travel (or the left when `flip`), `width` along the plane normal.
pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pts: &[Vec2], width: f64, flip: bool) -> Result<SheetBody> {
/// A sheet from an open chain of lines (contour flange): `pts` in `plane`, as
/// [`contour_flange_path`].
pub fn contour_flange(
body: &str,
rule: &str,
rv: &RuleValues,
plane: &Plane,
pts: &[Vec2],
width: f64,
orientation: &str,
symmetric: bool,
flip: bool,
) -> Result<SheetBody> {
if pts.len() < 2 {
return Err(DocError::Invalid("a contour flange needs an open chain of lines".into()));
}
let segs: Vec<Seg2> = pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect();
contour_flange_path(body, rule, rv, plane, &segs, width, flip)
contour_flange_path(body, rule, rv, plane, &segs, width, orientation, symmetric, flip)
}

/// A sheet from an open chain of lines and circular arcs (contour flange): `segs` in `plane`,
Expand All @@ -6913,14 +6923,30 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
/// surface: when the bend turns toward the material the sheet is outside that surface, so the
/// bend's inner radius is the arc's less the thickness; when it turns away the arc is the inner
/// surface and its radius is the bend's. A line-line corner is a sharp corner and gets a bend of
/// the rule's radius, as a chain of lines always did.
pub fn contour_flange_path(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, segs: &[Seg2], width: f64, flip: bool) -> Result<SheetBody> {
/// the rule's radius, as a chain of lines always did. `orientation` puts the profile on one face
/// of the sheet (`side1`, `side2`) or through its middle (`center`); `symmetric` takes `width`
/// each way across the sketch plane.
#[allow(clippy::too_many_arguments)]
pub fn contour_flange_path(
body: &str,
rule: &str,
rv: &RuleValues,
plane: &Plane,
segs: &[Seg2],
width: f64,
orientation: &str,
symmetric: bool,
flip: bool,
) -> Result<SheetBody> {
if segs.is_empty() {
return Err(DocError::Invalid("a contour flange needs an open profile".into()));
}
if !(width.is_finite() && width > 0.0) {
return Err(DocError::Invalid("the contour flange distance must be positive".into()));
}
if !matches!(orientation, "center" | "side1" | "side2") {
return Err(DocError::Invalid("contour flange orientation must be center, side1 or side2".into()));
}
let (t, r) = (rv.thickness, rv.bend_radius);
// Material side in the sketch: right of travel, or left when flipped.
let side = if flip { -1.0 } else { 1.0 };
Expand All @@ -6934,10 +6960,19 @@ pub fn contour_flange_path(body: &str, rule: &str, rv: &RuleValues, plane: &Plan
let d0 = d0 / l0;
let m0 = Vec2::new(d0.y, -d0.x) * side;
let (x, z) = (plane.dir_to_world(d0), plane.dir_to_world(m0));
let frame = Plane::new(plane.to_world(start), x, z.cross(x)).ok_or_else(|| DocError::Invalid("contour frame".into()))?;
// Where the profile lies across the thickness: on the side1 face (0), in the middle
// (center) or on the other face (side2); setbacks and arc radii are measured there.
let depth = match orientation {
"center" => t / 2.0,
"side2" => t,
_ => 0.0,
};
let thickness_offset = -depth;
let frame = Plane::new(plane.to_world(start) + z * thickness_offset, x, z.cross(x)).ok_or_else(|| DocError::Invalid("contour frame".into()))?;
let ysign = if frame.y.dot(plane.normal()) >= 0.0 { 1.0 } else { -1.0 };
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
let setback = |ang: f64, up: bool| if up { (r + t) * (ang / 2.0).tan() } else { r * (ang / 2.0).tan() };
let (p0, p1) = if symmetric { (-width, width) } else { (0.0, width) };
let (y0, y1) = if ysign > 0.0 { (p0, p1) } else { (-p1, -p0) };
let setback = |ang: f64, up: bool| if up { (r + t - depth) * (ang / 2.0).tan() } else { (r + depth) * (ang / 2.0).tan() };
// The junction after each segment: a sharp line-line corner (its unsigned turn and whether
// it bends toward the material), or none where a line meets an arc tangentially.
let mut sharp: Vec<Option<(f64, bool)>> = Vec::with_capacity(segs.len().saturating_sub(1));
Expand Down Expand Up @@ -7000,7 +7035,7 @@ pub fn contour_flange_path(body: &str, rule: &str, rv: &RuleValues, plane: &Plan
}
let angle = -sweep * side;
let up = angle >= 0.0;
let inner = if up { radius - t } else { radius };
let inner = if up { radius - (t - depth) } else { radius - depth };
if !(inner.is_finite() && inner > 1e-9) {
return Err(DocError::Invalid("a contour arc's radius is no larger than the sheet is thick".into()));
}
Expand Down Expand Up @@ -7390,14 +7425,77 @@ mod tests {
// Oracle 72: XZ chain (0,0)→(40,0)→(40,−30)→(70,−30), 50 wide.
let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap();
let pts = [Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, -30.0), Vec2::new(70.0, -30.0)];
let s = contour_flange("Body1", "Steel (mm)", &steel(), &xz, &pts, 50.0, false).unwrap();
let s = contour_flange("Body1", "Steel (mm)", &steel(), &xz, &pts, 50.0, "side1", false, false).unwrap();
let b = s.solid().unwrap();
assert!((vol(&b) - 12097.622).abs() < 1.2, "{}", vol(&b));
let m = kernel::measure(&b).unwrap();
assert!((m.bbox.max.z - 32.5).abs() < 1e-6 && (m.bbox.max.y - 50.0).abs() < 1e-6, "{:?}", m.bbox);
assert!((s.flat_size().unwrap().0 - 96.309734).abs() < 1e-5, "{:?}", s.flat_size());
}

#[test]
fn contour_flange_orientation_thickness_width_direction_and_radius() {
let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap();
let pts = [Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, -30.0)];
let geometry = |orientation, symmetric| {
let mut rv = steel();
rv.bend_radius = 1.5;
let sheet = contour_flange("Body1", "Steel (mm)", &rv, &xz, &pts, 50.0, orientation, symmetric, false).unwrap();
assert!(sheet.flanges.iter().all(|flange| flange.radius == 1.5));
let bounds = kernel::measure(&sheet.solid().unwrap()).unwrap().bbox;
(bounds.min.y, bounds.max.y, bounds.min.z)
};
let side1_min_z = geometry("side1", false).2;
for (orientation, expected_z) in [("center", -1.25), ("side1", 0.0), ("side2", -2.5)] {
for (symmetric, expected_y) in [(false, (0.0, 50.0)), (true, (-50.0, 50.0))] {
let actual = geometry(orientation, symmetric);
assert!((actual.0 - expected_y.0).abs() < 1e-8 && (actual.1 - expected_y.1).abs() < 1e-8, "{orientation} {symmetric}: {actual:?}");
assert!((actual.2 - side1_min_z - expected_z).abs() < 1e-8, "{orientation} {symmetric}: {actual:?}");
}
}
}

#[test]
fn contour_flange_orientation_tracks_all_segments_of_a_bent_chain() {
let plane = Plane::XZ;
let pts = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(200.0, 25.0), Vec2::new(260.0, 110.0)];
let mut rv = steel();
rv.bend_radius = 1.5;

for (orientation, reference_z) in [("center", rv.thickness / 2.0), ("side1", 0.0), ("side2", rv.thickness)] {
let sheet = contour_flange("Body1", "Steel (mm)", &rv, &plane, &pts, 50.0, orientation, false, false).unwrap();
for segment in 0..pts.len() - 1 {
let direction = (pts[segment + 1] - pts[segment]).normalized().unwrap();
let length = pts[segment].dist(pts[segment + 1]);
let (local_x, along) = if segment == 0 {
let along = length / 2.0;
(along, along)
} else {
let flange = &sheet.flanges[segment - 1];
let previous = (pts[segment] - pts[segment - 1]).normalized().unwrap();
let angle = previous.dot(direction).clamp(-1.0, 1.0).acos();
let turns_toward_material = previous.cross(direction) < 0.0;
let radius = match orientation {
"center" => rv.bend_radius + rv.thickness / 2.0,
"side1" if turns_toward_material => rv.bend_radius + rv.thickness,
"side1" => rv.bend_radius,
"side2" if turns_toward_material => rv.bend_radius,
"side2" => rv.bend_radius + rv.thickness,
_ => rv.bend_radius,
};
let before = radius * (angle / 2.0).tan();
let along = before + flange.leg / 2.0;
(flange.p.x + sheet.allowance(flange) + flange.leg / 2.0, along)
};
let local = Vec3::new(local_x, 0.0, reference_z);
let panel = if segment > 0 { Some(segment - 1) } else { None };
let actual = apply_point(&sheet.world(panel), local);
let expected = plane.to_world(pts[segment] + direction * along);
assert!(actual.dist(expected) < 1e-6, "{orientation}, segment {segment}: actual={actual:?}, expected={expected:?}");
}
}
}

#[test]
fn hem_and_holes() {
// Oracle 73: flange then a flat hem of 8 on its top edge.
Expand Down
25 changes: 24 additions & 1 deletion crates/engine/src/cmd/sheet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&FLANGE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines and tangent arcs) + distance (rule?, flip?, reverse?) | \
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines and tangent arcs) + distance; radius? (default: rule); \
orientation?: center|side1|side2 (where the contour lies across the sheet thickness; default side1); \
symmetric?: bool (the distance each way across the sketch plane; total width is twice the distance); rule?, flip?, reverse? | \
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?, segments?} for an edge with its own angle and height, \
or split into 1…64 segments [{from, to, angle?, height?, flip?}] that tile the stretch the sheet still has there (from, to are \
fractions 0…1 of it, first from 0, last to 1; consecutive segments that match make one bend)] + \
Expand Down Expand Up @@ -425,6 +427,24 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
}
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let radius = expr(p, "radius");
if let Some(r) = &radius {
check_expr(s, r, Kind::Length, cmd, "radius")?;
}
let raw_orientation = str_(p, "orientation");
if p.get("orientation").is_some() && raw_orientation.is_none() {
return Err(bad(cmd, "`orientation` must be center, side1 or side2"));
}
let orientation = match raw_orientation {
Some(orientation @ ("center" | "side1" | "side2")) => Some(orientation.to_string()),
Some(other) => return Err(bad(cmd, format!("`orientation` must be center, side1 or side2, not `{other}`"))),
None => None,
};
let symmetric = match p.get("symmetric") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(bad(cmd, "`symmetric` must be a boolean")),
};
let rule = rule_param(s, p, cmd)?;
add_feature(
s,
Expand All @@ -433,7 +453,10 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
sketch: SketchId(sketch),
curves,
distance,
radius,
rule,
orientation,
symmetric,
flip: bool_(p, "flip").unwrap_or(false),
reverse: bool_(p, "reverse").unwrap_or(false),
},
Expand Down
20 changes: 20 additions & 0 deletions crates/engine/src/cmd/sheet_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,26 @@ fn contour_flange_hem_and_tray() {
run(&mut s, "sheet.flange", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
assert!(rel(volume(&mut s), 12097.622) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 96.309734).abs() < 1e-5);
run(
&mut s,
"sheet.flange",
json!({
"type": "contour",
"sketch": "Contour",
"curves": ["l1", "l2", "l3"],
"distance": 50,
"radius": "1.5 mm",
"orientation": "side2",
"symmetric": true
}),
);
let feature = s.doc.features.last().map(|f| &f.kind);
assert!(matches!(
feature,
Some(crate::doc::FeatureKind::SheetContour { radius: Some(radius), orientation: Some(orientation), symmetric: true, .. })
if radius == "1.5 mm" && orientation == "side2"
));
assert!(s.model.state().sheets.last().is_some_and(|sheet| sheet.flanges.iter().all(|flange| flange.radius == 1.5)));
// Oracle 73: flange, then a flat hem of 8.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
Expand Down
30 changes: 30 additions & 0 deletions crates/engine/src/schema/sheet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,29 @@ word!(
["centerline", "start", "end", "mould", "mold"]
);

/// Where a contour flange's chain lies across the sheet thickness: center, side1 or side2, lower
/// case (read by `sheet::flange`).
#[derive(Clone, Debug, PartialEq)]
pub struct ContourOrientation(pub String);

impl<'de> Deserialize<'de> for ContourOrientation {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)?.as_str() {
Some(w @ ("center" | "side1" | "side2")) => Ok(ContourOrientation(w.to_string())),
_ => Err(D::Error::custom("must be center, side1 or side2")),
}
}
}

impl JsonSchema for ContourOrientation {
fn schema_name() -> Cow<'static, str> {
"ContourOrientation".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "center, side1 or side2, lower case.", "enum": ["center", "side1", "side2"]})
}
}

/// A corner relief's shape: notch, round, rectangular or tear, in any case and with blanks
/// around it (read by `ReliefShape::parse`).
#[derive(Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -392,6 +415,13 @@ pub struct FlangeParams {
/// Contour: the flange runs from the sketch plane the other way. Default: false.
#[serde(default)]
pub reverse: Option<bool>,
/// Contour: where the chain lies across the sheet thickness. Default: side1.
#[serde(default)]
pub orientation: Option<ContourOrientation>,
/// Contour: the flange runs `distance` each way across the sketch plane (twice as wide).
/// Default: false.
#[serde(default)]
pub symmetric: Option<bool>,
/// Edge: the edges that get a flange.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "FlangeEdges")]
Expand Down
Loading
Loading