Files
OneCAD-Draft/src/scene/camera_ops.rs
T
ramox81andClaude Opus 5.5 c8167c54ee -VIEW sEttings Background dialog and view backgrounds
Color, Gradient and Image at the background prompt open a Background
dialog (None / Solid / Gradient / Image, preview, solid colour, two- or
three-colour gradient with rotation, image file with position and
scale). Apply links the view to a new background object in
ACAD_BACKGROUND; restoring a view gives the model viewport a copy of
it (or none), and saving a view copies the viewport's background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:50:00 +03:00

1727 lines
69 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<glam::DVec3> {
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<String>,
annotation_scale: f32,
) -> Option<codec::types::Transform> {
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<Handle> {
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<Camera> {
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<Camera> {
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<codec::tables::VPort> = self
.document
.vports
.iter()
.filter(|v| is_active_vport_name(&v.name))
.cloned()
.collect();
if active_vports.len() <= 1 {
return false;
}
let tiles: Vec<ModelTile> = 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<codec::tables::VPort> = 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<codec::tables::VPort> = 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_<layout>` 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<glam::DVec3> = 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<f32> = 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<Handle> {
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,
}
}