Sheet metal rims and holes, plastic features, lofts, raised pieces and merged walls take face attachments (#677)

Sheet metal rims and bend ends attach by name (<panel>:rim.<edge>, <flange>:rim.bend0/1), as do face profiles from sheet faces. A plain or counterbored Hole square to a panel is machined into the flat pattern. Boss, Lip, Rest and Snap Fit keep the body's history and name their own faces. Hole tools name their faces by the part they cut, and pattern copies as copies. A pattern's repeated fillets keep the body's history. Loft sides are named by their section curves. A piece Offset Face moved off its face's plane attaches by its own qualified name. Sides of a profile grown to overlap a flush body keep their sketch line's name, so walls a Join merged take a sketch (#681). Face sketch 75% -> 90%, offset plane 75% -> 90%, press-pull/extrude 64% -> 85% in the action matrix; baseline blessed.

Fixes #494, fixes #495, fixes #560, fixes #561, fixes #563, fixes #564, fixes #681.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 09:13:09 -04:00
1 parent e30f3c9ebe
commit 5ef1dcba8e
15 files changed
+2882 -1503

No files matched your search

+163
View File
@@ -164,3 +164,166 @@ fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
assert!(error.contains("fixed:true"), "{error}");
}
/// Offset Face raises one piece of a split top: the pieces no longer share a plane, so each
/// takes a sketch by its own name (the face's, qualified by the faces around it) and follows it.
#[test]
fn a_raised_piece_of_a_split_top_takes_a_sketch_by_its_own_name() {
let mut s = slotted_block();
run(&mut s, "parameters.add", json!({"name": "raise", "expression": "2 mm"}));
run(&mut s, "solid.offset_face", json!({"faces": [[5, 15, 10]], "distance": "raise", "name": "Raise"}));
no_errors(&s);
sketch_on(&mut s, [5., 15., 12.], "Raised");
sketch_on(&mut s, [35., 15., 10.], "Kept");
let (raised, certificate) = healthy(&s, "Raised");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
assert!((raised.origin.z - 12.).abs() < 1e-9 && (raised.normal().z - 1.).abs() < 1e-9, "{raised:?}");
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
run(&mut s, "parameters.change", json!({"name": "raise", "expression": "3 mm"}));
no_errors(&s);
assert!((healthy(&s, "Raised").0.origin.z - 13.).abs() < 1e-9);
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
let t = reopened(&s);
assert!((healthy(&t, "Raised").0.origin.z - 13.).abs() < 1e-9);
}
/// A plastic Boss keeps the history of the body it stands on (#494): the plate's faces still
/// take attached sketches, and so does the boss's own top, which follows its height.
#[test]
fn faces_after_a_boss_take_a_sketch_that_follows_it() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "12 mm"}));
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 2, "name": "Plate"}));
run(
&mut s,
"plastic.boss",
json!({"position": [20, 20, 2], "diameter": 7, "height": "boss_h", "hole_diameter": 2.5, "fillet": 0.5, "name": "Boss"}),
);
no_errors(&s);
sketch_on(&mut s, [5., 25., 2.], "OnPlate");
sketch_on(&mut s, [80., 25., 1.], "OnSide");
sketch_on(&mut s, [22.5, 20., 14.], "OnBoss");
for name in ["OnPlate", "OnSide"] {
healthy(&s, name);
}
let (top, certificate) = healthy(&s, "OnBoss");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
assert!((top.origin.z - 14.).abs() < 1e-9, "{top:?}");
run(&mut s, "parameters.change", json!({"name": "boss_h", "expression": "15 mm"}));
no_errors(&s);
assert!((healthy(&s, "OnBoss").0.origin.z - 17.).abs() < 1e-9);
healthy(&reopened(&s), "OnBoss");
}
/// A Hole names its faces by the part they cut (a counterbore's floor is `F<hole>:cbore:base`),
/// and a pattern's copies of it as copies: each floor takes its own sketch, which follows the
/// counterbore's depth. The plate's faces stay attachable too.
#[test]
fn counterbore_floors_and_their_pattern_copies_take_a_sketch() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "cb", "expression": "3 mm"}));
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 10, "name": "Plate"}));
run(
&mut s,
"solid.hole",
json!({"position": [15, 15, 10], "diameter": 5, "type": "counterbore", "cb_diameter": 10, "cb_depth": "cb", "name": "Hole"}),
);
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 25, "name": "Row"}));
no_errors(&s);
for (name, x) in [("Seed", 18.5), ("Copy", 43.5), ("Top", 30.)] {
sketch_on(&mut s, [x, 15., if name == "Top" { 10. } else { 7. }], name);
}
for name in ["Seed", "Copy"] {
let (floor, certificate) = healthy(&s, name);
assert_eq!(certificate, Some(FaceCertificate::Lineage), "{name}");
assert!((floor.origin.z - 7.).abs() < 1e-9, "{name}: {floor:?}");
}
healthy(&s, "Top");
run(&mut s, "parameters.change", json!({"name": "cb", "expression": "4 mm"}));
no_errors(&s);
for name in ["Seed", "Copy"] {
assert!((healthy(&s, name).0.origin.z - 6.).abs() < 1e-9, "{name}");
}
healthy(&reopened(&s), "Copy");
}
/// The middle of the largest triangle of each flat face of the first body.
fn flat_faces(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3)> {
let state = s.model.state();
let body = &state.bodies[0].body;
let mesh = body.tessellate(0.01).unwrap();
(0..body.face_count())
.filter(|index| body.planar_face(*index).is_some())
.filter_map(|index| {
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| **f as usize == index)
.filter_map(|(t, _)| mesh.tri(t))
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0))?;
Some((index, point))
})
.collect()
}
/// Every flat face of the first body takes an attached sketch (#681: a flat face is a flat face,
/// whichever features it was merged from).
fn every_flat_face_takes_a_sketch(s: &mut Session) {
no_errors(s);
let faces = flat_faces(s);
assert!(faces.len() >= 8, "{}", faces.len());
let mut failed = Vec::new();
for (index, point) in faces {
match s.execute("sketch.create", &json!({"plane": {"face": point, "body": "Body1", "index": index}})) {
Ok(made) => {
run(s, "sketch.finish", json!({}));
let id = made["sketch"].as_u64().unwrap();
if !matches!(s.placed_document().sketch_face_attachment(id, &s.model), FaceAttachmentStatus::Healthy { .. }) {
failed.push(format!("face {index} at {point:?}: not healthy"));
}
}
Err(e) => failed.push(format!("face {index} at {point:?}: {e}")),
}
}
assert!(failed.is_empty(), "{}", failed.join("\n"));
}
/// #681: a block with a lower step joined flush beside it. The step's side walls continue the
/// block's (a Join merged them), and they take a sketch like every other flat face.
#[test]
fn every_flat_face_of_a_block_and_a_flush_step_takes_a_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "A", "distance": 10, "name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 10], "p1": [20, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 5, "operation": "join", "name": "Step"}));
every_flat_face_takes_a_sketch(&mut s);
}
/// #681 as reported: one sketch of a 2 x 2 grid, three cells extruded up (an elbow) and three
/// joined down (the inverse elbow).
#[test]
#[ignore = "the join of the elbow down fails until #679 is fixed"]
fn every_flat_face_of_an_elbow_up_and_an_elbow_down_takes_a_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true}));
run(&mut s, "sketch.line", json!({"points": [[10, 0], [10, 20]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 10], [20, 10]]}));
run(&mut s, "sketch.finish", json!({}));
let cells = |pts: [[f64; 2]; 3]| pts.iter().map(|p| json!({"point": p})).collect::<Vec<_>>();
run(&mut s, "solid.extrude", json!({"sketch": "Grid", "distance": 10, "profiles": cells([[5., 5.], [15., 5.], [5., 15.]]), "name": "Up"}));
let down = cells([[15., 5.], [5., 15.], [15., 15.]]);
run(
&mut s,
"solid.extrude",
json!({"sketch": "Grid", "distance": 10, "direction": "negative", "operation": "join", "profiles": down, "name": "Down"}),
);
every_flat_face_takes_a_sketch(&mut s);
}
+76
View File
@@ -3023,3 +3023,79 @@ fn fold_fixed_point_must_touch_the_sheet_but_any_face_will_do() {
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet")), "{fold}");
assert!((bbox(&mut f)["max"][0].as_f64().unwrap_or(f64::NAN) - x).abs() < 1e-6, "the right side stays");
}
/// The middle of the largest triangle of each flat face of the first body: well inside it.
fn flat_face_points(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3, solidcraft_geom::Vec3)> {
let state = s.model.state();
let body = &state.bodies[0].body;
let mesh = body.tessellate(0.01).unwrap();
(0..body.face_count())
.filter_map(|index| {
let normal = body.planar_face(index)?.normal();
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| **f as usize == index)
.filter_map(|(t, _)| mesh.tri(t))
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0))?;
Some((index, point, normal))
})
.collect()
}
/// Every flat face of a sheet takes an attached sketch (#495): its panels, the rims along the
/// panels' straight edges (named by the base sketch line or the flange's far edge and sides) and
/// the flat ends of a bend. Each lies on its face, facing out of the sheet, and a rim's sketch
/// follows the rim when the base grows. A press-pull on a rim pushes it out, and a plain hole into
/// a panel is kept as a cut-out of the flat pattern (#560).
#[test]
fn every_flat_face_of_a_sheet_takes_an_attached_sketch() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "W", "expression": "100 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["p1"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
let faces = flat_face_points(&s);
// The base: its two faces and three rims (the fourth edge bends); the flange: its two faces,
// its far rim and two sides; the two ends of the bend.
assert_eq!(faces.len(), 12);
let mut on_side = None;
for (index, point, normal) in faces {
let made = run(&mut s, "sketch.create", json!({"plane": {"face": point, "body": "Body1", "index": index}}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(made["attachment"], "sheet_face", "face {index} at {point:?}");
let id = made["sketch"].as_u64().unwrap();
let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(id, &s.model) else {
panic!("face {index} at {point:?}")
};
assert!(plane.normal().dot(normal) > 1.0 - 1e-9 && (point - plane.origin).dot(normal).abs() < 1e-6, "face {index}: {plane:?}");
if (point.x - 100.0).abs() < 1e-6 && point.y > 10.0 {
on_side = Some(id);
}
}
// The base's rim along x = W (the sketch's l2) moves with W.
let side = on_side.expect("the base rim on x = 100");
run(&mut s, "parameters.change", json!({"name": "W", "expression": "120 mm"}));
let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(side, &s.model) else {
panic!("the rim sketch after the edit")
};
assert!((plane.origin.x - 120.0).abs() < 1e-9 && (plane.normal().x - 1.0).abs() < 1e-9, "{plane:?}");
// Press-pull the rim 5 mm out: the base grows by 5 x 60 x 2.5 less what the flange covers.
let v0 = volume(&mut s);
run(&mut s, "solid.press_pull", json!({"face": [120, 30, 1.25], "distance": 5}));
assert!(volume(&mut s) > v0 + 5.0 * 50.0 * 2.5, "{} {v0}", volume(&mut s));
// A plain hole into the base is a cut-out of the flat pattern.
let mut t = Session::default();
plate_with_flange(&mut t);
let v0 = volume(&mut t);
run(&mut t, "solid.hole", json!({"position": [50, 30, 2.5], "direction": [0, 0, -1], "diameter": 6}));
assert!((v0 - volume(&mut t) - PI * 9.0 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut t));
let f = flat(&mut t);
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
}
+3 -3
View File
@@ -105,10 +105,10 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
let local_point = apply_point(&inverse, point);
let tolerance = (body.body.size() * 1e-4).max(1e-5);
// A flat face of a sheet metal body attaches to its panel: the exact plane comes from
// the panel's flat region and fold, and the sketch keeps the face's name to follow it.
// A flat face of a sheet metal body attaches to its panel (or a panel's rim): the
// exact plane comes from the panel's flat region and fold, and the sketch keeps the
// face's name to follow it.
if !fixed
&& !internal
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
&& let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
{