From bfb786d56e4481952ebed313a1d3236b35ac9b46 Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 16:20:17 +0200 Subject: [PATCH] Add UCS FACE and UCS OBJECT Draw a profile, extrude it, then sketch on a face of the result: the operations were all there, but nothing turned a solid's face into the active drawing plane, so the second half of that loop was unreachable. UCS FACE takes a surface pick, finds the planar face under it and builds the plane from the face normal. X comes from the Arbitrary Axis Algorithm, so the same face always yields the same X axis and a sketch drawn on it does not spin when the UCS is rebuilt. UCS OBJECT adopts a planar entity's own stored axes instead of re-deriving X, so a sketch started from a circle lines up with how that circle is stored. It reuses the profile extraction EXTRUDE already runs, which means anything extrudable can also be sketched on. Bare UCS FACE starts an interactive pick that resolves through the same inline handler, so the clicked and typed paths cannot diverge. --- src/app/commands/layerprops.rs | 125 +++++++++++++++++++++++++++++++++ src/app/helpers.rs | 34 +++++++++ src/command.rs | 56 +++++++++++++++ 3 files changed, 215 insertions(+) diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 0ae2e63b5..1efe23024 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -314,6 +314,131 @@ impl OpenCADStudio { self.command_line .push_output(crate::t!("UCS aligned to the current view.").as_ref()); } + // UCS FACE — adopt a solid's planar face + // as the drawing plane. This is the move Fusion calls + // "create sketch on a face": pick the face, draw flat on + // it, extrude. The pick comes from a surface pick, so it + // already lies on the face and serves as the origin. + "FACE" | "F" => { + let target = parts + .get(2) + .and_then(|token| { + u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok() + }) + .map(acadrust::Handle::new) + .zip( + parts + .get(3) + .and_then(|value| super::super::helpers::parse_coord(value)) + .map(|(point, _)| point), + ); + match target { + // Bare `UCS FACE` starts the pick. The click comes + // back through this same arm with a handle and a + // surface point, so both entry points share the + // plane construction below. + None => { + let picker = crate::command::UcsFaceCommand; + self.command_line.push_info(&picker.prompt()); + self.tabs[i].active_cmd = Some(Box::new(picker)); + } + Some((handle, pick)) => { + let built = self.tabs[i] + .scene + .solid_models + .get(&handle) + .and_then(|body| { + 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), + ) + }); + match built { + Some(ucs) => { + self.tabs[i].active_ucs = Some(ucs); + active_changed = true; + self.command_line.push_output( + crate::t!("UCS aligned to the selected face.").as_ref(), + ); + } + None => self.command_line.push_error( + crate::t!("UCS FACE: no planar face at that point.") + .as_ref(), + ), + } + } + } + } + // UCS OBJECT — adopt the plane of a planar + // entity. Reuses the profile extraction EXTRUDE already + // runs, so anything extrudable can also be sketched on. + "OBJECT" | "OB" => { + let target = parts + .get(2) + .and_then(|token| { + u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok() + }) + .map(acadrust::Handle::new); + match target { + None => self + .command_line + .push_error(crate::t!("Usage: UCS OBJECT ").as_ref()), + Some(handle) => { + let built = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(crate::scene::model::sweep_model::extrusion_profile_of) + .and_then(|(profile, _)| { + // The profile plane already carries the + // entity's own in-plane axes, so adopt + // them rather than re-deriving X from + // the normal: a sketch started on a + // circle then lines up with how that + // circle is stored, not with an + // arbitrary reference direction. + let plane = profile.plane; + let x = glam::DVec3::from_array(plane.x_axis); + let y = glam::DVec3::from_array(plane.y_axis); + if x.length_squared() < 1e-12 + || y.length_squared() < 1e-12 + { + return None; + } + let mut ucs = Ucs::new("*ACTIVE*"); + ucs.origin = Vector3::new( + plane.origin[0], + plane.origin[1], + plane.origin[2], + ); + ucs.x_axis = + Vector3::new(x.x, x.y, x.z); + ucs.y_axis = + Vector3::new(y.x, y.y, y.z); + Some(ucs) + }); + match built { + Some(ucs) => { + self.tabs[i].active_ucs = Some(ucs); + active_changed = true; + self.command_line.push_output( + crate::t!("UCS aligned to the selected object.") + .as_ref(), + ); + } + None => self.command_line.push_error( + crate::t!("UCS OBJECT: entity defines no plane.").as_ref(), + ), + } + } + } + } "3POINTW" => { let raw = parts.get(2).copied().unwrap_or(""); let points: Vec = raw diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 678ae2144..5e084275e 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -247,6 +247,40 @@ pub(super) fn ucs_z_axis(ucs: &Ucs) -> glam::DVec3 { UcsXform::from_ucs(ucs).axes().3 } +/// Build a UCS whose Z axis is `normal`, anchored at `origin`. +/// +/// X and Y come from the Arbitrary Axis Algorithm, the same rule DWG uses to +/// turn an extrusion direction into an object coordinate system. Picking the +/// world axis that is *least* parallel to the normal keeps the cross product +/// well conditioned, and makes the result stable: the same face always yields +/// the same X axis, so a sketch drawn on it does not spin when the UCS is +/// rebuilt. +/// +/// Returns `None` when `normal` is degenerate and defines no plane. +pub(super) fn ucs_from_normal(origin: glam::DVec3, normal: glam::DVec3) -> Option { + let z = normal.normalize_or_zero(); + if z.length_squared() < 1e-12 { + return None; + } + // 1/64 is the threshold the DWG spec names for "close enough to the world + // Z axis that using it as the reference would be ill conditioned". + let reference = if z.x.abs() < 1.0 / 64.0 && z.y.abs() < 1.0 / 64.0 { + glam::DVec3::Y + } else { + glam::DVec3::Z + }; + let x = reference.cross(z).normalize_or_zero(); + if x.length_squared() < 1e-12 { + return None; + } + let y = z.cross(x).normalize_or_zero(); + let mut ucs = Ucs::new("*ACTIVE*"); + ucs.origin = acadrust::types::Vector3::new(origin.x, origin.y, origin.z); + ucs.x_axis = acadrust::types::Vector3::new(x.x, x.y, x.z); + ucs.y_axis = acadrust::types::Vector3::new(y.x, y.y, y.z); + Some(ucs) +} + /// Build a UCS with `origin` and axes rotated by `angle_z_rad` around the Z axis. pub(super) fn ucs_rotated_z(origin: glam::DVec3, angle_z: f32) -> Ucs { let cos = angle_z.cos() as f64; diff --git a/src/command.rs b/src/command.rs index 1704fc0ea..fb7138b02 100644 --- a/src/command.rs +++ b/src/command.rs @@ -347,6 +347,62 @@ impl CadCommand for ValuePromptCommand { } } +/// Interactive front-end for `UCS FACE` — Fusion's "create sketch on a face". +/// +/// Picks one face of a solid and hands the resulting plane to the inline +/// `UCS FACE ` handler via [`CmdResult::Dispatch`], so the +/// plane construction lives in exactly one place whether the user clicked a +/// face or typed the arguments. `entity_pick_uses_surface_point` is what +/// makes the click land *on the solid's surface* rather than on the current +/// drawing plane, which is the whole point: the pick coordinate is the face. +pub struct UcsFaceCommand; + +impl CadCommand for UcsFaceCommand { + fn name(&self) -> &'static str { + "UCS" + } + + fn prompt(&self) -> String { + crate::t!("UCS FACE Select a face to draw on:").into_owned() + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + // Hex handle and comma-separated coordinates are what the inline + // parser reads back. Full `{}` precision, not a rounded format: the + // point has to stay on the face for the planar-face lookup. + CmdResult::Dispatch(format!( + "UCS FACE {:X} {},{},{}", + handle.value(), + pt.x, + pt.y, + pt.z + )) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + /// Interactive front-end for RENAME. Prompts for the object type (as clickable /// buttons), then the current name, then the new name, and delegates to the /// inline `RENAME ` handler via [`CmdResult::Dispatch`] — the