#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use crate::Session; use serde_json::{Value, json}; use solidcraft_doc::construct::ConstructGeom; use solidcraft_doc::{Document, FeatureKind, PlaneRef}; use solidcraft_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| solidcraft_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}"); } }