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 <[email protected]>
This commit is contained in:
1 parent
c308a1ba7a
commit
cab698bf12
5 files changed
+134
-7
No files matched your search
@@ -87,7 +87,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane")
|
||||
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
|
||||
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr; name?"),
|
||||
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane")
|
||||
@@ -359,10 +359,17 @@ pub(super) fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str
|
||||
|
||||
/// Add a feature, evaluate, and report its result (an evaluation error fails the command).
|
||||
pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
|
||||
add_feature_in(s, p, kind, true)
|
||||
}
|
||||
|
||||
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
|
||||
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
|
||||
let name = str_(p, "name");
|
||||
// Commands speak world coordinates; the feature is authored in the active component.
|
||||
let mut kind = kind;
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
if world {
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
}
|
||||
// Bare lengths typed in an inch (or cm…) design mean that unit.
|
||||
if s.doc.units.trim() != "mm" {
|
||||
let doc = s.doc.clone();
|
||||
@@ -1007,9 +1014,17 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
|
||||
|
||||
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "construct.plane.offset";
|
||||
let base = plane_param(s, p.get("base"), cmd)?;
|
||||
let d = req_expr(cmd, p, "offset")?;
|
||||
check_expr(s, &d, Kind::Length, cmd, "offset")?;
|
||||
// A planar body face: attached exactly, as a sketch on that face is, so the plane follows it.
|
||||
if let Some(face) = p.get("base").filter(|b| b.get("face").is_some()) {
|
||||
let base = super::sketch::face::capture(s, face, cmd)?;
|
||||
super::sketch::face::capture_positions(s, &base)?;
|
||||
let kind = FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } };
|
||||
// The capture is already in the authoring frame.
|
||||
return add_feature_in(s, p, kind, false);
|
||||
}
|
||||
let base = plane_param(s, p.get("base"), cmd)?;
|
||||
add_feature(s, p, FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } })
|
||||
}
|
||||
|
||||
@@ -1434,3 +1449,7 @@ mod offset_identity_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "offset_edit_guard_tests.rs"]
|
||||
mod offset_edit_guard_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_plane_face_tests.rs"]
|
||||
mod offset_plane_face_tests;
|
||||
@@ -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