cad-editor/src/ui/overlay.rs
Hakan Seven c3e86d08cc feat(snap): OTRACK ortho lock + edge-extension tracking (#218, #219)
#218: with Ortho on and a base point the axis is now a hard lock — a lone
tracking ray can't pull the cursor off it. Only a tracking ray crossing
the ortho axis locks (the corner-finding case); otherwise the ortho
constraint applies as the fallback, so Ortho reliably locks when OTRACK
is also on.

#219: an acquired corner now contributes an alignment ray along its own
edge direction(s) (captured once at acquisition), so OTRACK follows a
segment's extension, not just the ortho/polar axes. Acquisition is also
captured on leave — a perfectly still cursor emits no move events, so
"pause on a corner, then drag" now reliably acquires it. The acquired
marker projects through the active pane's camera + rect (canvas offset
included) instead of vanishing, and the active alignment path is drawn
as a full-length dashed guide through the corner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:34:20 +03:00

2032 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Viewport overlay widgets.
use glam::{Mat4, Vec3};
use iced::mouse;
use iced::widget::canvas;
use iced::{Color, Element, Length, Point, Size, Theme};
use crate::app::Message;
use crate::scene::model::object::GripShape;
use crate::scene::SelectionState;
/// Half-size of the crosshair center square in screen pixels (square = SQ*2 × SQ*2).
pub const CROSSHAIR_SQ: f32 = 7.5;
/// Arm length of the crosshair from center — used as the snap aperture radius.
pub const CROSSHAIR_ARM: f32 = 60.0;
use crate::snap::SnapType;
// ── Grip marker data ──────────────────────────────────────────────────────
/// Describes one grip to be drawn in the viewport overlay.
#[derive(Clone, Debug)]
pub struct GripMarker {
/// Screen-space position (viewport-relative pixels).
pub pos: Point,
/// Explicit marker shape.
pub shape: GripShape,
/// True → grip is currently being dragged (drawn filled red).
pub is_hot: bool,
/// World-XY direction vector — only consumed by the `Rectangle`
/// shape to orient the box along its segment. `None` for grips
/// that don't need rotation.
pub dir: Option<[f32; 2]>,
}
// ── Grid display params ───────────────────────────────────────────────────
/// Which world-space plane the grid is drawn on — switches with camera angle.
#[derive(Clone, Copy, PartialEq)]
pub enum GridPlane {
/// Horizontal XY plane (Z = 0). Default top-down view (Z-up).
Xy,
/// Vertical XZ plane (Y = 0). Front/back view.
Xz,
/// Vertical YZ plane (X = 0). Side view.
Yz,
}
/// Passed to the canvas when the GRID display is active.
#[derive(Clone)]
pub struct GridParams {
/// Rotation-only view-projection (Camera::view_proj_rte). Grid points are
/// made relative to `eye` in f64 before projecting, so the grid stays
/// precise / jitter-free at UTM-scale absolute coordinates.
pub view_rot: Mat4,
/// Camera eye in absolute world f64 — subtracted from each grid point.
pub eye: glam::DVec3,
pub bounds: iced::Rectangle,
pub plane: GridPlane,
/// Grid origin in absolute world f64 and the active UCS axis directions.
/// The grid rules along these instead of world X/Y/Z, so it aligns to the
/// user's coordinate system. Plain WCS passes `(ZERO, X, Y, Z)`.
pub origin: glam::DVec3,
pub axes: (Vec3, Vec3, Vec3),
}
/// Compute the adaptive grid step size (world units) that the grid renderer
/// would use for a given view-projection matrix and viewport bounds.
///
/// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at
/// least `MIN_GRID_PX` pixels apart. This matches exactly what `draw_grid`
/// renders, so callers can sync snap spacing to the visible grid.
pub fn compute_grid_step(view_rot: Mat4, bounds: iced::Rectangle) -> f32 {
use glam::Vec3;
// Only the per-unit screen scale is needed; project small eye-relative
// offsets (0 / X / Y) through the rotation-only matrix so this stays correct
// and precise at any absolute coordinate (no eye term required).
let w2s = |world: Vec3| {
let ndc = view_rot.project_point3(world);
glam::Vec2::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
)
};
let o = w2s(Vec3::ZERO);
let a1 = w2s(Vec3::X);
let a2 = w2s(Vec3::Y);
let d1 = (a1 - o).length();
let d2 = (a2 - o).length();
let px_per_unit = d1.max(d2);
if px_per_unit < 1e-6 {
return 1.0;
}
let mut s = 1.0_f32;
while s * px_per_unit < MIN_GRID_PX {
s *= 5.0;
if s > 1e9 {
return 1.0;
}
}
s
}
/// Parameters for the screen-space UCS icon drawn in the viewport corner.
pub struct UcsIconParams {
/// View-projection matrix used to project world axis directions to screen.
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),
/// Absolute screen position of the UCS origin, when the icon should track
/// it (UCSICON ORigin). `None` → pin to the corner. The tripod still snaps
/// back to the corner if this point falls outside the viewport bounds.
pub origin_screen: Option<Point>,
/// Cursor is over the icon — brighten the tripod (hover affordance).
pub hover: bool,
/// Icon is selected — draw draggable grip squares at the origin and tips.
pub selected: bool,
}
// ── Selection overlay ───────────────────────────────────────────────────
/// An acquired OST tracking point with its screen position.
#[derive(Clone, Debug)]
pub struct OstTrackPoint {
pub screen: Point,
}
pub fn grid_overlay<'a>(
grid: Vec<GridParams>,
) -> Element<'a, Message> {
canvas(GridCanvas { grid })
.width(Length::Fill)
.height(Length::Fill)
.into()
}
struct GridCanvas {
grid: Vec<GridParams>,
}
impl canvas::Program<Message> for GridCanvas {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
_theme: &Theme,
bounds: iced::Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
for g in &self.grid {
let gb = g.bounds;
let cx0 = gb.x.max(0.0);
let cy0 = gb.y.max(0.0);
let cx1 = (gb.x + gb.width).min(bounds.width);
let cy1 = (gb.y + gb.height).min(bounds.height);
if cx1 <= cx0 || cy1 <= cy0 {
continue;
}
let clip = iced::Rectangle {
x: cx0,
y: cy0,
width: cx1 - cx0,
height: cy1 - cy0,
};
frame.with_clip(clip, |f| {
draw_grid(f, g.view_rot, g.eye, g.plane, gb, g.origin, g.axes)
});
}
vec![frame.into_geometry()]
}
}
pub fn selection_overlay<'a>(
selection: SelectionState,
snap: Option<(Point, SnapType)>,
snap_ext_base: Option<Point>,
snap_ext_base2: Option<Point>,
grips: Vec<GripMarker>,
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
otrack_line: Option<(Point, Point)>,
show_viewcube: bool,
dividers: Vec<iced::Rectangle>,
pane_move_rect: Option<iced::Rectangle>,
pane_drop_rect: Option<iced::Rectangle>,
pan_mode: bool,
suppressed: bool,
hover_locked: bool,
) -> Element<'a, Message> {
canvas(SelectionCanvas {
selection,
snap,
snap_ext_base,
snap_ext_base2,
grips,
ucs_icons,
ost_points,
otrack_line,
show_viewcube,
dividers,
pane_move_rect,
pane_drop_rect,
pan_mode,
suppressed,
hover_locked,
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
struct SelectionCanvas {
selection: SelectionState,
snap: Option<(Point, SnapType)>,
/// Screen position of the endpoint an active Extension snap extends from,
/// so the dashed extension guide line can be drawn back to it. (#238)
snap_ext_base: Option<Point>,
/// Second extension-guide base, present only for an extended intersection so
/// both crossing extensions stay drawn when the crossing is caught. (#247, #259)
snap_ext_base2: Option<Point>,
grips: Vec<GripMarker>,
/// One UCS icon per Model pane (each viewport shows its own at its origin);
/// a single entry for paper / floating-viewport. Only the active pane's
/// entry carries hover/selected (grips).
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
/// Active OTRACK alignment: (acquired tracking point, locked cursor), both
/// in screen space. Drawn as a dashed guide extended a little past the
/// cursor so the extension / tracking line the user snapped to is visible.
/// (#219)
otrack_line: Option<(Point, Point)>,
show_viewcube: bool,
/// Divider bars (pixel rects, canvas-relative) between Model panes — drawn
/// as filled lines and used to suppress the crosshair over a divider.
dividers: Vec<iced::Rectangle>,
/// When a pane move is armed (drag handle pressed), the source pane's rect
/// (px) — dimmed, with a ghost card dragged along under the cursor.
pane_move_rect: Option<iced::Rectangle>,
/// The pane under the cursor during a pane move (drop target), highlighted.
pane_drop_rect: Option<iced::Rectangle>,
/// Interactive PAN mode: the crosshair is hidden and the cursor becomes a
/// hand so the viewport reads as a draggable surface.
pan_mode: bool,
/// A ribbon dropdown (or similar overlay) is open over the viewport. The
/// crosshair is not drawn and the OS cursor is shown normally so the panel
/// is usable instead of the cursor vanishing over it. (#227)
suppressed: bool,
/// The entity under the crosshair is on a locked layer — draw a small lock
/// badge by the cursor so the user knows it can't be selected/edited.
hover_locked: bool,
}
impl SelectionCanvas {
/// True when the cursor sits on a Model-pane divider (within a few px), so
/// `draw` can suppress the CAD crosshair there. The resize cursor itself is
/// supplied by the input pane_grid layered above.
fn divider_under(&self, cursor: mouse::Cursor, bounds: iced::Rectangle) -> bool {
const TOL_PX: f32 = 3.0;
let Some(pos) = cursor.position_in(bounds) else {
return false;
};
self.dividers.iter().any(|d| {
pos.x >= d.x - TOL_PX
&& pos.x <= d.x + d.width + TOL_PX
&& pos.y >= d.y - TOL_PX
&& pos.y <= d.y + d.height + TOL_PX
})
}
}
impl canvas::Program<Message> for SelectionCanvas {
type State = ();
fn mouse_interaction(
&self,
_state: &(),
bounds: iced::Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
// A dropdown/overlay is open over the viewport — show the normal OS
// cursor over the whole canvas instead of hiding it for the crosshair,
// so the cursor doesn't vanish while using the panel. (#227)
if self.suppressed {
return mouse::Interaction::default();
}
// PAN mode owns the whole viewport: an open hand when hovering, a
// closed hand while dragging.
if self.pan_mode && cursor.is_over(bounds) {
return if self.selection.middle_down {
mouse::Interaction::Grabbing
} else {
mouse::Interaction::Grab
};
}
if self.show_viewcube {
if let Some(pos) = cursor.position_in(bounds) {
use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX};
let vc_x = bounds.width - VIEWCUBE_REGION_PX - VIEWCUBE_PAD;
let vc_y = VIEWCUBE_PAD;
if pos.x >= vc_x
&& pos.x <= vc_x + VIEWCUBE_REGION_PX
&& pos.y >= vc_y
&& pos.y <= vc_y + VIEWCUBE_REGION_PX
{
return mouse::Interaction::None;
}
}
}
// The resize cursor over a divider is supplied by the input pane_grid
// layered above this overlay; `draw` only suppresses the CAD crosshair
// there (see `divider_under`).
// Over the viewport (no divider, no viewcube): hide the system
// cursor entirely. `Interaction::None` would let the stack fall
// through to a sibling — `Hidden` is the explicit "no cursor"
// signal that actually suppresses the OS arrow.
if cursor.is_over(bounds) {
mouse::Interaction::Hidden
} else {
mouse::Interaction::default()
}
}
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
_theme: &Theme,
bounds: iced::Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
// ── Pane dividers (Model-space tiled layout) ──────────────────────
// Filled bars in the pane_grid spacing gaps, so adjacent panes read as
// distinct viewports. Drawn first so all other overlays sit on top.
if !self.dividers.is_empty() {
const DIVIDER: Color = Color {
r: 0.46,
g: 0.52,
b: 0.62,
a: 1.0,
};
for d in &self.dividers {
let bar = canvas::Path::rectangle(
Point::new(d.x, d.y),
iced::Size::new(d.width.max(1.0), d.height.max(1.0)),
);
frame.fill(&bar, DIVIDER);
}
}
// ── Pane move (drag-to-swap) ──────────────────────────────────────
// While armed: dim the lifted source pane, highlight the drop target
// under the cursor, and drag a translucent ghost card along the cursor
// so the pane is visibly "moving".
if let Some(src) = self.pane_move_rect {
let accent = Color {
r: 0.30,
g: 0.62,
b: 1.0,
a: 1.0,
};
// Source pane: dimmed + dashed-feel outline (it has been lifted).
let src_path =
canvas::Path::rectangle(Point::new(src.x, src.y), iced::Size::new(src.width, src.height));
frame.fill(
&src_path,
Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.28,
},
);
frame.stroke(
&src_path,
canvas::Stroke {
width: 1.5,
style: canvas::Style::Solid(Color { a: 0.6, ..accent }),
..Default::default()
},
);
// Drop target pane: bright fill + outline.
if let Some(dst) = self.pane_drop_rect {
let dst_path = canvas::Path::rectangle(
Point::new(dst.x, dst.y),
iced::Size::new(dst.width, dst.height),
);
frame.fill(&dst_path, Color { a: 0.16, ..accent });
frame.stroke(
&dst_path,
canvas::Stroke {
width: 2.5,
style: canvas::Style::Solid(accent),
..Default::default()
},
);
}
// Ghost card dragged under the cursor — a 0.32× preview of the
// source pane, centred on the cursor.
if let Some(c) = self.selection.last_move_pos {
let gw = (src.width * 0.32).clamp(60.0, 280.0);
let gh = (src.height * 0.32).clamp(40.0, 200.0);
let g = canvas::Path::rectangle(
Point::new(c.x - gw * 0.5, c.y - gh * 0.5),
iced::Size::new(gw, gh),
);
frame.fill(&g, Color { a: 0.30, ..accent });
frame.stroke(
&g,
canvas::Stroke {
width: 2.0,
style: canvas::Style::Solid(Color { a: 0.95, ..accent }),
..Default::default()
},
);
}
}
if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) {
let (fill, stroke) = if self.selection.box_crossing {
(
Color {
r: 0.20,
g: 0.72,
b: 0.44,
a: 0.12,
},
Color {
r: 0.20,
g: 0.72,
b: 0.44,
a: 0.9,
},
)
} else {
(
Color {
r: 0.20,
g: 0.44,
b: 0.72,
a: 0.12,
},
Color {
r: 0.20,
g: 0.44,
b: 0.72,
a: 0.9,
},
)
};
let x0 = a.x.min(b.x);
let y0 = a.y.min(b.y);
let w = (a.x - b.x).abs();
let h = (a.y - b.y).abs();
let rect = canvas::Path::rectangle(Point::new(x0, y0), Size::new(w, h));
frame.fill(&rect, fill);
frame.stroke(
&rect,
canvas::Stroke {
width: 1.0,
style: canvas::Style::Solid(stroke),
..Default::default()
},
);
}
if self.selection.poly_active && self.selection.poly_points.len() > 1 {
let (fill, stroke) = if self.selection.poly_crossing {
(
Color {
r: 0.20,
g: 0.72,
b: 0.44,
a: 0.12,
},
Color {
r: 0.20,
g: 0.72,
b: 0.44,
a: 0.9,
},
)
} else {
(
Color {
r: 0.20,
g: 0.44,
b: 0.72,
a: 0.12,
},
Color {
r: 0.20,
g: 0.44,
b: 0.72,
a: 0.9,
},
)
};
if let Some(cur) = self.selection.last_move_pos {
let start = self.selection.poly_points[0];
let fill_path = canvas::Path::new(|p| {
p.move_to(start);
for pt in &self.selection.poly_points[1..] {
p.line_to(*pt);
}
p.line_to(cur);
p.line_to(start);
});
frame.fill(&fill_path, fill);
}
let path = canvas::Path::new(|p| {
p.move_to(self.selection.poly_points[0]);
for pt in &self.selection.poly_points[1..] {
p.line_to(*pt);
}
});
frame.stroke(
&path,
canvas::Stroke {
width: 1.0,
style: canvas::Style::Solid(stroke),
..Default::default()
},
);
if let Some(cur) = self.selection.last_move_pos {
let start = self.selection.poly_points[0];
let last = *self.selection.poly_points.last().unwrap();
let preview = canvas::Path::new(|p| {
p.move_to(last);
p.line_to(cur);
p.line_to(start);
});
frame.stroke(
&preview,
canvas::Stroke {
width: 1.0,
style: canvas::Style::Solid(stroke),
..Default::default()
},
);
}
}
// ── Grip markers ──────────────────────────────────────────────────
for grip in &self.grips {
let sp = grip.pos;
let h = crate::scene::pick::grip::GRIP_HALF_PX;
let path = match grip.shape {
GripShape::Square => canvas::Path::rectangle(
Point::new(sp.x - h, sp.y - h),
Size::new(h * 2.0, h * 2.0),
),
GripShape::Rectangle => {
// Mid-segment stretch handle: small box, longer along
// the segment direction so the affordance reads as
// "stretch perpendicular to the segment". `dir` is a
// world-XY direction vector; project it onto the
// screen-X / screen-Y axes implied by the grip's
// 2-D screen position to compute the in-plane angle.
let half_long = h * 1.4;
let half_short = h * 0.7;
let (cos_t, sin_t) = match grip.dir {
Some([dx, dy]) if (dx * dx + dy * dy) > 1e-12 => {
let n = (dx * dx + dy * dy).sqrt();
// Screen Y is inverted vs world Y → flip sin.
(dx / n, -dy / n)
}
_ => (1.0, 0.0),
};
let ax = (cos_t * half_long, sin_t * half_long);
let ay = (-sin_t * half_short, cos_t * half_short);
canvas::Path::new(|b| {
b.move_to(Point::new(sp.x + ax.0 + ay.0, sp.y + ax.1 + ay.1));
b.line_to(Point::new(sp.x + ax.0 - ay.0, sp.y + ax.1 - ay.1));
b.line_to(Point::new(sp.x - ax.0 - ay.0, sp.y - ax.1 - ay.1));
b.line_to(Point::new(sp.x - ax.0 + ay.0, sp.y - ax.1 + ay.1));
b.close();
})
}
GripShape::Triangle => canvas::Path::new(|b| {
b.move_to(Point::new(sp.x, sp.y - h));
b.line_to(Point::new(sp.x + h, sp.y + h));
b.line_to(Point::new(sp.x - h, sp.y + h));
b.close();
}),
};
if grip.is_hot {
// Hot grip: filled red marker
let color = Color {
r: 1.0,
g: 0.15,
b: 0.10,
a: 1.0,
};
frame.fill(&path, color);
} else {
// Normal grip: hollow blue marker
let color = Color {
r: 0.10,
g: 0.45,
b: 0.90,
a: 1.0,
};
let stroke = canvas::Stroke {
width: 1.5,
style: canvas::Style::Solid(color),
..Default::default()
};
// Fill with semi-transparent background then stroke
frame.fill(
&path,
Color {
r: 0.10,
g: 0.10,
b: 0.20,
a: 0.7,
},
);
frame.stroke(&path, stroke);
}
}
// ── Snap marker ───────────────────────────────────────────────────
if let Some((sp, snap_type)) = self.snap {
let (r, g, b) = if snap_type == SnapType::ObjectPick {
(0.95_f32, 0.50, 0.08) // orange object-snap marker
} else {
(1.0, 0.9, 0.1) // classic yellow OSNAP
};
let marker = Color { r, g, b, a: 1.0 };
let stroke = canvas::Stroke {
width: if snap_type == SnapType::ObjectPick { 2.0 } else { 1.5 },
style: canvas::Style::Solid(marker),
..Default::default()
};
match snap_type {
SnapType::ObjectPick => {
// Target box + center dot (object-acquisition glyph).
let h = 7.0_f32;
let rect = canvas::Path::rectangle(
Point::new(sp.x - h, sp.y - h),
Size::new(h * 2.0, h * 2.0),
);
frame.stroke(&rect, stroke.clone());
let r = 3.0_f32;
frame.fill(
&canvas::Path::circle(sp, r),
Color {
r: 0.95,
g: 0.50,
b: 0.08,
a: 0.85,
},
);
}
SnapType::Endpoint => {
let h = 5.0_f32;
let rect = canvas::Path::rectangle(
Point::new(sp.x - h, sp.y - h),
Size::new(h * 2.0, h * 2.0),
);
frame.stroke(&rect, stroke);
}
SnapType::Midpoint => {
let r = 6.0_f32;
let path = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x, sp.y - r));
b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5));
b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5));
b.close();
});
frame.stroke(&path, stroke);
}
SnapType::Center => {
let r = 5.5_f32;
let path = canvas::Path::circle(sp, r);
frame.stroke(&path, stroke);
}
SnapType::Node => {
// Circle with an inscribed X.
let r = 5.5_f32;
let cpath = canvas::Path::circle(sp, r);
frame.stroke(&cpath, stroke.clone());
let d = r * std::f32::consts::FRAC_1_SQRT_2;
let x1 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - d, sp.y - d));
b.line_to(Point::new(sp.x + d, sp.y + d));
});
let x2 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - d, sp.y + d));
b.line_to(Point::new(sp.x + d, sp.y - d));
});
frame.stroke(&x1, stroke.clone());
frame.stroke(&x2, stroke);
}
SnapType::Quadrant => {
let r = 6.0_f32;
let path = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y));
b.line_to(Point::new(sp.x, sp.y + r));
b.line_to(Point::new(sp.x - r, sp.y));
b.close();
});
frame.stroke(&path, stroke);
}
SnapType::Intersection => {
// An extended intersection sets one or two extension bases;
// draw a dashed guide from each endpoint through the crossing
// so both contributing extension paths stay visible. A real
// on-segment crossing carries no bases, so nothing draws here.
// (#247, #259)
for base in [self.snap_ext_base, self.snap_ext_base2]
.into_iter()
.flatten()
{
let dx = sp.x - base.x;
let dy = sp.y - base.y;
let len = (dx * dx + dy * dy).sqrt();
if len > 1e-3 {
let dash = canvas::Stroke {
line_dash: canvas::LineDash {
segments: &[4.0, 4.0],
offset: 0,
},
..canvas::Stroke::default().with_color(marker).with_width(1.0)
};
let tip = Point::new(
sp.x + dx / len * 18.0,
sp.y + dy / len * 18.0,
);
frame.stroke(&canvas::Path::line(base, tip), dash);
}
}
let r = 5.0_f32;
let p1 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y + r));
});
let p2 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y + r));
b.line_to(Point::new(sp.x + r, sp.y - r));
});
frame.stroke(&p1, stroke.clone());
frame.stroke(&p2, stroke);
}
SnapType::ApparentIntersection => {
// X like Intersection, framed by a small square so the
// two are visually distinguishable.
let r = 5.0_f32;
let rect = canvas::Path::rectangle(
Point::new(sp.x - r, sp.y - r),
Size::new(r * 2.0, r * 2.0),
);
frame.stroke(&rect, stroke.clone());
let xr = r - 1.5;
let p1 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - xr, sp.y - xr));
b.line_to(Point::new(sp.x + xr, sp.y + xr));
});
let p2 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - xr, sp.y + xr));
b.line_to(Point::new(sp.x + xr, sp.y - xr));
});
frame.stroke(&p1, stroke.clone());
frame.stroke(&p2, stroke);
}
SnapType::Insertion => {
// Two overlapping rectangles (a small "tag" glyph).
let r = 5.0_f32;
let inner = canvas::Path::rectangle(
Point::new(sp.x - r * 0.5, sp.y - r),
Size::new(r, r * 2.0),
);
let outer = canvas::Path::rectangle(
Point::new(sp.x - r, sp.y - r * 0.5),
Size::new(r * 2.0, r),
);
frame.stroke(&outer, stroke.clone());
frame.stroke(&inner, stroke);
}
SnapType::Perpendicular => {
// Right-angle hook in the lower-left quadrant.
let r = 6.0_f32;
let p = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y - r));
b.line_to(Point::new(sp.x - r, sp.y + r));
b.line_to(Point::new(sp.x + r, sp.y + r));
});
let foot = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y));
b.line_to(Point::new(sp.x, sp.y));
b.line_to(Point::new(sp.x, sp.y + r));
});
frame.stroke(&p, stroke.clone());
frame.stroke(&foot, stroke);
}
SnapType::Tangent => {
// Circle with a tangent bar across the top.
let r = 5.5_f32;
let c = canvas::Path::circle(sp, r);
frame.stroke(&c, stroke.clone());
let bar = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y - r));
});
frame.stroke(&bar, stroke);
}
SnapType::Nearest => {
// Bowtie / hourglass — two opposed triangles meeting at sp.
let r = 5.5_f32;
let path = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y - r));
b.line_to(Point::new(sp.x - r, sp.y + r));
b.line_to(Point::new(sp.x + r, sp.y + r));
b.close();
});
frame.stroke(&path, stroke);
}
SnapType::Extension => {
// Unit direction from the endpoint the snap extends from
// toward the snap point, so the guide line and the three
// dots follow the actual extension path (#238).
let dir = self.snap_ext_base.and_then(|base| {
let dx = sp.x - base.x;
let dy = sp.y - base.y;
let len = (dx * dx + dy * dy).sqrt();
(len > 1e-3).then(|| (base, Point::new(dx / len, dy / len)))
});
// Dashed guide line from that endpoint, through the snap
// point and a little beyond, so the extension path is
// visible as the cursor tracks along it.
if let Some((base, u)) = dir {
let dash = canvas::Stroke {
line_dash: canvas::LineDash {
segments: &[4.0, 4.0],
offset: 0,
},
..canvas::Stroke::default().with_color(marker).with_width(1.0)
};
let tip = Point::new(sp.x + u.x * 18.0, sp.y + u.y * 18.0);
frame.stroke(&canvas::Path::line(base, tip), dash);
}
// Three dots at the snap point, strung along the extension
// direction (horizontal fallback when the base is unknown).
let u = dir.map(|(_, u)| u).unwrap_or(Point::new(1.0, 0.0));
let r = 1.4_f32;
for k in [-7.0_f32, 0.0, 7.0] {
let dot =
canvas::Path::circle(Point::new(sp.x + u.x * k, sp.y + u.y * k), r);
frame.fill(&dot, marker);
}
}
SnapType::Parallel => {
// Two short parallel diagonal bars.
let r = 6.0_f32;
let off = 3.0_f32;
let b1 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r - off, sp.y + r));
b.line_to(Point::new(sp.x + r - off, sp.y - r));
});
let b2 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r + off, sp.y + r));
b.line_to(Point::new(sp.x + r + off, sp.y - r));
});
frame.stroke(&b1, stroke.clone());
frame.stroke(&b2, stroke);
}
SnapType::Grid => {
let arm = 4.0_f32;
let h = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - arm, sp.y));
b.line_to(Point::new(sp.x + arm, sp.y));
});
let v = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x, sp.y - arm));
b.line_to(Point::new(sp.x, sp.y + arm));
});
frame.stroke(&h, stroke.clone());
frame.stroke(&v, stroke);
}
}
}
// ── CAD crosshair cursor ──────────────────────────────────────────────
let over_viewcube = self.show_viewcube && {
use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX};
cursor.position_in(bounds).map_or(false, |pos| {
let vc_x = bounds.width - VIEWCUBE_REGION_PX - VIEWCUBE_PAD;
let vc_y = VIEWCUBE_PAD;
pos.x >= vc_x
&& pos.x <= vc_x + VIEWCUBE_REGION_PX
&& pos.y >= vc_y
&& pos.y <= vc_y + VIEWCUBE_REGION_PX
})
};
// Over a Model-tile divider the OS cursor switches to a resize
// arrow (see `mouse_interaction`); drawing the CAD crosshair on
// top of it would double up the visual feedback.
let over_divider = self.divider_under(cursor, bounds);
// PAN mode replaces the crosshair with a hand cursor.
if !over_viewcube && !over_divider && !self.pan_mode && !self.suppressed {
if let Some(cp) = self.selection.last_move_pos {
let color = Color {
r: 0.85,
g: 0.85,
b: 0.85,
a: 0.90,
};
let stroke = canvas::Stroke {
width: 1.0,
style: canvas::Style::Solid(color),
..Default::default()
};
let sq = CROSSHAIR_SQ; // square half-size → 15×15
let arm = CROSSHAIR_ARM; // crosshair arm length from center
// Horizontal arms (start at square edge, end at arm length)
let h_left = canvas::Path::new(|b| {
b.move_to(Point::new(cp.x - sq, cp.y));
b.line_to(Point::new(cp.x - arm, cp.y));
});
let h_right = canvas::Path::new(|b| {
b.move_to(Point::new(cp.x + sq, cp.y));
b.line_to(Point::new(cp.x + arm, cp.y));
});
// Vertical arms
let v_top = canvas::Path::new(|b| {
b.move_to(Point::new(cp.x, cp.y - sq));
b.line_to(Point::new(cp.x, cp.y - arm));
});
let v_bot = canvas::Path::new(|b| {
b.move_to(Point::new(cp.x, cp.y + sq));
b.line_to(Point::new(cp.x, cp.y + arm));
});
// Center square
let square = canvas::Path::rectangle(
Point::new(cp.x - sq, cp.y - sq),
Size::new(sq * 2.0, sq * 2.0),
);
frame.stroke(&h_left, stroke.clone());
frame.stroke(&h_right, stroke.clone());
frame.stroke(&v_top, stroke.clone());
frame.stroke(&v_bot, stroke.clone());
frame.stroke(&square, stroke);
// Locked-layer badge: a small padlock beside the crosshair when
// the hovered object sits on a locked layer (issue: locked
// objects are visible + snappable but not selectable/editable).
if self.hover_locked {
let amber = Color { r: 0.96, g: 0.76, b: 0.26, a: 0.98 };
let dark = Color { r: 0.12, g: 0.10, b: 0.04, a: 1.0 };
let bx = cp.x + sq + 7.0;
let by = cp.y - sq - 13.0;
// Lock body (filled).
let body = canvas::Path::rectangle(
Point::new(bx, by + 6.0),
Size::new(12.0, 9.0),
);
frame.fill(&body, amber);
// Shackle: an inverted-U above the body (squared so the
// shape is unambiguous regardless of arc winding).
let shackle = canvas::Path::new(|b| {
b.move_to(Point::new(bx + 2.5, by + 6.0));
b.line_to(Point::new(bx + 2.5, by + 2.5));
b.line_to(Point::new(bx + 9.5, by + 2.5));
b.line_to(Point::new(bx + 9.5, by + 6.0));
});
frame.stroke(
&shackle,
canvas::Stroke {
width: 1.8,
style: canvas::Style::Solid(amber),
line_join: canvas::LineJoin::Round,
..Default::default()
},
);
// Keyhole.
let hole = canvas::Path::circle(Point::new(bx + 6.0, by + 10.5), 1.4);
frame.fill(&hole, dark);
}
}
} // end !over_viewcube
// ── UCS icon (one per Model pane) ─────────────────────────────────
for ucs in &self.ucs_icons {
draw_ucs_icon(
&mut frame,
ucs.view_proj,
ucs.bounds,
ucs.axes,
ucs.origin_screen,
ucs.hover,
ucs.selected,
);
}
// ── Object Snap Tracking ─────────────────────────────────────────────
let track_color = Color {
r: 0.15,
g: 0.85,
b: 0.95,
a: 0.7,
};
// The alignment line the cursor is currently locked to — drawn at its
// real angle from the acquired point through the lock and a little
// beyond, dashed so it reads as a construction guide. This covers the
// ortho (0°/90°), polar, and edge-extension cases uniformly (#219).
if let Some((base, tip)) = self.otrack_line {
let dx = tip.x - base.x;
let dy = tip.y - base.y;
let len = (dx * dx + dy * dy).sqrt();
if len > 1e-3 {
// Extend the alignment path well past both ends along its
// direction (the canvas clips it to the viewport) so it reads
// as a full construction line through the acquired point, not a
// stub between the corner and the cursor. (#219)
let (ux, uy) = (dx / len, dy / len);
const L: f32 = 5000.0;
let p0 = Point::new(base.x - ux * L, base.y - uy * L);
let p1 = Point::new(tip.x + ux * L, tip.y + uy * L);
let dash = canvas::Stroke {
line_dash: canvas::LineDash {
segments: &[6.0, 4.0],
offset: 0,
},
..canvas::Stroke::default()
.with_color(track_color)
.with_width(1.0)
};
frame.stroke(&canvas::Path::line(p0, p1), dash);
}
}
// Small cross at each acquired tracking point.
for ost in &self.ost_points {
let tp = ost.screen;
let stroke = canvas::Stroke::default()
.with_color(track_color)
.with_width(1.0);
let sz = 5.0_f32;
let h = canvas::Path::line(
Point {
x: tp.x - sz,
y: tp.y,
},
Point {
x: tp.x + sz,
y: tp.y,
},
);
let v = canvas::Path::line(
Point {
x: tp.x,
y: tp.y - sz,
},
Point {
x: tp.x,
y: tp.y + sz,
},
);
frame.stroke(&h, stroke.clone());
frame.stroke(&v, stroke);
}
vec![frame.into_geometry()]
}
}
// ── Grid line drawing ─────────────────────────────────────────────────────
/// Minimum pixel gap between adjacent grid lines before stepping up to next spacing.
const MIN_GRID_PX: f32 = 20.0;
fn draw_grid(
frame: &mut canvas::Frame,
view_rot: Mat4,
eye: glam::DVec3,
plane: GridPlane,
bounds: iced::Rectangle,
grid_origin: glam::DVec3,
grid_axes: (Vec3, Vec3, Vec3),
) {
// World → canvas screen via relative-to-eye: subtract the f64 eye first so
// grid points near the camera stay precise at UTM-scale absolute coords.
let w2s = |world: glam::DVec3| -> Point {
let rel = (world - eye).as_vec3();
let ndc = view_rot.project_point3(rel);
Point::new(
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
)
};
// Plane-tangent axes: axis1 and axis2 span the grid plane, taken from the
// active UCS basis so the grid aligns to the user's coordinate system.
let (gx, gy, gz) = grid_axes;
let (axis1, axis2) = match plane {
GridPlane::Xz => (gx, gz),
GridPlane::Xy => (gx, gy),
GridPlane::Yz => (gy, gz),
};
// Adaptive spacing: measure pixels per 1-unit step along each axis,
// then find the smallest power-of-5 multiple that gives ≥ MIN_GRID_PX.
let o = w2s(grid_origin);
let a1s = w2s(grid_origin + axis1.as_dvec3());
let a2s = w2s(grid_origin + axis2.as_dvec3());
let px1 = ((a1s.x - o.x).powi(2) + (a1s.y - o.y).powi(2)).sqrt();
let px2 = ((a2s.x - o.x).powi(2) + (a2s.y - o.y).powi(2)).sqrt();
let px_per_unit = px1.max(px2);
if px_per_unit < 1e-6 {
return;
}
let mut s = 1.0_f32;
while s * px_per_unit < MIN_GRID_PX {
s *= 5.0;
if s > 1e9 {
return;
}
}
// Visible world extent: unproject screen corners (mid-depth approximation)
// and project them onto the grid axes.
let inv = view_rot.inverse();
let unproject = |sx: f32, sy: f32| -> glam::DVec3 {
let ndc_x = (sx / bounds.width) * 2.0 - 1.0;
let ndc_y = 1.0 - (sy / bounds.height) * 2.0;
// Unproject in eye-relative space, then add the f64 eye back.
eye + inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.5)).as_dvec3()
};
let corners = [
unproject(0.0, 0.0),
unproject(bounds.width, 0.0),
unproject(0.0, bounds.height),
unproject(bounds.width, bounds.height),
];
let range = |ax: Vec3| -> (f32, f32) {
let vals: Vec<f32> = corners
.iter()
.map(|p| (*p - grid_origin).as_vec3().dot(ax))
.collect();
(
vals.iter().cloned().fold(f32::INFINITY, f32::min),
vals.iter().cloned().fold(f32::NEG_INFINITY, f32::max),
)
};
let (min1, max1) = range(axis1);
let (min2, max2) = range(axis2);
let n1_s = (min1 / s).floor() as i32 - 1;
let n1_e = (max1 / s).ceil() as i32 + 1;
let n2_s = (min2 / s).floor() as i32 - 1;
let n2_e = (max2 / s).ceil() as i32 + 1;
if (n1_e - n1_s) > 500 || (n2_e - n2_s) > 500 {
return;
}
let gc = Color {
r: 0.28,
g: 0.28,
b: 0.28,
a: 0.7,
};
let st = canvas::Stroke {
width: 0.5,
style: canvas::Style::Solid(gc),
..Default::default()
};
// Lines parallel to axis2 (varying axis1 position)
for i in n1_s..=n1_e {
let v = i as f32 * s;
let p0 = w2s(grid_origin + (axis1 * v + axis2 * (min2 - s)).as_dvec3());
let p1 = w2s(grid_origin + (axis1 * v + axis2 * (max2 + s)).as_dvec3());
frame.stroke(
&canvas::Path::new(|b| {
b.move_to(p0);
b.line_to(p1);
}),
st.clone(),
);
}
// Lines parallel to axis1 (varying axis2 position)
for i in n2_s..=n2_e {
let v = i as f32 * s;
let p0 = w2s(grid_origin + (axis2 * v + axis1 * (min1 - s)).as_dvec3());
let p1 = w2s(grid_origin + (axis2 * v + axis1 * (max1 + s)).as_dvec3());
frame.stroke(
&canvas::Path::new(|b| {
b.move_to(p0);
b.line_to(p1);
}),
st.clone(),
);
}
// Coloured axes drawn on top of the grid lines, along the same UCS basis.
let extent = (min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs()) + s) * 1.5;
draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, grid_axes);
}
// ── Coloured UCS axes ──────────────────────────────────────────────────────
fn draw_axes(
frame: &mut canvas::Frame,
view_rot: Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
extent: f32,
origin: glam::DVec3,
axes: (Vec3, Vec3, Vec3),
) {
let w2s = |world: glam::DVec3| -> Point {
let ndc = view_rot.project_point3((world - eye).as_vec3());
Point::new(
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
)
};
let e = extent;
let (ax, ay, az) = axes;
let axis_stroke = |r: f32, g: f32, b: f32| canvas::Stroke {
width: 1.5,
style: canvas::Style::Solid(Color { r, g, b, a: 0.85 }),
..Default::default()
};
// Axes run through the UCS origin along the UCS axis directions.
let mut line = |dir: Vec3, r: f32, g: f32, b: f32| {
frame.stroke(
&canvas::Path::new(|p| {
p.move_to(w2s(origin - (dir * e).as_dvec3()));
p.line_to(w2s(origin + (dir * e).as_dvec3()));
}),
axis_stroke(r, g, b),
);
};
line(ax, 0.90, 0.20, 0.20); // X — red
line(ay, 0.20, 0.85, 0.20); // Y — green
line(az, 0.20, 0.40, 0.90); // Z — blue
}
// ── UCS icon ──────────────────────────────────────────────────────────────
//
// 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. 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;
const UCS_ICON_LEN: f32 = 38.0; // longest axis arm in screen pixels
const UCS_ICON_TIP: f32 = 7.0; // arrowhead size in pixels
const UCS_GRIP_BOX: f32 = 7.0; // selected-grip square size in pixels
/// One projected UCS axis arm: scaled screen delta from the anchor plus its
/// depth (for back-to-front draw order).
struct IconAxis {
dx: f32,
dy: f32,
sc_len: f32,
depth: f32,
}
/// Screen positions of the UCS icon grips, present only when the tripod is
/// anchored at the on-screen UCS origin (i.e. draggable). `tips` is X, Y, Z.
pub struct UcsIconHit {
pub origin: Point,
pub tips: [Point; 3],
}
/// Shared icon geometry used by both the renderer and the grip hit-test, so the
/// two never drift. Projects the UCS axis directions to screen, picks the
/// anchor (on-screen origin when available, else the corner) and returns each
/// axis's scaled screen delta. The bool is `at_origin` — true when anchored at
/// the projected origin, which is the only state where grips are live.
fn ucs_icon_geometry(
vp: Mat4,
bounds: iced::Rectangle,
axes: (Vec3, Vec3, Vec3),
origin_screen: Option<Point>,
) -> Option<(Point, bool, [IconAxis; 3])> {
if bounds.width < 10.0 || bounds.height < 10.0 {
return None;
}
// 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() || !ndc.z.is_finite() {
return None;
}
Some(ndc)
};
let ndc2s = |ndc: Vec3| -> Point {
Point::new(
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
)
};
// 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 org = w2ndc(Vec3::ZERO)?;
let xn = w2ndc(ax)?;
let yn = w2ndc(ay)?;
let zn = w2ndc(az)?;
let org_s = ndc2s(org);
let corner = Point::new(
bounds.x + UCS_ICON_MARGIN,
bounds.y + (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN),
);
// Snap the tripod to the projected UCS origin when it is on-screen
// (UCSICON ORigin); otherwise keep it in the corner. `org_s` (the
// rotation-only origin) stays the reference for axis-tip directions —
// those are translation-invariant, so only the anchor point moves.
let (icon_origin, at_origin) = match origin_screen {
Some(p)
if p.x >= bounds.x
&& p.x <= bounds.x + bounds.width
&& p.y >= bounds.y
&& p.y <= bounds.y + bounds.height =>
{
(p, true)
}
_ => (corner, false),
};
// 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;
let mk = |dx: f32, dy: f32, len: f32, tip_z: f32| IconAxis {
dx: dx * sc,
dy: dy * sc,
sc_len: len * sc,
// depth > 0 → tip farther than origin (into screen); < 0 → toward viewer.
depth: tip_z - org.z,
};
Some((
icon_origin,
at_origin,
[
mk(xdx, xdy, xlen, xn.z),
mk(ydx, ydy, ylen, yn.z),
mk(zdx, zdy, zlen, zn.z),
],
))
}
/// Screen grip targets for the UCS icon, or `None` when it is pinned to the
/// corner (origin off-screen) and therefore not draggable.
pub fn ucs_icon_hit(
vp: Mat4,
bounds: iced::Rectangle,
axes: (Vec3, Vec3, Vec3),
origin_screen: Option<Point>,
) -> Option<UcsIconHit> {
// Grips are available wherever the icon is drawn — including parked in the
// corner (origin off-screen), where dragging the origin grip relocates it.
let (o, _at_origin, g) = ucs_icon_geometry(vp, bounds, axes, origin_screen)?;
Some(UcsIconHit {
origin: o,
tips: [
Point::new(o.x + g[0].dx, o.y + g[0].dy),
Point::new(o.x + g[1].dx, o.y + g[1].dy),
Point::new(o.x + g[2].dx, o.y + g[2].dy),
],
})
}
#[allow(clippy::too_many_arguments)]
fn draw_ucs_icon(
frame: &mut canvas::Frame,
vp: Mat4,
bounds: iced::Rectangle,
axes: (Vec3, Vec3, Vec3),
origin_screen: Option<Point>,
hover: bool,
selected: bool,
) {
let Some((icon_origin, at_origin, geom)) =
ucs_icon_geometry(vp, bounds, axes, origin_screen)
else {
return;
};
// The icon is interactive wherever it is drawn (including parked in the
// corner), so hover/selection highlight applies there too.
let _ = at_origin;
let highlight = hover || selected;
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: geom[0].dx,
dy: geom[0].dy,
sc_len: geom[0].sc_len,
depth: geom[0].depth,
r: 0.90,
g: 0.22,
b: 0.22,
label: "X",
},
AxisInfo {
dx: geom[1].dx,
dy: geom[1].dy,
sc_len: geom[1].sc_len,
depth: geom[1].depth,
r: 0.22,
g: 0.85,
b: 0.22,
label: "Y",
},
AxisInfo {
dx: geom[2].dx,
dy: geom[2].dy,
sc_len: geom[2].sc_len,
depth: geom[2].depth,
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 {
// On highlight, lerp the axis colour toward white and thicken the shaft
// so the whole tripod reads as "live".
let mix = if highlight { 0.45 } else { 0.0 };
let col = Color {
r: ax.r + (1.0 - ax.r) * mix,
g: ax.g + (1.0 - ax.g) * mix,
b: ax.b + (1.0 - ax.b) * mix,
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: if highlight { 3.0 } else { 2.0 },
style: canvas::Style::Solid(col),
line_cap: canvas::LineCap::Butt,
..Default::default()
},
);
}
// 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),
shaping: iced::advanced::text::Shaping::Advanced,
..Default::default()
});
}
}
// 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,
},
);
// Draggable grips when selected: a square at the origin and at the X / Y
// tips. Warm grip colour with a light border, like an entity grip.
if selected {
let x_tip = Point::new(icon_origin.x + geom[0].dx, icon_origin.y + geom[0].dy);
let y_tip = Point::new(icon_origin.x + geom[1].dx, icon_origin.y + geom[1].dy);
let fill = Color {
r: 0.20,
g: 0.85,
b: 0.95,
a: 1.0,
};
let border = Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 0.9,
};
for c in [icon_origin, x_tip, y_tip] {
let h = UCS_GRIP_BOX / 2.0;
let sq = canvas::Path::rectangle(
Point::new(c.x - h, c.y - h),
iced::Size::new(UCS_GRIP_BOX, UCS_GRIP_BOX),
);
frame.fill(&sq, fill);
frame.stroke(
&sq,
canvas::Stroke {
width: 1.0,
style: canvas::Style::Solid(border),
..Default::default()
},
);
}
}
}
// ── Dynamic Input overlay ─────────────────────────────────────────────────
use crate::command::{DynGuide, DynRole};
const DYN_OFFSET_X: f32 = 14.0;
const DYN_PAD: f32 = 4.0;
const DYN_GAP: f32 = 6.0;
const DYN_FONT: f32 = 11.0;
const DYN_CHAR_W: f32 = DYN_FONT * 0.62;
const DYN_BOX_H: f32 = DYN_FONT + DYN_PAD * 2.0;
/// One value box in the dynamic-input overlay. Its `role` drives both the
/// label and where the box is placed relative to the step's guide geometry.
#[derive(Clone)]
pub struct DynBox {
pub label: String,
pub value: String,
/// TAB-focused box — keystrokes edit this one.
pub active: bool,
/// User has typed a value (the box no longer tracks the cursor).
pub locked: bool,
pub role: DynRole,
}
pub fn dynamic_input_overlay<'a>(
cursor_screen: Point,
base_screen: Option<Point>,
ref_screen: Option<Point>,
guide: DynGuide,
boxes: Vec<DynBox>,
prompt: String,
) -> Element<'a, Message> {
canvas(DynInputCanvas {
cursor_screen,
base_screen,
ref_screen,
guide,
boxes,
prompt,
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
struct DynInputCanvas {
cursor_screen: Point,
/// Step anchor in viewport pixels (projected `dyn_anchor`). Guided layouts
/// (polar / radius / axis-delta) need it; `None` falls back to a cursor row.
base_screen: Option<Point>,
/// Far end of the reference line (projected `dyn_ref`) — for `Perp`.
ref_screen: Option<Point>,
guide: DynGuide,
boxes: Vec<DynBox>,
/// The active command's current prompt, drawn just above the boxes.
prompt: String,
}
impl DynInputCanvas {
fn dotted() -> canvas::Stroke<'static> {
canvas::Stroke {
width: 1.0,
style: canvas::Style::Solid(Color { r: 0.55, g: 0.55, b: 0.58, a: 0.9 }),
line_dash: canvas::LineDash { segments: &[2.0, 3.0], offset: 0 },
..Default::default()
}
}
fn box_content(b: &DynBox) -> String {
match b.role {
DynRole::Angle => format!("{}\u{00B0}", b.value),
_ if b.label.is_empty() => b.value.clone(),
_ => format!("{}{}", b.label, b.value),
}
}
/// Draw a value box centred at `center`, clamped inside `bounds`.
fn draw_box(frame: &mut canvas::Frame, b: &DynBox, center: Point, bounds: iced::Rectangle) {
let content = Self::box_content(b);
let w = (content.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0;
let x = (center.x - w * 0.5).clamp(0.0, (bounds.width - w).max(0.0));
let y = (center.y - DYN_BOX_H * 0.5).clamp(0.0, (bounds.height - DYN_BOX_H).max(0.0));
let rect = canvas::Path::rectangle(Point { x, y }, Size { width: w, height: DYN_BOX_H });
let (fill, border) = Self::box_colors(b);
frame.fill(&rect, fill);
frame.stroke(
&rect,
canvas::Stroke::default()
.with_color(border)
.with_width(if b.active { 1.6 } else { 1.0 }),
);
frame.fill_text(canvas::Text {
content,
position: Point { x: x + DYN_PAD, y: y + DYN_PAD },
color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 },
size: iced::Pixels(DYN_FONT),
// Force Advanced shaping: the default `Auto` uses Basic shaping for
// ASCII-only strings, which the web (wgpu/webgl) backend fails to
// render — so all-digit value boxes came up blank while the angle
// box (containing the non-ASCII `°`) rendered. (#117)
shaping: iced::advanced::text::Shaping::Advanced,
..Default::default()
});
}
fn box_colors(b: &DynBox) -> (Color, Color) {
if b.active {
(
Color { r: 0.12, g: 0.18, b: 0.30, a: 0.95 },
Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 },
)
} else if b.locked {
(
Color { r: 0.05, g: 0.05, b: 0.12, a: 0.9 },
Color { r: 0.95, g: 0.75, b: 0.30, a: 0.9 },
)
} else {
(
Color { r: 0.05, g: 0.05, b: 0.12, a: 0.9 },
Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 },
)
}
}
/// Prompt pill at `pos`.
fn draw_prompt(&self, frame: &mut canvas::Frame, pos: Point) {
if self.prompt.is_empty() {
return;
}
let pw = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0;
let rect = canvas::Path::rectangle(pos, Size { width: pw, height: DYN_BOX_H });
frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.12, a: 1.0 });
frame.stroke(
&rect,
canvas::Stroke::default()
.with_color(Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 })
.with_width(1.0),
);
frame.fill_text(canvas::Text {
content: self.prompt.clone(),
position: Point { x: pos.x + DYN_PAD, y: pos.y + DYN_PAD },
color: Color { r: 0.70, g: 0.85, b: 0.70, a: 1.0 },
size: iced::Pixels(DYN_FONT),
shaping: iced::advanced::text::Shaping::Advanced,
..Default::default()
});
}
/// Guided layout: draw the guide geometry anchored at `base`, then place
/// each box according to its role.
fn draw_guided(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle, base: Point) {
let cursor = self.cursor_screen;
let (vx, vy) = (cursor.x - base.x, cursor.y - base.y);
let len = (vx * vx + vy * vy).sqrt().max(1.0);
let (dx, dy) = (vx / len, vy / len);
// Perpendicular pointing to the lower half so labels sit under the line.
let (mut nx, mut ny) = (-dy, dx);
if ny < 0.0 {
nx = -nx;
ny = -ny;
}
// Polar arc reference direction: a supplied reference point (e.g. the
// ROTATE reference), else the +X axis. The arc sweeps the short way
// from that reference to the cursor.
let a_cur = dy.atan2(dx);
let a_ref = self
.ref_screen
.map(|r| (r.y - base.y).atan2(r.x - base.x))
.unwrap_or(0.0);
let mut sweep = a_cur - a_ref;
while sweep > std::f32::consts::PI {
sweep -= std::f32::consts::TAU;
}
while sweep <= -std::f32::consts::PI {
sweep += std::f32::consts::TAU;
}
let corner = Point { x: cursor.x, y: base.y }; // axis-delta elbow
// Perp / PerpDim: perpendicular direction to the reference line, the
// measured endpoint along it (`end`), and an offset dimension segment
// (`off_base`→`off_end`) drawn clear of the edge for PerpDim.
let perp_info = self.ref_screen.map(|r| {
let (ax, ay) = (r.x - base.x, r.y - base.y);
let al = (ax * ax + ay * ay).sqrt().max(1.0);
let (ux, uy) = (ax / al, ay / al); // axis unit (base → ref)
let (px, py) = (-uy, ux); // perpendicular unit
let signed = (cursor.x - base.x) * px + (cursor.y - base.y) * py;
let end = Point { x: base.x + px * signed, y: base.y + py * signed };
const OFF: f32 = 16.0; // dimension offset, away from the reference
let off_base = Point { x: base.x - ux * OFF, y: base.y - uy * OFF };
let off_end = Point { x: end.x - ux * OFF, y: end.y - uy * OFF };
(end, off_base, off_end)
});
// ── Guide geometry ──
match self.guide {
DynGuide::Polar => {
// Reference line along `a_ref` (the +X axis, or the supplied
// reference direction), then the arc from it to the cursor.
let href = canvas::Path::new(|p| {
p.move_to(base);
p.line_to(Point {
x: base.x + a_ref.cos() * len,
y: base.y + a_ref.sin() * len,
});
});
frame.stroke(&href, Self::dotted());
let arc = canvas::Path::new(|p| {
let steps = 48;
for k in 0..=steps {
let a = a_ref + sweep * (k as f32 / steps as f32);
let pt = Point {
x: base.x + a.cos() * len,
y: base.y + a.sin() * len,
};
if k == 0 {
p.move_to(pt);
} else {
p.line_to(pt);
}
}
});
frame.stroke(&arc, Self::dotted());
}
DynGuide::Radius => {
let line = canvas::Path::new(|p| {
p.move_to(base);
p.line_to(cursor);
});
frame.stroke(&line, Self::dotted());
}
DynGuide::Perp => {
if let Some((end, _, _)) = perp_info {
// The measured semi-axis: anchor → perpendicular endpoint.
let line = canvas::Path::new(|p| {
p.move_to(base);
p.line_to(end);
});
frame.stroke(&line, Self::dotted());
}
}
DynGuide::PerpDim => {
if let Some((end, ob, oe)) = perp_info {
// Dimension segment offset off the edge, with extension
// lines back to the two measured corners.
let dim = canvas::Path::new(|p| {
p.move_to(ob);
p.line_to(oe);
});
frame.stroke(&dim, Self::dotted());
let ext = canvas::Path::new(|p| {
p.move_to(base);
p.line_to(ob);
p.move_to(end);
p.line_to(oe);
});
frame.stroke(&ext, Self::dotted());
}
}
DynGuide::AxisDelta | DynGuide::RectSides => {
// Dotted legs from the anchor along its axes to the cursor.
let legs = canvas::Path::new(|p| {
p.move_to(base);
p.line_to(corner);
p.line_to(cursor);
});
frame.stroke(&legs, Self::dotted());
if self.guide == DynGuide::RectSides {
// Close the rectangle so both side pairs read as a box.
let rest = canvas::Path::new(|p| {
p.move_to(base);
p.line_to(Point { x: base.x, y: cursor.y });
p.line_to(cursor);
});
frame.stroke(&rest, Self::dotted());
}
}
DynGuide::None => {}
}
// ── Box placement by role ──
for b in &self.boxes {
let center = match b.role {
DynRole::Angle => {
let a_mid = a_ref + sweep * 0.5;
// Pull the box back along the ray and lift it to the side
// opposite the distance box. A near-zero sweep collapses the
// mid-angle direction onto the cursor ray, so placing the box
// at full `len` would plant it on the cursor / snap point and
// hide it. (#124)
let r = (len - DYN_BOX_H * 2.0).max(len * 0.5);
Point {
x: base.x + a_mid.cos() * r - nx * 18.0,
y: base.y + a_mid.sin() * r - ny * 18.0,
}
}
DynRole::X | DynRole::Width => Point {
x: (base.x + cursor.x) * 0.5,
y: base.y + 14.0,
},
DynRole::Y | DynRole::Height => Point {
x: corner.x + 18.0,
y: (base.y + cursor.y) * 0.5,
},
// Perpendicular measure: on the measured segment / dim line.
_ if matches!(self.guide, DynGuide::Perp | DynGuide::PerpDim)
&& perp_info.is_some() =>
{
let (end, ob, oe) = perp_info.unwrap();
if self.guide == DynGuide::PerpDim {
Point { x: (ob.x + oe.x) * 0.5 + 8.0, y: (ob.y + oe.y) * 0.5 }
} else {
Point { x: (base.x + end.x) * 0.5 + 8.0, y: (base.y + end.y) * 0.5 }
}
}
// Distance / Radius / Diameter and anything else ride the line.
_ => Point {
x: base.x + dx * len * 0.5 + nx * 16.0,
y: base.y + dy * len * 0.5 + ny * 16.0,
},
};
Self::draw_box(frame, b, center, bounds);
}
}
/// Fallback row layout near the cursor (no anchor / `None` guide).
fn draw_row(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle) {
let texts: Vec<String> = self
.boxes
.iter()
.map(|b| {
if b.label.is_empty() {
b.value.clone()
} else {
format!("{}:{}", b.label, b.value)
}
})
.collect();
let widths: Vec<f32> = texts
.iter()
.map(|t| (t.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0)
.collect();
let total_w: f32 =
widths.iter().sum::<f32>() + DYN_GAP * (self.boxes.len() as f32 - 1.0);
// Offset the block off the crosshair by the same gap horizontally and
// vertically; the prompt sits a gap below the horizontal axis and the
// value boxes a further gap below the prompt.
let pad = DYN_OFFSET_X;
let has_prompt = !self.prompt.is_empty();
let prompt_w = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0;
let block_w = total_w.max(if has_prompt { prompt_w } else { 0.0 });
let mut bx = self.cursor_screen.x + pad;
let mut py = self.cursor_screen.y + pad;
let mut by = if has_prompt { py + DYN_BOX_H + pad } else { py };
if bx + block_w > bounds.width {
bx = (self.cursor_screen.x - block_w - 4.0).max(0.0);
}
if by + DYN_BOX_H > bounds.height {
// Flip the block above the cursor, keeping the same gaps.
by = (self.cursor_screen.y - pad - DYN_BOX_H).max(0.0);
py = (by - pad - DYN_BOX_H).max(0.0);
}
if has_prompt {
self.draw_prompt(frame, Point { x: bx, y: py });
}
let mut x = bx;
for (i, b) in self.boxes.iter().enumerate() {
let w = widths[i];
let rect =
canvas::Path::rectangle(Point { x, y: by }, Size { width: w, height: DYN_BOX_H });
let (fill, border) = Self::box_colors(b);
frame.fill(&rect, fill);
frame.stroke(
&rect,
canvas::Stroke::default()
.with_color(border)
.with_width(if b.active { 1.6 } else { 1.0 }),
);
frame.fill_text(canvas::Text {
content: texts[i].clone(),
position: Point { x: x + DYN_PAD, y: by + DYN_PAD },
color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 },
size: iced::Pixels(DYN_FONT),
shaping: iced::advanced::text::Shaping::Advanced,
..Default::default()
});
x += w + DYN_GAP;
}
}
}
impl canvas::Program<Message> for DynInputCanvas {
type State = ();
fn mouse_interaction(
&self,
_state: &(),
_bounds: iced::Rectangle,
_cursor: mouse::Cursor,
) -> mouse::Interaction {
mouse::Interaction::None
}
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
_theme: &Theme,
bounds: iced::Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
// No boxes — just the prompt pill near the cursor.
if self.boxes.is_empty() {
if !self.prompt.is_empty() {
let pw = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0;
let mut px = self.cursor_screen.x + DYN_OFFSET_X;
let mut py = self.cursor_screen.y + DYN_OFFSET_X;
if px + pw > bounds.width {
px = (self.cursor_screen.x - pw - 4.0).max(0.0);
}
if py + DYN_BOX_H > bounds.height {
py = (self.cursor_screen.y - DYN_BOX_H - 4.0).max(0.0);
}
self.draw_prompt(&mut frame, Point { x: px, y: py });
}
return vec![frame.into_geometry()];
}
// Guided layouts need the anchor; without it fall back to a cursor row.
match (self.guide, self.base_screen) {
(DynGuide::None, _) | (_, None) => self.draw_row(&mut frame, bounds),
(_, Some(base)) => self.draw_guided(&mut frame, bounds, base),
}
vec![frame.into_geometry()]
}
}