2026-05-27 17:49:23 +03:00
|
|
|
|
//! Viewport overlay widgets.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
|
|
use glam::{Mat4, Vec3};
|
|
|
|
|
|
use iced::mouse;
|
2026-05-27 17:49:23 +03:00
|
|
|
|
use iced::widget::canvas;
|
|
|
|
|
|
use iced::{Color, Element, Length, Point, Size, Theme};
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
2026-05-12 10:36:15 +03:00
|
|
|
|
use crate::app::Message;
|
2026-06-16 11:33:46 +03:00
|
|
|
|
use crate::scene::model::object::GripShape;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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,
|
refactor(grip): square-only marker set + oriented stretch handles
Phase 1 of bringing the grip vocabulary in line with the standard
CAD convention: drop the per-position-type shape encoding (Diamond
for translate, Circle for parameter) and use a plain square for
every endpoint / vertex / centre grip. The behavioural midpoint vs
vertex distinction stays — it lives on `GripDef::is_midpoint` and
drives `Translate` vs `Absolute` in the grip-edit dispatcher — only
the marker shape collapses.
- `GripShape` keeps `Square`, `Rectangle`, `Triangle`. Diamond /
Circle are gone.
- `GripDef` gains `dir: Option<[f32; 2]>` — a world-XY direction
vector consumed by `Rectangle` to orient the box along its
segment.
- `entities::common` exposes `square_grip`, `center_grip` (same
square marker, flagged as translate), `rectangle_grip(id, world,
dir)` for oriented mid-segment handles, and a kept-for-Phase-2
`triangle_grip`.
- `overlay::GripMarker` / `grips_to_screen[_paper]` thread `dir`
through to the canvas painter; the Rectangle path rotates the
box around the grip centre using the world-XY direction (flipping
the sin for screen-Y).
Every existing `diamond_grip(id, w)` call becomes `center_grip(id,
w)` — the visual change is square-replacing-diamond at line
midpoints / arc midpoints / circle centres / ellipse centres /
viewport / hatch loop centroids / dimension grips / image corners /
multileader / ray / underlay / ole2frame / solid3d.
`LwPolyline` grows a stretch handle on every segment (straight or
arc), drawn as the new oriented rectangle. The grip-edit path
translates both segment endpoints for straight segments and
adjusts the arc bulge from the new midpoint for arc segments.
2026-05-29 02:07:34 +03:00
|
|
|
|
/// 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]>,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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 {
|
2026-06-23 22:57:31 +03:00
|
|
|
|
/// 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,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub bounds: iced::Rectangle,
|
|
|
|
|
|
pub plane: GridPlane,
|
2026-06-23 22:57:31 +03:00
|
|
|
|
/// Grid origin in absolute world f64 and the active UCS axis directions.
|
2026-06-22 10:03:35 +03:00
|
|
|
|
/// 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)`.
|
2026-06-23 22:57:31 +03:00
|
|
|
|
pub origin: glam::DVec3,
|
2026-06-22 10:03:35 +03:00
|
|
|
|
pub axes: (Vec3, Vec3, Vec3),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 19:30:25 +03:00
|
|
|
|
/// 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.
|
2026-06-23 23:33:21 +03:00
|
|
|
|
pub fn compute_grid_step(view_rot: Mat4, bounds: iced::Rectangle) -> f32 {
|
2026-04-07 19:30:25 +03:00
|
|
|
|
use glam::Vec3;
|
2026-06-23 23:33:21 +03:00
|
|
|
|
// 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).
|
2026-04-07 19:30:25 +03:00
|
|
|
|
let w2s = |world: Vec3| {
|
2026-06-23 23:33:21 +03:00
|
|
|
|
let ndc = view_rot.project_point3(world);
|
2026-04-07 19:30:25 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 19:25:55 +03:00
|
|
|
|
/// 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,
|
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>
2026-06-22 09:57:39 +03:00
|
|
|
|
/// 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),
|
2026-06-27 01:12:45 +03:00
|
|
|
|
/// 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,
|
2026-04-03 19:25:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
// ── Selection overlay ───────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-08 00:26:54 +03:00
|
|
|
|
/// An acquired OST tracking point with its screen position.
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
|
pub struct OstTrackPoint {
|
|
|
|
|
|
pub screen: Point,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 18:17:01 +01:00
|
|
|
|
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()]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub fn selection_overlay<'a>(
|
|
|
|
|
|
selection: SelectionState,
|
|
|
|
|
|
snap: Option<(Point, SnapType)>,
|
|
|
|
|
|
grips: Vec<GripMarker>,
|
2026-06-27 11:50:11 +03:00
|
|
|
|
ucs_icons: Vec<UcsIconParams>,
|
2026-04-08 00:26:54 +03:00
|
|
|
|
ost_points: Vec<OstTrackPoint>,
|
|
|
|
|
|
cursor_screen: Point,
|
2026-04-23 19:11:19 +03:00
|
|
|
|
show_viewcube: bool,
|
2026-06-27 11:50:11 +03:00
|
|
|
|
dividers: Vec<iced::Rectangle>,
|
|
|
|
|
|
pane_move_rect: Option<iced::Rectangle>,
|
|
|
|
|
|
pane_drop_rect: Option<iced::Rectangle>,
|
2026-06-25 10:03:54 +03:00
|
|
|
|
pan_mode: bool,
|
2026-07-01 00:45:23 +03:00
|
|
|
|
suppressed: bool,
|
2026-07-01 13:10:39 +03:00
|
|
|
|
hover_locked: bool,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
) -> Element<'a, Message> {
|
|
|
|
|
|
canvas(SelectionCanvas {
|
|
|
|
|
|
selection,
|
|
|
|
|
|
snap,
|
|
|
|
|
|
grips,
|
2026-06-27 11:50:11 +03:00
|
|
|
|
ucs_icons,
|
2026-04-08 00:26:54 +03:00
|
|
|
|
ost_points,
|
|
|
|
|
|
cursor_screen,
|
2026-04-23 19:11:19 +03:00
|
|
|
|
show_viewcube,
|
2026-06-27 11:50:11 +03:00
|
|
|
|
dividers,
|
|
|
|
|
|
pane_move_rect,
|
|
|
|
|
|
pane_drop_rect,
|
2026-06-25 10:03:54 +03:00
|
|
|
|
pan_mode,
|
2026-07-01 00:45:23 +03:00
|
|
|
|
suppressed,
|
2026-07-01 13:10:39 +03:00
|
|
|
|
hover_locked,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
})
|
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
|
.into()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct SelectionCanvas {
|
|
|
|
|
|
selection: SelectionState,
|
|
|
|
|
|
snap: Option<(Point, SnapType)>,
|
|
|
|
|
|
grips: Vec<GripMarker>,
|
2026-06-27 11:50:11 +03:00
|
|
|
|
/// 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>,
|
2026-04-08 00:26:54 +03:00
|
|
|
|
ost_points: Vec<OstTrackPoint>,
|
|
|
|
|
|
cursor_screen: Point,
|
2026-04-23 19:11:19 +03:00
|
|
|
|
show_viewcube: bool,
|
2026-06-27 11:50:11 +03:00
|
|
|
|
/// 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>,
|
2026-06-25 10:03:54 +03:00
|
|
|
|
/// Interactive PAN mode: the crosshair is hidden and the cursor becomes a
|
|
|
|
|
|
/// hand so the viewport reads as a draggable surface.
|
|
|
|
|
|
pan_mode: bool,
|
2026-07-01 00:45:23 +03:00
|
|
|
|
/// 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,
|
2026-07-01 13:10:39 +03:00
|
|
|
|
/// 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,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 19:52:34 +03:00
|
|
|
|
impl SelectionCanvas {
|
2026-06-27 11:50:11 +03:00
|
|
|
|
/// 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
|
|
|
|
|
|
})
|
2026-05-28 19:52:34 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
impl canvas::Program<Message> for SelectionCanvas {
|
|
|
|
|
|
type State = ();
|
|
|
|
|
|
|
|
|
|
|
|
fn mouse_interaction(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
_state: &(),
|
|
|
|
|
|
bounds: iced::Rectangle,
|
|
|
|
|
|
cursor: mouse::Cursor,
|
|
|
|
|
|
) -> mouse::Interaction {
|
2026-07-01 00:45:23 +03:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-06-25 10:03:54 +03:00
|
|
|
|
// 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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-23 19:11:19 +03:00
|
|
|
|
if self.show_viewcube {
|
|
|
|
|
|
if let Some(pos) = cursor.position_in(bounds) {
|
2026-06-25 23:46:43 +03:00
|
|
|
|
use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX};
|
|
|
|
|
|
let vc_x = bounds.width - VIEWCUBE_REGION_PX - VIEWCUBE_PAD;
|
2026-04-23 19:11:19 +03:00
|
|
|
|
let vc_y = VIEWCUBE_PAD;
|
2026-05-12 10:36:15 +03:00
|
|
|
|
if pos.x >= vc_x
|
2026-06-25 23:46:43 +03:00
|
|
|
|
&& pos.x <= vc_x + VIEWCUBE_REGION_PX
|
2026-05-12 10:36:15 +03:00
|
|
|
|
&& pos.y >= vc_y
|
2026-06-25 23:46:43 +03:00
|
|
|
|
&& pos.y <= vc_y + VIEWCUBE_REGION_PX
|
2026-04-23 19:11:19 +03:00
|
|
|
|
{
|
|
|
|
|
|
return mouse::Interaction::None;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-27 11:50:11 +03:00
|
|
|
|
// 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`).
|
2026-05-28 19:52:34 +03:00
|
|
|
|
// 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.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if cursor.is_over(bounds) {
|
2026-05-28 19:52:34 +03:00
|
|
|
|
mouse::Interaction::Hidden
|
2026-03-22 14:56:27 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
mouse::Interaction::default()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn draw(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
_state: &(),
|
|
|
|
|
|
renderer: &iced::Renderer,
|
|
|
|
|
|
_theme: &Theme,
|
|
|
|
|
|
bounds: iced::Rectangle,
|
2026-04-23 19:11:19 +03:00
|
|
|
|
cursor: mouse::Cursor,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
) -> Vec<canvas::Geometry> {
|
|
|
|
|
|
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
|
|
|
|
|
|
2026-06-27 11:50:11 +03:00
|
|
|
|
// ── 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() {
|
2026-05-28 18:42:39 +03:00
|
|
|
|
const DIVIDER: Color = Color {
|
2026-06-27 11:50:11 +03:00
|
|
|
|
r: 0.46,
|
|
|
|
|
|
g: 0.52,
|
|
|
|
|
|
b: 0.62,
|
|
|
|
|
|
a: 1.0,
|
2026-05-28 18:42:39 +03:00
|
|
|
|
};
|
2026-06-27 11:50:11 +03:00
|
|
|
|
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 });
|
2026-05-28 18:42:39 +03:00
|
|
|
|
frame.stroke(
|
2026-06-27 11:50:11 +03:00
|
|
|
|
&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,
|
2026-05-28 18:42:39 +03:00
|
|
|
|
canvas::Stroke {
|
|
|
|
|
|
width: 2.0,
|
2026-06-27 11:50:11 +03:00
|
|
|
|
style: canvas::Style::Solid(Color { a: 0.95, ..accent }),
|
2026-05-28 18:42:39 +03:00
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-16 11:33:46 +03:00
|
|
|
|
let h = crate::scene::pick::grip::GRIP_HALF_PX;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
2026-05-29 01:46:33 +03:00
|
|
|
|
GripShape::Rectangle => {
|
refactor(grip): square-only marker set + oriented stretch handles
Phase 1 of bringing the grip vocabulary in line with the standard
CAD convention: drop the per-position-type shape encoding (Diamond
for translate, Circle for parameter) and use a plain square for
every endpoint / vertex / centre grip. The behavioural midpoint vs
vertex distinction stays — it lives on `GripDef::is_midpoint` and
drives `Translate` vs `Absolute` in the grip-edit dispatcher — only
the marker shape collapses.
- `GripShape` keeps `Square`, `Rectangle`, `Triangle`. Diamond /
Circle are gone.
- `GripDef` gains `dir: Option<[f32; 2]>` — a world-XY direction
vector consumed by `Rectangle` to orient the box along its
segment.
- `entities::common` exposes `square_grip`, `center_grip` (same
square marker, flagged as translate), `rectangle_grip(id, world,
dir)` for oriented mid-segment handles, and a kept-for-Phase-2
`triangle_grip`.
- `overlay::GripMarker` / `grips_to_screen[_paper]` thread `dir`
through to the canvas painter; the Rectangle path rotates the
box around the grip centre using the world-XY direction (flipping
the sin for screen-Y).
Every existing `diamond_grip(id, w)` call becomes `center_grip(id,
w)` — the visual change is square-replacing-diamond at line
midpoints / arc midpoints / circle centres / ellipse centres /
viewport / hatch loop centroids / dimension grips / image corners /
multileader / ray / underlay / ole2frame / solid3d.
`LwPolyline` grows a stretch handle on every segment (straight or
arc), drawn as the new oriented rectangle. The grip-edit path
translates both segment endpoints for straight segments and
adjusts the arc bulge from the new midpoint for arc segments.
2026-05-29 02:07:34 +03:00
|
|
|
|
// 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();
|
|
|
|
|
|
})
|
2026-05-29 01:46:33 +03:00
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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 {
|
2026-06-09 13:41:21 -04:00
|
|
|
|
let (r, g, b) = if snap_type == SnapType::ObjectPick {
|
2026-06-18 21:28:07 +03:00
|
|
|
|
(0.95_f32, 0.50, 0.08) // orange object-snap marker
|
2026-06-09 13:41:21 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
(1.0, 0.9, 0.1) // classic yellow OSNAP
|
2026-03-22 14:56:27 +03:00
|
|
|
|
};
|
2026-06-09 13:41:21 -04:00
|
|
|
|
let marker = Color { r, g, b, a: 1.0 };
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let stroke = canvas::Stroke {
|
2026-06-09 13:41:21 -04:00
|
|
|
|
width: if snap_type == SnapType::ObjectPick { 2.0 } else { 1.5 },
|
|
|
|
|
|
style: canvas::Style::Solid(marker),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
..Default::default()
|
|
|
|
|
|
};
|
|
|
|
|
|
match snap_type {
|
2026-06-09 13:41:21 -04:00
|
|
|
|
SnapType::ObjectPick => {
|
2026-06-18 21:28:07 +03:00
|
|
|
|
// Target box + center dot (object-acquisition glyph).
|
2026-06-09 13:41:21 -04:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
SnapType::Endpoint => {
|
2026-05-29 01:39:02 +03:00
|
|
|
|
let h = 5.0_f32;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let rect = canvas::Path::rectangle(
|
2026-05-29 01:39:02 +03:00
|
|
|
|
Point::new(sp.x - h, sp.y - h),
|
|
|
|
|
|
Size::new(h * 2.0, h * 2.0),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
);
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-29 01:39:02 +03:00
|
|
|
|
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 => {
|
|
|
|
|
|
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 => {
|
|
|
|
|
|
// Three dots strung along a tracked direction.
|
|
|
|
|
|
let r = 1.4_f32;
|
|
|
|
|
|
for k in [-7.0_f32, 0.0, 7.0] {
|
|
|
|
|
|
let dot = canvas::Path::circle(Point::new(sp.x + k, sp.y), r);
|
2026-06-09 13:41:21 -04:00
|
|
|
|
frame.fill(&dot, marker);
|
2026-05-29 01:39:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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 ──────────────────────────────────────────────
|
2026-04-23 19:11:19 +03:00
|
|
|
|
let over_viewcube = self.show_viewcube && {
|
2026-06-25 23:46:43 +03:00
|
|
|
|
use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX};
|
2026-04-23 19:11:19 +03:00
|
|
|
|
cursor.position_in(bounds).map_or(false, |pos| {
|
2026-06-25 23:46:43 +03:00
|
|
|
|
let vc_x = bounds.width - VIEWCUBE_REGION_PX - VIEWCUBE_PAD;
|
2026-04-23 19:11:19 +03:00
|
|
|
|
let vc_y = VIEWCUBE_PAD;
|
2026-05-12 10:36:15 +03:00
|
|
|
|
pos.x >= vc_x
|
2026-06-25 23:46:43 +03:00
|
|
|
|
&& pos.x <= vc_x + VIEWCUBE_REGION_PX
|
2026-05-12 10:36:15 +03:00
|
|
|
|
&& pos.y >= vc_y
|
2026-06-25 23:46:43 +03:00
|
|
|
|
&& pos.y <= vc_y + VIEWCUBE_REGION_PX
|
2026-04-23 19:11:19 +03:00
|
|
|
|
})
|
|
|
|
|
|
};
|
2026-05-28 19:52:34 +03:00
|
|
|
|
// 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.
|
2026-06-27 11:50:11 +03:00
|
|
|
|
let over_divider = self.divider_under(cursor, bounds);
|
2026-06-25 10:03:54 +03:00
|
|
|
|
// PAN mode replaces the crosshair with a hand cursor.
|
2026-07-01 00:45:23 +03:00
|
|
|
|
if !over_viewcube && !over_divider && !self.pan_mode && !self.suppressed {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
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
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
2026-05-12 10:36:15 +03:00
|
|
|
|
// 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),
|
|
|
|
|
|
);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
2026-05-12 10:36:15 +03:00
|
|
|
|
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);
|
2026-07-01 13:10:39 +03:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
|
}
|
2026-04-23 19:11:19 +03:00
|
|
|
|
} // end !over_viewcube
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
2026-06-27 11:50:11 +03:00
|
|
|
|
// ── UCS icon (one per Model pane) ─────────────────────────────────
|
|
|
|
|
|
for ucs in &self.ucs_icons {
|
2026-06-27 01:12:45 +03:00
|
|
|
|
draw_ucs_icon(
|
|
|
|
|
|
&mut frame,
|
|
|
|
|
|
ucs.view_proj,
|
|
|
|
|
|
ucs.bounds,
|
|
|
|
|
|
ucs.axes,
|
|
|
|
|
|
ucs.origin_screen,
|
|
|
|
|
|
ucs.hover,
|
|
|
|
|
|
ucs.selected,
|
|
|
|
|
|
);
|
2026-04-03 19:25:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 00:26:54 +03:00
|
|
|
|
// ── Object Snap Tracking lines ────────────────────────────────────
|
|
|
|
|
|
for ost in &self.ost_points {
|
|
|
|
|
|
let tp = ost.screen;
|
|
|
|
|
|
let cx = self.cursor_screen.x;
|
|
|
|
|
|
let cy = self.cursor_screen.y;
|
2026-05-12 10:36:15 +03:00
|
|
|
|
let track_color = Color {
|
|
|
|
|
|
r: 0.15,
|
|
|
|
|
|
g: 0.85,
|
|
|
|
|
|
b: 0.95,
|
|
|
|
|
|
a: 0.7,
|
|
|
|
|
|
};
|
2026-04-08 00:26:54 +03:00
|
|
|
|
let dash_stroke = canvas::Stroke::default()
|
|
|
|
|
|
.with_color(track_color)
|
|
|
|
|
|
.with_width(1.0);
|
|
|
|
|
|
|
|
|
|
|
|
// Draw horizontal line from tracking point to cursor.
|
|
|
|
|
|
if (cy - tp.y).abs() < 8.0 {
|
|
|
|
|
|
let path = canvas::Path::line(tp, Point { x: cx, y: tp.y });
|
|
|
|
|
|
frame.stroke(&path, dash_stroke.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
// Draw vertical line.
|
|
|
|
|
|
if (cx - tp.x).abs() < 8.0 {
|
|
|
|
|
|
let path = canvas::Path::line(tp, Point { x: tp.x, y: cy });
|
|
|
|
|
|
frame.stroke(&path, dash_stroke.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
// Small cross at the tracking point.
|
|
|
|
|
|
let sz = 5.0_f32;
|
|
|
|
|
|
let h = canvas::Path::line(
|
2026-05-12 10:36:15 +03:00
|
|
|
|
Point {
|
|
|
|
|
|
x: tp.x - sz,
|
|
|
|
|
|
y: tp.y,
|
|
|
|
|
|
},
|
|
|
|
|
|
Point {
|
|
|
|
|
|
x: tp.x + sz,
|
|
|
|
|
|
y: tp.y,
|
|
|
|
|
|
},
|
2026-04-08 00:26:54 +03:00
|
|
|
|
);
|
|
|
|
|
|
let v = canvas::Path::line(
|
2026-05-12 10:36:15 +03:00
|
|
|
|
Point {
|
|
|
|
|
|
x: tp.x,
|
|
|
|
|
|
y: tp.y - sz,
|
|
|
|
|
|
},
|
|
|
|
|
|
Point {
|
|
|
|
|
|
x: tp.x,
|
|
|
|
|
|
y: tp.y + sz,
|
|
|
|
|
|
},
|
2026-04-08 00:26:54 +03:00
|
|
|
|
);
|
|
|
|
|
|
frame.stroke(&h, dash_stroke.clone());
|
|
|
|
|
|
frame.stroke(&v, dash_stroke);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-22 10:03:35 +03:00
|
|
|
|
fn draw_grid(
|
|
|
|
|
|
frame: &mut canvas::Frame,
|
2026-06-23 22:57:31 +03:00
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-06-22 10:03:35 +03:00
|
|
|
|
plane: GridPlane,
|
|
|
|
|
|
bounds: iced::Rectangle,
|
2026-06-23 22:57:31 +03:00
|
|
|
|
grid_origin: glam::DVec3,
|
2026-06-22 10:03:35 +03:00
|
|
|
|
grid_axes: (Vec3, Vec3, Vec3),
|
|
|
|
|
|
) {
|
2026-06-23 22:57:31 +03:00
|
|
|
|
// 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);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
Point::new(
|
2026-05-28 09:45:19 +03:00
|
|
|
|
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
|
|
|
|
|
|
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-22 10:03:35 +03:00
|
|
|
|
// 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;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let (axis1, axis2) = match plane {
|
2026-06-22 10:03:35 +03:00
|
|
|
|
GridPlane::Xz => (gx, gz),
|
|
|
|
|
|
GridPlane::Xy => (gx, gy),
|
|
|
|
|
|
GridPlane::Yz => (gy, gz),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Adaptive spacing: measure pixels per 1-unit step along each axis,
|
|
|
|
|
|
// then find the smallest power-of-5 multiple that gives ≥ MIN_GRID_PX.
|
2026-06-22 10:03:35 +03:00
|
|
|
|
let o = w2s(grid_origin);
|
2026-06-23 22:57:31 +03:00
|
|
|
|
let a1s = w2s(grid_origin + axis1.as_dvec3());
|
|
|
|
|
|
let a2s = w2s(grid_origin + axis2.as_dvec3());
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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.
|
2026-06-23 22:57:31 +03:00
|
|
|
|
let inv = view_rot.inverse();
|
|
|
|
|
|
let unproject = |sx: f32, sy: f32| -> glam::DVec3 {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let ndc_x = (sx / bounds.width) * 2.0 - 1.0;
|
|
|
|
|
|
let ndc_y = 1.0 - (sy / bounds.height) * 2.0;
|
2026-06-23 22:57:31 +03:00
|
|
|
|
// 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()
|
2026-03-22 14:56:27 +03:00
|
|
|
|
};
|
|
|
|
|
|
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) {
|
2026-06-23 22:57:31 +03:00
|
|
|
|
let vals: Vec<f32> = corners
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|p| (*p - grid_origin).as_vec3().dot(ax))
|
|
|
|
|
|
.collect();
|
2026-03-22 14:56:27 +03:00
|
|
|
|
(
|
|
|
|
|
|
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;
|
2026-06-23 22:57:31 +03:00
|
|
|
|
let p0 = w2s(grid_origin + (axis1 * v + axis2 * (min2 - s)).as_dvec3());
|
|
|
|
|
|
let p1 = w2s(grid_origin + (axis1 * v + axis2 * (max2 + s)).as_dvec3());
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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;
|
2026-06-23 22:57:31 +03:00
|
|
|
|
let p0 = w2s(grid_origin + (axis2 * v + axis1 * (min1 - s)).as_dvec3());
|
|
|
|
|
|
let p1 = w2s(grid_origin + (axis2 * v + axis1 * (max1 + s)).as_dvec3());
|
2026-03-22 14:56:27 +03:00
|
|
|
|
frame.stroke(
|
|
|
|
|
|
&canvas::Path::new(|b| {
|
|
|
|
|
|
b.move_to(p0);
|
|
|
|
|
|
b.line_to(p1);
|
|
|
|
|
|
}),
|
|
|
|
|
|
st.clone(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 11:27:34 +03:00
|
|
|
|
// Coloured axes drawn on top of the grid lines, along the same UCS basis.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let extent = (min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs()) + s) * 1.5;
|
2026-06-23 22:57:31 +03:00
|
|
|
|
draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, grid_axes);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 11:27:34 +03:00
|
|
|
|
// ── Coloured UCS axes ──────────────────────────────────────────────────────
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
2026-06-22 11:27:34 +03:00
|
|
|
|
fn draw_axes(
|
|
|
|
|
|
frame: &mut canvas::Frame,
|
2026-06-23 22:57:31 +03:00
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-06-22 11:27:34 +03:00
|
|
|
|
bounds: iced::Rectangle,
|
|
|
|
|
|
extent: f32,
|
2026-06-23 22:57:31 +03:00
|
|
|
|
origin: glam::DVec3,
|
2026-06-22 11:27:34 +03:00
|
|
|
|
axes: (Vec3, Vec3, Vec3),
|
|
|
|
|
|
) {
|
2026-06-23 22:57:31 +03:00
|
|
|
|
let w2s = |world: glam::DVec3| -> Point {
|
|
|
|
|
|
let ndc = view_rot.project_point3((world - eye).as_vec3());
|
2026-03-22 14:56:27 +03:00
|
|
|
|
Point::new(
|
2026-05-28 09:45:19 +03:00
|
|
|
|
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
|
|
|
|
|
|
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
)
|
|
|
|
|
|
};
|
|
|
|
|
|
let e = extent;
|
2026-06-22 11:27:34 +03:00
|
|
|
|
let (ax, ay, az) = axes;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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()
|
|
|
|
|
|
};
|
2026-06-22 11:27:34 +03:00
|
|
|
|
// 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| {
|
2026-06-23 22:57:31 +03:00
|
|
|
|
p.move_to(w2s(origin - (dir * e).as_dvec3()));
|
|
|
|
|
|
p.line_to(w2s(origin + (dir * e).as_dvec3()));
|
2026-06-22 11:27:34 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
2026-04-03 19:25:55 +03:00
|
|
|
|
|
|
|
|
|
|
// ── 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
|
2026-04-23 21:54:31 +03:00
|
|
|
|
// 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.
|
2026-04-03 19:25:55 +03:00
|
|
|
|
|
2026-04-23 21:54:31 +03:00
|
|
|
|
const UCS_ICON_MARGIN: f32 = 50.0;
|
2026-05-12 10:36:15 +03:00
|
|
|
|
const UCS_ICON_LEN: f32 = 38.0; // longest axis arm in screen pixels
|
|
|
|
|
|
const UCS_ICON_TIP: f32 = 7.0; // arrowhead size in pixels
|
2026-06-27 01:12:45 +03:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-04-03 19:25:55 +03:00
|
|
|
|
|
2026-06-27 01:12:45 +03:00
|
|
|
|
/// 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(
|
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>
2026-06-22 09:57:39 +03:00
|
|
|
|
vp: Mat4,
|
|
|
|
|
|
bounds: iced::Rectangle,
|
|
|
|
|
|
axes: (Vec3, Vec3, Vec3),
|
2026-06-27 01:12:45 +03:00
|
|
|
|
origin_screen: Option<Point>,
|
|
|
|
|
|
) -> Option<(Point, bool, [IconAxis; 3])> {
|
2026-04-03 21:53:19 +03:00
|
|
|
|
if bounds.width < 10.0 || bounds.height < 10.0 {
|
2026-06-27 01:12:45 +03:00
|
|
|
|
return None;
|
2026-04-03 21:53:19 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 21:54:31 +03:00
|
|
|
|
// Project to NDC (including depth) then to screen pixels.
|
|
|
|
|
|
let w2ndc = |world: Vec3| -> Option<Vec3> {
|
2026-04-03 19:25:55 +03:00
|
|
|
|
let ndc = vp.project_point3(world);
|
2026-04-23 21:54:31 +03:00
|
|
|
|
if !ndc.x.is_finite() || !ndc.y.is_finite() || !ndc.z.is_finite() {
|
2026-04-03 21:53:19 +03:00
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2026-04-23 21:54:31 +03:00
|
|
|
|
Some(ndc)
|
|
|
|
|
|
};
|
|
|
|
|
|
let ndc2s = |ndc: Vec3| -> Point {
|
|
|
|
|
|
Point::new(
|
2026-05-28 09:45:19 +03:00
|
|
|
|
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
|
|
|
|
|
|
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
|
2026-04-23 21:54:31 +03:00
|
|
|
|
)
|
2026-04-03 19:25:55 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
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>
2026-06-22 09:57:39 +03:00
|
|
|
|
// 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;
|
2026-06-27 01:12:45 +03:00
|
|
|
|
let org = w2ndc(Vec3::ZERO)?;
|
|
|
|
|
|
let xn = w2ndc(ax)?;
|
|
|
|
|
|
let yn = w2ndc(ay)?;
|
|
|
|
|
|
let zn = w2ndc(az)?;
|
2026-04-03 19:25:55 +03:00
|
|
|
|
|
2026-04-23 21:54:31 +03:00
|
|
|
|
let org_s = ndc2s(org);
|
2026-06-27 01:12:45 +03:00
|
|
|
|
let corner = Point::new(
|
2026-05-28 09:45:19 +03:00
|
|
|
|
bounds.x + UCS_ICON_MARGIN,
|
|
|
|
|
|
bounds.y + (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN),
|
2026-04-23 21:54:31 +03:00
|
|
|
|
);
|
2026-06-27 01:12:45 +03:00
|
|
|
|
// 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),
|
|
|
|
|
|
};
|
2026-04-23 21:54:31 +03:00
|
|
|
|
|
|
|
|
|
|
// 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())
|
2026-04-03 19:25:55 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-23 21:54:31 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-27 01:12:45 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-04-23 21:54:31 +03:00
|
|
|
|
struct AxisInfo {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
dx: f32,
|
|
|
|
|
|
dy: f32,
|
|
|
|
|
|
sc_len: f32,
|
|
|
|
|
|
depth: f32,
|
|
|
|
|
|
r: f32,
|
|
|
|
|
|
g: f32,
|
|
|
|
|
|
b: f32,
|
|
|
|
|
|
label: &'static str,
|
2026-04-03 19:25:55 +03:00
|
|
|
|
}
|
2026-04-23 21:54:31 +03:00
|
|
|
|
let mut axes = [
|
2026-05-12 10:36:15 +03:00
|
|
|
|
AxisInfo {
|
2026-06-27 01:12:45 +03:00
|
|
|
|
dx: geom[0].dx,
|
|
|
|
|
|
dy: geom[0].dy,
|
|
|
|
|
|
sc_len: geom[0].sc_len,
|
|
|
|
|
|
depth: geom[0].depth,
|
2026-05-12 10:36:15 +03:00
|
|
|
|
r: 0.90,
|
|
|
|
|
|
g: 0.22,
|
|
|
|
|
|
b: 0.22,
|
|
|
|
|
|
label: "X",
|
|
|
|
|
|
},
|
|
|
|
|
|
AxisInfo {
|
2026-06-27 01:12:45 +03:00
|
|
|
|
dx: geom[1].dx,
|
|
|
|
|
|
dy: geom[1].dy,
|
|
|
|
|
|
sc_len: geom[1].sc_len,
|
|
|
|
|
|
depth: geom[1].depth,
|
2026-05-12 10:36:15 +03:00
|
|
|
|
r: 0.22,
|
|
|
|
|
|
g: 0.85,
|
|
|
|
|
|
b: 0.22,
|
|
|
|
|
|
label: "Y",
|
|
|
|
|
|
},
|
|
|
|
|
|
AxisInfo {
|
2026-06-27 01:12:45 +03:00
|
|
|
|
dx: geom[2].dx,
|
|
|
|
|
|
dy: geom[2].dy,
|
|
|
|
|
|
sc_len: geom[2].sc_len,
|
|
|
|
|
|
depth: geom[2].depth,
|
2026-05-12 10:36:15 +03:00
|
|
|
|
r: 0.22,
|
|
|
|
|
|
g: 0.45,
|
|
|
|
|
|
b: 0.90,
|
|
|
|
|
|
label: "Z",
|
|
|
|
|
|
},
|
2026-04-23 21:54:31 +03:00
|
|
|
|
];
|
|
|
|
|
|
// Back-to-front: draw axis farthest from viewer first.
|
2026-05-12 10:36:15 +03:00
|
|
|
|
axes.sort_by(|a, b| {
|
|
|
|
|
|
b.depth
|
|
|
|
|
|
.partial_cmp(&a.depth)
|
|
|
|
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
|
|
|
|
});
|
2026-04-23 21:54:31 +03:00
|
|
|
|
|
|
|
|
|
|
for ax in &axes {
|
2026-06-27 01:12:45 +03:00
|
|
|
|
// 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 };
|
2026-05-12 10:36:15 +03:00
|
|
|
|
let col = Color {
|
2026-06-27 01:12:45 +03:00
|
|
|
|
r: ax.r + (1.0 - ax.r) * mix,
|
|
|
|
|
|
g: ax.g + (1.0 - ax.g) * mix,
|
|
|
|
|
|
b: ax.b + (1.0 - ax.b) * mix,
|
2026-05-12 10:36:15 +03:00
|
|
|
|
a: 1.0,
|
|
|
|
|
|
};
|
2026-04-23 21:54:31 +03:00
|
|
|
|
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 {
|
2026-06-27 01:12:45 +03:00
|
|
|
|
width: if highlight { 3.0 } else { 2.0 },
|
2026-04-23 21:54:31 +03:00
|
|
|
|
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),
|
2026-06-18 13:34:03 +03:00
|
|
|
|
shaping: iced::advanced::text::Shaping::Advanced,
|
2026-04-23 21:54:31 +03:00
|
|
|
|
..Default::default()
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-03 19:25:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 21:54:31 +03:00
|
|
|
|
// Origin dot.
|
|
|
|
|
|
let circle = canvas::Path::circle(icon_origin, 3.5);
|
2026-05-12 10:36:15 +03:00
|
|
|
|
frame.fill(
|
|
|
|
|
|
&circle,
|
|
|
|
|
|
Color {
|
|
|
|
|
|
r: 0.9,
|
|
|
|
|
|
g: 0.9,
|
|
|
|
|
|
b: 0.9,
|
|
|
|
|
|
a: 0.95,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-06-27 01:12:45 +03:00
|
|
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-03 19:25:55 +03:00
|
|
|
|
}
|
2026-04-08 00:12:09 +03:00
|
|
|
|
|
|
|
|
|
|
// ── Dynamic Input overlay ─────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-15 17:12:08 +03:00
|
|
|
|
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.
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
#[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,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
pub role: DynRole,
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 18:32:03 +03:00
|
|
|
|
pub fn dynamic_input_overlay<'a>(
|
|
|
|
|
|
cursor_screen: Point,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
base_screen: Option<Point>,
|
2026-06-15 18:46:34 +03:00
|
|
|
|
ref_screen: Option<Point>,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
guide: DynGuide,
|
2026-06-09 18:32:03 +03:00
|
|
|
|
boxes: Vec<DynBox>,
|
|
|
|
|
|
prompt: String,
|
|
|
|
|
|
) -> Element<'a, Message> {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
canvas(DynInputCanvas {
|
|
|
|
|
|
cursor_screen,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
base_screen,
|
2026-06-15 18:46:34 +03:00
|
|
|
|
ref_screen,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
guide,
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
boxes,
|
2026-06-09 18:32:03 +03:00
|
|
|
|
prompt,
|
2026-05-12 10:36:15 +03:00
|
|
|
|
})
|
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
|
.into()
|
2026-04-08 00:12:09 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct DynInputCanvas {
|
|
|
|
|
|
cursor_screen: Point,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
/// 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>,
|
2026-06-15 18:46:34 +03:00
|
|
|
|
/// Far end of the reference line (projected `dyn_ref`) — for `Perp`.
|
|
|
|
|
|
ref_screen: Option<Point>,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
guide: DynGuide,
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
boxes: Vec<DynBox>,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
/// The active command's current prompt, drawn just above the boxes.
|
2026-06-09 18:32:03 +03:00
|
|
|
|
prompt: String,
|
2026-04-08 00:12:09 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 17:12:08 +03:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-08 00:12:09 +03:00
|
|
|
|
|
2026-06-15 17:12:08 +03:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
2026-04-08 00:12:09 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 17:12:08 +03:00
|
|
|
|
/// 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),
|
2026-06-18 13:34:03 +03:00
|
|
|
|
// 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,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
..Default::default()
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-08 00:12:09 +03:00
|
|
|
|
|
2026-06-15 17:12:08 +03:00
|
|
|
|
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),
|
2026-06-18 13:34:03 +03:00
|
|
|
|
shaping: iced::advanced::text::Shaping::Advanced,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
..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;
|
|
|
|
|
|
}
|
2026-06-15 23:03:08 +03:00
|
|
|
|
// 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;
|
2026-06-15 17:12:08 +03:00
|
|
|
|
while sweep > std::f32::consts::PI {
|
|
|
|
|
|
sweep -= std::f32::consts::TAU;
|
|
|
|
|
|
}
|
2026-06-15 23:03:08 +03:00
|
|
|
|
while sweep <= -std::f32::consts::PI {
|
2026-06-15 17:12:08 +03:00
|
|
|
|
sweep += std::f32::consts::TAU;
|
|
|
|
|
|
}
|
|
|
|
|
|
let corner = Point { x: cursor.x, y: base.y }; // axis-delta elbow
|
|
|
|
|
|
|
2026-06-15 18:46:34 +03:00
|
|
|
|
// 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)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-15 17:12:08 +03:00
|
|
|
|
// ── Guide geometry ──
|
|
|
|
|
|
match self.guide {
|
|
|
|
|
|
DynGuide::Polar => {
|
2026-06-15 23:03:08 +03:00
|
|
|
|
// Reference line along `a_ref` (the +X axis, or the supplied
|
|
|
|
|
|
// reference direction), then the arc from it to the cursor.
|
2026-06-15 17:12:08 +03:00
|
|
|
|
let href = canvas::Path::new(|p| {
|
|
|
|
|
|
p.move_to(base);
|
2026-06-15 23:03:08 +03:00
|
|
|
|
p.line_to(Point {
|
|
|
|
|
|
x: base.x + a_ref.cos() * len,
|
|
|
|
|
|
y: base.y + a_ref.sin() * len,
|
|
|
|
|
|
});
|
2026-06-15 17:12:08 +03:00
|
|
|
|
});
|
|
|
|
|
|
frame.stroke(&href, Self::dotted());
|
|
|
|
|
|
let arc = canvas::Path::new(|p| {
|
|
|
|
|
|
let steps = 48;
|
|
|
|
|
|
for k in 0..=steps {
|
2026-06-15 23:03:08 +03:00
|
|
|
|
let a = a_ref + sweep * (k as f32 / steps as f32);
|
2026-06-15 17:12:08 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-13 23:58:56 +03:00
|
|
|
|
});
|
2026-06-15 17:12:08 +03:00
|
|
|
|
frame.stroke(&arc, Self::dotted());
|
2026-06-13 23:58:56 +03:00
|
|
|
|
}
|
2026-06-15 17:12:08 +03:00
|
|
|
|
DynGuide::Radius => {
|
|
|
|
|
|
let line = canvas::Path::new(|p| {
|
|
|
|
|
|
p.move_to(base);
|
|
|
|
|
|
p.line_to(cursor);
|
|
|
|
|
|
});
|
|
|
|
|
|
frame.stroke(&line, Self::dotted());
|
|
|
|
|
|
}
|
2026-06-15 18:46:34 +03:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-15 17:12:08 +03:00
|
|
|
|
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 => {
|
2026-06-15 23:03:08 +03:00
|
|
|
|
let a_mid = a_ref + sweep * 0.5;
|
2026-06-18 20:45:04 +03:00
|
|
|
|
// 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);
|
2026-06-15 17:12:08 +03:00
|
|
|
|
Point {
|
2026-06-18 20:45:04 +03:00
|
|
|
|
x: base.x + a_mid.cos() * r - nx * 18.0,
|
|
|
|
|
|
y: base.y + a_mid.sin() * r - ny * 18.0,
|
2026-06-15 17:12:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
|
},
|
2026-06-15 18:46:34 +03:00
|
|
|
|
// 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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-15 17:12:08 +03:00
|
|
|
|
// 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);
|
2026-06-13 23:58:56 +03:00
|
|
|
|
}
|
2026-06-15 17:12:08 +03:00
|
|
|
|
}
|
2026-06-13 23:58:56 +03:00
|
|
|
|
|
2026-06-15 17:12:08 +03:00
|
|
|
|
/// Fallback row layout near the cursor (no anchor / `None` guide).
|
|
|
|
|
|
fn draw_row(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle) {
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
let texts: Vec<String> = self
|
|
|
|
|
|
.boxes
|
|
|
|
|
|
.iter()
|
2026-06-15 17:12:08 +03:00
|
|
|
|
.map(|b| {
|
|
|
|
|
|
if b.label.is_empty() {
|
|
|
|
|
|
b.value.clone()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!("{}:{}", b.label, b.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
.collect();
|
|
|
|
|
|
let widths: Vec<f32> = texts
|
|
|
|
|
|
.iter()
|
2026-06-15 17:12:08 +03:00
|
|
|
|
.map(|t| (t.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0)
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
.collect();
|
2026-06-15 17:12:08 +03:00
|
|
|
|
let total_w: f32 =
|
|
|
|
|
|
widths.iter().sum::<f32>() + DYN_GAP * (self.boxes.len() as f32 - 1.0);
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
|
2026-06-15 22:23:35 +03:00
|
|
|
|
// 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);
|
2026-05-12 10:36:15 +03:00
|
|
|
|
}
|
2026-06-15 17:12:08 +03:00
|
|
|
|
if by + DYN_BOX_H > bounds.height {
|
2026-06-15 22:23:35 +03:00
|
|
|
|
// 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 });
|
2026-06-09 18:32:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
let mut x = bx;
|
|
|
|
|
|
for (i, b) in self.boxes.iter().enumerate() {
|
|
|
|
|
|
let w = widths[i];
|
2026-06-15 17:12:08 +03:00
|
|
|
|
let rect =
|
|
|
|
|
|
canvas::Path::rectangle(Point { x, y: by }, Size { width: w, height: DYN_BOX_H });
|
|
|
|
|
|
let (fill, border) = Self::box_colors(b);
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
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(),
|
2026-06-15 17:12:08 +03:00
|
|
|
|
position: Point { x: x + DYN_PAD, y: by + DYN_PAD },
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 },
|
2026-06-15 17:12:08 +03:00
|
|
|
|
size: iced::Pixels(DYN_FONT),
|
2026-06-18 13:34:03 +03:00
|
|
|
|
shaping: iced::advanced::text::Shaping::Advanced,
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
..Default::default()
|
|
|
|
|
|
});
|
2026-06-15 17:12:08 +03:00
|
|
|
|
x += w + DYN_GAP;
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
|
}
|
2026-06-15 17:12:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl canvas::Program<Message> for DynInputCanvas {
|
|
|
|
|
|
type State = ();
|
2026-04-08 00:12:09 +03:00
|
|
|
|
|
2026-06-15 17:12:08 +03:00
|
|
|
|
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;
|
2026-06-15 22:23:35 +03:00
|
|
|
|
let mut py = self.cursor_screen.y + DYN_OFFSET_X;
|
2026-06-15 17:12:08 +03:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
2026-04-08 00:12:09 +03:00
|
|
|
|
vec![frame.into_geometry()]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|