feat: 3D UCS icon with foreshortening, depth ordering and axis labels

Replace the flat fixed-length axis arrows with a proper 3D tripod:
- Axis lengths are proportional (longest fills UCS_ICON_LEN, shorter
  axes stay foreshortened) giving real depth perception as the camera
  rotates
- Draw order computed from NDC Z so back axes render behind front ones
- X / Y / Z labels rendered beyond each arrowhead tip
- All axes drawn at full opacity with solid filled arrowheads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-23 21:54:31 +03:00
commit 6a0125e4d6

View file

@ -784,55 +784,82 @@ fn draw_axes(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle, exten
// //
// Draws a small X/Y/Z axis tripod in the bottom-left corner of the viewport. // Draws a small X/Y/Z axis tripod in the bottom-left corner of the viewport.
// The axis directions are projected from world space so the icon rotates with // The axis directions are projected from world space so the icon rotates with
// the camera, matching the orientation of the drawing plane. // the camera. Axis lengths are proportional (foreshortening preserved), depth
// ordering is computed from NDC Z, and axes going away from the viewer are
// drawn as outlined circles with reduced opacity.
const UCS_ICON_MARGIN: f32 = 50.0; // px from bottom-left corner to icon origin const UCS_ICON_MARGIN: f32 = 50.0;
const UCS_ICON_LEN: f32 = 35.0; // axis arm length in screen pixels const UCS_ICON_LEN: f32 = 38.0; // longest axis arm in screen pixels
const UCS_ICON_TIP: f32 = 6.0; // arrowhead size in 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) {
// Guard against zero-size viewport (before layout pass) or degenerate matrix.
if bounds.width < 10.0 || bounds.height < 10.0 { if bounds.width < 10.0 || bounds.height < 10.0 {
return; return;
} }
// Project world origin and axis unit vectors to NDC, then to screen px. // Project to NDC (including depth) then to screen pixels.
let w2s = |world: Vec3| -> Option<Point> { let w2ndc = |world: Vec3| -> Option<Vec3> {
let ndc = vp.project_point3(world); let ndc = vp.project_point3(world);
if !ndc.x.is_finite() || !ndc.y.is_finite() { if !ndc.x.is_finite() || !ndc.y.is_finite() || !ndc.z.is_finite() {
return None; return None;
} }
Some(Point::new( Some(ndc)
};
let ndc2s = |ndc: Vec3| -> Point {
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width, (ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height, (1.0 - ndc.y) * 0.5 * bounds.height,
)) )
}; };
// Origin in screen space — used to compute axis directions. let Some(org) = w2ndc(Vec3::ZERO) else { return };
let Some(origin_s) = w2s(Vec3::ZERO) else { return }; let Some(xn) = w2ndc(Vec3::X) else { return };
let Some(x_tip_s) = w2s(Vec3::X) else { return }; let Some(yn) = w2ndc(Vec3::Y) else { return };
let Some(y_tip_s) = w2s(Vec3::Y) else { return }; let Some(zn) = w2ndc(Vec3::Z) else { return };
let Some(z_tip_s) = w2s(Vec3::Z) else { return };
// Icon origin: fixed bottom-left corner. let org_s = ndc2s(org);
let ox = UCS_ICON_MARGIN; let icon_origin = Point::new(
let oy = (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN); UCS_ICON_MARGIN,
let icon_origin = Point::new(ox, oy); (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN),
);
// Compute normalized screen-space axis directions, then scale to UCS_ICON_LEN. // Raw screen-space displacement for each axis tip.
let axis_dir = |tip: Point| -> Option<Point> { let raw = |ndc_tip: Vec3| -> (f32, f32, f32) {
let dx = tip.x - origin_s.x; let s = ndc2s(ndc_tip);
let dy = tip.y - origin_s.y; let dx = s.x - org_s.x;
let len = (dx * dx + dy * dy).sqrt(); let dy = s.y - org_s.y;
if len < 1e-4 { (dx, dy, (dx * dx + dy * dy).sqrt())
return None; };
let (xdx, xdy, xlen) = raw(xn);
let (ydx, ydy, ylen) = raw(yn);
let (zdx, zdy, zlen) = raw(zn);
// Scale so the longest projected axis fills UCS_ICON_LEN; shorter axes
// stay proportionally shorter (this IS the foreshortening effect).
let max_len = xlen.max(ylen).max(zlen).max(1e-4);
let sc = UCS_ICON_LEN / max_len;
// depth > 0 → tip is farther from viewer than origin (axis going into screen).
// depth < 0 → tip is closer (axis coming toward viewer).
struct AxisInfo {
dx: f32, dy: f32, sc_len: f32, depth: f32,
r: f32, g: f32, b: f32, label: &'static str,
} }
Some(Point::new(dx / len * UCS_ICON_LEN, dy / len * UCS_ICON_LEN)) let mut axes = [
}; AxisInfo { dx: xdx*sc, dy: xdy*sc, sc_len: xlen*sc, depth: xn.z - org.z, r: 0.90, g: 0.22, b: 0.22, label: "X" },
AxisInfo { dx: ydx*sc, dy: ydy*sc, sc_len: ylen*sc, depth: yn.z - org.z, r: 0.22, g: 0.85, b: 0.22, label: "Y" },
AxisInfo { dx: zdx*sc, dy: zdy*sc, sc_len: zlen*sc, depth: zn.z - org.z, r: 0.22, g: 0.45, b: 0.90, label: "Z" },
];
// Back-to-front: draw axis farthest from viewer first.
axes.sort_by(|a, b| b.depth.partial_cmp(&a.depth).unwrap_or(std::cmp::Ordering::Equal));
let draw_axis = |frame: &mut canvas::Frame, dir: Point, r: f32, g: f32, b: f32| { for ax in &axes {
let tip = Point::new(icon_origin.x + dir.x, icon_origin.y + dir.y); let col = Color { r: ax.r, g: ax.g, b: ax.b, a: 1.0 };
if !tip.x.is_finite() || !tip.y.is_finite() { return; } let tip = Point::new(icon_origin.x + ax.dx, icon_origin.y + ax.dy);
// Shaft
if ax.sc_len > 1.0 {
let path = canvas::Path::new(|p| { let path = canvas::Path::new(|p| {
p.move_to(icon_origin); p.move_to(icon_origin);
p.line_to(tip); p.line_to(tip);
@ -841,42 +868,61 @@ fn draw_ucs_icon(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle) {
&path, &path,
canvas::Stroke { canvas::Stroke {
width: 2.0, width: 2.0,
style: canvas::Style::Solid(Color { r, g, b, a: 1.0 }), style: canvas::Style::Solid(col),
line_cap: canvas::LineCap::Round, line_cap: canvas::LineCap::Butt,
..Default::default() ..Default::default()
}, },
); );
// Arrowhead: small filled triangle at tip. }
let len = (dir.x * dir.x + dir.y * dir.y).sqrt().max(1e-4);
let nx = dir.x / len; // Filled arrowhead at tip.
let ny = dir.y / len; if ax.sc_len > 3.0 {
let (nx, ny) = if ax.sc_len > 1e-3 {
(ax.dx / ax.sc_len, ax.dy / ax.sc_len)
} else {
(1.0, 0.0)
};
let px = -ny; let px = -ny;
let py = nx; let py = nx;
let tip_l = Point::new(tip.x - nx * UCS_ICON_TIP + px * (UCS_ICON_TIP * 0.4), tip.y - ny * UCS_ICON_TIP + py * (UCS_ICON_TIP * 0.4)); let tl = Point::new(
let tip_r = Point::new(tip.x - nx * UCS_ICON_TIP - px * (UCS_ICON_TIP * 0.4), tip.y - ny * UCS_ICON_TIP - py * (UCS_ICON_TIP * 0.4)); tip.x - nx * UCS_ICON_TIP + px * (UCS_ICON_TIP * 0.45),
tip.y - ny * UCS_ICON_TIP + py * (UCS_ICON_TIP * 0.45),
);
let tr = Point::new(
tip.x - nx * UCS_ICON_TIP - px * (UCS_ICON_TIP * 0.45),
tip.y - ny * UCS_ICON_TIP - py * (UCS_ICON_TIP * 0.45),
);
let arrow = canvas::Path::new(|p| { let arrow = canvas::Path::new(|p| {
p.move_to(tip); p.move_to(tip);
p.line_to(tip_l); p.line_to(tl);
p.line_to(tip_r); p.line_to(tr);
p.close(); p.close();
}); });
frame.fill(&arrow, Color { r, g, b, a: 1.0 }); frame.fill(&arrow, col);
}
// Axis label (X / Y / Z) beyond the tip.
if ax.sc_len > 4.0 {
let (nx, ny) = if ax.sc_len > 1e-3 {
(ax.dx / ax.sc_len, ax.dy / ax.sc_len)
} else {
(1.0, 0.0)
}; };
frame.fill_text(canvas::Text {
// Draw Z first (behind), then Y, then X (in front). content: ax.label.to_string(),
if let Some(dir) = axis_dir(z_tip_s) { // Offset beyond tip along the axis direction; subtract ~half glyph
draw_axis(frame, dir, 0.20, 0.40, 0.90); // Z blue // size to visually center the single character on the axis line.
position: Point::new(tip.x + nx * 8.0 - 3.5, tip.y + ny * 8.0 - 5.0),
color: col,
size: iced::Pixels(10.0),
..Default::default()
});
} }
if let Some(dir) = axis_dir(y_tip_s) {
draw_axis(frame, dir, 0.20, 0.85, 0.20); // Y green
}
if let Some(dir) = axis_dir(x_tip_s) {
draw_axis(frame, dir, 0.90, 0.20, 0.20); // X red
} }
// Small circle at origin. // Origin dot.
let circle = canvas::Path::circle(icon_origin, 3.0); let circle = canvas::Path::circle(icon_origin, 3.5);
frame.fill(&circle, Color { r: 0.9, g: 0.9, b: 0.9, a: 0.9 }); frame.fill(&circle, Color { r: 0.9, g: 0.9, b: 0.9, a: 0.95 });
} }
// ── Dynamic Input overlay ───────────────────────────────────────────────── // ── Dynamic Input overlay ─────────────────────────────────────────────────