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>
373 lines
20 KiB
Rust
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);
|
|
}
|