From 973d7b0bb67de3cc6e7bd0ae226e86b3d13eacda Mon Sep 17 00:00:00 2001 From: yztink Date: Sat, 3 Oct 2026 22:02:03 -0700 Subject: [PATCH] feat(ucs): align UCS Face origin and axes to nearest vertex and edge --- src/app/commands/layerprops.rs | 8 +-- src/scene/model/solid_model.rs | 119 +++++++++++++++++++++++++++++++++ tests/ucs_face_plane.rs | 71 ++++++++++++++++++++ 3 files changed, 191 insertions(+), 7 deletions(-) diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index d0e2e8d97..e39b64a24 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -352,13 +352,7 @@ impl OpenCADStudio { use crate::scene::model::solid_model; let face = solid_model::nearest_planar_face(body, pick.to_array())?; - solid_model::planar_face_normal(body, face) - }) - .and_then(|normal| { - super::super::helpers::ucs_from_normal( - pick, - glam::DVec3::from_array(normal), - ) + solid_model::planar_face_ucs(body, face, pick.to_array()) }); match built { Some(ucs) => { diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 031c15c04..5ae2fc72e 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -1,5 +1,6 @@ // Kernel B-rep construction and display tessellation. +use glam::DVec3; use kernel::brep::{self, Body, Curve3, EdgeKey, FaceKey, Surface}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -371,6 +372,124 @@ pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> { Some(if face.forward { normal } else { -normal }.to_array()) } +/// Construct a UCS aligned with a solid's planar face matching AutoCAD's UCS Face behavior: +/// - Origin snaps to the face vertex closest to the pick point. +/// - X-axis aligns along the boundary edge emanating from that vertex closest to the pick point. +/// - Z-axis is the face's outward normal. +/// - Y-axis is Z × X (in-plane, right-handed). +pub fn planar_face_ucs( + body: &Body, + face: FaceKey, + pick: [f64; 3], +) -> Option { + let normal = planar_face_normal(body, face)?; + let z = DVec3::from_array(normal).normalize_or_zero(); + if z.length_squared() < 1e-12 { + return None; + } + let pick_vec = DVec3::from_array(pick); + + // 1. Origin: snap to nearest face vertex to the pick point. + let mut best_origin: Option<(kernel::brep::VertexKey, DVec3, f64)> = None; + for coedge_key in body.face_coedges(face) { + let Some((va, vb)) = body.coedge_vertices(coedge_key) else { + continue; + }; + for vk in [va, vb] { + let Some(v) = body.vertices.get(vk) else { + continue; + }; + let pt = DVec3::from_array(v.point); + if !pt.is_finite() { + continue; + } + let dist_sq = pt.distance_squared(pick_vec); + match best_origin { + None => best_origin = Some((vk, pt, dist_sq)), + Some((_, _, best_sq)) if dist_sq < best_sq => { + best_origin = Some((vk, pt, dist_sq)); + } + _ => {} + } + } + } + + let (origin, origin_key) = match best_origin { + Some((vk, pt, _)) => (pt, Some(vk)), + None => (pick_vec, None), + }; + + // 2. X-axis: align along the boundary edge emanating from origin closest to pick. + let mut best_edge_dir: Option = None; + let mut best_edge_dist_sq = f64::INFINITY; + + if let Some(origin_vk) = origin_key { + for coedge_key in body.face_coedges(face) { + let Some((va, vb)) = body.coedge_vertices(coedge_key) else { + continue; + }; + if va != origin_vk && vb != origin_vk { + continue; + } + let Some(coedge) = body.coedges.get(coedge_key) else { + continue; + }; + let Some(edge) = body.edges.get(coedge.edge) else { + continue; + }; + + let other_vk = if va == origin_vk { vb } else { va }; + if other_vk != origin_vk { + if let Some(other_v) = body.vertices.get(other_vk) { + let other_pt = DVec3::from_array(other_v.point); + let seg = other_pt - origin; + let seg_len_sq = seg.length_squared(); + if seg_len_sq > 1e-12 { + let t = ((pick_vec - origin).dot(seg) / seg_len_sq).clamp(0.0, 1.0); + let closest = origin + seg * t; + let dist_sq = pick_vec.distance_squared(closest); + if dist_sq < best_edge_dist_sq { + best_edge_dist_sq = dist_sq; + best_edge_dir = Some(seg); + } + } + } + } else { + // Closed edge sharing start and end vertex (e.g. circle seam). + if let Some(curve) = body.curves.get(edge.curve) { + let tangent = DVec3::from_array(curve.tangent_at(edge.start_parameter)); + let dir = if coedge.forward { tangent } else { -tangent }; + let dist_sq = pick_vec.distance_squared(origin); + if dist_sq < best_edge_dist_sq { + best_edge_dist_sq = dist_sq; + best_edge_dir = Some(dir); + } + } + } + } + } + + // Project candidate X onto face plane and orthogonalize against normal Z. + let x = best_edge_dir + .and_then(|dir| { + let in_plane = dir - z * dir.dot(z); + let unit_x = in_plane.normalize_or_zero(); + (unit_x.length_squared() > 1e-6).then_some(unit_x) + }) + .unwrap_or_else(|| { + let ((xx, xy, xz), _) = crate::scene::view::transform::ocs_axes((z.x, z.y, z.z)); + DVec3::new(xx, xy, xz) + }); + + let y = z.cross(x).normalize_or_zero(); + + let mut ucs = codec::tables::Ucs::new("*ACTIVE*"); + ucs.origin = codec::types::Vector3::new(origin.x, origin.y, origin.z); + ucs.x_axis = codec::types::Vector3::new(x.x, x.y, x.z); + ucs.y_axis = codec::types::Vector3::new(y.x, y.y, y.z); + Some(ucs) +} + /// Centre of every B-rep face: the average of its boundary-loop vertices. /// Exact for planar faces; for curved faces the loop average floats slightly /// inside the surface — a documented approximation the snap path must not pay diff --git a/tests/ucs_face_plane.rs b/tests/ucs_face_plane.rs index b840d2984..bf967ecde 100644 --- a/tests/ucs_face_plane.rs +++ b/tests/ucs_face_plane.rs @@ -105,3 +105,74 @@ fn ucs_pick_command_enables_solid_face_picking() { _ => panic!("Expected CmdResult::Dispatch"), } } + +#[test] +fn autocad_style_face_ucs_snaps_origin_and_aligns_axes_on_box() { + let box_body = solid_model::box_solid([0.0, 0.0, 0.0], 10.0, 20.0, 30.0) + .expect("box_solid constructs successfully"); + + // Top face is at Z = +15, bounds X in [-5, 5], Y in [-10, 10]. + // Pick near corner (5, 10, 15), closer to the edge along -X than the edge along -Y. + let face = solid_model::nearest_planar_face(&box_body, [3.0, 9.0, 15.0]) + .expect("top face found"); + let ucs = solid_model::planar_face_ucs(&box_body, face, [3.0, 9.0, 15.0]) + .expect("planar_face_ucs constructed"); + + // Origin must snap to the nearest corner (5, 10, 15) instead of the pick coordinate. + assert!((ucs.origin.x - 5.0).abs() < 1e-6); + assert!((ucs.origin.y - 10.0).abs() < 1e-6); + assert!((ucs.origin.z - 15.0).abs() < 1e-6); + + // Z axis must be outward normal (0, 0, 1). + let z = glam::DVec3::new(ucs.x_axis.x, ucs.x_axis.y, ucs.x_axis.z) + .cross(glam::DVec3::new(ucs.y_axis.x, ucs.y_axis.y, ucs.y_axis.z)); + assert!((z.z - 1.0).abs() < 1e-6); + + // Closer to edge along -X: X axis must point along (-1, 0, 0). + assert!((ucs.x_axis.x - (-1.0)).abs() < 1e-6); + assert!(ucs.x_axis.y.abs() < 1e-6); + assert!(ucs.x_axis.z.abs() < 1e-6); + + // Y axis = Z x X = (0, 0, 1) x (-1, 0, 0) = (0, -1, 0). + assert!(ucs.y_axis.x.abs() < 1e-6); + assert!((ucs.y_axis.y - (-1.0)).abs() < 1e-6); + assert!(ucs.y_axis.z.abs() < 1e-6); + + // Now pick near the same corner (5, 10, 15), but closer to the edge along -Y. + let ucs_y_edge = solid_model::planar_face_ucs(&box_body, face, [4.5, 7.0, 15.0]) + .expect("planar_face_ucs constructed"); + + // Origin is still the same corner (5, 10, 15). + assert!((ucs_y_edge.origin.x - 5.0).abs() < 1e-6); + assert!((ucs_y_edge.origin.y - 10.0).abs() < 1e-6); + assert!((ucs_y_edge.origin.z - 15.0).abs() < 1e-6); + + // Closer to edge along -Y: X axis must point along (0, -1, 0). + assert!(ucs_y_edge.x_axis.x.abs() < 1e-6); + assert!((ucs_y_edge.x_axis.y - (-1.0)).abs() < 1e-6); + assert!(ucs_y_edge.x_axis.z.abs() < 1e-6); + + // Y axis = Z x X = (0, 0, 1) x (0, -1, 0) = (1, 0, 0). + assert!((ucs_y_edge.y_axis.x - 1.0).abs() < 1e-6); + assert!(ucs_y_edge.y_axis.y.abs() < 1e-6); + assert!(ucs_y_edge.y_axis.z.abs() < 1e-6); +} + +#[test] +fn autocad_style_face_ucs_on_puck() { + let body = puck(); + let top_face = solid_model::nearest_planar_face(&body, [60.0, -25.0, 10.0]).unwrap(); + let ucs = solid_model::planar_face_ucs(&body, top_face, [60.0, -25.0, 10.0]).unwrap(); + + // Normal Z = (0, 0, 1). + let z = glam::DVec3::new(ucs.x_axis.x, ucs.x_axis.y, ucs.x_axis.z) + .cross(glam::DVec3::new(ucs.y_axis.x, ucs.y_axis.y, ucs.y_axis.z)); + assert!((z.z - 1.0).abs() < 1e-6); + + // Orthonormality of X and Y. + let x = glam::DVec3::new(ucs.x_axis.x, ucs.x_axis.y, ucs.x_axis.z); + let y = glam::DVec3::new(ucs.y_axis.x, ucs.y_axis.y, ucs.y_axis.z); + assert!((x.length() - 1.0).abs() < 1e-6); + assert!((y.length() - 1.0).abs() < 1e-6); + assert!(x.dot(y).abs() < 1e-6); +}