cad-editor/src/scene/mspace.rs
Hakan Seven 12bf126646 perf: remove large drawing stalls
Keep resident geometry and GPU resources stable across edits, MSPACE zoom, and Model/Paper switches. Add unified PERF output for tracing remaining costs.
2026-07-25 13:34:48 +03:00

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
}
})
}
}