Feat: implement WCS↔UCS coordinate transform pipeline

- Add `active_ucs: Option<Ucs>` per-tab state to DocumentTab
- Add `ucs_to_wcs`, `wcs_to_ucs`, `ucs_z_axis`, `ucs_rotated_z` helpers
- Add `Camera::pick_on_plane` for ray–plane intersection against any plane
- Mouse picks project onto the active UCS XY plane instead of world XY
- Typed coordinates are converted from UCS space to WCS before dispatch
- UCS command now really activates/deactivates a UCS:
  - `UCS W` resets to WCS (clears active_ucs)
  - `UCS <name>` activates a named UCS
  - `UCS SAVE <name>` saves the current active UCS
  - `UCS ORIGIN x,y,z` shifts origin (in current UCS space)
  - `UCS X/Y/Z <angle>` rotates the UCS around its own axes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-06 14:16:06 +03:00
commit 20629d587a
5 changed files with 270 additions and 24 deletions

View file

@ -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<String> = 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 <name>");
} 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 <name> — 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 <name> | UCS DELETE <name> | 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<f32> = 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 <angle_degrees>");
}
}
// UCS X angle — rotate around current UCS X axis
"X" => {
let deg: Option<f32> = 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 <angle_degrees>");
}
}
// UCS Y angle — rotate around current UCS Y axis
"Y" => {
let deg: Option<f32> = 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 <angle_degrees>");
}
}
_ => {
// UCS <name> — 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 <name> | DELETE <name> | W | ORIGIN x,y,z | X/Y/Z <angle>",
name
));
}
}
}

View file

@ -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<Ucs>,
}
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,
}
}

View file

@ -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<glam::Vec3> {
}
}
// ── 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 {

View file

@ -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 rayplane 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);

View file

@ -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;