Keep resident geometry and GPU resources stable across edits, MSPACE zoom, and Model/Paper switches. Add unified PERF output for tracing remaining costs.
480 lines
22 KiB
Rust
480 lines
22 KiB
Rust
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
|
use super::*;
|
|
|
|
impl Scene {
|
|
// ── MSPACE helpers ───────────────────────────────────────────────────
|
|
|
|
/// 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.
|
|
/// 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,
|
|
};
|
|
let vp = match self.document.get_entity(vp_handle) {
|
|
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;
|
|
}
|
|
let tx = vp.view_target.x;
|
|
let ty = vp.view_target.y;
|
|
let pcx = vp.center.x;
|
|
let pcy = vp.center.y;
|
|
glam::DVec3::new(
|
|
(paper_pt.x - pcx) / scale + tx,
|
|
(paper_pt.y - pcy) / scale + ty,
|
|
paper_pt.z,
|
|
)
|
|
}
|
|
|
|
/// 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. 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,
|
|
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,
|
|
)
|
|
}
|
|
|
|
/// 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
|
|
/// 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.
|
|
pub fn pan_active_viewport(&mut self, screen_dx: f32, screen_dy: f32, bounds: iced::Rectangle) {
|
|
let vp_handle = match self.active_viewport {
|
|
Some(h) => h,
|
|
None => return,
|
|
};
|
|
// Use the viewport's own camera for the pan axes (matches 3-D view orientation).
|
|
let vp_cam = match self.camera_for_viewport(vp_handle) {
|
|
Some(c) => c,
|
|
None => return,
|
|
};
|
|
|
|
// Read viewport dims (immutable borrow ends here).
|
|
let (view_height, vp_height, locked) = match self.document.get_entity(vp_handle) {
|
|
Some(acadrust::EntityType::Viewport(vp)) => {
|
|
(vp.view_height as f32, vp.height as f32, vp.status.locked)
|
|
}
|
|
_ => return,
|
|
};
|
|
if locked {
|
|
return;
|
|
}
|
|
|
|
// Correct pan speed: how many model units correspond to one screen pixel.
|
|
//
|
|
// The paper camera's ortho_size() gives the visible paper-space half-height
|
|
// (in paper mm). One screen pixel = 2*half_h / canvas_height paper mm.
|
|
// Inside the viewport, one paper mm = view_height / vp_height model units.
|
|
// Together: model_per_pixel = (2*half_h / canvas_height) * (view_height / vp_height)
|
|
let paper_half_h = self.camera.borrow().ortho_size();
|
|
let speed = if bounds.height > 0.0 && paper_half_h > 1e-6 && vp_height > 1e-6 {
|
|
(2.0 * paper_half_h / bounds.height) * (view_height / vp_height)
|
|
} else {
|
|
vp_cam.distance * 0.001
|
|
};
|
|
|
|
let cam_right = vp_cam.rotation * glam::Vec3::X;
|
|
let cam_up = vp_cam.rotation * glam::Vec3::Y;
|
|
let model_delta = -(cam_right * screen_dx * speed) + (cam_up * screen_dy * speed);
|
|
|
|
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
|
vp.view_target.x += model_delta.x as f64;
|
|
vp.view_target.y += model_delta.y as f64;
|
|
vp.view_target.z += model_delta.z as f64;
|
|
}
|
|
}
|
|
|
|
/// Zoom the active viewport's model-space view by `steps` notches.
|
|
/// Positive = zoom in (increase detail), negative = zoom out.
|
|
/// `cursor_paper`: optional paper-space XY of the cursor; when supplied the
|
|
/// model point under the cursor is kept stationary (AutoCAD-style zoom).
|
|
/// No-op when there is no active viewport.
|
|
pub fn zoom_active_viewport(&mut self, steps: f32, cursor_paper: Option<glam::Vec2>) {
|
|
let vp_handle = match self.active_viewport {
|
|
Some(h) => h,
|
|
None => return,
|
|
};
|
|
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
|
if vp.status.locked {
|
|
return;
|
|
}
|
|
// Zoom in = shrink view_height → higher scale → objects appear larger.
|
|
let factor = (1.0_f64 - 0.15 * steps as f64).clamp(0.1, 10.0);
|
|
|
|
if let Some(cp) = cursor_paper {
|
|
// Compute the model-space point under the cursor before zoom.
|
|
let scale_before =
|
|
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height) as f32;
|
|
let cx = vp.center.x as f32;
|
|
let cy = vp.center.y as f32;
|
|
let tx = vp.view_target.x as f32;
|
|
let ty = vp.view_target.y as f32;
|
|
let mx = (cp.x - cx) / scale_before + tx;
|
|
let my = (cp.y - cy) / scale_before + ty;
|
|
|
|
// Apply zoom.
|
|
vp.view_height = (vp.view_height * factor).max(1e-6);
|
|
if vp.view_height.abs() > 1e-9 {
|
|
vp.custom_scale = vp.height / vp.view_height;
|
|
}
|
|
let scale_after = vp.custom_scale as f32;
|
|
|
|
// Adjust view_target so the model point under cursor stays there.
|
|
let mx_after = (cp.x - cx) / scale_after + vp.view_target.x as f32;
|
|
let my_after = (cp.y - cy) / scale_after + vp.view_target.y as f32;
|
|
vp.view_target.x += (mx - mx_after) as f64;
|
|
vp.view_target.y += (my - my_after) as f64;
|
|
} else {
|
|
vp.view_height = (vp.view_height * factor).max(1e-6);
|
|
if vp.view_height.abs() > 1e-9 {
|
|
vp.custom_scale = vp.height / vp.view_height;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Orbit the active viewport's view direction by the given screen-pixel delta.
|
|
/// No-op when there is no active viewport or it is locked.
|
|
pub fn orbit_active_viewport(&mut self, delta_x: f32, delta_y: f32) {
|
|
let vp_handle = match self.active_viewport {
|
|
Some(h) => h,
|
|
None => return,
|
|
};
|
|
let mut cam = match self.camera_for_viewport(vp_handle) {
|
|
Some(c) => c,
|
|
None => return,
|
|
};
|
|
// Floating viewport orbits about its own target (no selection pivot).
|
|
cam.orbit(delta_x, delta_y, None);
|
|
// yaw_pitch_to_quat(y,p)*Z = (cos(p)*sin(y), -cos(p)*cos(y), sin(p))
|
|
// `camera_for_viewport` reconstructs the rotation so that
|
|
// `rotation * Z == view_direction` exactly (its `yaw = atan2(x, -y)`
|
|
// cancels the sign). Store `eye` directly so the orbit round-trips —
|
|
// negating Y here made each drag step read back a Y-mirrored camera,
|
|
// flipping the model between a rotation and its opposite every frame.
|
|
let eye = cam.rotation * glam::Vec3::Z;
|
|
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
|
if vp.status.locked {
|
|
return;
|
|
}
|
|
vp.view_direction.x = eye.x as f64;
|
|
vp.view_direction.y = eye.y as f64;
|
|
vp.view_direction.z = eye.z as f64;
|
|
}
|
|
}
|
|
|
|
/// Snap the active viewport's view direction to `eye_dir` (unit
|
|
/// vector from target toward camera). Twist angle is left at its
|
|
/// current value so the up-sense is preserved across successive
|
|
/// snaps. No-op when there is no active viewport or it is locked.
|
|
pub fn snap_active_viewport_to_direction(&mut self, eye_dir: glam::Vec3, ucs: glam::Mat4) {
|
|
let vp_handle = match self.active_viewport {
|
|
Some(h) => h,
|
|
None => return,
|
|
};
|
|
// Build the full UCS-aligned orientation exactly as the model snap does
|
|
// (snap_to_direction picks the in-plane roll from the UCS axes), seeded
|
|
// from the viewport's current camera so the "best up" stays stable, then
|
|
// decode it back to the stored (view_direction, twist_angle). Writing
|
|
// only view_direction loses the roll and the rebuilt camera snaps to
|
|
// WCS-up instead of the UCS the clicked cube was drawn in.
|
|
let mut tmp = self.camera_for_viewport(vp_handle).unwrap_or_default();
|
|
tmp.snap_to_direction(eye_dir, ucs);
|
|
let dir = (tmp.rotation * glam::Vec3::Z).normalize_or(glam::Vec3::Z);
|
|
let desired_up = (tmp.rotation * glam::Vec3::Y).normalize_or(glam::Vec3::Y);
|
|
|
|
// camera_from_view rebuilds the rotation with its OWN yaw convention
|
|
// (atan2(x, -y)) and applies roll = -twist, which is *not* the camera's
|
|
// internal yaw/roll convention — so `-tmp.roll()` does not round-trip.
|
|
// Instead reproduce the decoder's zero-twist basis here, then measure
|
|
// the signed roll about the view axis that carries its up onto the
|
|
// desired UCS up. Store twist = -roll (the decoder negates it back).
|
|
let pitch = dir.z.clamp(-1.0, 1.0).asin();
|
|
let yaw = if dir.x.abs() < 1e-6 && dir.y.abs() < 1e-6 {
|
|
0.0
|
|
} else {
|
|
dir.x.atan2(-dir.y)
|
|
};
|
|
let up0 = (view::camera::yaw_pitch_to_quat(yaw, pitch, 0.0) * glam::Vec3::Y)
|
|
.normalize_or(glam::Vec3::Y);
|
|
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
|
let twist = -roll as f64;
|
|
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
|
if vp.status.locked {
|
|
return;
|
|
}
|
|
vp.view_direction.x = dir.x as f64;
|
|
vp.view_direction.y = dir.y as f64;
|
|
vp.view_direction.z = dir.z as f64;
|
|
vp.twist_angle = twist;
|
|
}
|
|
}
|
|
|
|
/// Mutate the active viewport's camera through a closure, then re-encode the
|
|
/// result to the stored `(view_direction, twist_angle)` — the same decode
|
|
/// the ViewCube snap uses. Lets the home / roll / nudge controls drive a
|
|
/// floating viewport just like the model camera. Returns `false` if there is
|
|
/// no active (unlocked) viewport.
|
|
pub fn mutate_active_viewport_camera(
|
|
&mut self,
|
|
f: impl FnOnce(&mut view::camera::Camera),
|
|
) -> bool {
|
|
let Some(vp_handle) = self.active_viewport else {
|
|
return false;
|
|
};
|
|
let mut tmp = self.camera_for_viewport(vp_handle).unwrap_or_default();
|
|
f(&mut tmp);
|
|
let dir = (tmp.rotation * glam::Vec3::Z).normalize_or(glam::Vec3::Z);
|
|
let desired_up = (tmp.rotation * glam::Vec3::Y).normalize_or(glam::Vec3::Y);
|
|
let pitch = dir.z.clamp(-1.0, 1.0).asin();
|
|
let yaw = if dir.x.abs() < 1e-6 && dir.y.abs() < 1e-6 {
|
|
0.0
|
|
} else {
|
|
dir.x.atan2(-dir.y)
|
|
};
|
|
let up0 = (view::camera::yaw_pitch_to_quat(yaw, pitch, 0.0) * glam::Vec3::Y)
|
|
.normalize_or(glam::Vec3::Y);
|
|
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
|
let twist = -roll as f64;
|
|
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
|
if vp.status.locked {
|
|
return false;
|
|
}
|
|
vp.view_direction.x = dir.x as f64;
|
|
vp.view_direction.y = dir.y as f64;
|
|
vp.view_direction.z = dir.z as f64;
|
|
vp.twist_angle = twist;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Render mode of the active paper-space viewport, or `None` when no
|
|
/// viewport is active (PSPACE / model layout).
|
|
pub fn active_viewport_render_mode(
|
|
&self,
|
|
) -> Option<acadrust::entities::ViewportRenderMode> {
|
|
let h = self.active_viewport?;
|
|
match self.document.get_entity(h) {
|
|
Some(acadrust::EntityType::Viewport(vp)) => Some(vp.render_mode),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Set the active paper-space viewport's render mode. Returns `true`
|
|
/// when a viewport was active and updated; `false` (no-op) otherwise,
|
|
/// so the caller can fall back to the model-layout render mode.
|
|
pub fn set_active_viewport_render_mode(
|
|
&mut self,
|
|
mode: acadrust::entities::ViewportRenderMode,
|
|
) -> bool {
|
|
let Some(h) = self.active_viewport else {
|
|
return false;
|
|
};
|
|
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(h) {
|
|
vp.render_mode = mode;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Visual style of the active Model tile (for the render-mode picker).
|
|
pub fn active_model_tile_render_mode(
|
|
&self,
|
|
) -> acadrust::entities::ViewportRenderMode {
|
|
let tiles = self.model_tiles.borrow();
|
|
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
|
tiles
|
|
.get(active)
|
|
.map(|t| t.render_mode)
|
|
.unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D)
|
|
}
|
|
|
|
/// Set only the active Model tile's render mode. Other tiles keep theirs.
|
|
pub fn set_active_model_tile_render_mode(
|
|
&self,
|
|
mode: acadrust::entities::ViewportRenderMode,
|
|
) {
|
|
let mut tiles = self.model_tiles.borrow_mut();
|
|
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
|
if let Some(t) = tiles.get_mut(active) {
|
|
t.render_mode = mode;
|
|
}
|
|
}
|
|
|
|
/// Current gaze direction (canonical +Z eye dir, world space) of whichever
|
|
/// camera owns the ViewCube — the active floating viewport in MSPACE, else
|
|
/// the main camera. Used by the ViewCube "already there → flip to opposite"
|
|
/// check, which must test the camera the cube actually reflects (the paper
|
|
/// camera always looks straight down, so testing it flipped every snap).
|
|
pub fn active_gaze_dir(&self) -> glam::Vec3 {
|
|
if let Some(h) = self.active_viewport {
|
|
if let Some(cam) = self.camera_for_viewport(h) {
|
|
return cam.rotation * glam::Vec3::Z;
|
|
}
|
|
}
|
|
self.camera.borrow().rotation * glam::Vec3::Z
|
|
}
|
|
|
|
/// View-rotation matrix for the active viewport (MSPACE), or the
|
|
/// paper-space camera's matrix when not in MSPACE.
|
|
/// Used by ViewCube hit-testing so clicks map to the correct camera.
|
|
pub fn active_view_rotation_mat(&self) -> glam::Mat4 {
|
|
// Must match exactly what the drawn cube uses (see ViewportData's
|
|
// `cam_rotation`): the active context's camera composed with the
|
|
// ViewCube UCS. Inside a floating viewport that's the viewport's own
|
|
// camera; the UCS factor applies in both cases.
|
|
if let Some(h) = self.active_viewport {
|
|
if let Some(cam) = self.camera_for_viewport(h) {
|
|
return cam.view_rotation_mat() * self.viewcube_ucs_mat();
|
|
}
|
|
}
|
|
self.camera.borrow().view_rotation_mat() * self.viewcube_ucs_mat()
|
|
}
|
|
|
|
/// The UCS→world rotation the ViewCube should compose with the camera —
|
|
/// the active UCS in model space, identity everywhere else. Render,
|
|
/// hit-test, and click-snap all go through this so they stay in lock-step.
|
|
pub fn viewcube_ucs_mat(&self) -> glam::Mat4 {
|
|
// UCS applies in model space and inside a floating viewport (MSPACE);
|
|
// plain paper space stays WCS.
|
|
if self.current_layout == "Model" || self.active_viewport.is_some() {
|
|
self.viewcube_ucs
|
|
} else {
|
|
glam::Mat4::IDENTITY
|
|
}
|
|
}
|
|
|
|
/// Return the handle of the user viewport whose *visible* on-screen
|
|
/// rectangle (clamped to the canvas) contains the given screen-pixel point.
|
|
/// Viewport activation goes through this so a click only enters a viewport
|
|
/// when it lands on the part the user can actually see — clicking the empty
|
|
/// area beside a viewport that runs off-screen no longer matches its full
|
|
/// (partly off-canvas) paper rect and switches to it by mistake.
|
|
pub fn viewport_at_screen_point(
|
|
&self,
|
|
px: f32,
|
|
py: f32,
|
|
canvas: (f32, f32),
|
|
) -> Option<Handle> {
|
|
let (_, _, handles) = self.paper_viewport_handles();
|
|
handles
|
|
.iter()
|
|
.filter_map(|handle| {
|
|
let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else {
|
|
return None;
|
|
};
|
|
if !vp.status.is_on {
|
|
return None;
|
|
}
|
|
let rect = self.viewport_screen_rect(vp.common.handle, canvas)?;
|
|
let x0 = rect.x.max(0.0);
|
|
let y0 = rect.y.max(0.0);
|
|
let x1 = (rect.x + rect.width).min(canvas.0);
|
|
let y1 = (rect.y + rect.height).min(canvas.1);
|
|
if x1 <= x0 || y1 <= y0 {
|
|
return None; // fully off-canvas → nothing to click
|
|
}
|
|
if px >= x0 && px <= x1 && py >= y0 && py <= y1 {
|
|
Some((vp.common.handle, (x1 - x0) * (y1 - y0)))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
|
.map(|(h, _)| h)
|
|
}
|
|
|
|
/// 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> {
|
|
let (_, _, handles) = self.paper_viewport_handles();
|
|
handles.iter().find_map(|handle| {
|
|
let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else {
|
|
return None;
|
|
};
|
|
if vp.status.is_on {
|
|
Some(vp.common.handle)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
}
|