forked from 1b-agent/opencadstudio-upstream
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:
1 parent
4aa8e6681f
commit
a66d873e51
5 files changed
+428
-172
No files matched your search
+248
-143
@@ -4,7 +4,7 @@ use super::helpers::{
|
||||
};
|
||||
use super::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, GripEdit};
|
||||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
||||
use crate::scene::model::object::GripApply;
|
||||
use crate::scene::{
|
||||
self, hover_id, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
@@ -360,21 +360,21 @@ impl OpenCADStudio {
|
||||
/// model tile so a save writes them to that viewport's VPort entry (#121).
|
||||
fn sync_vport_display(&mut self, i: usize) {
|
||||
let grid_on = self.show_grid;
|
||||
let snap_on = self.snapper.is_on(crate::snap::SnapType::Grid);
|
||||
let snap_on = self.snapper.grid_snap();
|
||||
self.tabs[i].scene.set_active_tile_grid_snap(grid_on, snap_on);
|
||||
}
|
||||
|
||||
/// Adopt the active viewport's grid display + grid-snap into the live
|
||||
/// toggles. Called on load and whenever the active tab or viewport changes,
|
||||
/// so grid/snap follow the active viewport rather than a global setting.
|
||||
/// Adopt the active viewport's grid *display* into the live toggle. Called
|
||||
/// on load and whenever the active tab or viewport changes, so the grid
|
||||
/// drawing follows the active viewport.
|
||||
///
|
||||
/// Grid *snap* (`SnapType::Grid`) is deliberately NOT adopted here: it stays
|
||||
/// off by default everywhere and is controlled solely by the user's snap
|
||||
/// toggle, so it never silently leaks into object-snap when entering a
|
||||
/// viewport whose stored `snap_on` flag happens to be set.
|
||||
fn adopt_view_display(&mut self, i: usize) {
|
||||
if let Some((grid_on, snap_on)) = self.tabs[i].scene.active_tile_grid_snap() {
|
||||
if let Some((grid_on, _snap_on)) = self.tabs[i].scene.active_tile_grid_snap() {
|
||||
self.show_grid = grid_on;
|
||||
if snap_on {
|
||||
self.snapper.enabled.insert(crate::snap::SnapType::Grid);
|
||||
} else {
|
||||
self.snapper.enabled.remove(&crate::snap::SnapType::Grid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2598,9 +2598,20 @@ impl OpenCADStudio {
|
||||
// the active pane. `p_full` keeps the canvas-space cursor
|
||||
// for screen overlays (cursor marker, snap glyph).
|
||||
let p_full = p;
|
||||
let tile_b = self.tabs[i]
|
||||
.scene
|
||||
.active_model_tile_bounds(vp_size.0, vp_size.1);
|
||||
// Inside a floating viewport (MSPACE) the active "pane" is the
|
||||
// viewport's own screen rectangle and its own camera — the very
|
||||
// camera the GPU draws the content with. Routing picking / snap
|
||||
// / projection through it makes in-viewport editing behave like
|
||||
// the main model view (model coords, no paper round-trip) and
|
||||
// track the viewport's pan / zoom / twist exactly.
|
||||
let edit_frame = self.tabs[i].scene.viewport_edit_frame(vp_size);
|
||||
let tile_b = match &edit_frame {
|
||||
Some((_, full)) => *full,
|
||||
None => self.tabs[i]
|
||||
.scene
|
||||
.active_model_tile_bounds(vp_size.0, vp_size.1),
|
||||
};
|
||||
let edit_cam = edit_frame.map(|(cam, _)| cam);
|
||||
let p = iced::Point {
|
||||
x: p_full.x - tile_b.x,
|
||||
y: p_full.y - tile_b.y,
|
||||
@@ -2616,11 +2627,28 @@ impl OpenCADStudio {
|
||||
width: vw,
|
||||
height: vh,
|
||||
};
|
||||
let cam = self.tabs[i].scene.camera.borrow();
|
||||
let raw_paper = cam.pick_on_target_plane(p, bounds);
|
||||
let view_rot = cam.view_proj_rte(bounds); let eye = cam.eye_f64();
|
||||
drop(cam);
|
||||
let raw = self.tabs[i].scene.paper_to_model(raw_paper);
|
||||
// In a viewport, pick the cursor's model point with the
|
||||
// viewport camera directly; otherwise project on the paper
|
||||
// sheet and map to model. Either way `raw` is model space.
|
||||
let raw = match &edit_cam {
|
||||
Some(cam) => cam.pick_on_target_plane(p, bounds),
|
||||
None => {
|
||||
let paper = self
|
||||
.tabs[i]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.pick_on_target_plane(p, bounds);
|
||||
self.tabs[i].scene.paper_to_model(paper)
|
||||
}
|
||||
};
|
||||
let (view_rot, eye) = match &edit_cam {
|
||||
Some(cam) => (cam.view_proj_rte(bounds), cam.eye_f64()),
|
||||
None => {
|
||||
let cam = self.tabs[i].scene.camera.borrow();
|
||||
(cam.view_proj_rte(bounds), cam.eye_f64())
|
||||
}
|
||||
};
|
||||
|
||||
// First move of this drag: hide the edited entity from the
|
||||
// base tessellation (one re-tess) so subsequent moves only
|
||||
@@ -2643,12 +2671,21 @@ impl OpenCADStudio {
|
||||
// The edited entity is hidden, so it's already absent from
|
||||
// `hit_test_wires` — snap against the set directly, no clone
|
||||
// and no self-snap.
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let all_wires = if let (Some(_), Some(h)) =
|
||||
(&edit_cam, self.tabs[i].scene.active_viewport)
|
||||
{
|
||||
self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height)
|
||||
} else {
|
||||
self.tabs[i].scene.hit_test_wires()
|
||||
};
|
||||
// Grip drag has no single rubber-band origin for a perp foot.
|
||||
self.snapper.from_point = None;
|
||||
let (go, gr) = self.tabs[i].ucs_grid_basis();
|
||||
let snap_hit =
|
||||
self.snapper.snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr);
|
||||
// `raw` is already model space (viewport camera or paper→model),
|
||||
// and the wires are model space, so the snap result is model.
|
||||
let snap_hit = self
|
||||
.snapper
|
||||
.snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr);
|
||||
let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw);
|
||||
self.tabs[i].snap_result = snap_hit;
|
||||
if let Some(s) = self.tabs[i].snap_result.as_mut() {
|
||||
@@ -2713,22 +2750,7 @@ impl OpenCADStudio {
|
||||
width: vp_size.0,
|
||||
height: vp_size.1,
|
||||
};
|
||||
let paper = if let Some(ref ucs) = self.tabs[i].active_ucs {
|
||||
let origin = glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z);
|
||||
let normal = ucs_z_axis(ucs);
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.pick_on_plane(p, bounds, normal, origin)
|
||||
} else {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.pick_on_target_plane(p, bounds)
|
||||
};
|
||||
let world = self.tabs[i].scene.paper_to_model(paper);
|
||||
let world = self.cursor_model_point(i, &edit_cam, p, bounds);
|
||||
self.tabs[i].last_cursor_world = world.as_vec3();
|
||||
}
|
||||
|
||||
@@ -2761,43 +2783,31 @@ impl OpenCADStudio {
|
||||
width: vw,
|
||||
height: vh,
|
||||
};
|
||||
let cursor_paper = if let Some(ref ucs) = self.tabs[i].active_ucs {
|
||||
let origin = glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z);
|
||||
let normal = ucs_z_axis(ucs);
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.pick_on_plane(p, bounds, normal, origin)
|
||||
} else {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.pick_on_target_plane(p, bounds)
|
||||
};
|
||||
let (view_rot, eye) = {
|
||||
let cam = self.tabs[i].scene.camera.borrow();
|
||||
(cam.view_proj_rte(bounds), cam.eye_f64())
|
||||
// Inside a floating viewport the cursor, camera and wires are
|
||||
// all model-space (the viewport's own camera draws the
|
||||
// content), so snap / hit-test / preview run exactly like the
|
||||
// main model view — no paper projection, tracks pan/zoom/twist.
|
||||
let cursor_world = self.cursor_model_point(i, &edit_cam, p, bounds);
|
||||
let (view_rot, eye) = match &edit_cam {
|
||||
Some(cam) => (cam.view_proj_rte(bounds), cam.eye_f64()),
|
||||
None => {
|
||||
let cam = self.tabs[i].scene.camera.borrow();
|
||||
(cam.view_proj_rte(bounds), cam.eye_f64())
|
||||
}
|
||||
};
|
||||
// Sync grid-snap spacing to the adaptive spacing of the visible grid.
|
||||
self.snapper.grid_spacing =
|
||||
crate::ui::overlay::compute_grid_step(view_rot, bounds);
|
||||
// In MSPACE, map paper-space cursor to model space so that
|
||||
// command previews and snapping work in the correct coordinate space.
|
||||
let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper);
|
||||
// Inside a viewport the hit-test wires are projected into
|
||||
// PAPER coordinates, so snapping must compare against the
|
||||
// paper cursor — feeding the model-space (UTM) cursor would
|
||||
// make `wire_in_range` reject every wire and snap nothing.
|
||||
// The snapped paper point is mapped back to model below.
|
||||
let snap_cursor = if self.tabs[i].scene.active_viewport.is_some() {
|
||||
cursor_paper
|
||||
} else {
|
||||
cursor_world
|
||||
};
|
||||
// Cursor and wires are model-space; the snap result is model.
|
||||
let snap_cursor = cursor_world;
|
||||
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let all_wires = if let (Some(_), Some(h)) =
|
||||
(&edit_cam, self.tabs[i].scene.active_viewport)
|
||||
{
|
||||
self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height)
|
||||
} else {
|
||||
self.tabs[i].scene.hit_test_wires()
|
||||
};
|
||||
let needs_entity = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
@@ -2876,11 +2886,11 @@ impl OpenCADStudio {
|
||||
// ortho/polar don't re-constrain it.
|
||||
h.aligned.as_dvec3()
|
||||
} else {
|
||||
// snap.world is paper-space for viewport-projected
|
||||
// wires; convert to model-space for previews.
|
||||
// Snap runs in model space (viewport camera or the
|
||||
// model/paper view), so the result is already model.
|
||||
let mut pt = self.tabs[i]
|
||||
.snap_result
|
||||
.map(|s| self.tabs[i].scene.paper_to_model(s.world))
|
||||
.map(|s| s.world)
|
||||
.unwrap_or(cursor_world);
|
||||
// Object snap wins over ortho/polar: a snapped point
|
||||
// is taken as-is so it isn't pulled onto the ortho
|
||||
@@ -2924,32 +2934,25 @@ impl OpenCADStudio {
|
||||
// Project the step anchor (an explicit `dyn_anchor` or the
|
||||
// last point) so the dynamic-input overlay can place its
|
||||
// guide geometry and labels.
|
||||
// `proj` returns a pane-local pixel; shift by the active pane
|
||||
// origin (`tile_b`, the viewport rect inside a viewport) so
|
||||
// the DYN guide/labels share the canvas frame with
|
||||
// `last_cursor_screen` (= p_full, canvas space).
|
||||
let proj = |bp: glam::Vec3| {
|
||||
let ndc = view_rot.project_point3((bp.as_dvec3() - eye).as_vec3());
|
||||
iced::Point::new(
|
||||
(ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
(1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
(ndc.x + 1.0) * 0.5 * bounds.width + tile_b.x,
|
||||
(1.0 - ndc.y) * 0.5 * bounds.height + tile_b.y,
|
||||
)
|
||||
};
|
||||
// Anchors are stored in model coords; inside a viewport they
|
||||
// must be mapped onto the paper sheet before projecting with
|
||||
// the paper camera — otherwise a UTM model point lands
|
||||
// millions of pixels away and the dynamic-input guide arc
|
||||
// (radius = anchor→cursor distance) tessellates into a buffer
|
||||
// big enough to abort the GPU. Compute into locals first to
|
||||
// release the scene borrow before assigning.
|
||||
let in_vp = self.tabs[i].scene.active_viewport.is_some();
|
||||
// Anchors are stored in model coords and `proj` (view_rot /
|
||||
// eye) is the model→screen view — the viewport camera inside
|
||||
// a viewport, the model/paper camera otherwise — so feed them
|
||||
// straight through; no paper mapping needed.
|
||||
let anchor = self.tabs[i].dyn_anchor.or(self.last_point);
|
||||
let dyn_ref = self.tabs[i].dyn_ref;
|
||||
let map_paper = |bp: glam::Vec3| -> glam::Vec3 {
|
||||
if in_vp {
|
||||
self.tabs[i].scene.model_to_paper(bp.as_dvec3()).as_vec3()
|
||||
} else {
|
||||
bp
|
||||
}
|
||||
};
|
||||
let lps = anchor.map(|a| proj(map_paper(a)));
|
||||
let drs = dyn_ref.map(|r| proj(map_paper(r)));
|
||||
let lps = anchor.map(|a| proj(a));
|
||||
let drs = dyn_ref.map(|r| proj(r));
|
||||
self.tabs[i].last_point_screen = lps;
|
||||
self.tabs[i].dyn_ref_screen = drs;
|
||||
|
||||
@@ -3276,7 +3279,14 @@ impl OpenCADStudio {
|
||||
// cursor into it and use the tile's size for picking, so
|
||||
// grip / selection hit-tests land in the right pane.
|
||||
let p_full = p;
|
||||
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
|
||||
// Inside a floating viewport the pane is the viewport's own rect
|
||||
// + camera (matches the GPU); otherwise the active model tile.
|
||||
let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh));
|
||||
let tile_b = match &edit_frame {
|
||||
Some((_, full)) => *full,
|
||||
None => self.tabs[i].scene.active_model_tile_bounds(vw, vh),
|
||||
};
|
||||
let edit_cam = edit_frame.map(|(cam, _)| cam);
|
||||
let p = iced::Point {
|
||||
x: p_full.x - tile_b.x,
|
||||
y: p_full.y - tile_b.y,
|
||||
@@ -3295,7 +3305,19 @@ impl OpenCADStudio {
|
||||
{
|
||||
if let Some(handle) = self.tabs[i].selected_handle {
|
||||
let is_paper = self.tabs[i].scene.current_layout != "Model";
|
||||
let grip_hit = if is_paper {
|
||||
// In-viewport grips are model-space; project them with the
|
||||
// viewport camera so they hit-test where the GPU draws
|
||||
// them. Paper-space entities use the 2-D paper transform;
|
||||
// the model tab uses the model camera.
|
||||
let grip_hit = if let Some(cam) = &edit_cam {
|
||||
find_hit_grip_rte(
|
||||
p,
|
||||
&self.tabs[i].selected_grips,
|
||||
cam.view_proj_rte(bounds),
|
||||
cam.eye_f64(),
|
||||
bounds,
|
||||
)
|
||||
} else if is_paper {
|
||||
let cam = self.tabs[i].scene.camera.borrow();
|
||||
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
|
||||
let half_h = cam.ortho_size();
|
||||
@@ -3420,11 +3442,22 @@ impl OpenCADStudio {
|
||||
// camera + bounds. `p_full` keeps the canvas point for the
|
||||
// box/poly selection rectangle (drawn in canvas space).
|
||||
let p_full = p;
|
||||
let (tile_vw, tile_vh, tile_off) = {
|
||||
let (svw, svh) = self.tabs[i].scene.selection.borrow().vp_size;
|
||||
let tb = self.tabs[i].scene.active_model_tile_bounds(svw, svh);
|
||||
(tb.width, tb.height, iced::Point::new(tb.x, tb.y))
|
||||
// Inside a floating viewport the pane is the viewport's own rect
|
||||
// and camera (see the MoveCursor path); pick / snap then run in
|
||||
// model space exactly where the GPU draws the content.
|
||||
let canvas_sz = self.tabs[i].scene.selection.borrow().vp_size;
|
||||
let edit_frame = self.tabs[i].scene.viewport_edit_frame(canvas_sz);
|
||||
let (tile_vw, tile_vh, tile_off) = match &edit_frame {
|
||||
Some((_, full)) => (full.width, full.height, iced::Point::new(full.x, full.y)),
|
||||
None => {
|
||||
let tb = self
|
||||
.tabs[i]
|
||||
.scene
|
||||
.active_model_tile_bounds(canvas_sz.0, canvas_sz.1);
|
||||
(tb.width, tb.height, iced::Point::new(tb.x, tb.y))
|
||||
}
|
||||
};
|
||||
let edit_cam = edit_frame.map(|(cam, _)| cam);
|
||||
let p = iced::Point {
|
||||
x: p_full.x - tile_off.x,
|
||||
y: p_full.y - tile_off.y,
|
||||
@@ -3449,35 +3482,24 @@ impl OpenCADStudio {
|
||||
let tangent_obj_at_click = snap_taken.and_then(|s| s.tangent_obj);
|
||||
|
||||
let world_pt = {
|
||||
// Project screen point onto the active UCS XY plane (or world XY when
|
||||
// no UCS is active).
|
||||
let raw_paper = if let Some(ref ucs) = self.tabs[i].active_ucs {
|
||||
let origin = glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z);
|
||||
let normal = ucs_z_axis(ucs);
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.pick_on_plane(p, bounds, normal, origin)
|
||||
} else {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.pick_on_target_plane(p, bounds)
|
||||
// Cursor → model point (viewport camera inside a viewport,
|
||||
// else paper sheet → model). Model space throughout.
|
||||
let raw = self.cursor_model_point(i, &edit_cam, p, bounds);
|
||||
let (view_rot, eye) = match &edit_cam {
|
||||
Some(cam) => (cam.view_proj_rte(bounds), cam.eye_f64()),
|
||||
None => {
|
||||
let c = self.tabs[i].scene.camera.borrow();
|
||||
(c.view_proj_rte(bounds), c.eye_f64())
|
||||
}
|
||||
};
|
||||
// Convert paper-space → model-space when inside a viewport.
|
||||
let raw = self.tabs[i].scene.paper_to_model(raw_paper);
|
||||
// Inside a viewport snapping runs in paper coords (the
|
||||
// hit-test wires are paper-projected); the snapped paper
|
||||
// point is mapped back to model below.
|
||||
let snap_cursor = if self.tabs[i].scene.active_viewport.is_some() {
|
||||
raw_paper
|
||||
let snap_cursor = raw;
|
||||
let all_wires = if let (Some(_), Some(h)) =
|
||||
(&edit_cam, self.tabs[i].scene.active_viewport)
|
||||
{
|
||||
self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height)
|
||||
} else {
|
||||
raw
|
||||
self.tabs[i].scene.hit_test_wires()
|
||||
};
|
||||
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) };
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let needs_tan = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
@@ -3498,11 +3520,8 @@ impl OpenCADStudio {
|
||||
self.snapper.from_point = self.last_point;
|
||||
self.snapper.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr)
|
||||
};
|
||||
// snap.world is in paper-space (projected wire coords in MSPACE);
|
||||
// convert to model-space so commands receive consistent coordinates.
|
||||
let mut pt = snap_hit
|
||||
.map(|s| self.tabs[i].scene.paper_to_model(s.world))
|
||||
.unwrap_or(raw);
|
||||
// Snap runs in model space; the result is already model.
|
||||
let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw);
|
||||
// When no UCS is active clamp to world XY; with a UCS the point is
|
||||
// already constrained to that plane by the ray–plane intersection.
|
||||
if self.tabs[i].active_ucs.is_none() {
|
||||
@@ -3607,8 +3626,7 @@ impl OpenCADStudio {
|
||||
.map(|c| c.needs_entity_pick())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let (view_rot2, eye2) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) };
|
||||
let all_wires2 = self.tabs[i].scene.hit_test_wires();
|
||||
let (view_rot2, eye2, all_wires2) = self.pick_view(i, &edit_cam, bounds);
|
||||
let hit = scene::pick::hit_test::click_hit(p, &all_wires2[..], view_rot2, eye2, bounds)
|
||||
.and_then(|s| Scene::handle_from_wire_name(s));
|
||||
if let Some(handle) = hit {
|
||||
@@ -3796,8 +3814,7 @@ impl OpenCADStudio {
|
||||
if elapsed_ms < POLY_START_DELAY_MS {
|
||||
if let Some(a) = box_anchor {
|
||||
let crossing = box_crossing;
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) };
|
||||
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
|
||||
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
|
||||
a,
|
||||
p,
|
||||
@@ -3857,8 +3874,7 @@ impl OpenCADStudio {
|
||||
(pts, sel.poly_crossing)
|
||||
};
|
||||
self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing;
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) };
|
||||
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
|
||||
let mut handles: Vec<Handle> = scene::pick::hit_test::poly_hit(
|
||||
&poly_pts,
|
||||
crossing,
|
||||
@@ -3911,8 +3927,8 @@ impl OpenCADStudio {
|
||||
sel.box_current = None;
|
||||
} else {
|
||||
if box_anchor.is_none() {
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) };
|
||||
let (view_rot, eye, all_wires) =
|
||||
self.pick_view(i, &edit_cam, bounds);
|
||||
|
||||
// Selection cycling: where two or more objects
|
||||
// overlap, open a list box to pick which one; a
|
||||
@@ -4004,8 +4020,7 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
let a = box_anchor.unwrap();
|
||||
let crossing = box_crossing;
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) };
|
||||
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
|
||||
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
|
||||
a,
|
||||
p,
|
||||
@@ -4463,8 +4478,16 @@ impl OpenCADStudio {
|
||||
height: dwell.tile_size.1,
|
||||
};
|
||||
let p = dwell.point;
|
||||
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) };
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
// Inside a viewport, hover-pick through the viewport camera +
|
||||
// model wires so the rollover highlights the entity under the
|
||||
// cursor (dwell.point / dwell.tile_size are already pane-local).
|
||||
let canvas_sz = self.tabs[i].scene.selection.borrow().vp_size;
|
||||
let edit_cam = self
|
||||
.tabs[i]
|
||||
.scene
|
||||
.viewport_edit_frame(canvas_sz)
|
||||
.map(|(cam, _)| cam);
|
||||
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
|
||||
// Mirror the click-selection pick order so the rollover
|
||||
// highlights every selectable object: wire → hatch →
|
||||
// block-internal hatch → shaded 3D solid body.
|
||||
@@ -4617,7 +4640,7 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleGridSnap => {
|
||||
self.snapper.toggle(crate::snap::SnapType::Grid);
|
||||
self.snapper.toggle_grid_snap();
|
||||
self.sync_vport_display(self.active_tab);
|
||||
Task::none()
|
||||
}
|
||||
@@ -8287,6 +8310,70 @@ impl OpenCADStudio {
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
}
|
||||
|
||||
/// Project a pane-local cursor onto the active drawing plane and return a
|
||||
/// **model-space** point. Inside a floating viewport `edit_cam` is the
|
||||
/// viewport's own camera (a target-plane / UCS pick there already yields
|
||||
/// model coords); otherwise the paper camera projects onto the sheet and
|
||||
/// the result is mapped paper→model. Used by the readout, snap and click
|
||||
/// paths so all three agree on the cursor's model location.
|
||||
fn cursor_model_point(
|
||||
&self,
|
||||
i: usize,
|
||||
edit_cam: &Option<crate::scene::view::camera::Camera>,
|
||||
p: iced::Point,
|
||||
bounds: iced::Rectangle,
|
||||
) -> glam::DVec3 {
|
||||
let plane = self.tabs[i].active_ucs.as_ref().map(|ucs| {
|
||||
(
|
||||
ucs_z_axis(ucs),
|
||||
glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z),
|
||||
)
|
||||
});
|
||||
let pick = |cam: &crate::scene::view::camera::Camera| match plane {
|
||||
Some((normal, origin)) => cam.pick_on_plane(p, bounds, normal, origin),
|
||||
None => cam.pick_on_target_plane(p, bounds),
|
||||
};
|
||||
match edit_cam {
|
||||
Some(cam) => pick(cam),
|
||||
None => {
|
||||
let paper = {
|
||||
let c = self.tabs[i].scene.camera.borrow();
|
||||
pick(&c)
|
||||
};
|
||||
self.tabs[i].scene.paper_to_model(paper)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Projection + hit-test wires for the active pane. Inside a floating
|
||||
/// viewport (`edit_cam` Some) it returns the viewport camera and the live
|
||||
/// **model** wires, so wire / hatch picking lands on the entity under the
|
||||
/// cursor exactly where the GPU draws it; otherwise the model/paper camera
|
||||
/// and the normal hit-test wires. `bounds` is the pane-local rectangle.
|
||||
fn pick_view(
|
||||
&self,
|
||||
i: usize,
|
||||
edit_cam: &Option<crate::scene::view::camera::Camera>,
|
||||
bounds: iced::Rectangle,
|
||||
) -> (glam::Mat4, glam::DVec3, std::sync::Arc<Vec<crate::scene::WireModel>>) {
|
||||
match edit_cam {
|
||||
Some(cam) => {
|
||||
let wires = match self.tabs[i].scene.active_viewport {
|
||||
Some(h) => self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height),
|
||||
None => self.tabs[i].scene.hit_test_wires(),
|
||||
};
|
||||
(cam.view_proj_rte(bounds), cam.eye_f64(), wires)
|
||||
}
|
||||
None => {
|
||||
let (view_rot, eye) = {
|
||||
let c = self.tabs[i].scene.camera.borrow();
|
||||
(c.view_proj_rte(bounds), c.eye_f64())
|
||||
};
|
||||
(view_rot, eye, self.tabs[i].scene.hit_test_wires())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_grip_hover(&mut self, i: usize, p: iced::Point) {
|
||||
const HOVER_OPEN_MS: u128 = 600;
|
||||
const POPUP_DISMISS_PX: f32 = 80.0;
|
||||
@@ -8311,7 +8398,25 @@ impl OpenCADStudio {
|
||||
height: vh,
|
||||
};
|
||||
let is_paper = self.tabs[i].scene.current_layout != "Model";
|
||||
let hit = if is_paper {
|
||||
// In-viewport grips are model-space — project with the viewport camera
|
||||
// at the viewport's own rect; the cursor is mapped into that rect.
|
||||
let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh));
|
||||
let hit = if let Some((cam, full)) = &edit_frame {
|
||||
let local = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: full.width,
|
||||
height: full.height,
|
||||
};
|
||||
let p_local = iced::Point::new(p.x - full.x, p.y - full.y);
|
||||
find_hit_grip_rte(
|
||||
p_local,
|
||||
&self.tabs[i].selected_grips,
|
||||
cam.view_proj_rte(local),
|
||||
cam.eye_f64(),
|
||||
local,
|
||||
)
|
||||
} else if is_paper {
|
||||
let cam = self.tabs[i].scene.camera.borrow();
|
||||
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
|
||||
let half_h = cam.ortho_size();
|
||||
|
||||
+22
-5
@@ -3,7 +3,7 @@ use super::document::{DynComponent, DynFieldEntry};
|
||||
use super::helpers::grid_plane_from_camera;
|
||||
use super::history::history_dropdown_labels;
|
||||
use super::{Message, OpenCADStudio};
|
||||
use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper};
|
||||
use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte};
|
||||
use crate::scene::view::viewport_pane::ViewportPane;
|
||||
use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD};
|
||||
use crate::ui::overlay;
|
||||
@@ -86,9 +86,26 @@ impl OpenCADStudio {
|
||||
// they must use the active tile's screen rectangle (with
|
||||
// its canvas offset) — not the whole canvas — or they
|
||||
// land in the wrong place in a tiled layout.
|
||||
let bounds = tab.scene.active_model_tile_bounds(vw, vh);
|
||||
// Inside a floating viewport the pane is the viewport's own
|
||||
// rect + camera; otherwise the active model tile.
|
||||
let edit_frame = tab.scene.viewport_edit_frame((vw, vh));
|
||||
let bounds = match &edit_frame {
|
||||
Some((_, full)) => *full,
|
||||
None => tab.scene.active_model_tile_bounds(vw, vh),
|
||||
};
|
||||
let sel_h = tab.selected_handle;
|
||||
let screen_grips = if is_paper {
|
||||
// In-viewport grips are model-space; project them with the
|
||||
// viewport camera so they sit on the wire the GPU draws.
|
||||
// Paper entities use the 2-D paper transform; the model tab
|
||||
// uses the model camera.
|
||||
let screen_grips = if let Some((cam, _)) = &edit_frame {
|
||||
grips_to_screen_rte(
|
||||
&tab.selected_grips,
|
||||
cam.view_proj_rte(bounds),
|
||||
cam.eye_f64(),
|
||||
bounds,
|
||||
)
|
||||
} else if is_paper {
|
||||
let cam = tab.scene.camera.borrow();
|
||||
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
|
||||
let half_h = cam.ortho_size();
|
||||
@@ -388,7 +405,7 @@ impl OpenCADStudio {
|
||||
let bar = viewport_controls(
|
||||
tab.render_mode,
|
||||
self.show_grid,
|
||||
self.snapper.is_on(crate::snap::SnapType::Grid),
|
||||
self.snapper.grid_snap(),
|
||||
true,
|
||||
);
|
||||
// Position the bar at the active model tile's top-left corner so
|
||||
@@ -464,7 +481,7 @@ impl OpenCADStudio {
|
||||
iced::widget::opaque(viewport_controls(
|
||||
vp_mode,
|
||||
self.show_grid,
|
||||
self.snapper.is_on(crate::snap::SnapType::Grid),
|
||||
self.snapper.grid_snap(),
|
||||
false,
|
||||
)),
|
||||
],
|
||||
|
||||
+31
-4
@@ -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
|
||||
|
||||
+66
-1
@@ -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
|
||||
}
|
||||
+61
-19
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user