diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 5e084275e..d1b3d3fb7 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -611,3 +611,78 @@ pub(super) fn build_window_icon() -> Option> { resvg::render(&tree, transform, &mut pixmap.as_mut()); Some(pixmap.take()) } + +#[cfg(test)] +mod ucs_from_normal_tests { + use super::ucs_from_normal; + use glam::DVec3; + + fn axes(ucs: &acadrust::tables::Ucs) -> (DVec3, DVec3, DVec3) { + let x = DVec3::new(ucs.x_axis.x, ucs.x_axis.y, ucs.x_axis.z); + let y = DVec3::new(ucs.y_axis.x, ucs.y_axis.y, ucs.y_axis.z); + (x, y, x.cross(y)) + } + + /// The contract `UCS FACE` depends on: the plane's Z is the face normal it + /// was handed. If this drifts, sketches tilt off the face they were + /// started on. + #[test] + fn z_axis_is_the_supplied_normal() { + for normal in [ + DVec3::Z, + DVec3::NEG_Z, + DVec3::X, + DVec3::Y, + DVec3::new(1.0, 2.0, 3.0).normalize(), + ] { + let ucs = ucs_from_normal(DVec3::ZERO, normal).expect("a normal defines a plane"); + let (_, _, z) = axes(&ucs); + assert!( + (z - normal).length() < 1e-9, + "expected Z {normal:?}, got {z:?}" + ); + } + } + + /// Right-handed and unit length, or every coordinate read off the plane is + /// skewed. + #[test] + fn axes_are_orthonormal() { + for normal in [DVec3::Z, DVec3::new(-2.0, 0.5, 1.0).normalize()] { + let ucs = ucs_from_normal(DVec3::ZERO, normal).unwrap(); + let (x, y, z) = axes(&ucs); + for (name, axis) in [("X", x), ("Y", y), ("Z", z)] { + assert!((axis.length() - 1.0).abs() < 1e-9, "{name} is not unit"); + } + assert!(x.dot(y).abs() < 1e-9, "X and Y are not perpendicular"); + assert!(x.dot(z).abs() < 1e-9, "X and Z are not perpendicular"); + } + } + + /// The 1/64 branch exists so a normal along world Z does not cross-product + /// with itself. Both sides of that branch must still produce a usable + /// plane. + #[test] + fn a_normal_along_world_z_still_yields_a_plane() { + let ucs = ucs_from_normal(DVec3::ZERO, DVec3::Z).expect("world Z is a valid normal"); + let (x, _, _) = axes(&ucs); + assert!(x.length() > 0.5, "X degenerated next to the world Z axis"); + } + + /// The same face must always give the same X axis, or a sketch would spin + /// each time the UCS was rebuilt from it. + #[test] + fn the_same_normal_is_reproducible() { + let normal = DVec3::new(0.3, -0.7, 0.2).normalize(); + let first = axes(&ucs_from_normal(DVec3::ZERO, normal).unwrap()); + let second = axes(&ucs_from_normal(DVec3::ZERO, normal).unwrap()); + assert!((first.0 - second.0).length() < 1e-12); + } + + /// A zero vector defines no plane; it must be refused rather than + /// producing a degenerate UCS that silently misplaces geometry. + #[test] + fn a_degenerate_normal_is_rejected() { + assert!(ucs_from_normal(DVec3::ZERO, DVec3::ZERO).is_none()); + } +} diff --git a/tests/ucs_face_plane.rs b/tests/ucs_face_plane.rs new file mode 100644 index 000000000..57f49f0a3 --- /dev/null +++ b/tests/ucs_face_plane.rs @@ -0,0 +1,80 @@ +//! `UCS FACE` turns a pick on a solid into a drawing plane. The plane is only +//! as good as the face lookup underneath it, so this covers that half: given a +//! body and a point on one of its faces, does the right face come back, and is +//! its outward normal the one a sketch should be built on? +//! +//! The axis construction on top of that normal is unit-tested beside +//! `ucs_from_normal` in `src/app/helpers.rs`, which is `pub(crate)` and so out +//! of reach from here. + +use acadrust::{entities::Circle, types::Vector3, EntityType}; +use OpenCADStudio::scene::model::{presspull_model, solid_model}; + +/// Extrude a disc into a puck 10 units tall, centred on the origin. +fn puck() -> cadkernel::brep::Body { + let entity = EntityType::Circle(Circle::from_center_radius( + Vector3::new(0.0, 0.0, 0.0), + 100.0, + )); + let body = presspull_model::extrusion_body(&entity, [0.0, 0.0, 10.0]) + .expect("a closed circular profile extrudes"); + assert!(body.validate().is_empty(), "extrusion produced a valid body"); + body +} + +/// The two flat ends point away from the material, in opposite directions. +/// Getting this wrong is what would make a sketch land inside the solid, or +/// extrude back into it. +#[test] +fn opposite_faces_report_opposite_outward_normals() { + let body = puck(); + + let top = solid_model::nearest_planar_face(&body, [0.0, 0.0, 10.0]) + .expect("the top cap is planar"); + let bottom = solid_model::nearest_planar_face(&body, [0.0, 0.0, 0.0]) + .expect("the bottom cap is planar"); + assert_ne!(top, bottom, "the caps are distinct faces"); + + let top_normal = solid_model::planar_face_normal(&body, top).expect("top cap has a normal"); + let bottom_normal = + solid_model::planar_face_normal(&body, bottom).expect("bottom cap has a normal"); + + assert!( + (top_normal[2] - 1.0).abs() < 1e-9, + "top cap should point +Z, got {top_normal:?}" + ); + assert!( + (bottom_normal[2] + 1.0).abs() < 1e-9, + "bottom cap should point -Z, got {bottom_normal:?}" + ); +} + +/// A pick anywhere on a cap resolves to that cap, not merely to the one +/// nearest the origin. A sketch is started by clicking somewhere on a face, +/// rarely at its centre. +#[test] +fn an_off_centre_pick_still_lands_on_its_own_face() { + let body = puck(); + let centre = solid_model::nearest_planar_face(&body, [0.0, 0.0, 10.0]).unwrap(); + let off_centre = solid_model::nearest_planar_face(&body, [60.0, -25.0, 10.0]).unwrap(); + assert_eq!(centre, off_centre, "both picks are on the top cap"); +} + +/// The curved side is not a plane, so it cannot become a sketch plane. The +/// lookup has to decline rather than hand back a nonsense normal. +#[test] +fn the_curved_side_is_not_offered_as_a_plane() { + let body = puck(); + // Halfway up the barrel, on its surface. + let face = solid_model::nearest_planar_face(&body, [100.0, 0.0, 5.0]); + if let Some(face) = face { + // A planar cap may still be the nearest planar face; what must never + // happen is the barrel itself being reported as planar. + let normal = solid_model::planar_face_normal(&body, face) + .expect("whatever came back claims to be planar, so it has a normal"); + assert!( + normal[2].abs() > 1e-9, + "a cap normal has Z; a barrel wall wrongly called planar would not" + ); + } +}