Sketch on cut walls, chamfers, pattern copies, join-merged sides and split faces (#625)

A flat face that no construction certificate names on its own now attaches by its exact history name (a new lineage face certificate): cut walls and floors, chamfer faces, each pattern or mirror copy, side walls a Join merged, and the pieces a later feature split a face into. Construction certificates still come first; a seed keeps its certificate when it is patterned. Two flush extrudes of one outline joined end to end keep their exact history in the prism boolean. Face sketch 52% -> 71%, offset plane 52% -> 71%, press-pull/extrude from face 41% -> 60% in the action matrix; baseline blessed.

Fixes #491, fixes #492, fixes #493, fixes #557.

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 05:26:29 -04:00
1 parent 27f20f97cb
commit 6d5df4b9b3
18 files changed
+786 -381

No files matched your search

+27 -16
View File
@@ -136,31 +136,42 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
}
}
/// A 40 x 30 x 20 box whose top is split at x = 20 and its left half raised 5 by Offset Face:
/// the raised top has no construction certificate and no exact history name yet (#494).
fn raised_half(s: &mut Session, body: &str) {
run(s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":body}));
run(s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
run(s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
}
#[test]
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
let mut s = Session::default();
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"}));
// 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"}));
// A face Offset Face raised has no construction certificate or exact history name yet:
// the legacy path.
raised_half(&mut s, "Stock");
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();
let index = state
// The raised half's top.
let end = state
.body("Stock")
.unwrap()
.body
.faces(1e-4)
.unwrap()
.iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 20.0).abs() < 1e-6)
.unwrap()
.index;
.into_iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
.unwrap();
let before = s.doc.to_json();
let error =
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,20],"body":"Stock","index":index,"path":[occurrence]}), "internal")
.unwrap_err();
let error = super::super::sketch::face::capture_internal(
&s,
&json!({"face":[40,15,25],"body":"Stock","index":end.index,"path":[occurrence]}),
"internal",
)
.unwrap_err();
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
assert_eq!(s.doc.to_json(), before);
}
@@ -278,14 +289,14 @@ fn join_keeps_exact_face_attachments_from_both_operands() {
}
#[test]
fn join_leaves_a_face_merged_from_both_operands_without_an_attachment() {
fn join_gives_a_face_merged_from_both_operands_its_history_name() {
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}}));
// Both boxes' tops share one support: no single construction certificate owns it, but its
// exact history names it (#491).
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10]}}))["sketch"].as_u64().unwrap();
assert_eq!(face_anchor(&s, top).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Lineage));
// 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();
+3
View File
@@ -1574,6 +1574,9 @@ mod offset_edit_guard_tests;
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
#[cfg(test)]
#[path = "lineage_face_tests.rs"]
mod lineage_face_tests;
#[cfg(test)]
#[path = "stacked_face_tests.rs"]
mod stacked_face_tests;
+166
View File
@@ -0,0 +1,166 @@
//! Sketches on flat faces that no construction certificate names: a Cut's walls and floor, a
//! chamfer, a pattern's copies, a side a Join merged from two features, the pieces of a split
//! face (#491, #492, #493, #557). They attach by the face's exact history name
//! (`FaceCertificate::Lineage`), follow the face through parameter edits and survive a save.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::FaceAttachmentStatus;
use solidcraft_doc::config::FaceCertificate;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
}
fn sketch_on(s: &mut Session, at: [f64; 3], name: &str) {
run(s, "sketch.create", json!({"plane": {"face": at}, "name": name}));
run(s, "sketch.finish", json!({}));
}
fn status(s: &Session, sketch: &str) -> FaceAttachmentStatus {
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
s.placed_document().sketch_face_attachment(id, &s.model)
}
fn healthy(s: &Session, sketch: &str) -> (solidcraft_geom::Plane, Option<FaceCertificate>) {
match status(s, sketch) {
FaceAttachmentStatus::Healthy { plane, target, .. } => (plane, target.certificate),
other => panic!("{sketch}: {other:?}"),
}
}
fn no_errors(s: &Session) {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
fn reopened(s: &Session) -> Session {
let mut t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
t.refresh();
no_errors(&t);
t
}
/// A 40 x 30 x 10 block with a slot cut 5 deep across it from x = `slot_x` (a parameter), 10 wide.
fn slotted_block() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
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"}));
run(&mut s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "10 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Slot"}));
no_errors(&s);
s
}
#[test]
fn a_cut_wall_and_floor_take_a_sketch_that_follows_the_cut() {
let mut s = slotted_block();
sketch_on(&mut s, [15., 15., 7.5], "Wall");
sketch_on(&mut s, [20., 15., 5.], "Floor");
let (wall, certificate) = healthy(&s, "Wall");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
// The wall faces into the slot (+x), its frame's origin over the design origin.
assert!((wall.normal().x - 1.).abs() < 1e-9 && wall.origin.x.abs() > 14.9 && (wall.origin.x - 15.).abs() < 1e-9, "{wall:?}");
assert!((wall.origin.y.abs() + wall.origin.z.abs()) < 1e-9, "{wall:?}");
let (floor, _) = healthy(&s, "Floor");
assert!((floor.normal().z - 1.).abs() < 1e-9 && (floor.origin.z - 5.).abs() < 1e-9, "{floor:?}");
// The slot moves: the sketch on its wall moves with it.
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
no_errors(&s);
let (wall, _) = healthy(&s, "Wall");
assert!((wall.origin.x - 20.).abs() < 1e-9, "{wall:?}");
let t = reopened(&s);
assert!((healthy(&t, "Wall").0.origin.x - 20.).abs() < 1e-9);
assert!((healthy(&t, "Floor").0.origin.z - 5.).abs() < 1e-9);
}
#[test]
fn the_pieces_of_a_split_top_take_a_sketch() {
let mut s = slotted_block();
// The slot splits the block's top in two; each piece takes a sketch (on that face's plane).
for (name, x) in [("Left", 5.), ("Right", 35.)] {
sketch_on(&mut s, [x, 15., 10.], name);
assert!((healthy(&s, name).0.origin.z - 10.).abs() < 1e-9, "{name}");
}
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
no_errors(&s);
assert!((healthy(&s, "Left").0.origin.z - 10.).abs() < 1e-9);
}
#[test]
fn a_chamfer_face_takes_a_sketch_that_follows_the_chamfer() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "bevel", "expression": "3 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Base"}));
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 10]], "distance": "bevel", "name": "Bevel"}));
no_errors(&s);
sketch_on(&mut s, [38.5, 15., 8.5], "OnBevel");
let (plane, certificate) = healthy(&s, "OnBevel");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
let n = std::f64::consts::FRAC_1_SQRT_2;
assert!((plane.normal().x - n).abs() < 1e-9 && (plane.normal().z - n).abs() < 1e-9, "{plane:?}");
// A bigger chamfer moves the face inward: the sketch goes with it.
let height = |p: &solidcraft_geom::Plane| (p.origin - solidcraft_geom::Vec3::new(40., 0., 10.)).dot(p.normal());
assert!((height(&plane) + 3. * n).abs() < 1e-9, "{plane:?}");
run(&mut s, "parameters.change", json!({"name": "bevel", "expression": "5 mm"}));
no_errors(&s);
assert!((height(&healthy(&s, "OnBevel").0) + 5. * n).abs() < 1e-9);
healthy(&reopened(&s), "OnBevel");
}
/// A plate with a boss patterned 3 times along x, `pitch` apart.
fn patterned_bosses(sketch_on_seed_first: bool) -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "pitch", "expression": "20 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": {"face": [40, 10, 5]}}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 6, "operation": "join", "name": "Boss"}));
if sketch_on_seed_first {
sketch_on(&mut s, [10., 10., 11.], "Seed");
}
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Boss"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "pitch", "name": "Row"}));
no_errors(&s);
s
}
#[test]
fn each_patterned_copy_takes_its_own_sketch() {
let mut s = patterned_bosses(true);
// A sketch made on the seed before the pattern keeps its construction certificate: the
// copies carry it too, but are named as copies.
assert!(matches!(healthy(&s, "Seed").1, Some(FaceCertificate::ExtrudeCap { .. })));
for (name, x) in [("First", 10.), ("Middle", 30.), ("Last", 50.)] {
sketch_on(&mut s, [x, 10., 11.], name);
assert!((healthy(&s, name).0.origin.z - 11.).abs() < 1e-9, "{name}");
}
run(&mut s, "parameters.change", json!({"name": "pitch", "expression": "25 mm"}));
no_errors(&s);
for name in ["Seed", "First", "Middle", "Last"] {
healthy(&s, name);
}
let t = reopened(&s);
for name in ["Seed", "Middle", "Last"] {
healthy(&t, name);
}
}
#[test]
fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Modified"}));
run(&mut s, "solid.split_face", json!({"body": "Modified", "faces": [[5, 5, 10]], "plane": {"origin": [5, 0, 0], "normal": [1, 0, 0]}}));
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
assert!(error.contains("fixed:true"), "{error}");
}
+4 -3
View File
@@ -75,9 +75,10 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
#[test]
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"}));
// 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"}));
// A top half Offset Face raised: no construction certificate and no exact history name yet.
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Base"}));
run(&mut s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
run(&mut s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
let mut pick = top_pick(&s, "Base");
let before = s.doc.to_json();
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
+5 -12
View File
@@ -67,20 +67,13 @@ fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
.unwrap_or_else(|e| panic!("{case}: {e}"));
// The second extrude's flat side is an exact source too, unless a full-face
// Join merged it with the first extrude's side: that face belongs to neither,
// so it offers the explicit fixed plane with a readable reason.
// The second extrude's flat side attaches too, also where a full-face Join
// merged it with the first extrude's side (#491): that face is known by its
// exact history name.
if !circle {
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
let params = json!({"plane": {"face": side}, "name": "Side"});
if full && operation == "join" {
let error = s.execute("sketch.create", &params).unwrap_err().to_string();
assert!(error.contains("merged") && error.contains("fixed:true"), "{case}: {error}");
run(&mut s, "sketch.create", json!({"plane": {"face": side, "fixed": true}, "name": "Side"}));
} else {
s.execute("sketch.create", &params).unwrap_or_else(|e| panic!("{case} side: {e}"));
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
}
s.execute("sketch.create", &json!({"plane": {"face": side}, "name": "Side"})).unwrap_or_else(|e| panic!("{case} side: {e}"));
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
run(&mut s, "sketch.finish", json!({}));
}
s.refresh();
+10 -6
View File
@@ -830,12 +830,16 @@ fn step_open_then_model_on_it() {
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
want -= PI * 9.0 * 20.0;
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
// Modified imported faces no longer have an immutable construction certificate.
// The public attachment refuses them; the user can explicitly choose a fixed plane.
let before_fixed = s.doc.to_json();
assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err());
assert_eq!(s.doc.to_json(), before_fixed);
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}}));
// Modified imported faces no longer have an immutable construction certificate; the top
// attaches by its exact history name instead.
let top = run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}))["sketch"].as_u64().unwrap();
assert!(matches!(
s.placed_document().sketch_face_attachment(top, &s.model),
doc::FaceAttachmentStatus::Healthy {
target: doc::config::ConfigFaceTarget { certificate: Some(doc::config::FaceCertificate::Lineage), .. },
..
}
));
let c = [x0 + 10.0, y0 + 15.0];
// Sketch coordinates on the face are relative to the model origin projected onto it.
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));