A Union dropped every construction certificate, so after any join no face could take a durable sketch: sketch.create failed with "this face has no exact planar source" even on a face the join never touched (#420, and the "CREATE SKETCH: operation failed" reports #344, #195). - kernel: map planar sources through a Union from both operands, with the same exact-support rule Cut uses. Coplanar supports of the two operands carry distinct identities, so a face merged from both stays without one. - doc: Join extrude and Join box tools are certified like new bodies; a cap moved off its designed height (into the target, or past a curved To face) is not certified. Join producers are accepted when certifying. Tests: both operands' faces attach and follow an upstream edit and a reopen; a merged coplanar face is refused and fixed:true works; a face a join covers whole is lost without moving to the new roof. Tests that used a joined face as their "uncertified" example now use a merged face; the trimmed-face case in a_sketch_on_a_face_stays_on_that_face now stays attached to the surviving piece. Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e60080b10
commit
722bd007c8
8 files changed
+199
-41
No files matched your search
@@ -142,7 +142,8 @@ fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
|
||||
run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
|
||||
// A top merged from two joined boxes has no single certified source: the legacy path.
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"corner":[40,0,0],"operation":"join"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
|
||||
let state = s.model.state();
|
||||
@@ -153,12 +154,12 @@ fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
.faces(1e-4)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 20.0).abs() < 1e-6)
|
||||
.unwrap()
|
||||
.index;
|
||||
let before = s.doc.to_json();
|
||||
let error =
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,20],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
@@ -229,3 +230,90 @@ fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
|
||||
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
|
||||
assert_eq!(body["faces"], 14, "{m}");
|
||||
}
|
||||
|
||||
fn face_anchor(s: &Session, sketch: u64) -> solidcraft_doc::FaceAnchor {
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("sketch {sketch} has no face anchor")
|
||||
};
|
||||
(**anchor).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_keeps_exact_face_attachments_from_both_operands() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-10,-10],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let base = run(&mut s, "solid.extrude", json!({"distance":40,"name":"Base"}))["feature"].as_u64().unwrap();
|
||||
// A cross bar joined through the base (the shape of #420) and a boss joined on its top.
|
||||
run(&mut s, "sketch.create", json!({"plane":"YZ"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,20],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":20,"symmetric":true,"operation":"join"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,40]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let boss = run(&mut s, "solid.extrude", json!({"distance":10,"operation":"join","name":"Boss"}))["feature"].as_u64().unwrap();
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
|
||||
// The base's top around the boss, and the boss's own top (the Union's second operand).
|
||||
let on_base = run(&mut s, "sketch.create", json!({"plane":{"face":[8,8,40]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let on_boss = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,50]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(face_anchor(&s, on_base).target.origin_feature, base);
|
||||
assert_eq!(face_anchor(&s, on_boss).target.origin_feature, boss);
|
||||
|
||||
// The attachments follow their faces through an upstream edit and a reopen.
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"extent":{"distance":"50"}}}));
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
|
||||
let state = s.model.state();
|
||||
assert!((state.sketch(on_base).unwrap().plane.origin.z - 50.0).abs() < 1e-6);
|
||||
assert!((state.sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
|
||||
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
|
||||
assert!((reopened.model.state().sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_leaves_a_face_merged_from_both_operands_without_an_attachment() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
|
||||
// Both boxes' tops share one support: no single construction identity owns it.
|
||||
let error = s.execute("sketch.create", &json!({"plane":{"face":[10,10,10]}})).unwrap_err().to_string();
|
||||
assert!(error.contains("fixed:true"), "{error}");
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10],"fixed":true}}));
|
||||
// An unshared support keeps its box role.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
|
||||
assert!(matches!(face_anchor(&s, side).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Box { axis: 0, high: false })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_covering_a_whole_attached_face_loses_the_attachment_without_moving_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"name":"Block"}));
|
||||
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[30,15,10]},"name":"Top"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[30,15],"radius":3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Top","distance":5,"operation":"join","name":"Peg"}));
|
||||
// An earlier join whose roof replaces Block's whole top.
|
||||
run(&mut s, "timeline.roll_to", json!({"feature":"Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[45,35]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"B","distance":20,"operation":"join","name":"Boss"}));
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
let failed = |s: &Session, id: u64| s.model.results.iter().find(|r| r.id == id).unwrap().error.is_some();
|
||||
assert!(failed(&s, top), "the attachment never follows the substitute roof");
|
||||
assert!(s.model.state().sketch(top).is_none());
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
|
||||
assert!(!failed(&s, top));
|
||||
assert!((s.model.state().sketch(top).unwrap().plane.origin.z - 10.).abs() < 1e-9);
|
||||
}
|
||||
@@ -76,7 +76,8 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
|
||||
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"}));
|
||||
// The tops of two joined boxes merge into one face that no single construction owns.
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[10,0,0],"operation":"join"}));
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
|
||||
|
||||
Reference in new issue
Block a user