Files
solidcraft-upstream/crates/ui-egui/src/sketch_attachment_ui_tests.rs
T
bherbruckandClaude Opus 5.5 45bc22fc30 fix: faces of an extrude drawn over a face's projected edges keep an exact source (#505)
A profile traced over a face sketch's projected boundary left the extrude's caps and sides without an exact planar source, so a sketch or offset plane on the new top face was refused. The drawn curve is now the edge's source where it covers a projection. Clearer fixed-plane message; regression tests in the engine, UI harness and design corpus, plus a corpus-wide flat-face attachment check (known gaps under #491-#495).

Fixes #489

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:01:35 -04:00

373 lines
20 KiB
Rust

use crate::scenario::Harness;
use serde_json::{Value, json};
use solidcraft_engine::doc::{FaceAttachmentStatus, FeatureKind, PlaneRef};
fn picked_sketch() -> (Harness, u64) {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.session.auto_project = false;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BaseBox","name":"Original"})).unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,20],"body_name":"ControlBox","name":"Control"})).unwrap();
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[3,4,10]}})).unwrap();
h.settle();
let sketch = h.app.session.active_sketch.expect("actual picked face creates the sketch");
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else {
panic!("picked face must create a certified attachment");
};
assert_eq!(anchor.target.body, "BaseBox");
assert!(anchor.path.is_empty());
h.app.run("parameters.add", json!({"name":"badge_diameter","expression":"2 mm"})).unwrap();
h.step(&json!({"start":"sketch.circle.center"})).unwrap();
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
h.step(&json!({"move":{"world":[7,5,10]}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"badge_diameter"})).unwrap();
h.step(&json!({"key":"Enter"})).unwrap();
h.app.finish_sketch();
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap();
assert!(!h.app.session.doc.sketch(sketch).unwrap().constraints.is_empty());
(h, sketch)
}
fn raw_sketch(h: &Harness, id: u64) -> Value {
serde_json::to_value(h.app.session.doc.sketch(id).unwrap()).unwrap()
}
#[test]
fn actual_picked_face_and_noop_edit_preserve_exact_anchor_and_dimensions() {
let (mut h, sketch) = picked_sketch();
let before = serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap();
h.app.edit_sketch(sketch);
h.settle();
h.app.finish_sketch();
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before);
let path = h.dir.join("picked-face.solidcraft");
h.app.run("file.save", json!({"path":path})).unwrap();
h.app.run("doc.open", json!({"path":path})).unwrap();
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before);
assert!(matches!(h.app.session.doc.sketch_face_attachment(sketch, &h.app.session.model), FaceAttachmentStatus::Healthy { .. }));
}
#[test]
fn lost_attachment_ghost_rebind_preview_cancel_apply_and_undo_preserve_sketch() {
let (mut h, sketch) = picked_sketch();
h.app.run("solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new_body","body_name":"Boss"})).unwrap();
h.app.run("timeline.suppress", json!({"feature":"Original","suppressed":true})).unwrap();
assert!(h.app.session.model.result(sketch).unwrap().error.is_some());
assert!(h.app.session.world_state().body("Boss").is_none(), "lost attachment cannot produce a Boss on ControlBox");
assert!(h.app.session.world_state().sketch(sketch).is_none(), "saved repair guides are not solved sketch geometry");
let raw = raw_sketch(&h, sketch);
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let redo = h.app.session.redo.len();
h.app.edit_sketch(sketch);
h.frames(4);
assert_eq!(h.app.tree.redefine, Some(sketch));
assert!(h.widget("Face attachment lost").is_some());
let guides = crate::sketch_attachment_ui::guide_lines(&h.app);
assert!(!guides.is_empty());
assert!(guides.iter().flatten().all(|point| (point.z - 10.0).abs() < 1e-8));
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap();
h.app.session.selection.clear();
h.frames(4);
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
assert!(h.app.session.selection.is_empty(), "saved lost-plane guides must not be selectable geometry");
assert!(h.app.session.active_sketch.is_none());
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap();
h.frames(4);
h.step(&json!({"click":{"world":[3,4,30]}})).unwrap();
assert!(matches!(h.app.session.selection.first(), Some(solidcraft_engine::Sel::Face { body, .. }) if body=="ControlBox"));
h.frames(2);
h.step(&json!({"widget":"Reattach selected face"})).unwrap();
let commands = crate::sketch_attachment_ui::commands(&h.app)
.unwrap_or_else(|| panic!("repair draft {:?}, selection {:?}", h.app.tree.redefine_draft, h.app.session.selection));
assert_eq!(commands[0].1["plane"]["body"], "ControlBox");
assert!(commands[0].1["plane"]["index"].as_u64().is_some());
let preview = h.app.session.preview_visible_world(&commands).unwrap();
let boss = preview.after.body("Boss").unwrap().mesh().bounds();
assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7);
assert_eq!(h.app.session.doc.to_json(), before, "preview is scratch only");
h.step(&json!({"widget":"Cancel"})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.redo.len(), redo);
assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty());
crate::browser::redefine_sketch(&mut h.app, sketch);
h.frames(4);
h.step(&json!({"widget":"Reattach selected face"})).unwrap();
h.frames(8);
h.step(&json!({"widget":"Apply"})).unwrap();
assert!(h.app.tree.redefine.is_none(), "{:?}", h.app.tree.redefine_draft);
assert_eq!(raw_sketch(&h, sketch), raw, "curves, dimensions and expressions remain exact");
assert_eq!(h.app.session.undo.len(), undo + 1);
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
let boss = h.app.session.world_state().body("Boss").unwrap().mesh().bounds();
assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7);
let repaired = h.app.session.doc.to_json();
let path = h.dir.join("rebound-face.solidcraft");
h.app.run("file.save", json!({"path":path})).unwrap();
// Saving the untitled design names it after its file; everything else is exact.
let named = |doc: &str| {
let mut doc = solidcraft_engine::doc::Document::from_json(doc).unwrap();
doc.name = "rebound-face".into();
doc.to_json()
};
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), named(&before));
h.app.run("doc.open", json!({"path":path})).unwrap();
assert_eq!(h.app.session.doc.to_json(), named(&repaired));
assert_eq!(raw_sketch(&h, sketch), raw);
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
}
#[test]
fn attachment_drafts_leave_on_document_replacement_but_survive_failed_open() {
let (mut h, sketch) = picked_sketch();
crate::browser::redefine_sketch(&mut h.app, sketch);
h.frames(2);
h.step(&json!({"widget":"Use XZ plane"})).unwrap();
assert!(crate::sketch_attachment_ui::commands(&h.app).is_some());
assert!(h.app.run("doc.open", json!({"path":h.dir.join("missing.solidcraft")})).is_err());
assert!(crate::sketch_attachment_ui::commands(&h.app).is_some());
h.app.run("file.new", json!({})).unwrap();
assert!(h.app.tree.redefine.is_none());
assert!(h.app.tree.redefine_draft.is_none());
}
#[test]
fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap();
// An intentional split gives the +z source two distinct planar pieces. A
// strict attachment cannot claim the original whole-face certificate.
h.app.run("solid.split_face", json!({"body":"Modified","faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}})).unwrap();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[2.5,5,10]}})).unwrap();
assert!(h.app.session.active_sketch.is_none());
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
h.frames(4);
assert!(h.widget("Use fixed plane").is_some());
h.step(&json!({"widget":"Use fixed plane"})).unwrap();
let sketch = h.app.session.active_sketch.expect("explicit fixed choice succeeds for the actual planar surface");
let FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: geometry } = &h.app.session.doc.feature(sketch).unwrap().kind else {
panic!("fixed choice must never create a durable-looking Face reference");
};
assert!((plane.origin.z - 10.0).abs() < 1e-7);
assert!(geometry.links.is_empty(), "a fixed snapshot cannot carry live source projections");
assert_eq!(h.app.session.undo.len(), undo + 1);
}
fn occurrence_click(h: &mut Harness, path: &[u64], control: &str) {
h.step(&json!({"click":{"occurrence":path,"control":control}})).unwrap();
}
#[test]
fn actual_nested_second_placement_face_pick_keeps_target_and_author_paths() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.session.auto_project = false;
let parent = h.app.run("component.create", json!({"name":"Parent"})).unwrap();
let parent_component = parent["component"].as_u64().unwrap();
let first_parent = parent["occurrence"].as_u64().unwrap();
let child = h.app.run("component.create", json!({"name":"Child","parent":parent_component,"parent_path":[first_parent]})).unwrap();
let child_component = child["component"].as_u64().unwrap();
let child_occurrence = child["occurrence"].as_u64().unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"NestedBase"})).unwrap();
h.app.run("component.activate", json!({"component":0})).unwrap();
let second_parent =
h.app.run("occurrence.copy", json!({"occurrence":first_parent,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
h.frames(4);
let path = vec![second_parent, child_occurrence];
if !crate::browser::drawn_occurrences().iter().any(|(drawn, _, _, _)| drawn == &path) {
occurrence_click(&mut h, &[second_parent], "fold");
}
occurrence_click(&mut h, &path, "activate");
assert_eq!(h.app.session.active_component, child_component);
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path));
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[43,4,10]}})).unwrap();
let sketch = h.app.session.active_sketch.expect("actual second nested face pick");
let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone();
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &kind else {
panic!("strict face attachment");
};
assert_eq!(anchor.target.body, "NestedBase");
assert_eq!(anchor.path, path);
assert_eq!(anchor.author_path, path);
assert_eq!(anchor.component, child_component);
let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else {
panic!("selected saved placement resolves");
};
assert!((plane.origin.x - 40.0).abs() < 1e-7 && (plane.origin.z - 10.0).abs() < 1e-7);
h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap();
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":2})).unwrap();
h.app.finish_sketch();
let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone();
h.app.run("component.activate", json!({"component":0})).unwrap();
h.app.edit_sketch(sketch);
assert_eq!(h.app.session.active_component, child_component);
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path));
h.app.finish_sketch();
assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind);
let file = h.dir.join("nested-face.solidcraft");
h.app.run("file.save", json!({"path":file})).unwrap();
h.app.run("doc.open", json!({"path":file})).unwrap();
assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind);
h.app.run("selection.delete", json!({"occurrences":[second_parent]})).unwrap();
assert!(h.app.session.model.result(sketch).unwrap().error.is_some());
let FaceAttachmentStatus::Lost { repair_plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else {
panic!("deleted second placement must not substitute first");
};
assert!(repair_plane.is_none(), "a missing saved author placement cannot be drawn at its sibling");
h.app.edit_sketch(sketch);
h.frames(4);
assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty());
assert_eq!(h.app.tree.redefine, Some(sketch));
}
#[test]
fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane() {
use solidcraft_engine::doc::{EmbossFace, apply_point, apply_vector};
use solidcraft_engine::geom::Plane;
use solidcraft_engine::geom::Vec3;
let mut h = Harness::new();
h.app.session.auto_project = false;
let created = h.app.run("component.create", json!({"name":"Author"})).unwrap();
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})).unwrap();
let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,1,0],"angle":"90 deg","capture":true})).unwrap();
h.app.run("component.activate", json!({"component":component,"path":[second],"occurrence":second})).unwrap();
let doc = h.app.session.placed_document();
let body_name = doc.body_frames_with_source(&h.app.session.model.state()).into_iter().find(|(_, _, _, path, _)| path == &[second]).unwrap().0;
let world = h.app.session.world_state();
let face = world
.body(&body_name)
.unwrap()
.body
.faces(0.01)
.unwrap()
.into_iter()
.find(|face| world.body(&body_name).unwrap().body.face_plane(face.index).is_some_and(|plane| plane.normal().dot(Vec3::X) > 0.999))
.unwrap();
let id = h
.app
.run("sketch.create", json!({"plane":{"body":body_name,"index":face.index,"face":face.centroid,"path":[second]},"project_edges":false}))
.unwrap()["sketch"]
.as_u64()
.unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap();
h.app.finish_sketch();
// Exercise a durable anchor under both public plane wrappers, as imported
// documents can contain these without an interactive wrapper editor.
let mut document = (*h.app.session.doc).clone();
let FeatureKind::Sketch { plane, .. } = &mut document.feature_mut(id).unwrap().kind else {
panic!("sketch");
};
*plane = PlaneRef::AtAngle {
base: Box::new(PlaneRef::Offset { base: Box::new(plane.clone()), distance: "2 mm".into() }),
axis_origin: Vec3::ZERO,
axis_dir: Vec3::X,
angle: "15 deg".into(),
};
h.app.session = solidcraft_engine::Session::new(document);
let raw = h.app.session.doc.sketch(id).unwrap().clone();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let FaceAttachmentStatus::Healthy { plane: expected, .. } = h.app.session.placed_document().sketch_face_attachment(id, &h.app.session.model)
else {
panic!("wrapped anchor must resolve");
};
crate::browser::redefine_sketch(&mut h.app, id);
h.frames(4);
assert_eq!(h.app.tree.redefine_draft.as_ref().unwrap().author_path, Some(vec![second]));
h.step(&json!({"widget":"Use saved fixed plane"})).unwrap();
let commands = crate::sketch_attachment_ui::commands(&h.app).unwrap();
let preview = h.app.session.preview_visible_world(&commands).unwrap();
// The shared sketch's primary world entry belongs to the untransformed
// first placement. Verify that local plane at the specifically chosen
// second placement, rather than treating the primary entry as that path.
let frame = EmbossFace::component_frame(&h.app.session.placed_document(), component, &[second]).unwrap();
let selected_plane =
|local: Plane| Plane::new(apply_point(&frame, local.origin), apply_vector(&frame, local.x), apply_vector(&frame, local.y)).unwrap();
let shown = selected_plane(preview.after.sketch(id).unwrap().plane);
assert!(
shown.origin.dist(expected.origin) < 1e-7,
"expected {expected:?}, shown {shown:?}, primary {:?}, commands {commands:?}",
preview.after.sketch(id).unwrap().plane
);
assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7);
assert_eq!(h.app.session.doc.to_json(), before);
h.frames(6);
h.step(&json!({"widget":"Apply"})).unwrap();
assert!(h.app.tree.redefine.is_none());
assert_eq!(h.app.session.doc.sketch(id).unwrap(), &raw);
assert_eq!(h.app.session.undo.len(), undo + 1);
let shown = selected_plane(h.app.session.model.state().sketch(id).unwrap().plane);
assert!(shown.origin.dist(expected.origin) < 1e-7);
assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
}
/// Issue #489: extrude, sketch a rectangle over the whole top face (its corners snap to the
/// face's projected boundary), extrude it up as a Join, then pick the new top face for a sketch.
#[test]
fn picked_top_face_of_a_stacked_join_extrude_takes_a_sketch() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,40]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("solid.extrude", json!({"distance":10,"name":"First"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
let pick_face = |h: &mut Harness, z: f64| {
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[20,20,z]}})).unwrap();
h.settle();
let error = h.app.dialog.as_ref().and_then(|d| d.error.clone());
let sketch = h.app.session.active_sketch.unwrap_or_else(|| panic!("top face at z={z} must take a sketch: {error:?}"));
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(_), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else {
panic!("the top face at z={z} must give a certified attachment");
};
sketch
};
pick_face(&mut h, 10.);
h.step(&json!({"start":"sketch.rectangle.two_point"})).unwrap();
h.step(&json!({"click":{"world":[0,0,10]}})).unwrap();
h.step(&json!({"click":{"world":[40,40,10]}})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.app.finish_sketch();
h.app.run("solid.extrude", json!({"distance":10,"operation":"join","name":"Second"})).unwrap();
assert_eq!(h.app.session.model.state().bodies.len(), 1);
h.settle();
let top = pick_face(&mut h, 20.);
let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(top, &h.app.session.model) else {
panic!("attachment must be healthy");
};
assert!((plane.origin.z - 20.).abs() < 1e-9);
}