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:
parent
00da80f6e5
commit
6a0125e4d6
1 changed files with 119 additions and 73 deletions
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue