Offset Plane from a planar body face (#167)
construct.plane.offset now takes a planar face as its base
({"face": [x,y,z], body?, index?, path?}), captured with the same exact
face attachment a sketch on that face uses. The Offset Plane dialog accepts
planar faces as well as origin and construction planes.
A construction plane on a face is resolved through its face anchor when the
timeline is evaluated, so it follows the face when upstream features change
(and after reopening), instead of keeping the plane as it was when picked.
Tests: engine (top and side faces, follows a box resize, survives reopen,
hostile picks are refused without adding a feature) and a UI scenario that
picks a face in the dialog and builds a pad on the new plane.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c308a1ba7a
commit
cab698bf12
5 files changed
+134
-7
No files matched your search
@@ -0,0 +1,82 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::construct::ConstructGeom;
|
||||
use solvecraft_doc::{Document, FeatureKind, PlaneRef};
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
|
||||
}
|
||||
|
||||
fn plane(s: &Session, name: &str) -> Plane {
|
||||
let state = s.model.state();
|
||||
match state.construct.iter().find(|c| c.name == name).map(|c| &c.geom) {
|
||||
Some(ConstructGeom::Plane(p)) => *p,
|
||||
other => panic!("construction plane `{name}`: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &Session) -> f64 {
|
||||
s.model.state().bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_plane_from_a_planar_face_follows_the_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
|
||||
// Top face (z = 20), 5 mm out along its normal.
|
||||
run(&mut s, "construct.plane.offset", json!({"base": {"face": [5, 5, 20]}, "offset": 5, "name": "Above"}));
|
||||
let above = plane(&s, "Above");
|
||||
assert!((above.origin.z - 25.0).abs() < 1e-9, "{above:?}");
|
||||
assert!((above.normal().z.abs() - 1.0).abs() < 1e-9, "{above:?}");
|
||||
// Stored as an exact face attachment, not a frozen copy of the plane.
|
||||
let feature = s.doc.find_feature("Above").unwrap();
|
||||
let FeatureKind::ConstructionPlane { plane: PlaneRef::Offset { base, .. } } = &feature.kind else { panic!("offset plane") };
|
||||
assert!(matches!(base.as_ref(), PlaneRef::Face { anchor: Some(_), .. }), "{base:?}");
|
||||
|
||||
// Side face (x = 40), 3 mm out.
|
||||
run(&mut s, "construct.plane.offset", json!({"base": {"face": [40, 15, 10]}, "offset": 3, "name": "Side"}));
|
||||
let side = plane(&s, "Side");
|
||||
assert!((side.origin.x - 43.0).abs() < 1e-9, "{side:?}");
|
||||
assert!((side.normal().x.abs() - 1.0).abs() < 1e-9, "{side:?}");
|
||||
|
||||
// A 10 × 10 × 10 pad sketched on the new plane sits 5 mm above the box.
|
||||
run(&mut s, "sketch.create", json!({"plane": "Above"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "new"}));
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
assert!((volume(&s) - (40.0 * 30.0 * 20.0 + 1000.0)).abs() < 1e-6, "{}", volume(&s));
|
||||
|
||||
// Taller and longer box: both planes move with their faces.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30", "length": "50"}}));
|
||||
assert!((plane(&s, "Above").origin.z - 35.0).abs() < 1e-9);
|
||||
assert!((plane(&s, "Side").origin.x - 53.0).abs() < 1e-9);
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
|
||||
// Saved and reopened, the attachment still resolves.
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((plane(&reopened, "Above").origin.z - 35.0).abs() < 1e-9);
|
||||
assert!((plane(&reopened, "Side").origin.x - 53.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_plane_rejects_bad_face_picks_without_panicking() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
for base in [
|
||||
json!({"face": [100, 100, 100]}),
|
||||
json!({"face": [1e300, 0, 0]}),
|
||||
json!({"face": "top"}),
|
||||
json!({"face": [5, 5]}),
|
||||
json!({"face": [5, 5, 20], "index": 0}),
|
||||
json!({"face": [5, 5, 20], "body": 7, "index": 0}),
|
||||
] {
|
||||
let features = s.doc.features.len();
|
||||
assert!(s.execute("construct.plane.offset", &json!({"base": base, "offset": 5})).is_err(), "{base}");
|
||||
assert_eq!(s.doc.features.len(), features, "{base}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user