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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-24 17:06:59 +03:00
commit a66d873e51
5 changed files with 427 additions and 171 deletions

View file

@ -3677,10 +3677,10 @@ impl Scene {
/// Inverse of [`paper_to_model`]: map a model-space point to the paper
/// sheet through the active viewport. Returns the input unchanged when
/// there is no active viewport. Used to place overlays (dynamic-input
/// anchors, guides) that are stored in model coords while editing inside a
/// viewport — projecting the raw UTM model point with the paper camera puts
/// them millions of pixels away.
/// there is no active viewport. Kept as the inverse companion to
/// `paper_to_model`; in-viewport overlays now project via the viewport
/// camera ([`viewport_edit_frame`]) rather than mapping onto the sheet.
#[allow(dead_code)]
pub fn model_to_paper(&self, model_pt: glam::DVec3) -> glam::DVec3 {
let vp_handle = match self.active_viewport {
Some(h) => h,
@ -3698,6 +3698,33 @@ impl Scene {
)
}
/// In-viewport (MSPACE) editing frame: the active floating viewport's own
/// camera — *exactly* the one the GPU renders its content with
/// ([`camera_for_viewport`]) — together with the viewport's full screen
/// rectangle in canvas pixels ([`viewport_screen_rect`]).
///
/// This is the unified editing adapter (the "süzgeç"). Inside a viewport,
/// editing IS model-space: treat the returned camera as *the* camera, the
/// returned rect as *the* pane, and the cursor relative to that rect — then
/// the existing model-space snap / hit-test / grip / preview / plane-pick
/// code runs unchanged and lands on the same pixels the GPU draws. Results
/// come back as model coordinates directly (no paper round-trip).
///
/// Because the camera is the real GPU camera, this tracks the viewport's
/// pan / zoom / twist / oblique view correctly — unlike a linear
/// paper-projection, whose auto-fit / saved-view / crop divergence left the
/// snap stale after pan/zoom. Returns `None` when not editing inside a
/// floating viewport, or the camera / rect cannot be derived.
pub fn viewport_edit_frame(
&self,
canvas_px: (f32, f32),
) -> Option<(view::camera::Camera, iced::Rectangle)> {
let vp_handle = self.active_viewport?;
let cam = self.camera_for_viewport(vp_handle)?;
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
Some((cam, full))
}
/// Fold the active viewport's saved view onto the effective camera (the
/// auto-fit centre for stale UTM views) and persist it into `view_target` /
/// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the

View file

@ -1,7 +1,7 @@
//! OpenCADStudio-style grip editing.
use acadrust::Handle;
use glam::DVec3;
use glam::{DVec3, Mat4, Vec2};
use iced::{Point, Rectangle};
use crate::scene::model::object::{GripDef, GripShape};
@ -129,3 +129,68 @@ pub fn find_hit_grip(
}
best
}
/// Project an f64 world point with an explicit relative-to-eye `(view_rot,
/// eye)` pair — the camera-less form of `Camera::project_f64`. Used by the
/// in-viewport editing path, which supplies a *composed* model→screen view
/// (see `Scene::composed_viewport_view`) instead of a real camera.
fn project_rte(world: DVec3, view_rot: Mat4, eye: DVec3, bounds: Rectangle) -> Option<Vec2> {
let rel = (world - eye).as_vec3();
let clip = view_rot * rel.extend(1.0);
if clip.w.abs() < 1e-9 {
return None;
}
let ndc = clip.truncate() / clip.w;
Some(Vec2::new(
(ndc.x * 0.5 + 0.5) * bounds.width,
(0.5 - ndc.y * 0.5) * bounds.height,
))
}
/// Relative-to-eye variant of [`grips_to_screen`] taking a `(view_rot, eye)`
/// pair instead of a `Camera`, so the composed in-viewport view can project
/// model-space grips exactly like the GPU renders the viewport content.
pub fn grips_to_screen_rte(
grips: &[GripDef],
view_rot: Mat4,
eye: DVec3,
bounds: Rectangle,
) -> Vec<(usize, Point, bool, GripShape, Option<[f32; 2]>)> {
grips
.iter()
.map(|g| {
let screen = match project_rte(g.world, view_rot, eye, bounds) {
Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y),
None => Point::new(f32::NAN, f32::NAN),
};
(g.id, screen, g.is_midpoint, g.shape, g.dir)
})
.collect()
}
/// Relative-to-eye variant of [`find_hit_grip`] taking a `(view_rot, eye)`
/// pair instead of a `Camera`, for in-viewport (MSPACE) grip hit-testing.
pub fn find_hit_grip_rte(
cursor: Point,
grips: &[GripDef],
view_rot: Mat4,
eye: DVec3,
bounds: Rectangle,
) -> Option<(usize, bool, DVec3)> {
let mut best_dist = GRIP_THRESHOLD_PX;
let mut best: Option<(usize, bool, DVec3)> = None;
for g in grips {
let Some(screen) = project_rte(g.world, view_rot, eye, bounds) else {
continue;
};
let dx = screen.x - cursor.x;
let dy = screen.y - cursor.y;
let d = (dx * dx + dy * dy).sqrt();
if d < best_dist {
best_dist = d;
best = Some((g.id, g.is_midpoint, g.world));
}
}
best
}