wip(viewport): render UTM viewport content; _f64 cleanup; paper hit-test

Checkpoint on the way to unified in-viewport editing.

- Drop the _f64 transition suffixes: pick_on_plane / pick_on_target_plane /
  paper_to_model are single f64 functions (wrappers removed).
- Paper-space hit-test/snap projects viewport content with points_low, and
  projects its aabb / key_vertices / snap_pts into paper coords so click
  picking and snap candidates land on the visible sheet geometry.
- camera_for_viewport + paper_to_model + EnterViewport agree on an absolute
  drawing-centre view (normalize_active_viewport_view) so UTM viewports
  render instead of aiming at the origin.
- model_to_paper inverse; dynamic-input anchors map model→paper before the
  paper camera projects them, so the guide no longer tessellates a 512 MB
  buffer (clicking the first in-viewport LINE point) — crash fixed.

Known-incomplete (next: unify on the model-space path via one coordinate
adapter): in-viewport snaps stale after pan/zoom (paper_projected_cache not
keyed on the viewport view), grips don't show, command preview misplaced.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-24 14:53:38 +03:00
1 parent f9b27e7b62
commit 4aa8e6681f
3 files changed
+209 -89

No files matched your search

+62 -25
View File
@@ -2617,10 +2617,10 @@ impl OpenCADStudio {
height: vh,
};
let cam = self.tabs[i].scene.camera.borrow();
let raw_paper = cam.pick_on_target_plane_f64(p, bounds);
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_f64(raw_paper);
let raw = self.tabs[i].scene.paper_to_model(raw_paper);
// First move of this drag: hide the edited entity from the
// base tessellation (one re-tess) so subsequent moves only
@@ -2714,11 +2714,7 @@ impl OpenCADStudio {
height: vp_size.1,
};
let paper = if let Some(ref ucs) = self.tabs[i].active_ucs {
let origin = glam::Vec3::new(
ucs.origin.x as f32,
ucs.origin.y as f32,
ucs.origin.z as f32,
);
let origin = glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z);
let normal = ucs_z_axis(ucs);
self.tabs[i]
.scene
@@ -2733,7 +2729,7 @@ impl OpenCADStudio {
.pick_on_target_plane(p, bounds)
};
let world = self.tabs[i].scene.paper_to_model(paper);
self.tabs[i].last_cursor_world = world;
self.tabs[i].last_cursor_world = world.as_vec3();
}
// Rollover highlight: when idle (no active command, no
@@ -2772,13 +2768,13 @@ impl OpenCADStudio {
.scene
.camera
.borrow()
.pick_on_plane_f64(p, bounds, normal, origin)
.pick_on_plane(p, bounds, normal, origin)
} else {
self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane_f64(p, bounds)
.pick_on_target_plane(p, bounds)
};
let (view_rot, eye) = {
let cam = self.tabs[i].scene.camera.borrow();
@@ -2789,7 +2785,17 @@ impl OpenCADStudio {
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_f64(cursor_paper);
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
};
let all_wires = self.tabs[i].scene.hit_test_wires();
let needs_entity = self.tabs[i]
@@ -2816,7 +2822,7 @@ impl OpenCADStudio {
None
} else if needs_tan {
self.snapper.snap_tangent_only(
cursor_world.as_vec3(),
snap_cursor.as_vec3(),
p,
&all_wires[..],
view_rot,
@@ -2827,7 +2833,7 @@ impl OpenCADStudio {
let (go, gr) = self.tabs[i].ucs_grid_basis();
self.snapper.from_point = self.last_point;
self.snapper
.snap(cursor_world, p, &all_wires[..], view_rot, eye, bounds, go, gr)
.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr)
};
// Object Snap Tracking: update dwell, then align the cursor
@@ -2874,7 +2880,7 @@ impl OpenCADStudio {
// wires; convert to model-space for previews.
let mut pt = self.tabs[i]
.snap_result
.map(|s| self.tabs[i].scene.paper_to_model_f64(s.world))
.map(|s| self.tabs[i].scene.paper_to_model(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
@@ -2918,16 +2924,34 @@ 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.
let project = |bp: glam::Vec3| {
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,
)
};
// 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();
let anchor = self.tabs[i].dyn_anchor.or(self.last_point);
self.tabs[i].last_point_screen = anchor.map(project);
self.tabs[i].dyn_ref_screen = self.tabs[i].dyn_ref.map(project);
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)));
self.tabs[i].last_point_screen = lps;
self.tabs[i].dyn_ref_screen = drs;
// Entity-pick previews (TRIM/EXTEND/FILLET…) compare the
// cursor against WCS document entities and return WCS wires.
@@ -3434,16 +3458,24 @@ impl OpenCADStudio {
.scene
.camera
.borrow()
.pick_on_plane_f64(p, bounds, normal, origin)
.pick_on_plane(p, bounds, normal, origin)
} else {
self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane_f64(p, bounds)
.pick_on_target_plane(p, bounds)
};
// Convert paper-space → model-space when inside a viewport.
let raw = self.tabs[i].scene.paper_to_model_f64(raw_paper);
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
} else {
raw
};
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]
@@ -3460,16 +3492,16 @@ impl OpenCADStudio {
None
} else if needs_tan {
self.snapper
.snap_tangent_only(raw.as_vec3(), p, &all_wires[..], view_rot, eye, bounds)
.snap_tangent_only(snap_cursor.as_vec3(), p, &all_wires[..], view_rot, eye, bounds)
} else {
let (go, gr) = self.tabs[i].ucs_grid_basis();
self.snapper.from_point = self.last_point;
self.snapper.snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr)
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_f64(s.world))
.map(|s| self.tabs[i].scene.paper_to_model(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.
@@ -4178,7 +4210,7 @@ impl OpenCADStudio {
.pick_on_target_plane(p, bounds);
self.tabs[i]
.scene
.viewport_at_paper_point(paper_pt.x, paper_pt.y)
.viewport_at_paper_point(paper_pt.x as f32, paper_pt.y as f32)
});
if let Some(handle) = hit_vp {
@@ -4284,7 +4316,7 @@ impl OpenCADStudio {
.camera
.borrow()
.pick_on_target_plane(cp, bounds);
glam::Vec2::new(pt.x, pt.y)
glam::Vec2::new(pt.x as f32, pt.y as f32)
});
self.tabs[i].scene.zoom_active_viewport(s, cursor_paper);
// Bump so the GPU re-uploads the viewport's re-culled wire
@@ -6151,6 +6183,11 @@ impl OpenCADStudio {
// Clear paper-space selection before entering model space.
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.active_viewport = Some(handle);
// Fold a stale UTM saved view onto the effective (auto-fit)
// centre so pan/zoom, paper↔model and the display all agree —
// otherwise the camera auto-fits to the model while the cursor
// math stays at the origin, jittering as pan toggles the two.
self.tabs[i].scene.normalize_active_viewport_view();
// Grid/snap follow the entered viewport.
self.adopt_view_display(i);
self.refresh_properties();
+142 -41
View File
@@ -3483,49 +3483,49 @@ impl Scene {
let scale_d = scale as f64;
let pcx_d = pcx as f64;
let pcy_d = pcy as f64;
let [wo_x, wo_y, wo_z] = [0.0_f64; 3];
// Project one ABSOLUTE-WCS model point (f64) onto the paper sheet.
// Shared by the polyline points, snap points and key vertices so the
// hit-test / snap geometry lands in the same paper frame the wire is
// drawn in — otherwise snaps and the click-AABB stay in model (UTM)
// space and the cursor never reaches them.
let proj_abs = |ax: f64, ay: f64, az: f64| -> [f32; 3] {
let mp_x = ax - display_center_x;
let mp_y = ay - display_center_y;
let mp_z = az - display_center_z;
let u = mp_x * view_right_d.0 + mp_y * view_right_d.1 + mp_z * view_right_d.2;
let v = mp_x * view_up_d.0 + mp_y * view_up_d.1 + mp_z * view_up_d.2;
if use_perspective {
let d_vd = mp_x * view_fwd_d.0 + mp_y * view_fwd_d.1 + mp_z * view_fwd_d.2;
let fwd = camera_dist_d - d_vd;
if fwd <= 0.001 {
return [f32::NAN; 3];
}
let factor = camera_dist_d / fwd;
[
(pcx_d + u * factor * scale_d) as f32,
(pcy_d + v * factor * scale_d) as f32,
pcz,
]
} else {
[(pcx_d + u * scale_d) as f32, (pcy_d + v * scale_d) as f32, pcz]
}
};
let in_vp = |x: f32, y: f32| x >= vp_x0 && x <= vp_x1 && y >= vp_y0 && y <= vp_y1;
for wire in model_wires.iter() {
let projected_pts: Vec<[f32; 3]> = wire
.points
.iter()
.map(|&[mx, my, mz]| {
.enumerate()
.map(|(pi, &[mx, my, mz])| {
if mx.is_nan() || my.is_nan() || mz.is_nan() {
return [f32::NAN; 3];
}
// wire stored offset-rel; reconstruct WCS in f64
// then subtract display center in WCS → small
// f64 mp_proj with full precision.
let mp_x = (mx as f64 + wo_x) - display_center_x;
let mp_y = (my as f64 + wo_y) - display_center_y;
let mp_z = (mz as f64 + wo_z) - display_center_z;
let u = mp_x * view_right_d.0
+ mp_y * view_right_d.1
+ mp_z * view_right_d.2;
let v = mp_x * view_up_d.0
+ mp_y * view_up_d.1
+ mp_z * view_up_d.2;
if use_perspective {
let d_vd = mp_x * view_fwd_d.0
+ mp_y * view_fwd_d.1
+ mp_z * view_fwd_d.2;
let fwd = camera_dist_d - d_vd;
if fwd <= 0.001 {
return [f32::NAN; 3];
}
let factor = camera_dist_d / fwd;
[
(pcx_d + u * factor * scale_d) as f32,
(pcy_d + v * factor * scale_d) as f32,
pcz,
]
} else {
[
(pcx_d + u * scale_d) as f32,
(pcy_d + v * scale_d) as f32,
pcz,
]
}
// Reconstruct absolute WCS from the double-single high
// (`points`) + low (`points_low`) pair — the high f32
// alone is ~0.5 m off at UTM scale.
let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]);
proj_abs(mx as f64 + lo[0] as f64, my as f64 + lo[1] as f64, mz as f64 + lo[2] as f64)
})
.collect();
@@ -3562,10 +3562,63 @@ impl Scene {
continue;
}
// Paper-space AABB of the clipped polyline — the cloned model
// (UTM) AABB would make click_hit's screen-projected pre-reject
// discard the wire (box selection has no pre-reject, which is why
// it kept working while picking didn't).
let mut pmnx = f32::INFINITY;
let mut pmny = f32::INFINITY;
let mut pmxx = f32::NEG_INFINITY;
let mut pmxy = f32::NEG_INFINITY;
for &[x, y, _] in clipped.iter().filter(|p| p[0].is_finite()) {
pmnx = pmnx.min(x);
pmny = pmny.min(y);
pmxx = pmxx.max(x);
pmxy = pmxy.max(y);
}
// Project snap points + key vertices into the same paper frame,
// keeping only those inside the viewport rect, so endpoint /
// midpoint / centre snaps land on the visible sheet geometry
// instead of the model's UTM coordinates.
let snap_pts: Vec<(glam::DVec3, model::wire_model::SnapHint)> = wire
.snap_pts
.iter()
.filter_map(|(w, h)| {
let p = proj_abs(w.x, w.y, w.z);
(p[0].is_finite() && in_vp(p[0], p[1]))
.then(|| (glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), *h))
})
.collect();
let key_vertices: Vec<[f64; 3]> = wire
.key_vertices
.iter()
.filter_map(|&[kx, ky, kz]| {
let p = proj_abs(kx, ky, kz);
(p[0].is_finite() && in_vp(p[0], p[1]))
.then(|| [p[0] as f64, p[1] as f64, p[2] as f64])
})
.collect();
let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color);
let [r, g, b, a] = adapted;
let mut out = wire.clone();
out.points = clipped;
// Paper coordinates are small sheet units — no relative-to-eye
// residual is needed, and keeping the model wire's points_low
// here would add a model-scale offset to the paper points.
out.points_low = Vec::new();
out.snap_pts = snap_pts;
out.key_vertices = key_vertices;
// Tangent geometry is in model space and can't be trivially
// re-expressed in paper coords — drop it (no tangent snap on
// projected viewport content) rather than snap to UTM.
out.tangent_geoms = Vec::new();
out.aabb = if pmnx.is_finite() {
[pmnx, pmny, pmxx, pmxy]
} else {
WireModel::UNBOUNDED_AABB
};
out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
out.line_weight_px = wire.line_weight_px;
// Wire's pattern was sized for model-space coords during
@@ -3593,12 +3646,8 @@ impl Scene {
/// Convert a **paper-space** world coordinate to **model-space** using the
/// geometry of the currently active viewport. Returns the input unchanged
/// when there is no active viewport.
pub fn paper_to_model(&self, paper_pt: glam::Vec3) -> glam::Vec3 {
self.paper_to_model_f64(paper_pt.as_dvec3()).as_vec3()
}
/// f64 variant of [`paper_to_model`] — preserves UTM-scale precision.
pub fn paper_to_model_f64(&self, paper_pt: glam::DVec3) -> glam::DVec3 {
/// Convert a paper-space point to model space (precise at UTM scale).
pub fn paper_to_model(&self, paper_pt: glam::DVec3) -> glam::DVec3 {
let vp_handle = match self.active_viewport {
Some(h) => h,
None => return paper_pt,
@@ -3607,6 +3656,10 @@ impl Scene {
Some(acadrust::EntityType::Viewport(vp)) => vp,
_ => return paper_pt,
};
// Uses the viewport's own `view_target` — kept valid by
// `normalize_active_viewport_view` on entry, which folds a stale UTM
// saved view onto the auto-fit centre so the display, pan/zoom and this
// inverse all agree. Cheap (no per-call camera rebuild).
let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
if scale.abs() < 1e-9 {
return paper_pt;
@@ -3622,6 +3675,54 @@ 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.
pub fn model_to_paper(&self, model_pt: glam::DVec3) -> glam::DVec3 {
let vp_handle = match self.active_viewport {
Some(h) => h,
None => return model_pt,
};
let vp = match self.document.get_entity(vp_handle) {
Some(acadrust::EntityType::Viewport(vp)) => vp,
_ => return model_pt,
};
let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
glam::DVec3::new(
(model_pt.x - vp.view_target.x) * scale + vp.center.x,
(model_pt.y - vp.view_target.y) * scale + vp.center.y,
model_pt.z,
)
}
/// 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
/// rendered content all share one valid view — otherwise a stale `(0,0,0)`
/// target left the camera auto-fitting to the model centre while the cursor
/// math used the origin, and pan toggled the two (jitter).
pub fn normalize_active_viewport_view(&mut self) {
let Some(vp_handle) = self.active_viewport else {
return;
};
let Some(cam) = self.camera_for_viewport(vp_handle) else {
return;
};
let eff_h = cam.ortho_size() as f64 * 2.0;
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
vp.view_target.x = cam.target.x;
vp.view_target.y = cam.target.y;
vp.view_center.x = 0.0;
vp.view_center.y = 0.0;
if eff_h > 1e-9 {
vp.view_height = eff_h;
}
}
}
/// Pan the active viewport's model-space view by `(screen_dx, screen_dy)` pixels.
/// The delta is converted to model-space units using the camera and viewport scale.
/// No-op when there is no active viewport.
+5 -23
View File
@@ -232,28 +232,10 @@ impl Camera {
/// Project a screen point onto an arbitrary world-space plane.
///
/// The plane is defined by `plane_normal` (unit vector) and a `plane_point`
/// that lies on it. Returns the intersection of the view ray with the plane;
/// falls back to `plane_point` when the ray is nearly parallel to the plane.
/// that lies on it. Returns the ray–plane intersection (falling back to
/// `plane_point` when nearly parallel), eye-relative in f64 so the cursor
/// stays precise at UTM-scale coordinates.
pub fn pick_on_plane(
&self,
screen: Point,
bounds: Rectangle,
plane_normal: Vec3,
plane_point: Vec3,
) -> Vec3 {
// Delegate to the eye-relative f64 unproject so the cursor stays
// precise at UTM-scale coordinates (the old full view_proj.inverse()
// cancelled catastrophically in f32).
self.unproject_on_plane_f64(screen, bounds, plane_normal, plane_point.as_dvec3())
.as_vec3()
}
pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> Vec3 {
self.pick_on_target_plane_f64(screen, bounds).as_vec3()
}
/// f64 variant of [`pick_on_plane`] — keeps the cursor precise at UTM-scale.
pub fn pick_on_plane_f64(
&self,
screen: Point,
bounds: Rectangle,
@@ -263,8 +245,8 @@ impl Camera {
self.unproject_on_plane_f64(screen, bounds, plane_normal, plane_point)
}
/// f64 variant of [`pick_on_target_plane`].
pub fn pick_on_target_plane_f64(&self, screen: Point, bounds: Rectangle) -> glam::DVec3 {
/// Project a screen point onto the plane through the orbit target.
pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> glam::DVec3 {
let forward = (self.target.as_vec3() - self.eye()).normalize_or(Vec3::NEG_Z);
self.unproject_on_plane_f64(screen, bounds, forward, self.target)
}