diff --git a/src/app/commands.rs b/src/app/commands.rs index c81d2f351..f1e6224ce 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1839,16 +1839,25 @@ impl H7CAD { // ── UCS management ─────────────────────────────────────────── cmd if cmd == "UCS" || cmd.starts_with("UCS ") => { use acadrust::tables::Ucs; - let parts: Vec<&str> = cmd.splitn(3, ' ').collect(); + use acadrust::types::Vector3; + use super::helpers::{ucs_to_wcs, ucs_z_axis, ucs_rotated_z}; + let parts: Vec<&str> = cmd.splitn(4, ' ').collect(); let sub = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default(); match sub.as_str() { "" | "LIST" | "?" => { + let active_name = self.tabs[i].active_ucs.as_ref() + .map(|u| u.name.clone()) + .unwrap_or_else(|| "WCS".into()); let names: Vec = self.tabs[i].scene.document .ucss.iter().map(|u| u.name.clone()).collect(); if names.is_empty() { - self.command_line.push_output("No named UCSs defined."); + self.command_line.push_output(&format!( + "Active UCS: {} | No named UCSs defined.", active_name + )); } else { - self.command_line.push_output(&format!("UCSs: {}", names.join(", "))); + self.command_line.push_output(&format!( + "Active UCS: {} | Named: {}", active_name, names.join(", ") + )); } } "SAVE" | "S" => { @@ -1856,11 +1865,18 @@ impl H7CAD { if name.is_empty() { self.command_line.push_error("Usage: UCS SAVE "); } else { - // Save as WCS (identity) since we don't have active UCS state yet - let ucs = Ucs::new(&name); + // Save the current active UCS under this name. + let ucs = match &self.tabs[i].active_ucs { + Some(u) => { + let mut saved = u.clone(); + saved.name = name.clone(); + saved + } + None => Ucs::new(&name), // save WCS (identity) + }; self.tabs[i].scene.document.ucss.add_or_replace(ucs); self.tabs[i].dirty = true; - self.command_line.push_output(&format!("UCS '{}' saved (WCS).", name)); + self.command_line.push_output(&format!("UCS '{}' saved.", name)); } } "DELETE" | "DEL" | "D" => { @@ -1875,19 +1891,125 @@ impl H7CAD { } } "W" | "WORLD" => { - // Reset active UCS to WCS — currently just an informational message - // as full UCS integration awaits WCS↔UCS transform pipeline + self.tabs[i].active_ucs = None; self.command_line.push_output("UCS reset to World Coordinate System."); } - _ => { - // UCS — try as a restore/apply shortcut - let name = sub.clone(); - if self.tabs[i].scene.document.ucss.get(&name).is_some() { - self.command_line.push_output(&format!("UCS '{}' is defined. (Full UCS activation pending transform pipeline.)", name)); - } else { - self.command_line.push_error( - "Usage: UCS LIST | UCS SAVE | UCS DELETE | UCS W" + // UCS ORIGIN x,y,z — shift the active UCS origin, keep axes + "ORIGIN" | "O" => { + let coord_str = parts.get(2).copied().unwrap_or(""); + if let Some(pt) = super::helpers::parse_coord(coord_str) { + // `pt` is in current UCS space; convert to WCS + let wcs_origin = if let Some(ref ucs) = self.tabs[i].active_ucs { + ucs_to_wcs(pt, ucs) + } else { + pt + }; + let ucs = self.tabs[i].active_ucs.get_or_insert_with(|| Ucs::new("*ACTIVE*")); + ucs.origin = Vector3::new( + wcs_origin.x as f64, wcs_origin.y as f64, wcs_origin.z as f64, ); + self.command_line.push_output(&format!( + "UCS origin set to ({:.4}, {:.4}, {:.4}).", + wcs_origin.x, wcs_origin.y, wcs_origin.z + )); + } else { + self.command_line.push_error("Usage: UCS ORIGIN x,y,z"); + } + } + // UCS Z angle — rotate active UCS around its Z axis by degrees + "Z" => { + let deg: Option = parts.get(2).and_then(|s| s.trim().parse().ok()); + if let Some(angle_deg) = deg { + let rad = angle_deg.to_radians(); + let current = self.tabs[i].active_ucs.as_ref(); + let origin = current.map(|u| { + glam::Vec3::new( + u.origin.x as f32, u.origin.y as f32, u.origin.z as f32, + ) + }).unwrap_or(glam::Vec3::ZERO); + let mut new_ucs = ucs_rotated_z(origin, rad); + // If already had axes, compose rotation on top + if let Some(ref ucs) = self.tabs[i].active_ucs { + let old_x = glam::Vec3::new( + ucs.x_axis.x as f32, ucs.x_axis.y as f32, ucs.x_axis.z as f32, + ); + let old_y = glam::Vec3::new( + ucs.y_axis.x as f32, ucs.y_axis.y as f32, ucs.y_axis.z as f32, + ); + let z_ax = ucs_z_axis(ucs); + let rot = glam::Quat::from_axis_angle(z_ax, rad); + let nx = rot * old_x; + let ny = rot * old_y; + new_ucs.x_axis = Vector3::new( + nx.x as f64, nx.y as f64, nx.z as f64, + ); + new_ucs.y_axis = Vector3::new( + ny.x as f64, ny.y as f64, ny.z as f64, + ); + } + self.tabs[i].active_ucs = Some(new_ucs); + self.command_line.push_output(&format!( + "UCS rotated {:.2}° around Z.", angle_deg + )); + } else { + self.command_line.push_error("Usage: UCS Z "); + } + } + // UCS X angle — rotate around current UCS X axis + "X" => { + let deg: Option = parts.get(2).and_then(|s| s.trim().parse().ok()); + if let Some(angle_deg) = deg { + let rad = angle_deg.to_radians(); + let ucs = self.tabs[i].active_ucs.get_or_insert_with(|| Ucs::new("*ACTIVE*")); + let x_ax = glam::Vec3::new( + ucs.x_axis.x as f32, ucs.x_axis.y as f32, ucs.x_axis.z as f32, + ); + let old_y = glam::Vec3::new( + ucs.y_axis.x as f32, ucs.y_axis.y as f32, ucs.y_axis.z as f32, + ); + let rot = glam::Quat::from_axis_angle(x_ax, rad); + let ny = rot * old_y; + ucs.y_axis = Vector3::new(ny.x as f64, ny.y as f64, ny.z as f64); + self.command_line.push_output(&format!( + "UCS rotated {:.2}° around X.", angle_deg + )); + } else { + self.command_line.push_error("Usage: UCS X "); + } + } + // UCS Y angle — rotate around current UCS Y axis + "Y" => { + let deg: Option = parts.get(2).and_then(|s| s.trim().parse().ok()); + if let Some(angle_deg) = deg { + let rad = angle_deg.to_radians(); + let ucs = self.tabs[i].active_ucs.get_or_insert_with(|| Ucs::new("*ACTIVE*")); + let y_ax = glam::Vec3::new( + ucs.y_axis.x as f32, ucs.y_axis.y as f32, ucs.y_axis.z as f32, + ); + let old_x = glam::Vec3::new( + ucs.x_axis.x as f32, ucs.x_axis.y as f32, ucs.x_axis.z as f32, + ); + let rot = glam::Quat::from_axis_angle(y_ax, rad); + let nx = rot * old_x; + ucs.x_axis = Vector3::new(nx.x as f64, nx.y as f64, nx.z as f64); + self.command_line.push_output(&format!( + "UCS rotated {:.2}° around Y.", angle_deg + )); + } else { + self.command_line.push_error("Usage: UCS Y "); + } + } + _ => { + // UCS — activate a named UCS + let name = sub.clone(); + if let Some(named) = self.tabs[i].scene.document.ucss.get(&name).cloned() { + self.tabs[i].active_ucs = Some(named); + self.command_line.push_output(&format!("UCS '{}' activated.", name)); + } else { + self.command_line.push_error(&format!( + "UCS '{}' not found. Usage: UCS LIST | SAVE | DELETE | W | ORIGIN x,y,z | X/Y/Z ", + name + )); } } } diff --git a/src/app/document.rs b/src/app/document.rs index 0d4bb93a5..dc70bdc4a 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -5,6 +5,7 @@ use crate::snap::SnapResult; use crate::scene::grip::GripEdit; use crate::scene::GripDef; use acadrust::{CadDocument, Handle}; +use acadrust::tables::Ucs; use crate::linetypes; use std::path::PathBuf; @@ -28,6 +29,8 @@ pub(super) struct DocumentTab { pub(super) last_cursor_world: glam::Vec3, pub(super) history: HistoryState, pub(super) active_layer: String, + /// Currently active UCS. `None` means WCS (identity transform). + pub(super) active_ucs: Option, } impl DocumentTab { @@ -52,6 +55,7 @@ impl DocumentTab { last_cursor_world: glam::Vec3::ZERO, history: HistoryState::default(), active_layer: "0".to_string(), + active_ucs: None, } } diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 15a80ea64..32338a327 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -1,5 +1,6 @@ use crate::ui::overlay::GridPlane; use crate::scene::WireModel; +use acadrust::tables::Ucs; // ── Coordinate parsing ───────────────────────────────────────────────────── @@ -20,6 +21,50 @@ pub(super) fn parse_coord(text: &str) -> Option { } } +// ── UCS ↔ WCS transforms ─────────────────────────────────────────────────── + +/// Convert a point from UCS local coordinates to WCS. +/// +/// WCS = origin + x_axis*u + y_axis*v + z_axis*w +pub(super) fn ucs_to_wcs(pt: glam::Vec3, ucs: &Ucs) -> glam::Vec3 { + let o = glam::Vec3::new(ucs.origin.x as f32, ucs.origin.y as f32, ucs.origin.z as f32); + let x = glam::Vec3::new(ucs.x_axis.x as f32, ucs.x_axis.y as f32, ucs.x_axis.z as f32); + let y = glam::Vec3::new(ucs.y_axis.x as f32, ucs.y_axis.y as f32, ucs.y_axis.z as f32); + let z_ax = ucs_z_axis(ucs); + o + x * pt.x + y * pt.y + z_ax * pt.z +} + +/// Convert a WCS point back to UCS local coordinates. +#[allow(dead_code)] +pub(super) fn wcs_to_ucs(pt: glam::Vec3, ucs: &Ucs) -> glam::Vec3 { + let o = glam::Vec3::new(ucs.origin.x as f32, ucs.origin.y as f32, ucs.origin.z as f32); + let x = glam::Vec3::new(ucs.x_axis.x as f32, ucs.x_axis.y as f32, ucs.x_axis.z as f32); + let y = glam::Vec3::new(ucs.y_axis.x as f32, ucs.y_axis.y as f32, ucs.y_axis.z as f32); + let z_ax = ucs_z_axis(ucs); + let d = pt - o; + glam::Vec3::new(d.dot(x), d.dot(y), d.dot(z_ax)) +} + +/// Return the normalised Z axis of a UCS (cross product of X and Y axes). +pub(super) fn ucs_z_axis(ucs: &Ucs) -> glam::Vec3 { + let x = glam::Vec3::new(ucs.x_axis.x as f32, ucs.x_axis.y as f32, ucs.x_axis.z as f32); + let y = glam::Vec3::new(ucs.y_axis.x as f32, ucs.y_axis.y as f32, ucs.y_axis.z as f32); + x.cross(y).normalize_or_zero() +} + +/// Build a UCS with `origin` and axes rotated by `angle_z_rad` around the Z axis. +pub(super) fn ucs_rotated_z(origin: glam::Vec3, angle_z: f32) -> Ucs { + let cos = angle_z.cos() as f64; + let sin = angle_z.sin() as f64; + let mut ucs = Ucs::new("*ACTIVE*"); + ucs.origin = acadrust::types::Vector3::new( + origin.x as f64, origin.y as f64, origin.z as f64, + ); + ucs.x_axis = acadrust::types::Vector3::new(cos, sin, 0.0); + ucs.y_axis = acadrust::types::Vector3::new(-sin, cos, 0.0); + ucs +} + pub(super) fn angle_close(a: f32, b: f32, tol: f32) -> bool { let diff = (a - b).rem_euclid(std::f32::consts::TAU); let diff = if diff > std::f32::consts::PI { diff --git a/src/app/update.rs b/src/app/update.rs index 91bc2916e..a8898418e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1,5 +1,5 @@ use super::{H7CAD, Message, POLY_START_DELAY_MS}; -use super::helpers::{parse_coord, angle_close, ortho_constrain, polar_constrain}; +use super::helpers::{parse_coord, angle_close, ortho_constrain, polar_constrain, ucs_to_wcs, ucs_z_axis}; use crate::scene::{self, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX}; use crate::scene::grip::{find_hit_grip, GripEdit}; use crate::scene::object::GripApply; @@ -259,8 +259,14 @@ impl H7CAD { return Task::none(); } - if let Some(pt) = parse_coord(&text) { - let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt)); + if let Some(ucs_pt) = parse_coord(&text) { + // Typed coordinates are in active UCS space; convert to WCS. + let wcs_pt = if let Some(ref ucs) = self.tabs[i].active_ucs { + ucs_to_wcs(ucs_pt, ucs) + } else { + ucs_pt + }; + let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(wcs_pt)); if let Some(r) = result { return self.apply_cmd_result(r); } @@ -741,10 +747,18 @@ impl H7CAD { if self.tabs[i].active_cmd.is_some() { let (vw, vh) = vp_size; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh }; - let cam = self.tabs[i].scene.camera.borrow(); - let cursor_paper = cam.pick_on_target_plane(p, bounds); - let view_proj = cam.view_proj(bounds); - drop(cam); + let cursor_paper = if let Some(ref ucs) = self.tabs[i].active_ucs { + let origin = glam::Vec3::new( + ucs.origin.x as f32, ucs.origin.y as f32, ucs.origin.z as f32, + ); + let normal = ucs_z_axis(ucs); + self.tabs[i].scene.camera.borrow() + .pick_on_plane(p, bounds, normal, origin) + } else { + self.tabs[i].scene.camera.borrow() + .pick_on_target_plane(p, bounds) + }; + let view_proj = self.tabs[i].scene.camera.borrow().view_proj(bounds); // In MSPACE, map paper-space cursor to model space so that // command previews and snapping work in the correct coordinate space. let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper); @@ -769,7 +783,11 @@ impl H7CAD { let mut pt = self.tabs[i].snap_result .map(|s| self.tabs[i].scene.paper_to_model(s.world)) .unwrap_or(cursor_world); - if self.tabs[i].active_cmd.is_some() { pt.z = 0.0; } + // Clamp to world XY only when no UCS is active; with a UCS the + // point already lies on the UCS XY plane. + if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() { + pt.z = 0.0; + } if let Some(base) = self.last_point { if self.ortho_mode { pt = ortho_constrain(pt, base); @@ -899,7 +917,19 @@ impl H7CAD { let tangent_obj_at_click = snap_taken.and_then(|s| s.tangent_obj); let world_pt = { - let raw_paper = self.tabs[i].scene.camera.borrow().pick_on_target_plane(p, bounds); + // Project screen point onto the active UCS XY plane (or world XY when + // no UCS is active). + let raw_paper = if let Some(ref ucs) = self.tabs[i].active_ucs { + let origin = glam::Vec3::new( + ucs.origin.x as f32, ucs.origin.y as f32, ucs.origin.z as f32, + ); + let normal = ucs_z_axis(ucs); + self.tabs[i].scene.camera.borrow() + .pick_on_plane(p, bounds, normal, origin) + } else { + self.tabs[i].scene.camera.borrow() + .pick_on_target_plane(p, bounds) + }; // Convert paper-space → model-space when inside a viewport. let raw = self.tabs[i].scene.paper_to_model(raw_paper); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); @@ -920,7 +950,11 @@ impl H7CAD { let mut pt = snap_hit .map(|s| self.tabs[i].scene.paper_to_model(s.world)) .unwrap_or(raw); - pt.z = 0.0; + // When no UCS is active clamp to world XY; with a UCS the point is + // already constrained to that plane by the ray–plane intersection. + if self.tabs[i].active_ucs.is_none() { + pt.z = 0.0; + } if let Some(base) = self.last_point { if self.ortho_mode { pt = ortho_constrain(pt, base); diff --git a/src/scene/camera.rs b/src/scene/camera.rs index 36122cb39..acff7b942 100644 --- a/src/scene/camera.rs +++ b/src/scene/camera.rs @@ -101,6 +101,47 @@ impl Camera { OPENGL_TO_WGPU * proj * view } + /// Project a screen point onto an arbitrary world-space plane. + /// + /// The plane is defined by `plane_normal` (unit vector) and a `plane_point` + /// that lies on it. Returns the intersection of the view ray with the plane; + /// falls back to `plane_point` when the ray is nearly parallel to the plane. + pub fn pick_on_plane( + &self, + screen: Point, + bounds: Rectangle, + plane_normal: Vec3, + plane_point: Vec3, + ) -> Vec3 { + let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0; + let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0; + let inv = self.view_proj(bounds).inverse(); + + let (ray_origin, ray_dir) = match self.projection { + Projection::Perspective => { + let near_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0)); + let far_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 1.0)); + let dir = (far_pt - near_pt).normalize(); + (near_pt, dir) + } + Projection::Orthographic => { + let origin = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0)); + let forward = (self.target - self.eye()).normalize(); + (origin, forward) + } + }; + + let denom = ray_dir.dot(plane_normal); + if denom.abs() < 1e-6 { + return plane_point; + } + let t = (plane_point - ray_origin).dot(plane_normal) / denom; + if t < 0.0 { + return plane_point; + } + ray_origin + ray_dir * t + } + pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> Vec3 { let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0; let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0;