feat(scene): build a planar ViewportFrame from a layout viewport's live camera

`Scene::viewport_frame` derives the paper<->model mapping (paper_center,
model_target, paper-per-model scale, in-plane twist, lock state) from
`camera_for_viewport` — the same camera the renderer displays the viewport's
contents with — so a snap computed through the frame lands on the pixels the
GPU drew. Oblique / mirrored / perspective viewports return None.

Also generalises the MSPACE editing adapter: `viewport_edit_frame_for(handle)`
returns the camera + screen rect for any viewport, and `viewport_edit_frame`
becomes a wrapper over it for the active one. Adds
`viewport_displays_paper_point` (rectangular bounds plus a non-rectangular clip
boundary polygon, tested in paper coordinates) and public accessors for the
layout's content viewports and their VP ON state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Christopher OverbeckandClaude Fable 5.1 committed 2026-09-15 12:27:13 -05:00
1 parent 49fe48f89c
commit 53576fabdf
1 file changed
+171 -1
+171 -1
View File
@@ -80,12 +80,162 @@ impl Scene {
&self,
canvas_px: (f32, f32),
) -> Option<(view::camera::Camera, iced::Rectangle)> {
let vp_handle = self.active_viewport?;
self.viewport_edit_frame_for(self.active_viewport?, canvas_px)
}
/// [`viewport_edit_frame`] for an explicitly-named viewport, rather than
/// only the active (MSPACE) one.
///
/// Paper-space snapping through viewports (PR1) needs the same camera +
/// screen rectangle pair for a viewport the user has NOT entered, so the
/// existing snap engine can run against that viewport's model wires on the
/// pixels the GPU actually drew them on. `viewport_edit_frame` is now a
/// thin wrapper over this.
pub fn viewport_edit_frame_for(
&self,
vp_handle: Handle,
canvas_px: (f32, f32),
) -> Option<(view::camera::Camera, iced::Rectangle)> {
let cam = self.camera_for_viewport(vp_handle)?;
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
Some((cam, full))
}
/// Planar paper <-> model mapping for a layout viewport, derived from the
/// *same* camera the renderer displays its contents with
/// ([`camera_for_viewport`]) so a snap computed through the frame lands on
/// the pixels the GPU drew.
///
/// Returns `None` for a viewport whose view is not an in-plane orthographic
/// top view (oblique / isometric / perspective viewports are unsupported by
/// the planar frame — callers fall back to no viewport snapping there).
pub fn viewport_frame(&self, vp_handle: Handle) -> Option<crate::scene::viewport_ref::ViewportFrame> {
use crate::scene::viewport_ref::ViewportFrame;
let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => (
glam::DVec2::new(vp.center.x, vp.center.y),
vp.height,
vp.status.locked,
),
_ => return None,
};
if vp_height.abs() < 1e-9 {
return None;
}
let cam = self.camera_for_viewport(vp_handle)?;
if cam.projection != view::camera::Projection::Orthographic {
return None;
}
// Screen right/up of the viewport camera, in model space. A planar
// frame exists only when both stay in the model XY plane and form a
// right-handed pair (a plan view, possibly twisted). Anything else is
// an oblique 3-D view: unsupported.
let right = (cam.rotation * glam::Vec3::X).as_dvec3();
let up = (cam.rotation * glam::Vec3::Y).as_dvec3();
if right.z.abs() > 1e-6 || up.z.abs() > 1e-6 {
return None;
}
let normal = right.cross(up);
if normal.z < 1.0 - 1e-6 {
return None; // mirrored or degenerate
}
// Model units visible across the viewport's height -> paper per model.
let model_height = cam.ortho_size() as f64 * 2.0;
if model_height.abs() < 1e-12 {
return None;
}
let scale = vp_height / model_height;
// `ViewportFrame` rotates model geometry by `twist` (CCW) before
// scaling; the row that produces the paper X component is `right`, so
// cos(twist) = right.x and sin(twist) = -right.y.
let twist = (-right.y).atan2(right.x);
Some(ViewportFrame {
viewport: vp_handle,
paper_center,
model_target: glam::DVec2::new(cam.target.x as f64, cam.target.y as f64),
scale,
twist,
locked,
})
}
/// Does `paper` lie inside the viewport's displayed area?
///
/// Always bounded by the viewport's paper rectangle; a viewport with a
/// `clip_boundary_handle` (non-rectangular / clipped viewport) additionally
/// has to contain the point inside that boundary polygon. Used so a
/// paper-space snap never reports a model feature the viewport does not
/// actually show.
pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool {
let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else {
return false;
};
let hw = (vp.width * 0.5).abs();
let hh = (vp.height * 0.5).abs();
if hw < 1e-9 || hh < 1e-9 {
return false;
}
if (paper.x - vp.center.x).abs() > hw || (paper.y - vp.center.y).abs() > hh {
return false;
}
if vp.clip_boundary_handle.is_null() {
return true;
}
let poly = self.clip_boundary_polygon(vp.clip_boundary_handle, vp.center.z as f32);
if poly.len() < 3 {
// A clip entity we cannot tessellate: fall back to the rectangle
// rather than refusing every snap in the viewport.
return true;
}
point_in_polygon_xy(paper, &poly)
}
}
/// Even-odd point-in-polygon test on the XY plane (`poly` is a closed ring of
/// `[x, y, z]` paper coordinates; the closing edge is implicit).
fn point_in_polygon_xy(p: glam::DVec2, poly: &[[f32; 3]]) -> bool {
let mut inside = false;
let mut j = poly.len() - 1;
for i in 0..poly.len() {
let (xi, yi) = (poly[i][0] as f64, poly[i][1] as f64);
let (xj, yj) = (poly[j][0] as f64, poly[j][1] as f64);
if (yi > p.y) != (yj > p.y) {
let t = (p.y - yi) / (yj - yi);
if p.x < xi + t * (xj - xi) {
inside = !inside;
}
}
j = i;
}
inside
}
#[cfg(test)]
mod clip_tests {
use super::point_in_polygon_xy;
#[test]
fn concave_clip_boundary_rejects_the_notch() {
// An L: the notch at (8, 8) is outside even though it is inside the
// bounding rectangle.
let poly = [
[0.0f32, 0.0, 0.0],
[10.0, 0.0, 0.0],
[10.0, 5.0, 0.0],
[5.0, 5.0, 0.0],
[5.0, 10.0, 0.0],
[0.0, 10.0, 0.0],
];
assert!(point_in_polygon_xy(glam::DVec2::new(2.0, 2.0), &poly));
assert!(point_in_polygon_xy(glam::DVec2::new(8.0, 2.0), &poly));
assert!(!point_in_polygon_xy(glam::DVec2::new(8.0, 8.0), &poly));
assert!(!point_in_polygon_xy(glam::DVec2::new(-1.0, 5.0), &poly));
}
}
impl Scene {
/// 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
@@ -524,6 +674,26 @@ impl Scene {
.map(|(h, _)| h)
}
/// Content (floating) viewports of the current paper layout, in draw order
/// (the last entry draws on top). Empty in the Model layout.
pub fn layout_content_viewports(&self) -> Arc<Vec<Handle>> {
if self.current_layout == "Model" {
return Arc::new(Vec::new());
}
let (_, _, handles) = self.paper_viewport_handles();
handles
}
/// `true` when this viewport's contents are displayed (VP ON). The border
/// layer's visibility deliberately does NOT matter: a viewport whose frame
/// layer is off still shows its model content, so it still snaps.
pub fn viewport_displays_content(&self, vp_handle: Handle) -> bool {
matches!(
self.document.get_entity(vp_handle),
Some(EntityType::Viewport(vp)) if vp.status.is_on
)
}
/// Return the handle of the first active user viewport in the current layout,
/// or `None` if there are none. Used by the MS command.
pub fn first_user_viewport(&self) -> Option<Handle> {