feat(viewport): unify in-viewport editing on the model-space path

Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.

- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
  viewport_screen_rect): the unified adapter. Inside a floating viewport
  the viewport is the active pane, so snap/pick/grip/preview/DYN run in
  model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
  exactly (twist handled by camera_from_view); crop is pixel-preserving so
  full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
  hover (HoverDwellTick), selection (single/box/poly/cycle) and command
  entity-pick through it via pick_view / cursor_model_point helpers and an
  edit_cam-aware "active pane = viewport rect" override, so the existing
  tile offset plumbing just works. Fixes wrong-object hover/selection and
  stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
  so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
  flag, Grid removed from the osnap mode set, evaluated independently of
  the osnap master. Object snap never catches grid; grid corners are
  picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
  (view_rot, eye) so grips project through the composed view.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-24 17:06:59 +03:00
1 parent 4aa8e6681f
commit a66d873e51
5 files changed
+428 -172

No files matched your search

+61 -19
View File
@@ -50,7 +50,9 @@ pub const ALL_SNAP_MODES: &[(SnapType, &str, &str)] = &[
(SnapType::Nearest, "✧", "Nearest"),
(SnapType::ApparentIntersection, "✗", "Apparent Intersection"),
(SnapType::Parallel, "∥", "Parallel"),
(SnapType::Grid, "⊞", "Grid"),
// NOTE: Grid is intentionally NOT an object-snap mode. Grid snap is a
// separate system (`Snapper::grid_snap_on`) so object snap never catches a
// grid point; it is toggled on its own and handled directly in `snap()`.
];
// ── Snap result ───────────────────────────────────────────────────────────
@@ -86,6 +88,9 @@ pub struct Snapper {
pub snap_enabled: bool,
/// Which snap modes are configured (used when `snap_enabled` is true).
pub enabled: HashSet<SnapType>,
/// Grid snap on/off — a system fully separate from object snap. When on,
/// `snap()` can pick the nearest grid corner; object snap never does.
pub grid_snap_on: bool,
/// World-space grid spacing.
pub grid_spacing: f32,
/// Pixel-radius snap aperture, shared by OSNAP, tracking, polar and
@@ -121,6 +126,7 @@ impl Default for Snapper {
Self {
snap_enabled: false,
enabled,
grid_snap_on: false,
grid_spacing: 1.0,
osnap_radius_px: CROSSHAIR_ARM * 0.25,
otrack_enabled: false,
@@ -147,6 +153,19 @@ impl Snapper {
self.snap_enabled = !self.snap_enabled;
}
/// Grid snap on/off — independent of the object-snap master and mode set.
pub fn grid_snap(&self) -> bool {
self.grid_snap_on
}
pub fn toggle_grid_snap(&mut self) {
self.grid_snap_on = !self.grid_snap_on;
}
pub fn set_grid_snap(&mut self, on: bool) {
self.grid_snap_on = on;
}
pub fn toggle(&mut self, t: SnapType) {
if !self.enabled.remove(&t) {
self.enabled.insert(t);
@@ -422,6 +441,7 @@ impl Snapper {
s.insert(SnapType::Tangent);
s
},
grid_snap_on: false,
grid_spacing: self.grid_spacing,
osnap_radius_px: self.osnap_radius_px,
otrack_enabled: false,
@@ -458,9 +478,6 @@ impl Snapper {
grid_origin: Vec3,
grid_rot: Mat4,
) -> Option<SnapResult> {
if !self.snap_enabled {
return None;
}
// Object-snap selection is priority-then-distance, NOT nearest-wins.
// "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry
// and are therefore almost always closer to the cursor than a discrete
@@ -473,6 +490,46 @@ impl Snapper {
let mut best_rank = u8::MAX;
let mut best_d2 = f32::MAX;
// ── Grid snap — a SEPARATE system from object snap ───────────────────
// Grid snap has its own toggle (`grid_snap_on`) and is independent of
// the object-snap master (`snap_enabled`) and the object-snap mode set.
// Object snaps therefore NEVER catch grid points; only when grid snap is
// on can a grid corner be picked. It is evaluated first and at the
// lowest priority, so any object snap inside the aperture overrides it.
if self.grid_snap_on {
let s = self.grid_spacing as f64;
if s.abs() > 1e-9 {
// Round in the UCS grid frame, then map back to world.
let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3();
let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3();
let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3();
let origin = grid_origin.as_dvec3();
let rel = cursor_world - origin;
let ux = (rel.dot(ax) / s).round() * s;
let uy = (rel.dot(ay) / s).round() * s;
let uz = (rel.dot(az) / s).round() * s;
let gp = origin + ax * ux + ay * uy + az * uz;
let screen = world_to_screen(gp, view_rot, eye, bounds);
let d2 = dist2(screen, cursor_screen);
if d2 < radius2 {
best = Some(SnapResult {
world: gp,
screen,
snap_type: SnapType::Grid,
tangent_obj: None,
});
best_rank = snap_priority(SnapType::Grid);
best_d2 = d2;
}
}
}
// Object snaps are gated by the object-snap master toggle. With it off
// only the grid result (if any) stands.
if !self.snap_enabled {
return best;
}
// World-space snap radius — derived from the view scale so wires whose
// entire extent is clearly outside the snap circle can be skipped cheaply
// before projecting any of their vertices to screen space.
@@ -757,21 +814,6 @@ impl Snapper {
}
}
// ── Grid ───────────────────────────────────────────────────────────
if self.is_on(SnapType::Grid) {
let s = self.grid_spacing as f64;
// Round in the UCS grid frame, then map back to world.
let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3();
let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3();
let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3();
let origin = grid_origin.as_dvec3();
let rel = cursor_world - origin;
let ux = (rel.dot(ax) / s).round() * s;
let uy = (rel.dot(ay) / s).round() * s;
let uz = (rel.dot(az) / s).round() * s;
try_pt(origin + ax * ux + ay * uy + az * uz, SnapType::Grid);
}
// ── Tangent ────────────────────────────────────────────────────────
// Operates directly on tangent_geoms geometry — independent of the
// wire.points rendering structure so polyline segments work correctly.