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,99 +784,145 @@ 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.
// 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_LEN: f32 = 35.0; // axis arm length in screen pixels
const UCS_ICON_TIP: f32 = 6.0; // arrowhead size in pixels
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) {
// Guard against zero-size viewport (before layout pass) or degenerate matrix.
if bounds.width < 10.0 || bounds.height < 10.0 {
return;
}
// Project world origin and axis unit vectors to NDC, then to screen px.
let w2s = |world: Vec3| -> Option<Point> {
// Project to NDC (including depth) then to screen pixels.
let w2ndc = |world: Vec3| -> Option<Vec3> {
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;
}
Some(Point::new(
Some(ndc)
};
let ndc2s = |ndc: Vec3| -> Point {
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
))
)
};
// Origin in screen space — used to compute axis directions.
let Some(origin_s) = w2s(Vec3::ZERO) else { return };
let Some(x_tip_s) = w2s(Vec3::X) else { return };
let Some(y_tip_s) = w2s(Vec3::Y) else { return };
let Some(z_tip_s) = w2s(Vec3::Z) else { return };
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 };
// Icon origin: fixed bottom-left corner.
let ox = UCS_ICON_MARGIN;
let oy = (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN);
let icon_origin = Point::new(ox, oy);
let org_s = ndc2s(org);
let icon_origin = Point::new(
UCS_ICON_MARGIN,
(bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN),
);
// Compute normalized screen-space axis directions, then scale to UCS_ICON_LEN.
let axis_dir = |tip: Point| -> Option<Point> {
let dx = tip.x - origin_s.x;
let dy = tip.y - origin_s.y;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-4 {
return None;
// Raw screen-space displacement for each axis tip.
let raw = |ndc_tip: Vec3| -> (f32, f32, f32) {
let s = ndc2s(ndc_tip);
let dx = s.x - org_s.x;
let dy = s.y - org_s.y;
(dx, dy, (dx * dx + dy * dy).sqrt())
};
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,
}
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));
for ax in &axes {
let col = Color { r: ax.r, g: ax.g, b: ax.b, a: 1.0 };
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| {
p.move_to(icon_origin);
p.line_to(tip);
});
frame.stroke(
&path,
canvas::Stroke {
width: 2.0,
style: canvas::Style::Solid(col),
line_cap: canvas::LineCap::Butt,
..Default::default()
},
);
}
Some(Point::new(dx / len * UCS_ICON_LEN, dy / len * UCS_ICON_LEN))
};
let draw_axis = |frame: &mut canvas::Frame, dir: Point, r: f32, g: f32, b: f32| {
let tip = Point::new(icon_origin.x + dir.x, icon_origin.y + dir.y);
if !tip.x.is_finite() || !tip.y.is_finite() { return; }
let path = canvas::Path::new(|p| {
p.move_to(icon_origin);
p.line_to(tip);
});
frame.stroke(
&path,
canvas::Stroke {
width: 2.0,
style: canvas::Style::Solid(Color { r, g, b, a: 1.0 }),
line_cap: canvas::LineCap::Round,
// Filled arrowhead at tip.
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 py = nx;
let tl = 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 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| {
p.move_to(tip);
p.line_to(tl);
p.line_to(tr);
p.close();
});
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 {
content: ax.label.to_string(),
// Offset beyond tip along the axis direction; subtract ~half glyph
// 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()
},
);
// 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;
let ny = dir.y / len;
let px = -ny;
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 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));
let arrow = canvas::Path::new(|p| {
p.move_to(tip);
p.line_to(tip_l);
p.line_to(tip_r);
p.close();
});
frame.fill(&arrow, Color { r, g, b, a: 1.0 });
};
// Draw Z first (behind), then Y, then X (in front).
if let Some(dir) = axis_dir(z_tip_s) {
draw_axis(frame, dir, 0.20, 0.40, 0.90); // Z blue
}
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.
let circle = canvas::Path::circle(icon_origin, 3.0);
frame.fill(&circle, Color { r: 0.9, g: 0.9, b: 0.9, a: 0.9 });
// Origin dot.
let circle = canvas::Path::circle(icon_origin, 3.5);
frame.fill(&circle, Color { r: 0.9, g: 0.9, b: 0.9, a: 0.95 });
}
// ── Dynamic Input overlay ─────────────────────────────────────────────────