// Camera / view operations on `Scene`: zoom, fit-all, named-view restore, // camera<->document sync, and per-tile grid/snap. (The actual screen-space // hit-testing lives in `scene::pick::hit_test`.) use super::*; fn rendered_wire_bounds( wires: &[WireModel], fallback_z: f64, ) -> Option<(glam::DVec3, glam::DVec3)> { let mut min = glam::DVec3::splat(f64::INFINITY); let mut max = glam::DVec3::splat(f64::NEG_INFINITY); macro_rules! include_bound { ($p:expr) => { let point: glam::DVec3 = $p; if point.is_finite() { min = min.min(point); max = max.max(point); } }; } for wire in wires { let mut has_precise_position = false; let hw = wire.world_width as f64 * 0.5; for (index, &[x, y, z]) in wire.points.iter().enumerate() { let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); let p = glam::DVec3::new( x as f64 + low[0] as f64, y as f64 + low[1] as f64, z as f64 + low[2] as f64, ); if hw > 0.0 { include_bound!(p - glam::DVec3::splat(hw)); include_bound!(p + glam::DVec3::splat(hw)); } else { include_bound!(p); } has_precise_position = true; } for vertex in &wire.text_verts { include_bound!(glam::DVec3::new( vertex.pos[0] as f64 + vertex.pos_low[0] as f64, vertex.pos[1] as f64 + vertex.pos_low[1] as f64, vertex.pos[2] as f64 + vertex.pos_low[2] as f64, )); has_precise_position = true; } for &[x, y, z] in &wire.key_vertices { let p = glam::DVec3::new(x, y, z); if hw > 0.0 { include_bound!(p - glam::DVec3::splat(hw)); include_bound!(p + glam::DVec3::splat(hw)); } else { include_bound!(p); } has_precise_position = true; } for &(point, _) in &wire.snap_pts { include_bound!(point); has_precise_position = true; } if !has_precise_position { let [x0, y0, x1, y1] = wire.aabb; if wire.aabb != WireModel::UNBOUNDED_AABB && [x0, y0, x1, y1].iter().all(|value| value.is_finite()) && x0 <= x1 && y0 <= y1 { include_bound!(glam::DVec3::new( x0 as f64 - hw, y0 as f64 - hw, fallback_z - hw )); include_bound!(glam::DVec3::new( x1 as f64 + hw, y1 as f64 + hw, fallback_z + hw )); } } } if min.is_finite() && max.is_finite() { Some((min, max)) } else { None } } /// Merge `pair` into `bounds`, dropping coordinates that are parser junk /// rather than geometry: DXF writes ±1e20 as its "no extents" sentinel, and /// past 1e16 an f32 has no resolution left to place a point with. fn fold_sane_bounds( bounds: &mut Option<(glam::DVec3, glam::DVec3)>, pair: Option<(glam::DVec3, glam::DVec3)>, ) { const SANE_EXTENT: f64 = 1.0e16; let Some((lo, hi)) = pair else { return; }; if lo.abs().max_element() >= SANE_EXTENT || hi.abs().max_element() >= SANE_EXTENT { return; } *bounds = Some(match *bounds { Some((min, max)) => (min.min(lo), max.max(hi)), None => (lo, hi), }); } fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option { rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5) } fn block_entity_transform( document: &CadDocument, block_name: &str, target: Handle, visited: &mut Vec, annotation_scale: f32, ) -> Option { if visited .iter() .any(|name| name.eq_ignore_ascii_case(block_name)) { return None; } let record = document .block_records .iter() .find(|record| record.name.eq_ignore_ascii_case(block_name))?; if record.entity_handles.contains(&target) { return Some(codec::types::Transform::identity()); } visited.push(record.name.clone()); let transform = record.entity_handles.iter().find_map(|handle| { let entity = document.get_entity(*handle)?; crate::scene::render_graph::entity_render_block_uses(document, entity, annotation_scale) .into_iter() .filter(|block_use| block_use.active) .find_map(|block_use| { let inner = block_entity_transform( document, &block_use.insert.block_name, target, visited, annotation_scale, )?; Some( inner.then(&crate::scene::render_graph::insert_transform_with_policy( document, &block_use.insert, annotation_scale, block_use.scale_policy, )), ) }) }); visited.pop(); transform } /// The model-space active viewport is reserved as `*Active`, but that name is /// case-insensitive in DXF/DWG — a file may store it as `*ACTIVE`. Match it /// accordingly, otherwise an uppercased record reads as a *distinct* viewport: /// on save it survives the "drop the old *Active" filter and sits next to the /// fresh entry we add, so the file gets two overlapping full-window viewports /// that render "one on top of the other". (#319) fn is_active_vport_name(name: &str) -> bool { name.eq_ignore_ascii_case("*Active") } /// The world point a named view is centred on. The VIEW record's centre is /// in view (DCS) coordinates, an offset from its target in the view plane. /// Views saved by earlier versions of this application stored the target /// itself as the centre; that centre (equal to a non-zero target) is taken /// as the target, not as an offset from it. pub fn named_view_center(view: &codec::tables::View) -> glam::DVec3 { use glam::{DQuat, DVec3}; let target = DVec3::new(view.target.x, view.target.y, view.target.z); let (cx, cy) = (view.center.x, view.center.y); let tol = 1e-9 * target.length().max(1.0); let legacy = (target.x != 0.0 || target.y != 0.0) && (cx - target.x).abs() <= tol && (cy - target.y).abs() <= tol; if legacy { return target; } let eye = DVec3::new(view.direction.x, view.direction.y, view.direction.z) .try_normalize() .unwrap_or(DVec3::Z); target + DQuat::from_rotation_arc(DVec3::Z, eye) * DVec3::new(cx, cy, 0.0) } impl Scene { /// A geometry mutation makes every fitted Model camera AABB stale. Clear /// both the live camera and inactive tile snapshots immediately so no view /// can clip against the old drawing until its bounds are refreshed. pub(super) fn invalidate_projection_bounds(&self) { self.projection_bounds_epoch.set(0); self.camera.borrow_mut().invalidate_model_bounds(); for tile in self.model_tiles.borrow_mut().iter_mut() { tile.camera.invalidate_model_bounds(); } } // ── Hit-test convenience: wire name → Handle ────────────────────────── pub fn handle_from_wire_name(name: &str) -> Option { crate::scene::pipeline::wire_gpu::fast_parse_u64(name).map(Handle::new) } /// Restore camera to a named view from the document view table. pub fn restore_named_view(&mut self, view: &codec::tables::View) { use glam::Vec3; // The model viewport takes a copy of the view's background, or none. if self.current_layout == "Model" { let background = copy_background(&mut self.document, view.background_handle); self.set_active_background(background); } let cam = &mut *self.camera.borrow_mut(); // The stored direction points from the target toward the eye. let eye_dir = Vec3::new( view.direction.x as f32, view.direction.y as f32, view.direction.z as f32, ); let eye_dir = if eye_dir.length_squared() > 1e-10 { eye_dir.normalize() } else { Vec3::Z }; // Build rotation: canonical eye is +Z, rotate to eye_dir. cam.rotation = glam::Quat::from_rotation_arc(Vec3::Z, eye_dir); cam.target = named_view_center(view); // Sync yaw/pitch from new rotation (for ViewCube). let pitch = eye_dir.z.clamp(-1.0, 1.0).asin(); let yaw = eye_dir.x.atan2(eye_dir.y); cam.yaw = yaw; cam.pitch = pitch; // Derive distance from view height and fov. let h = view.height as f32; cam.distance = if h > 0.0 { h / (2.0 * (cam.fov_y * 0.5).tan()) } else { cam.distance }; self.camera_generation += 1; } /// The background the active model-space viewport shows (null for the /// drawing area colour). pub fn active_background(&self) -> Handle { self.active_tile_vport_handle() .and_then(|handle| self.document.vports.iter().find(|value| value.handle == handle)) .map_or(Handle::NULL, |value| value.background_handle) } fn set_active_background(&mut self, background: Handle) { let Some(handle) = self.active_tile_vport_handle() else { return }; if let Some(value) = self.document.vports.iter_mut().find(|value| value.handle == handle) { value.background_handle = background; self.camera_generation += 1; } } /// Save the current camera state into a new named view entry. /// Returns the view; caller must push it into document.views. pub fn current_as_named_view(&self, name: &str) -> codec::tables::View { use codec::types::Vector3; let cam = self.camera.borrow(); let eye_dir = cam.rotation * glam::Vec3::Z; let height = cam.ortho_size() * 2.0; // The window's shape: the last rendered aspect ratio. let width = height * self.active_camera_aspect(); let mut view = codec::tables::View::new(name); // The centre is in view (DCS) coordinates, relative to the target. view.center = Vector3 { x: 0.0, y: 0.0, z: 0.0, }; view.target = Vector3 { x: cam.target.x as f64, y: cam.target.y as f64, z: cam.target.z as f64, }; view.direction = Vector3 { x: eye_dir.x as f64, y: eye_dir.y as f64, z: eye_dir.z as f64, }; view.height = height as f64; view.width = width as f64; view.lens_length = 50.0; view.front_clip = 0.0; view.back_clip = 0.0; view.twist_angle = 0.0; // OCS saves an orthographic model view, not a perspective camera. view.perspective = false; view } /// Zoom the model-space camera in/out by a percentage. /// factor > 1 = zoom out, factor < 1 = zoom in. pub fn zoom_camera(&mut self, factor: f32) { let mut cam = self.camera.borrow_mut(); cam.distance = (cam.distance * factor).max(0.001); drop(cam); self.camera_generation += 1; } /// Save the active view immediately before a navigation operation. The /// paper-space camera and an entered floating viewport use different state, /// so the snapshot records whichever one currently owns navigation. pub fn remember_current_view(&mut self) { let layout = self.current_layout.clone(); if let Some(handle) = self.active_viewport { if let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) { self.previous_view = Some(ViewSnapshot::Floating { layout, handle, viewport: Box::new(viewport.clone()), }); return; } } self.previous_view = Some(ViewSnapshot::Main { layout, tile: self.active_model_tile.get(), camera: self.camera.borrow().clone(), }); } /// Swap the active view with the most recently remembered one. Keeping the /// displaced current view makes repeated ZOOM Previous calls toggle cleanly /// between the last two views. pub fn restore_previous_view(&mut self) -> bool { let Some(snapshot) = self.previous_view.take() else { return false; }; match snapshot { ViewSnapshot::Main { layout, tile, camera, } if self.active_viewport.is_none() && self.current_layout == layout && (layout != "Model" || self.active_model_tile.get() == tile) => { let current = ViewSnapshot::Main { layout, tile, camera: self.camera.borrow().clone(), }; *self.camera.borrow_mut() = camera; self.previous_view = Some(current); self.camera_generation += 1; true } ViewSnapshot::Floating { layout, handle, viewport, } if self.current_layout == layout && self.active_viewport == Some(handle) => { let Some(EntityType::Viewport(current)) = self.document.get_entity(handle) else { self.previous_view = Some(ViewSnapshot::Floating { layout, handle, viewport, }); return false; }; if current.status.locked { self.previous_view = Some(ViewSnapshot::Floating { layout, handle, viewport, }); return false; } let current = Box::new(current.clone()); if let Some(EntityType::Viewport(destination)) = self.document.get_entity_mut(handle) { destination.view_center = viewport.view_center; destination.view_direction = viewport.view_direction; destination.view_target = viewport.view_target; destination.lens_length = viewport.lens_length; destination.front_clip_z = viewport.front_clip_z; destination.back_clip_z = viewport.back_clip_z; destination.view_height = viewport.view_height; destination.twist_angle = viewport.twist_angle; destination.custom_scale = viewport.custom_scale; destination.status.perspective = viewport.status.perspective; } self.previous_view = Some(ViewSnapshot::Floating { layout, handle, viewport: current, }); self.camera_generation += 1; true } snapshot => { self.previous_view = Some(snapshot); false } } } /// Refresh model bounds without moving the live camera. Reusing `fit_all` /// keeps projection framing on the same visibility and outlier filters as /// Zoom Extents instead of accepting every finite cached AABB. pub(crate) fn refresh_projection_bounds(&mut self) { if self.current_layout != "Model" || self.active_viewport.is_some() || (self.projection_bounds_epoch.get() == self.geometry_epoch && self.camera.borrow().fitted_model_bounds().is_some()) { return; } let saved_camera = self.camera.borrow().clone(); let saved_generation = self.camera_generation; self.camera.borrow_mut().invalidate_model_bounds(); self.fit_all(); let refreshed = self.camera.borrow().fitted_model_bounds(); *self.camera.borrow_mut() = saved_camera; self.camera_generation = saved_generation; if let Some((min, max)) = refreshed { self.camera.borrow_mut().fit_depth_to_bounds_f64(min, max); self.projection_bounds_epoch.set(self.geometry_epoch); } } /// Switch the live camera projection without changing its target or /// orientation. Model layout bounds are refreshed first so perspective eye /// distance accounts for the current, visible drawing depth. pub fn set_projection_preserving_frame( &mut self, projection: view::camera::Projection, ) -> bool { if self.camera.borrow().projection == projection { return false; } self.refresh_projection_bounds(); let aspect = self.active_camera_aspect(); self.camera .borrow_mut() .set_projection_preserving_frame(projection, aspect); self.camera_generation += 1; true } /// Aspect ratio of the active camera's actual render rectangle. Model /// panes are separate shader widgets, so the full-canvas render aspect is /// wrong whenever a tiled viewport is active. pub(super) fn active_camera_aspect(&self) -> f32 { if self.current_layout == "Model" { let (canvas_w, canvas_h) = self.selection.borrow().view.vp_size; let tiles = self.model_tiles.borrow(); let active = self .active_model_tile .get() .min(tiles.len().saturating_sub(1)); if let Some(tile) = tiles.get(active) { let width = tile.rect.width * canvas_w; let height = tile.rect.height * canvas_h; if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { return width / height; } } } self.last_render_aspect.get().max(0.01) } /// Fit the camera to a world-space bounding box (corners p1, p2). pub fn zoom_to_window(&mut self, p1: glam::Vec3, p2: glam::Vec3) { let min = p1.min(p2); let max = p1.max(p2); if min == max { return; } let aspect = self.active_camera_aspect(); self.camera.borrow_mut().fit_to_bounds(min, max, aspect); self.projection_bounds_epoch.set(self.geometry_epoch); self.camera_generation += 1; } /// Fit the active view to the rendered bounds of the supplied entities. /// Returns false when the selection has no finite visible geometry. pub fn zoom_to_entities(&mut self, handles: &[Handle]) -> bool { let fallback_z = self.active_camera_target().z; let wires = self.wire_models_for(handles); let mut bounds = rendered_wire_bounds(&wires, fallback_z); for handle in handles { let Some(mesh) = self.meshes.get(handle) else { continue; }; let [x0, y0, x1, y1] = mesh.world_aabb; let [z0, z1] = mesh.z_aabb; let mesh_min = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64); let mesh_max = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64); if !mesh_min.is_finite() || !mesh_max.is_finite() { continue; } bounds = Some(match bounds { Some((min, max)) => (min.min(mesh_min), max.max(mesh_max)), None => (mesh_min, mesh_max), }); } let Some((mut min, mut max)) = bounds else { return false; }; if min == max { min -= glam::DVec3::splat(1.0); max += glam::DVec3::splat(1.0); } let min = min.as_vec3(); let max = max.as_vec3(); if !min.is_finite() || !max.is_finite() { return false; } if self.active_viewport.is_some() { return self.fit_active_viewport_to_bounds(min, max); } let aspect = self.active_camera_aspect(); self.camera.borrow_mut().fit_to_bounds(min, max, aspect); self.projection_bounds_epoch.set(self.geometry_epoch); self.camera_generation += 1; true } /// Centre the active camera on one rendered entity without changing zoom /// distance, orientation, or viewport scale. pub fn center_camera_on_entity(&mut self, handle: Handle) -> bool { let fallback_z = self.active_camera_target().z; let wires = self.wire_models_for(&[handle]); let Some(center) = rendered_wire_center(&wires, fallback_z) else { return false; }; self.center_active_camera_on(center) } /// Centre on one concrete attribute value attached to an INSERT rather /// than on the complete block extents. pub fn center_camera_on_insert_attribute(&mut self, insert: Handle, index: usize) -> bool { let (attribute, fallback) = { let Some(EntityType::Insert(entity)) = self.document.get_entity(insert) else { return false; }; let Some(attribute) = entity.attributes.get(index) else { return false; }; ( EntityType::AttributeEntity(attribute.clone()), glam::DVec3::new( attribute.insertion_point.x, attribute.insertion_point.y, attribute.insertion_point.z, ), ) }; let wires = self.tessellate_one(&attribute); let center = rendered_wire_center(&wires, fallback.z).unwrap_or(fallback); self.center_active_camera_on(center) } /// Centre on a text entity stored in a block definition, transformed /// through the concrete visible INSERT occurrence that FIND is visiting. pub fn center_camera_on_block_entity(&mut self, insert: Handle, entity: Handle) -> bool { let (source, transform) = { let Some(EntityType::Insert(insert_entity)) = self.document.get_entity(insert) else { return false; }; let Some(source) = self.document.get_entity(entity) else { return false; }; let mut visited = Vec::new(); let Some(inner) = block_entity_transform( &self.document, &insert_entity.block_name, entity, &mut visited, self.annotation_scale, ) else { return false; }; ( source.clone(), inner.then(&crate::scene::render_graph::insert_transform_at_scale( &self.document, insert_entity, self.annotation_scale, )), ) }; let fallback_z = self.active_camera_target().z; let wires = self.tessellate_one(&source); let Some(local_center) = rendered_wire_center(&wires, fallback_z) else { return false; }; let world = transform.apply(codec::types::Vector3::new( local_center.x, local_center.y, local_center.z, )); self.center_active_camera_on(glam::DVec3::new(world.x, world.y, world.z)) } fn active_camera_target(&self) -> glam::DVec3 { self.active_viewport .and_then(|handle| self.camera_for_viewport(handle)) .map_or_else(|| self.camera.borrow().target, |camera| camera.target) } fn center_active_camera_on(&mut self, center: glam::DVec3) -> bool { if !center.is_finite() { return false; } if let Some(handle) = self.active_viewport { let Some(EntityType::Viewport(viewport)) = self.document.get_entity_mut(handle) else { return false; }; if viewport.status.locked { return false; } viewport.view_target.x = center.x; viewport.view_target.y = center.y; viewport.view_target.z = center.z; self.notify_viewport_changed(handle); } else { self.camera.borrow_mut().target = center; } self.camera_generation += 1; true } /// Apply camera state from an opencadcodec View table entry, through the shared /// `camera_from_view` decoder so the twist round-trips like every other /// saved view. `model_space`: if true, subtracts world_offset from target /// (wire-space); paper-space entries carry no offset. fn apply_camera_from_view_entry( &mut self, view: &codec::tables::View, model_space: bool, ) -> bool { let _ = model_space; let Some(cam) = self.camera_from_view_mode( view.direction, view.target, codec::types::Vector2 { x: view.center.x, y: view.center.y, }, view.height, view.twist_angle, view.perspective, view.lens_length, ) else { return false; }; *self.camera.borrow_mut() = cam; self.camera_generation += 1; true } /// Set the model-space camera from the VPORT table's *Active entry. /// Returns true if the entry was found and the camera was set. fn apply_active_vport_camera(&mut self) -> bool { // Restore the single tile's visual style + grid/snap from the *Active // entry, independent of where the camera itself comes from below. if let Some(vp) = self .document .vports .iter() .find(|v| is_active_vport_name(&v.name)) { let mode = vp.render_mode; let (grid_on, snap_on) = (vp.grid_on, vp.snap_on); 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; t.grid_on = grid_on; t.snap_on = snap_on; } } // Restore from the standard *Active VPORT entry. (Earlier builds also // wrote an app-specific "OpenCADStudio_Camera_Model" View record and // preferred it here — that polluted the file for other CAD programs and // is no longer written or read; the view round-trips fine via VPORT.) let vp = match self .document .vports .iter() .find(|v| is_active_vport_name(&v.name)) { Some(v) => v.clone(), None => return false, }; let Some(new_cam) = self.camera_from_vport(&vp) else { return false; }; *self.camera.borrow_mut() = new_cam; self.camera_generation += 1; true } /// Decode a saved view into a `Camera`. This is the single shared decoder /// for both a model-space VPORT table entry (tiled) and a paper-space /// VIEWPORT entity (floating): tiled vs floating only changes *where* the /// fields come from and the floating auto-fit fallback — the projection /// math (view direction → yaw/pitch, twist → roll, view_center fold, /// view_height → distance) is identical, so it lives here once. Callers pass /// their already-effective `view_target` / `view_center` / `view_height`. /// /// Returns `None` for a zero `view_height` (an uninitialised entry). pub(super) fn camera_from_view_mode( &self, view_direction: codec::types::Vector3, view_target: codec::types::Vector3, view_center: codec::types::Vector2, view_height: f64, twist: f64, perspective: bool, lens_length: f64, ) -> Option { if view_height.abs() < 1e-9 { return None; } let vd = glam::Vec3::new( view_direction.x as f32, view_direction.y as f32, view_direction.z as f32, ) .normalize_or(glam::Vec3::Z); let pitch = vd.z.clamp(-1.0, 1.0).asin(); // view_dir = (sin(yaw)*cos(pitch), -cos(yaw)*cos(pitch), sin(pitch)) // → yaw = atan2(x, -y), but when looking straight up/down cos(pitch)≈0 // both x and y are near zero and atan2(0, -0.0) = π due to IEEE 754. let yaw = if vd.x.abs() < 1e-6 && vd.y.abs() < 1e-6 { 0.0_f32 // plan/nadir view: yaw is undefined, default to 0 } else { vd.x.atan2(-vd.y) }; // The saved view can carry a twist (rotation about the view axis), set // when the view was aligned to a rotated UCS. The twist is the angle // that rotates world-X onto screen-right, so the world direction that // ends up horizontal is its negative; feed that in as the camera roll // so the drawing opens square, the way it was saved, instead of in raw // world orientation (which looks tilted). let rotation = view::camera::yaw_pitch_to_quat(yaw, pitch, -twist as f32); let view_right = rotation * glam::Vec3::X; let view_up = rotation * glam::Vec3::Y; // view_target is WCS; wire-space subtracts world_offset. view_center is // a DCS (screen-plane) offset, so fold it through the view basis. // Keep the target in f64: casting it to f32 first quantizes the camera // to ~0.5 m at UTM scale, so panning/zooming inside a floating viewport // (which nudges view_target by sub-metre f64 steps) made the content // jump on the f32 grid. The axis directions stay f32 (orientation only); // only the position must stay precise — matching the model camera. let base = glam::DVec3::new(view_target.x, view_target.y, view_target.z); let target = base + view_right.as_dvec3() * view_center.x + view_up.as_dvec3() * view_center.y; let lens_length = lens_length.max(1.0) as f32; // The saved-view convention uses a 24 mm vertical aperture. This is // the inverse of the projection path's documented // `distance = view_height * lens / 24` relation. let fov_y = 2.0 * (12.0 / lens_length).atan(); let distance = ((view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001); Some(Camera { target, rotation, distance, fov_y, projection: if perspective { view::camera::Projection::Perspective } else { view::camera::Projection::Orthographic }, yaw, pitch, // Sized from the saved view height (not a zoom-scaled range) so a // restored view keeps stable depth precision. A THIN projection — // e.g. a model-documentation section/side view whose cut plane sits // half an object-depth from the view target — has content much // deeper than its visible height, so the depth half-range must be a // generous multiple: `×4` clipped a section view's outline that was // ~6× the view height deep. Orthographic depth precision is linear, // so the wide range costs no z-fighting. depth_half_range: (view_height as f32 * 64.0).max(1.0), // A restored view has no model AABB yet; `fit_depth_to_saved_extents` // fills it in from $EXTMIN/$EXTMAX right after, so the near/far tracks // the eye direction once the view is orbited. model_bounds: None, }) } /// Decode a VPort table entry (model-space tiled view) into a `Camera`. fn camera_from_vport(&self, vp: &codec::tables::VPort) -> Option { self.camera_from_view_mode( vp.view_direction, vp.view_target, vp.view_center, vp.view_height, vp.view_twist, vp.perspective, vp.lens_length, ) } fn apply_camera_to_vport( &self, entry: &mut codec::tables::VPort, cam: &Camera, lower_left: codec::types::Vector2, upper_right: codec::types::Vector2, ) { let view_dir = cam.rotation * glam::Vec3::Z; let view_height = cam.ortho_size() * 2.0; let target_wcs = codec::types::Vector3 { x: cam.target.x, y: cam.target.y, z: cam.target.z, }; entry.lower_left = lower_left; entry.upper_right = upper_right; entry.view_target = target_wcs; entry.view_direction = codec::types::Vector3 { x: view_dir.x as f64, y: view_dir.y as f64, z: view_dir.z as f64, }; entry.view_height = view_height as f64; let (canvas_width, canvas_height) = self.selection.borrow().view.vp_size; let viewport_width = (upper_right.x - lower_left.x).abs() * canvas_width as f64; let viewport_height = (upper_right.y - lower_left.y).abs() * canvas_height as f64; if viewport_width > 1e-9 && viewport_height > 1e-9 { entry.aspect_ratio = viewport_width / viewport_height; } entry.perspective = cam.projection == view::camera::Projection::Perspective; entry.lens_length = (12.0 / (cam.fov_y * 0.5).tan().max(1e-6)) as f64; entry.view_center = codec::types::Vector2::ZERO; // Stored twist = -roll, matching the decoder (roll = -twist). entry.view_twist = -cam.roll() as f64; } fn vport_rect_matches( entry: &codec::tables::VPort, lower_left: codec::types::Vector2, upper_right: codec::types::Vector2, ) -> bool { const EPSILON: f64 = 1e-5; (entry.lower_left.x - lower_left.x).abs() <= EPSILON && (entry.lower_left.y - lower_left.y).abs() <= EPSILON && (entry.upper_right.x - upper_right.x).abs() <= EPSILON && (entry.upper_right.y - upper_right.y).abs() <= EPSILON } fn vport_contains_rect_center( entry: &codec::tables::VPort, lower_left: codec::types::Vector2, upper_right: codec::types::Vector2, ) -> bool { const EPSILON: f64 = 1e-5; let center_x = (lower_left.x + upper_right.x) * 0.5; let center_y = (lower_left.y + upper_right.y) * 0.5; center_x >= entry.lower_left.x - EPSILON && center_x <= entry.upper_right.x + EPSILON && center_y >= entry.lower_left.y - EPSILON && center_y <= entry.upper_right.y + EPSILON } /// Convert a `ModelTile`'s normalized iced rectangle (top-left origin) to /// the (lower_left, upper_right) pair the VPort table uses (bottom-left /// origin). fn tile_rect_to_vport(rect: iced::Rectangle) -> (codec::types::Vector2, codec::types::Vector2) { let lower_left = codec::types::Vector2 { x: rect.x as f64, y: (1.0 - rect.y - rect.height) as f64, }; let upper_right = codec::types::Vector2 { x: (rect.x + rect.width) as f64, y: (1.0 - rect.y) as f64, }; (lower_left, upper_right) } /// Inverse of `tile_rect_to_vport`. fn vport_to_tile_rect( lower_left: codec::types::Vector2, upper_right: codec::types::Vector2, ) -> iced::Rectangle { iced::Rectangle { x: lower_left.x as f32, y: (1.0 - upper_right.y) as f32, width: (upper_right.x - lower_left.x) as f32, height: (upper_right.y - lower_left.y) as f32, } } /// Restore model tiles from duplicate `*Active` VPort entries. /// Returns true on success — the caller skips `apply_active_vport_camera` /// in that case because the active tile's camera has already been loaded /// into `self.camera`. fn restore_model_tiles_from_vports(&mut self) -> bool { let active_vports: Vec = self .document .vports .iter() .filter(|v| is_active_vport_name(&v.name)) .cloned() .collect(); if active_vports.len() <= 1 { return false; } let tiles: Vec = active_vports .iter() .filter_map(|vp| { self.camera_from_vport(vp).map(|cam| ModelTile { rect: Self::vport_to_tile_rect(vp.lower_left, vp.upper_right), camera: cam, render_mode: vp.render_mode, grid_on: vp.grid_on, snap_on: vp.snap_on, }) }) .collect(); if tiles.len() <= 1 { return false; } let active_cam = tiles[0].camera.clone(); *self.model_tiles.borrow_mut() = tiles; self.active_model_tile.set(0); *self.camera.borrow_mut() = active_cam; self.projection_bounds_epoch.set(0); // Rebuild the pane_grid layout from the restored tile rects so the panes // match (the renderer / input iterate panes, not tiles). self.rebuild_panes_from_tiles(); self.camera_generation += 1; true } /// Persist model tiles as duplicate `*Active` VPort entries. fn save_model_tiles_to_vports(&mut self) { // Stash the live camera into the active tile so the about-to-write // snapshot reflects the user's most recent orbit / pan / zoom. { let live_cam = self.camera.borrow().clone(); 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.camera = live_cam; } } let table_handle = self.document.vports.handle(); let mut active_vports: Vec = self .document .vports .iter() .filter(|v| is_active_vport_name(&v.name)) .cloned() .collect(); let source_vports = active_vports.clone(); let inherited_vport = active_vports.first().cloned(); let preserved_vps: Vec = self .document .vports .iter() .filter(|v| !is_active_vport_name(&v.name)) .cloned() .collect(); let mut new_vports = codec::tables::Table::with_handle(table_handle); for vp in preserved_vps { new_vports.add_or_replace(vp); } self.document.vports = new_vports; let tiles = self.model_tiles.borrow().clone(); if tiles.is_empty() { return; } let active = self .active_model_tile .get() .min(tiles.len().saturating_sub(1)); let mut ordered_tiles = Vec::with_capacity(tiles.len()); ordered_tiles.push(tiles[active].clone()); for (i, tile) in tiles.iter().enumerate() { if i != active { ordered_tiles.push(tile.clone()); } } let mut scene_objects_changed = false; for tile in ordered_tiles { let (ll, ur) = Self::tile_rect_to_vport(tile.rect); let exact = active_vports .iter() .position(|entry| Self::vport_rect_matches(entry, ll, ur)); let inherited = exact.or_else(|| { active_vports .iter() .enumerate() .filter(|(_, entry)| Self::vport_contains_rect_center(entry, ll, ur)) .min_by(|(_, left), (_, right)| { let left_area = (left.upper_right.x - left.lower_left.x) * (left.upper_right.y - left.lower_left.y); let right_area = (right.upper_right.x - right.lower_left.x) * (right.upper_right.y - right.lower_left.y); left_area.total_cmp(&right_area) }) .map(|(index, _)| index) }); let mut entry = inherited .map(|index| active_vports.remove(index)) .or_else(|| { source_vports .iter() .filter(|entry| Self::vport_contains_rect_center(entry, ll, ur)) .min_by(|left, right| { let left_area = (left.upper_right.x - left.lower_left.x) * (left.upper_right.y - left.lower_left.y); let right_area = (right.upper_right.x - right.lower_left.x) * (right.upper_right.y - right.lower_left.y); left_area.total_cmp(&right_area) }) .cloned() }) .or_else(|| inherited_vport.clone()) .unwrap_or_else(|| codec::tables::VPort::new("*Active")); let cloned = inherited.is_none(); if cloned { entry.handle = codec::Handle::NULL; } entry.name = "*Active".to_string(); self.apply_camera_to_vport(&mut entry, &tile.camera, ll, ur); entry.render_mode = tile.render_mode; // Each viewport persists its own grid display + grid-snap (#121). entry.grid_on = tile.grid_on; entry.snap_on = tile.snap_on; if !entry.handle.is_valid() { entry.handle = self.document.allocate_handle(); } if cloned && entry.sun_handle.is_valid() { let source_sun = self .document .objects .get(&entry.sun_handle) .and_then(|object| match object { codec::objects::ObjectType::ClassObject(value) if matches!(&value.data, codec::objects::ClassObjectData::Sun(_)) => { Some(value.clone()) } _ => None, }); if let Some(mut sun) = source_sun { let handle = self.document.allocate_handle(); sun.handle = handle; sun.owner = entry.handle; sun.reactors.clear(); sun.xdictionary_handle = None; self.document .objects .insert(handle, codec::objects::ObjectType::ClassObject(sun)); entry.sun_handle = handle; scene_objects_changed = true; } } self.document.vports.add_allow_duplicate(entry); } if scene_objects_changed { self.object_data_cache = crate::entities::object_data::build_cache(&self.document); self.lighting_cache.borrow_mut().clear(); } } /// Mirror the live grid/snap toggles onto the active view's own store so the /// state stays independent per viewport: a model tile in model space, the /// layout's sheet viewport in paper space. (#121) pub fn set_active_tile_grid_snap(&mut self, grid_on: bool, snap_on: bool) { if self.current_layout != "Model" { // Paper space: target the active floating viewport if the user is // working inside one, otherwise the layout's sheet viewport. Each // viewport keeps its own grid/snap (round-tripped via status flags). let h = self .active_viewport .filter(|h| h.is_valid()) .unwrap_or_else(|| self.current_layout_sheet_viewport_handle()); if h.is_valid() { if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(h) { vp.status.grid_on = grid_on; vp.status.snap_on = snap_on; } } return; } 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.grid_on = grid_on; t.snap_on = snap_on; } } /// The active view's grid display + grid-snap, adopted into the live toggles /// on load and whenever the active viewport / tab / layout changes. Reads the /// model tile in model space, the sheet viewport in paper space. (#121) pub fn active_tile_grid_snap(&self) -> Option<(bool, bool)> { if self.current_layout != "Model" { let h = self .active_viewport .filter(|h| h.is_valid()) .unwrap_or_else(|| self.current_layout_sheet_viewport_handle()); if h.is_valid() { if let Some(EntityType::Viewport(vp)) = self.document.get_entity(h) { return Some((vp.status.grid_on, vp.status.snap_on)); } } return Some((false, false)); } 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.grid_on, t.snap_on)) } /// Set the paper-space camera from the sheet viewport's stored view. /// Returns true if a valid sheet viewport was found and the camera was set. /// /// The sheet viewport entity is the authoritative paper-space view (it /// round-trips through both the DXF and DWG writers). An older /// `OpenCADStudio_Camera_` named View is honoured only as a /// backward-compatible fallback for files saved under the previous scheme. fn apply_sheet_viewport_camera(&mut self) -> bool { // The Layout object already owns the authoritative sheet-viewport // handle. Do not scan every drawing entity on each Model↔Paper switch. let sheet_vp = match self .document .get_entity(self.current_layout_sheet_viewport_handle()) { Some(EntityType::Viewport(vp)) => Some(vp.clone()), _ => None, }; let vp = match sheet_vp { Some(v) if v.view_height.abs() >= 1e-9 => v, _ => { // Back-compat: files OCS saved with the named-View side-channel. let view_name = format!("OpenCADStudio_Camera_{}", self.current_layout); let fallback = self .document .views .iter() .find(|v| v.name == view_name) .cloned(); if let Some(view) = fallback { return self.apply_camera_from_view_entry(&view, false); } return false; } }; // Paper-space entities carry no world_offset → decode with a zero // offset, through the same shared decoder (twist included). let Some(cam) = self.camera_from_view_mode( vp.view_direction, vp.view_target, codec::types::Vector2 { x: vp.view_center.x, y: vp.view_center.y, }, vp.view_height, vp.twist_angle, vp.status.perspective, vp.lens_length, ) else { return false; }; *self.camera.borrow_mut() = cam; self.camera_generation += 1; true } /// Write the current camera back into the document (VPort or sheet viewport) /// so it is saved with the file. Returns true if the document was modified. pub fn sync_camera_to_document(&mut self) -> bool { let cam = self.camera.borrow().clone(); let view_dir = cam.rotation * glam::Vec3::Z; let view_height = cam.ortho_size() * 2.0; // Stored twist is the negative of the camera roll (the decoder applies // roll = -twist), so the saved view round-trips square. let twist = -cam.roll() as f64; let vd3 = codec::types::Vector3 { x: view_dir.x as f64, y: view_dir.y as f64, z: view_dir.z as f64, }; if self.current_layout == "Model" { self.save_model_tiles_to_vports(); true } else { let target_wcs = codec::types::Vector3 { x: cam.target.x as f64, y: cam.target.y as f64, z: cam.target.z as f64, }; // The sheet viewport entity is the authoritative paper-space view; // it round-trips natively, so no named-View side-channel is needed. let sheet_handle = self.current_layout_sheet_viewport_handle(); if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(sheet_handle) { // Paper-space position is stored in view_center (DCS). vp.view_center = codec::types::Vector3::new(target_wcs.x, target_wcs.y, 0.0); vp.view_target = codec::types::Vector3::ZERO; vp.view_direction = vd3; vp.view_height = view_height as f64; vp.twist_angle = twist; vp.status.perspective = cam.projection == view::camera::Projection::Perspective; vp.lens_length = (12.0 / (cam.fov_y * 0.5).tan().max(1e-6)) as f64; } true } } /// Restore the camera from the file's saved view (called once on open). /// Falls back to fit_all() if no saved view is available. pub fn restore_saved_camera(&mut self) { let restored = if self.current_layout == "Model" { // Tiled-layout restore takes precedence — it sets the camera too. // Single-tile files fall through to the *Active branch. self.restore_model_tiles_from_vports() || self.apply_active_vport_camera() } else { // Every paper layout has a full-screen sheet viewport that holds // its view; create one if a loaded file lacks it. let layout = self.current_layout.clone(); self.ensure_sheet_viewport(&layout); self.apply_sheet_viewport_camera() }; if restored { // The pose is the file's and must not move — but nothing has sized // its near/far, so `depth_half_range` stays unset and the // projection falls back to a guess scaled from the zoom distance. // On a drawing whose geometry reaches far off its plane that guess // is far too narrow and the geometry is silently clipped away. self.fit_depth_to_saved_extents(); } else { self.fit_all(); } } /// Size the camera's near/far from the drawing's own recorded extent, /// leaving its pose alone. /// /// `$EXTMIN`/`$EXTMAX` is untrustworthy for *positioning* — stale after an /// edit, or the 1e20 sentinel when the writer never computed it (see the /// `world_offset` selection notes) — which is why `fit_all` scans entities /// instead. It is the right input *here*: a near/far that is too wide only /// costs a little depth precision, while one that is too narrow deletes /// geometry outright, so erring on the drawing's own claim is the safe /// direction. It also costs nothing — no tessellation pass on open. fn fit_depth_to_saved_extents(&mut self) { // Same bound `model_space_extents`'s header fallback uses; rejects the // sentinel and any garbage that would blow the range up. const SANE_EXTENT: f64 = 1.0e16; let h = &self.document.header; let (lo, hi) = (h.model_space_extents_min, h.model_space_extents_max); let ok = lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z && [lo.x, lo.y, lo.z, hi.x, hi.y, hi.z] .iter() .all(|v| v.is_finite() && v.abs() < SANE_EXTENT); if !ok { return; } let min = glam::DVec3::new(lo.x, lo.y, lo.z); let max = glam::DVec3::new(hi.x, hi.y, hi.z); self.camera.borrow_mut().fit_depth_to_bounds_f64(min, max); } fn fit_paper_space_extents(&mut self) { let layout_block = self.current_layout_block_handle(); let mut min = glam::Vec3::splat(f32::INFINITY); let mut max = glam::Vec3::splat(f32::NEG_INFINITY); { let mut include = |x: f64, y: f64, z: f64| { const SANE_EXTENT: f64 = 1.0e16; if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < SANE_EXTENT && y.abs() < SANE_EXTENT && z.abs() < SANE_EXTENT { let point = glam::Vec3::new(x as f32, y as f32, z as f32); min = min.min(point); max = max.max(point); } }; // The physical sheet is always part of Paper Space extents, even // when the layout contains no entities (#539). if let Some(((x0, y0), (x1, y1))) = self.paper_limits() { include(x0, y0, 0.0); include(x1, y1, 0.0); } // Paper entities and viewport borders belong to the sheet. Model // content projected through those viewports deliberately does not. let scale = if self.current_layout == "Model" { crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, ) } else { self.paper_annotation_scale_handle() }; for wire in self.wires_for_block_culled( layout_block, None, None, None, None, scale, self.annotation_all_visible(), None, ) { let is_infinite = Self::handle_from_wire_name(&wire.name) .and_then(|handle| self.document.get_entity(handle)) .is_some_and(|entity| { matches!(entity, EntityType::XLine(_) | EntityType::Ray(_)) }); if is_infinite { for &[x, y, z] in &wire.key_vertices { include(x, y, z); } } else { for (index, &[x, y, z]) in wire.points.iter().enumerate() { let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); include( x as f64 + low[0] as f64, y as f64 + low[1] as f64, z as f64 + low[2] as f64, ); } } } // Hatches, wipeouts and raster images are GPU-only and may carry // no normal wire outline, so include their visible paper bounds. let (hatches, wipeouts, images) = self.paper_sheet_render_models(); for hatch in hatches.iter().chain(wipeouts.iter()) { for &[x, y] in hatch.boundary.iter() { include( hatch.world_origin[0] + x as f64, hatch.world_origin[1] + y as f64, 0.0, ); } } for image in images.iter() { for (corner, low) in image.corners.iter().zip(image.corners_low.iter()) { include( corner[0] as f64 + low[0] as f64, corner[1] as f64 + low[1] as f64, corner[2] as f64 + low[2] as f64, ); } } } if !min.is_finite() || !max.is_finite() { return; } if min == max { max += glam::Vec3::splat(1.0); } self.camera .borrow_mut() .fit_to_bounds(min, max, self.last_render_aspect.get().max(0.01)); self.camera_generation += 1; } /// Absolute-WCS bounds of everything visible in `block` — the one extents /// walk behind ZOOM EXTENTS and the drawing-extents readers. /// /// Nothing is rejected here on a guess. An earlier version dropped any wire /// whose centroid fell outside the inter-quartile consensus of the others, /// to stop one junk wire from a bad parse poisoning the box. It also /// dropped legitimately remote geometry — an outlying detail, a survey /// point, a block placed far from the rest — so ZOOM EXTENTS quietly /// refused to reach it. `SANE_EXTENT` and the finite test are the only /// filters now; the ±1e6 display segments that motivated the old heuristic /// come from XLine / Ray, which are handled explicitly below. pub(super) fn visible_block_bounds(&self, block: Handle) -> Option<(glam::DVec3, glam::DVec3)> { if block.is_null() { return None; } let mut bounds: Option<(glam::DVec3, glam::DVec3)> = None; macro_rules! fold { ($pair:expr) => { fold_sane_bounds(&mut bounds, $pair) }; } // Drawing extents must not depend on where the camera happens to be // looking: the result is cached per geometry epoch, and a floating // viewport's own camera is derived FROM the extents // (`camera_for_viewport`), so reading the camera here recurses. A wire // with no precise position falls back to the z = 0 plane. let fallback_z = 0.0; let scale = crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, ); // The resident set is the full, un-culled, LOD-free tessellation the // block actually renders from. Culled input would fit only what is // already on screen, so each ZOOM EXTENTS would zoom out a little and // reveal more, converging on the true extent after several uses (#51). let wires = self.resident_wires_for(block, None, scale, None, None); // Infinite display segments do not contribute to drawing extents. // Keep their base points as a fallback for otherwise empty drawings. let mut infinite_base: Vec = Vec::new(); for wire in wires.iter() { let is_infinite = Self::handle_from_wire_name(&wire.name) .and_then(|handle| self.document.get_entity(handle)) .is_some_and(|entity| matches!(entity, EntityType::XLine(_) | EntityType::Ray(_))); if is_infinite { infinite_base.extend( wire.key_vertices .iter() .map(|&[x, y, z]| glam::DVec3::new(x, y, z)) .filter(|point| point.is_finite()), ); continue; } fold!(rendered_wire_bounds(std::slice::from_ref(wire), fallback_z)); } // 3D solids render as meshes, not wires: a drawing of only solids has // no wire to fit. for (&handle, set) in &self.meshes { let Some(entity) = self.document.get_entity(handle) else { continue; }; if !self.mesh_entity_visible(handle) || !self.belongs_to_visible_block(handle, entity.common().owner_handle, block) { continue; } let [x0, y0, x1, y1] = set.world_aabb; let [z0, z1] = set.z_aabb; let lo = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64); let hi = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64); if lo.is_finite() && hi.is_finite() { fold!(Some((lo.min(hi), lo.max(hi)))); } } // Raster images, OLE frames and underlays draw as GPU quads; with // their frames hidden they carry no wire outline at all. for (&handle, image) in &self.images { let Some(entity) = self.document.get_entity(handle) else { continue; }; if !self.mesh_entity_visible(handle) || !self.belongs_to_visible_block(handle, entity.common().owner_handle, block) { continue; } for (corner, low) in image.corners.iter().zip(image.corners_low.iter()) { let point = glam::DVec3::new( corner[0] as f64 + low[0] as f64, corner[1] as f64 + low[1] as f64, corner[2] as f64 + low[2] as f64, ); if point.is_finite() { fold!(Some((point, point))); } } } // HATCH tessellates to no wires at all (#131), so a drawing whose // outermost geometry is a fill — or which is nothing but fills — has // to pick its bounds up from the fill models themselves. The paper // sheet already does this; model space did not. // The renderer already holds the fill models for the block it is // drawing, keyed by geometry epoch — reuse that rather than exploding // every INSERT again on each ZOOM EXTENTS. Only a block that is not // the one on screen (model space while a BEDIT is open) has to build // its own set. let hatches = if block == self.content_render_block_handle() { // Tinted, matching the renderer's own call: the two then share one // cache entry. Asking for an untinted set would key the same slot // with a different selection signature and the two would evict // each other on every frame. Tint is colour; it moves no vertex. self.hatch_models_arc_for_view(true) } else { std::sync::Arc::new(self.synced_hatch_models( block, None, scale, self.annotation_all_visible(), None, false, )) }; for hatch in hatches.iter() { for &[x, y] in hatch.boundary.iter() { // NaN-NaN separators between disconnected paths fall out on // the finite test — they must not be treated as vertices. let point = glam::DVec3::new( hatch.world_origin[0] + x as f64, hatch.world_origin[1] + y as f64, 0.0, ); if point.is_finite() { fold!(Some((point, point))); } } } // Drawing holds only infinite construction geometry: fall back to its // base points rather than reporting nothing. if bounds.is_none() { for point in infinite_base { fold!(Some((point, point))); } } bounds } pub fn fit_all(&mut self) { if self.active_viewport.is_some() { if let Some((mut min, mut max)) = self.model_space_extents() { if min == max { max += glam::Vec3::splat(1.0); min -= glam::Vec3::splat(1.0); } self.fit_active_viewport_to_bounds(min, max); } return; } if self.current_layout != "Model" { self.fit_paper_space_extents(); return; } let Some((min, mut max)) = self.visible_block_bounds(self.current_layout_block_handle()) else { // Nothing usable to fit: leave the camera where it is. return; }; if min == max { max += glam::DVec3::splat(1.0); } let aspect = self.active_camera_aspect(); // f64 all the way into the camera: a UTM-scale drawing loses whole // metres to an f32 round-trip, which shows up as a fit that sits // slightly off the geometry it was asked to frame. self.camera.borrow_mut().fit_to_bounds_f64(min, max, aspect); self.projection_bounds_epoch.set(self.geometry_epoch); self.camera_generation += 1; } /// Fit every tiled Model viewport independently. The drawing bounds are /// shared, but each tile keeps its own orientation and uses its own screen /// aspect when calculating the required zoom. pub fn fit_all_model_viewports(&mut self) { if self.current_layout != "Model" || self.model_tiles.borrow().len() <= 1 { self.fit_all(); return; } let generation_before = self.camera_generation; self.fit_all(); if self.camera_generation == generation_before { return; } let Some((min, max)) = self.camera.borrow().fitted_model_bounds() else { return; }; let (canvas_w, canvas_h) = self.selection.borrow().view.vp_size; let fallback = self.last_render_aspect.get().max(0.01); let aspects: Vec = self .model_tiles .borrow() .iter() .map(|tile| { let width = tile.rect.width * canvas_w; let height = tile.rect.height * canvas_h; if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { width / height } else { fallback } }) .collect(); let active = self .active_model_tile .get() .min(aspects.len().saturating_sub(1)); let live_camera = self.camera.borrow_mut(); let mut tiles = self.model_tiles.borrow_mut(); for (index, tile) in tiles.iter_mut().enumerate() { let aspect = aspects.get(index).copied().unwrap_or(fallback); if index == active { tile.camera = live_camera.clone(); } else { tile.camera.fit_to_bounds_f64(min, max, aspect); } } if let Some(aspect) = aspects.get(active) { self.last_render_aspect.set(*aspect); } self.camera_generation += 1; } pub fn update(&mut self, _dt: Duration) { // The glyph atlas grew or re-baked since this scene last built its // caches (a CJK sheet set bakes thousands of tiles on first sight): // every quad laid out before that samples the wrong tile — scrambled // text in the affected blocks / viewports. One structural bump // re-lays-out all text against the settled atlas. let atlas_gen = crate::scene::text::sdf_atlas::generation(); if self.last_atlas_generation.get() != atlas_gen { self.last_atlas_generation.set(atlas_gen); self.bump_geometry(); } } } /// The drawing's ACAD_BACKGROUND dictionary, made when it has none. fn background_dictionary(document: &mut codec::CadDocument) -> Option { use codec::objects::{Dictionary, ObjectType}; let root = document.header.named_objects_dict_handle; let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get(&root) else { return None; }; if let Some(existing) = root_dictionary.get("ACAD_BACKGROUND") { if matches!(document.objects.get(&existing), Some(ObjectType::Dictionary(_))) { return Some(existing); } } let handle = document.allocate_handle(); let mut dictionary = Dictionary::new(); dictionary.handle = handle; dictionary.owner = root; dictionary.reactors.push(root); document.objects.insert(handle, ObjectType::Dictionary(dictionary)); if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) { root_dictionary.add_entry("ACAD_BACKGROUND", handle); } Some(handle) } /// A new background object in ACAD_BACKGROUND, keyed by its own handle as /// the reference keys it. Null when the drawing has no named-objects root. pub fn add_background( document: &mut codec::CadDocument, data: codec::objects::ClassObjectData, ) -> Handle { use codec::objects::{ClassObject, ObjectType}; let Some(dictionary) = background_dictionary(document) else { return Handle::NULL; }; let handle = document.allocate_handle(); let mut object = ClassObject::new(data); object.handle = handle; object.owner = dictionary; object.reactors = vec![dictionary]; document.objects.insert(handle, ObjectType::ClassObject(object)); if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) { entries.add_entry(format!("{:X}", handle.value()), handle); } handle } /// A copy of a background object (the reference gives each view and /// viewport its own); null for none. pub fn copy_background(document: &mut codec::CadDocument, handle: Handle) -> Handle { use codec::objects::{ClassObjectData as D, ObjectType}; match document.objects.get(&handle) { Some(ObjectType::ClassObject(object)) if matches!( object.data, D::SolidBackground(_) | D::GradientBackground(_) | D::ImageBackground(_) | D::GroundPlaneBackground(_) | D::SkyLightBackground(_) | D::IblBackground(_) ) => { let data = object.data.clone(); add_background(document, data) } _ => Handle::NULL, } }