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