feat(ucs): central WCS↔UCS converter; adopt file UCS, drive readout + icon

Introduce one bridge between WCS (how geometry and the file are stored)
and the active UCS (the coordinate system the user works in), so every
UCS-aware system routes through it instead of re-deriving axis math.

- UcsXform (app/helpers.rs): the converter — to_wcs/to_ucs (points),
  vec_to_wcs/vec_to_ucs (directions), axes(), is_identity(). Orthonormal
  axes, so the inverse is the transpose. The old ucs_to_wcs / ucs_rotate_vec
  free fns now delegate to it (one implementation).
- DocumentTab::ucs_xform() builds it from the tab's active_ucs (None = WCS).
- DocumentTab::adopt_active_ucs_from_header() loads the document's saved
  current UCS (header model-space UCS) into active_ucs on open, so the file's
  coordinate system is live immediately; called from both load paths.
- Status-bar coordinate readout reports the cursor in the active UCS.
- UCS icon tripod projects the active UCS axes, so it rotates to the UCS
  instead of always showing world X/Y/Z.

First slice of routing the app's coordinate systems through the converter;
input/picking, grid, ViewCube, and the remaining readouts follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-22 09:57:39 +03:00
commit 4aa1e90dcf
6 changed files with 135 additions and 44 deletions

View file

@ -224,6 +224,7 @@ impl OpenCADStudio {
Ok(doc) => {
let i = self.active_tab;
self.tabs[i].scene.document = doc;
self.tabs[i].adopt_active_ucs_from_header();
self.tabs[i].current_path = Some(PathBuf::from(path));
self.tabs[i].is_start = false;
self.tabs[i].scene.bump_geometry();

View file

@ -165,6 +165,29 @@ pub(super) struct DocumentTab {
}
impl DocumentTab {
/// The active WCS↔UCS converter for this tab — identity when no UCS is set.
/// Every consumer that needs UCS-relative coordinates goes through this.
pub(super) fn ucs_xform(&self) -> super::helpers::UcsXform {
super::helpers::UcsXform::from_active(self.active_ucs.as_ref())
}
/// Adopt the document's saved current UCS (the header's model-space UCS) as
/// the active UCS, so the coordinate readout / icon / input follow the
/// file's coordinate system the moment it opens. An identity UCS clears it
/// back to plain WCS. Call wherever a document is loaded into the tab.
pub(super) fn adopt_active_ucs_from_header(&mut self) {
let h = &self.scene.document.header;
let mut u = Ucs::new(h.model_space_ucs_name.clone());
u.origin = h.model_space_ucs_origin;
u.x_axis = h.model_space_ucs_x_axis;
u.y_axis = h.model_space_ucs_y_axis;
self.active_ucs = if super::helpers::UcsXform::from_ucs(&u).is_identity() {
None
} else {
Some(u)
};
}
pub(super) fn new_drawing(n: usize) -> Self {
let mut scene = Scene::new();
linetypes::populate_document(&mut scene.document);

View file

@ -42,54 +42,102 @@ pub(super) fn parse_coord(text: &str) -> Option<(glam::Vec3, CoordKind)> {
}
}
// ── UCS ↔ WCS converter ─────────────────────────────────────────────────────
/// The single bridge between WCS (how geometry and the file are stored) and the
/// active UCS (the coordinate system the user works in). Build one from the
/// tab's active UCS via [`DocumentTab::ucs_xform`](super::DocumentTab); the
/// `None` UCS yields the identity (plain WCS).
///
/// Every system that has to speak UCS — the coordinate readout, typed input,
/// the UCS icon, snap/ortho, the ViewCube — goes through this one type instead
/// of re-deriving the axis math. Axes are orthonormal, so the inverse rotation
/// is just the transpose (the dot products in `to_ucs`); no matrix inversion.
#[derive(Clone, Copy)]
pub(super) struct UcsXform {
origin: glam::Vec3,
x: glam::Vec3,
y: glam::Vec3,
z: glam::Vec3,
}
impl UcsXform {
/// Plain WCS — no active UCS.
pub(super) fn identity() -> Self {
Self {
origin: glam::Vec3::ZERO,
x: glam::Vec3::X,
y: glam::Vec3::Y,
z: glam::Vec3::Z,
}
}
pub(super) fn from_ucs(ucs: &Ucs) -> Self {
let v =
|a: acadrust::types::Vector3| glam::Vec3::new(a.x as f32, a.y as f32, a.z as f32);
let x = v(ucs.x_axis).normalize_or(glam::Vec3::X);
let y = v(ucs.y_axis).normalize_or(glam::Vec3::Y);
let z = x.cross(y).normalize_or(glam::Vec3::Z);
Self { origin: v(ucs.origin), x, y, z }
}
pub(super) fn from_active(ucs: Option<&Ucs>) -> Self {
ucs.map(Self::from_ucs).unwrap_or_else(Self::identity)
}
/// True when this is plain WCS — lets callers skip the conversion.
pub(super) fn is_identity(&self) -> bool {
self.origin == glam::Vec3::ZERO
&& self.x == glam::Vec3::X
&& self.y == glam::Vec3::Y
&& self.z == glam::Vec3::Z
}
/// UCS point → WCS.
pub(super) fn to_wcs(&self, p: glam::Vec3) -> glam::Vec3 {
self.origin + self.x * p.x + self.y * p.y + self.z * p.z
}
/// WCS point → UCS.
pub(super) fn to_ucs(&self, p: glam::Vec3) -> glam::Vec3 {
let d = p - self.origin;
glam::Vec3::new(d.dot(self.x), d.dot(self.y), d.dot(self.z))
}
/// UCS direction → WCS (rotation only, no origin shift).
pub(super) fn vec_to_wcs(&self, v: glam::Vec3) -> glam::Vec3 {
self.x * v.x + self.y * v.y + self.z * v.z
}
/// WCS direction → UCS (rotation only, no origin shift).
#[allow(dead_code)]
pub(super) fn vec_to_ucs(&self, v: glam::Vec3) -> glam::Vec3 {
glam::Vec3::new(v.dot(self.x), v.dot(self.y), v.dot(self.z))
}
/// `(origin, x, y, z)` axes in WCS — for drawing the UCS icon.
pub(super) fn axes(&self) -> (glam::Vec3, glam::Vec3, glam::Vec3, glam::Vec3) {
(self.origin, self.x, self.y, self.z)
}
}
// ── UCS ↔ WCS transforms (thin wrappers over `UcsXform`) ────────────────────
/// Rotate a UCS-local offset into WCS without applying the origin
/// translation — used for relative coordinate entry, where only the
/// axis orientation matters, not the UCS origin.
pub(super) fn ucs_rotate_vec(offset: glam::Vec3, 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);
let z = ucs_z_axis(ucs);
x * offset.x + y * offset.y + z * offset.z
UcsXform::from_ucs(ucs).vec_to_wcs(offset)
}
// ── 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
UcsXform::from_ucs(ucs).to_wcs(pt)
}
/// 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()
UcsXform::from_ucs(ucs).axes().3
}
/// Build a UCS with `origin` and axes rotated by `angle_z_rad` around the Z axis.

View file

@ -560,6 +560,8 @@ impl OpenCADStudio {
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document = doc;
// Follow the file's saved current UCS from the moment it opens.
self.tabs[i].adopt_active_ucs_from_header();
// Route shared CJK ideographs to the language matching this
// drawing's code page (web per-language font split). Drop the
// glyph cache if it changed so Han re-resolves to the new

View file

@ -154,9 +154,11 @@ impl OpenCADStudio {
let ucs_icon = if self.show_ucs_icon && !is_paper {
let cam = tab.scene.camera.borrow();
let (_, ux, uy, uz) = tab.ucs_xform().axes();
Some(overlay::UcsIconParams {
view_proj: cam.view_proj(vp_bounds),
bounds: vp_bounds,
axes: (ux, uy, uz),
})
} else {
None
@ -877,16 +879,19 @@ impl OpenCADStudio {
|| tab.scene.has_selected_viewport();
// The cursor is tracked in local render space; re-add the
// model-space world offset so the readout shows true
// drawing coordinates (paper space carries no offset).
// drawing coordinates (paper space carries no offset), then
// report it in the active UCS — the readout follows the
// user's coordinate system, not raw WCS (no-op without UCS).
let cursor_coord = {
let lc = tab.last_cursor_world;
if is_model {
let wo = tab.scene.world_offset;
glam::Vec3::new(
let wcs = glam::Vec3::new(
lc.x + wo[0] as f32,
lc.y + wo[1] as f32,
lc.z + wo[2] as f32,
)
);
tab.ucs_xform().to_ucs(wcs)
} else {
lc
}

View file

@ -93,6 +93,9 @@ pub struct UcsIconParams {
pub view_proj: Mat4,
/// Viewport bounds (used for NDC → pixel conversion).
pub bounds: iced::Rectangle,
/// The active UCS axis directions in world space (X, Y, Z). Plain WCS is
/// `(Vec3::X, Vec3::Y, Vec3::Z)`; a UCS rotates the tripod to match.
pub axes: (Vec3, Vec3, Vec3),
}
// ── Selection overlay ───────────────────────────────────────────────────
@ -789,7 +792,7 @@ impl canvas::Program<Message> for SelectionCanvas {
// ── UCS icon ──────────────────────────────────────────────────────
if let Some(ref ucs) = self.ucs_icon {
draw_ucs_icon(&mut frame, ucs.view_proj, ucs.bounds);
draw_ucs_icon(&mut frame, ucs.view_proj, ucs.bounds, ucs.axes);
}
// ── Object Snap Tracking lines ────────────────────────────────────
@ -1021,7 +1024,12 @@ const UCS_ICON_MARGIN: f32 = 50.0;
const UCS_ICON_LEN: f32 = 38.0; // longest axis arm in screen pixels
const UCS_ICON_TIP: f32 = 7.0; // arrowhead size in pixels
fn draw_ucs_icon(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle) {
fn draw_ucs_icon(
frame: &mut canvas::Frame,
vp: Mat4,
bounds: iced::Rectangle,
axes: (Vec3, Vec3, Vec3),
) {
if bounds.width < 10.0 || bounds.height < 10.0 {
return;
}
@ -1041,10 +1049,14 @@ fn draw_ucs_icon(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle) {
)
};
// Project the UCS axis directions (not fixed world axes) so the tripod
// rotates to the active UCS. Directions are translation-invariant, so a
// common origin is fine.
let (ax, ay, az) = axes;
let Some(org) = w2ndc(Vec3::ZERO) else { return };
let Some(xn) = w2ndc(Vec3::X) else { return };
let Some(yn) = w2ndc(Vec3::Y) else { return };
let Some(zn) = w2ndc(Vec3::Z) else { return };
let Some(xn) = w2ndc(ax) else { return };
let Some(yn) = w2ndc(ay) else { return };
let Some(zn) = w2ndc(az) else { return };
let org_s = ndc2s(org);
let icon_origin = Point::new(