feat: sketches on a sheet metal face attach to its panel (#474)
A sketch on a flat face of a sheet metal body was refused ("this face has no exact planar
source"), so the only way was a plane frozen in space. When the sheet changes (a flange's
angle, the thickness), the frozen sketch stays behind: a cut from it fails on the slanted
flange or leaves a blind pocket in the thicker sheet.
`sketch.create` with a face of a sheet metal body now attaches the sketch to the panel the
face belongs to. It keeps the face's name, `sheet:<body>:<panel>:<top|bottom>` (the panel is
the base or a flange's feature tag), and takes the panel's exact plane from its flat region
and fold each time the model is evaluated, so the sketch follows the sheet through parameter
changes, unfold and refold. Its coordinates are the flat pattern's (the bottom face's y
mirrored). A face that is gone is reported as a lost attachment, never replaced by the
nearest face. The attachment follows a renamed or copied body, shows as `sheet_face` in the
command result and as its own status in Redefine Sketch Plane. `fixed: true` still freezes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49956c2797
commit
af0440aaab
13 files changed
+515
-13
No files matched your search
@@ -464,6 +464,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Later features refer to bodies by name.
|
||||
let mut failed = None;
|
||||
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut f.kind {
|
||||
solidcraft_doc::sheet::rename_plane_sheet_body(plane, &old, name);
|
||||
}
|
||||
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
&& anchor.target.body == old
|
||||
@@ -614,7 +617,13 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.pending_moves = trial.pending_moves;
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
} else {
|
||||
"fixed"
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "sketch": id,"attachment":attachment }))
|
||||
|
||||
@@ -709,6 +709,212 @@ fn fold_keeps_the_larger_side_either_way() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100 x 60 plate with a 90° flange up from its y = 0 edge (steel: 2.5 thick, R = T, K 0.44).
|
||||
/// The flange's inner face is the plane y = 2.5 and its outer face y = 0; it stands from z = 5.
|
||||
fn plate_with_flange(s: &mut Session) {
|
||||
base(s, 100.0, 60.0);
|
||||
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
}
|
||||
|
||||
/// A circle sketch on `plane`, finished.
|
||||
fn circle_sketch(s: &mut Session, name: &str, plane: Value, center: [f64; 2], diameter: f64) -> Value {
|
||||
let made = run(s, "sketch.create", json!({"plane": plane, "name": name}));
|
||||
run(s, "sketch.circle.center", json!({"center": center, "diameter": diameter}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
made
|
||||
}
|
||||
|
||||
fn cut_through(s: &mut Session, sketch: &str) {
|
||||
run(s, "solid.extrude", json!({"sketch": sketch, "through_all": true, "direction": "negative", "operation": "cut"}));
|
||||
}
|
||||
|
||||
fn contains(s: &Session, p: [f64; 3]) -> bool {
|
||||
s.model.state().bodies.first().is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_on_a_flange_face_attaches_to_its_panel() {
|
||||
// Sketch coordinates on a sheet face are the flat pattern's: the flange panel starts at
|
||||
// y = 5 − BA and runs on toward −y, so a hole 7 mm up the face is at y = 5 − BA − 7.
|
||||
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
|
||||
let hole = PI * 1.8 * 1.8 * 2.5;
|
||||
let mut s = Session::default();
|
||||
plate_with_flange(&mut s);
|
||||
let v0 = volume(&mut s);
|
||||
let made = circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, 5.0 - ba - 7.0], 3.6);
|
||||
assert_eq!(made["attachment"], "sheet_face");
|
||||
// The sketch keeps the face's name: the body, the panel (its flange) and the side of the sheet.
|
||||
let sketch = s.doc.find_feature("Holes").cloned().unwrap();
|
||||
let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &sketch.kind else { panic!("{:?}", sketch.kind) };
|
||||
let name = solidcraft_doc::sheet::plane_sheet_face(plane).unwrap_or_default();
|
||||
assert!(name.starts_with("sheet:Body1:F") && name.ends_with(":top"), "{name}");
|
||||
// A cut through the flange from that sketch is a round hole 7 mm up the face, at x = 30.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 2.5, "direction": "negative", "operation": "cut"}));
|
||||
assert!((v0 - volume(&mut s) - hole).abs() < 0.1, "{}", v0 - volume(&mut s));
|
||||
assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]) && contains(&s, [33.0, 1.25, 12.0]));
|
||||
let f = flat(&mut s);
|
||||
let cut = f["cutouts"][0].as_array().cloned().unwrap_or_default();
|
||||
assert!(!cut.is_empty(), "{f}");
|
||||
for p in &cut {
|
||||
let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN));
|
||||
assert!(((x - 30.0).hypot(y - (5.0 - ba - 7.0)) - 1.8).abs() < 1e-3, "({x}, {y})");
|
||||
}
|
||||
// `fixed: true` still freezes the picked plane instead of attaching to the panel.
|
||||
let frozen = circle_sketch(&mut s, "Frozen", json!({"face": [50, 2.5, 12], "fixed": true}), [0.0, 0.0], 1.0);
|
||||
assert_eq!(frozen["attachment"], "fixed");
|
||||
}
|
||||
|
||||
/// The thickness of a sheet comes from a parameter: the flange's face moves with it, and a
|
||||
/// sketch frozen on the old plane cuts a blind pocket in the thicker sheet. The attached
|
||||
/// sketch cuts the same hole as one drawn on the thicker sheet in the first place.
|
||||
#[test]
|
||||
fn a_sketch_on_a_sheet_face_follows_the_sheet_when_its_thickness_changes() {
|
||||
let build = |t: f64, then: Option<f64>| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "T", "expression": format!("{t} mm")}));
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL", "thickness": "T", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, t]], "height": 20}));
|
||||
// The hole is drawn 10 mm up the 2 mm flange, in the flat pattern: its bend ends at
|
||||
// y = T + R = 3 and the bend allowance is (R + T/2)·π/2 = π. The coordinates stay as they are.
|
||||
let flat_y = 3.0 - PI - 10.0;
|
||||
circle_sketch(&mut s, "Holes", json!({"face": [50, t, 12]}), [30.0, flat_y], 6.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, t]}, "offset": "5 mm", "name": "Above"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let v0 = volume(&mut s);
|
||||
cut_through(&mut s, "Holes");
|
||||
if let Some(t2) = then {
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "T", "expression": format!("{t2} mm")}));
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
}
|
||||
(s, v0)
|
||||
};
|
||||
let (mut grown, _) = build(2.0, Some(3.0));
|
||||
let (mut direct, v_direct) = build(3.0, None);
|
||||
let (vg, vd) = (volume(&mut grown), volume(&mut direct));
|
||||
// The hole is the cylinder Ø6 × 3 through the thicker sheet, as if drawn there at first.
|
||||
assert!((v_direct - vd - PI * 9.0 * 3.0).abs() < 0.1, "{}", v_direct - vd);
|
||||
assert!(rel(vg, vd) < 1e-9, "{vg} {vd}");
|
||||
let (fg, fd) = (flat(&mut grown), flat(&mut direct));
|
||||
assert_eq!(fg["pockets"], json!([]), "a blind pocket instead of the hole: {fg}");
|
||||
assert_eq!(fg["cutouts"], fd["cutouts"]);
|
||||
// The thicker flange's panel starts at flat y = 4 − 3.927 and z = T + R = 4, so the hole is
|
||||
// 10.215 mm up it: through the whole 3 mm.
|
||||
assert!(!contains(&grown, [30.0, 1.5, 14.2]) && !contains(&grown, [30.0, 0.2, 14.2]) && contains(&grown, [30.0, 1.5, 19.0]));
|
||||
// The sketch offset from the base's top face moved up with it: 3 + 5.
|
||||
let plane = run(&mut grown, "sketch.inspect", json!({"sketch": "Above"}))["plane"]["origin"][2].as_f64().unwrap_or(f64::NAN);
|
||||
assert!((plane - 8.0).abs() < 1e-9, "{plane}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketches_on_the_top_and_bottom_faces_of_a_sheet_survive_unfold_refold_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
// The top face's coordinates are the base sketch's; the bottom face's mirror y, as seen from below.
|
||||
circle_sketch(&mut s, "Top", json!({"face": [30, 20, 2.5]}), [30.0, 20.0], 4.0);
|
||||
circle_sketch(&mut s, "Bottom", json!({"face": [60, 20, 0]}), [60.0, -20.0], 4.0);
|
||||
cut_through(&mut s, "Top");
|
||||
cut_through(&mut s, "Bottom");
|
||||
let want = 15000.0 - 2.0 * PI * 4.0 * 2.5;
|
||||
assert!((volume(&mut s) - want).abs() < 0.1, "{}", volume(&mut s));
|
||||
let holes = |f: &Value| {
|
||||
let mut centres: Vec<(f64, f64)> = f["cutouts"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let pts: Vec<(f64, f64)> = c
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)))
|
||||
.collect();
|
||||
let n = pts.len().max(1) as f64;
|
||||
(pts.iter().map(|p| p.0).sum::<f64>() / n, pts.iter().map(|p| p.1).sum::<f64>() / n)
|
||||
})
|
||||
.collect();
|
||||
centres.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
centres
|
||||
};
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
let centres = holes(&flat(&mut s));
|
||||
assert_eq!(centres.len(), 2, "{centres:?}");
|
||||
assert!((centres[0].0 - 30.0).abs() < 0.01 && (centres[0].1 - 20.0).abs() < 0.01, "{centres:?}");
|
||||
assert!((centres[1].0 - 60.0).abs() < 0.01 && (centres[1].1 - 20.0).abs() < 0.01, "{centres:?}");
|
||||
assert!((volume(&mut s) - want).abs() < 0.1);
|
||||
}
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut reopened) - want).abs() < 0.1);
|
||||
assert_eq!(holes(&flat(&mut reopened)), holes(&flat(&mut s)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_made_on_the_unfolded_sheet_lands_on_the_flange_after_refolding() {
|
||||
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
|
||||
let mut s = Session::default();
|
||||
plate_with_flange(&mut s);
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
// Unfolded, the flange lies in the sheet's plane: the one flat face covers it too.
|
||||
let y = 5.0 - ba - 7.0;
|
||||
circle_sketch(&mut s, "Holes", json!({"face": [30, y, 2.5]}), [30.0, y], 3.6);
|
||||
cut_through(&mut s, "Holes");
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s));
|
||||
assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_on_a_sheet_face_reports_a_lost_attachment_instead_of_moving_to_another_face() {
|
||||
let mut s = Session::default();
|
||||
plate_with_flange(&mut s);
|
||||
circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6);
|
||||
let id = s.doc.find_feature("Holes").map(|f| f.id).unwrap_or(0);
|
||||
let status = |s: &Session| s.placed_document().sketch_face_attachment(id, &s.model);
|
||||
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(_) }));
|
||||
// With the flange suppressed there is no such face; the base's top face is not a substitute.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": true}));
|
||||
let error = run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].as_str().unwrap_or_default().to_string();
|
||||
assert!(error.contains("lost face attachment") && error.contains("reattach"), "{error}");
|
||||
let solidcraft_doc::FaceAttachmentStatus::Lost { message, repair_plane } = status(&s) else { panic!("{:?}", status(&s)) };
|
||||
assert!(message.contains("sheet face") && repair_plane.is_some(), "{message}");
|
||||
// Redefine Sketch Plane repairs it on another face, here the base's top (its sketch keeps its geometry).
|
||||
let repaired = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [10, 30, 2.5]}}));
|
||||
assert_eq!(repaired["attachment"], "sheet_face");
|
||||
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null());
|
||||
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. }));
|
||||
// The flange is back, and a sketch attached to it again is healthy.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": false}));
|
||||
let again = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [50, 2.5, 12]}}));
|
||||
assert_eq!(again["attachment"], "sheet_face");
|
||||
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null());
|
||||
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_sheet_body_keeps_its_sketches_on_their_faces() {
|
||||
let mut s = Session::default();
|
||||
plate_with_flange(&mut s);
|
||||
let v0 = volume(&mut s);
|
||||
circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, 2.5]}, "offset": "5 mm", "name": "Above"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "body.rename", json!({"body": "Body1", "name": "Bracket"}));
|
||||
for (sketch, face) in [("Holes", "sheet:Bracket:F"), ("Above", "sheet:Bracket:base:top")] {
|
||||
let feature = s.doc.find_feature(sketch).cloned().unwrap();
|
||||
let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &feature.kind else { panic!("{:?}", feature.kind) };
|
||||
assert!(solidcraft_doc::sheet::plane_sheet_face(plane).is_some_and(|name| name.starts_with(face)), "{sketch}: {:?}", feature.kind);
|
||||
assert!(run(&mut s, "timeline.inspect", json!({"feature": sketch}))["error"].is_null(), "{sketch}");
|
||||
}
|
||||
cut_through(&mut s, "Holes");
|
||||
assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s));
|
||||
}
|
||||
|
||||
/// A tray shrunk by a parameter change unfolds like the same tray built at that size. The
|
||||
/// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar
|
||||
/// arrangement used to drop a bend zone through a gap that small: "planar region arrangement
|
||||
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sketch")
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
|
||||
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
|
||||
CommandSpec::new("sketch.line", "Line", draw_line)
|
||||
@@ -662,6 +662,8 @@ fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, interna
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
} else if internal_body.is_some() {
|
||||
"internal_legacy_face"
|
||||
} else {
|
||||
|
||||
@@ -79,6 +79,7 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
by_component.entry(doc.model_body_component(body)).or_default().push(body);
|
||||
}
|
||||
let mut chosen = None;
|
||||
let mut sheet_face: Option<(String, Plane)> = None;
|
||||
let mut work = state.bodies.len().saturating_add(doc.occurrences.len());
|
||||
for (component, path, placement) in placements {
|
||||
let Some(bodies) = by_component.get(&component) else {
|
||||
@@ -104,6 +105,22 @@ 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.
|
||||
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)
|
||||
{
|
||||
if chosen.is_some() || sheet_face.is_some() {
|
||||
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
|
||||
}
|
||||
let relative = mat_mul(&author_inverse, &frame);
|
||||
let plane = Plane::new(apply_point(&relative, plane.origin), apply_vector(&relative, plane.x), apply_vector(&relative, plane.y))
|
||||
.ok_or_else(|| bad(cmd, "invalid sheet face plane"))?;
|
||||
sheet_face = Some((solidcraft_doc::sheet::sheet_face_name(&body.name, &face), plane));
|
||||
continue;
|
||||
}
|
||||
let faces = body.body.faces(tolerance)?;
|
||||
let mesh = body.body.tessellate(tolerance)?;
|
||||
work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() }));
|
||||
@@ -138,6 +155,12 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((name, plane)) = sheet_face {
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
|
||||
}
|
||||
return Ok(PlaneRef::Face { plane, at: apply_point(&author_inverse, point), name: Some(name), anchor: None });
|
||||
}
|
||||
let (body, index, path, plane) =
|
||||
chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?;
|
||||
if fixed {
|
||||
|
||||
Reference in new issue
Block a user