Merge pull request #1606 from yztink/fix/ucs-face-selection

UCS FACE picks solid faces again; the UCS snaps to the nearest face corner with X along the nearest edge.
This commit is contained in:
Hakan Seven committed 2026-10-06 00:20:59 +03:00
commit 240eb0a95d
4 files changed
+223 -7

No files matched your search

+2 -7
View File
@@ -343,6 +343,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(picker));
}
Some((handle, pick)) => {
self.tabs[i].scene.restore_solid_models(&[handle]);
let built = self.tabs[i]
.scene
.solid_models
@@ -351,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) => {
+4
View File
@@ -384,6 +384,10 @@ impl CadCommand for UcsPickCommand {
true
}
fn entity_pick_includes_fills(&self) -> bool {
self.face
}
fn entity_pick_uses_surface_point(&self) -> bool {
self.face
}
+119
View File
@@ -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<codec::tables::Ucs> {
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<DVec3> = 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
+98
View File
@@ -78,3 +78,101 @@ fn the_curved_side_is_not_offered_as_a_plane() {
);
}
}
#[test]
fn ucs_pick_command_enables_solid_face_picking() {
use OpenCADStudio::command::{CadCommand, CmdResult, UcsPickCommand};
use glam::DVec3;
let cmd_face = UcsPickCommand { face: true };
assert!(cmd_face.needs_entity_pick(), "UCS FACE needs entity pick");
assert!(cmd_face.entity_pick_includes_fills(), "UCS FACE must include fills to raycast solid faces");
assert!(cmd_face.entity_pick_uses_surface_point(), "UCS FACE must use 3D surface point");
assert!(cmd_face.entity_pick_highlights_hover(), "UCS FACE must highlight hovered solid face");
let cmd_obj = UcsPickCommand { face: false };
assert!(cmd_obj.needs_entity_pick());
assert!(!cmd_obj.entity_pick_includes_fills(), "UCS OBJECT operates on curves, not fills");
assert!(!cmd_obj.entity_pick_uses_surface_point());
let handle = codec::Handle::new(0x2A);
let pt = DVec3::new(10.5, 20.25, 30.125);
let mut cmd = cmd_face;
match cmd.on_entity_pick(handle, pt) {
CmdResult::Dispatch(cmd_str) => {
assert_eq!(cmd_str, "UCS FACE 2A 10.5,20.25,30.125");
}
_ => 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);
}