forked from 1b-agent/opencadstudio-upstream
refactor: rename mislabeled scene files to match contents
Two names collided with the pick subsystem and misdescribed their files: - scene/hittest.rs -> scene/camera_ops.rs. Its contents are camera/view operations (zoom, fit-all, named-view restore, camera<->document sync, per-tile grid/snap), not hit-testing — the real screen-space hit-test lives in scene/pick/hit_test.rs. - scene/pick/selection.rs -> scene/pick/selection_state.rs. It holds only the SelectionState struct; the name clashed with scene/selection.rs (the Scene selection operations). Callers are unaffected (the crate::scene::SelectionState re-export path is unchanged). No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cd7aaed4e
commit
9b8ee94c0c
4 files changed
+6
-4
No files matched your search
@@ -0,0 +1,803 @@
|
||||
// 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::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Hit-test convenience: wire name → Handle ──────────────────────────
|
||||
|
||||
pub fn handle_from_wire_name(name: &str) -> Option<Handle> {
|
||||
name.parse::<u64>().ok().map(Handle::new)
|
||||
}
|
||||
|
||||
/// Restore camera to a named view from the document view table.
|
||||
pub fn restore_named_view(&mut self, view: &acadrust::tables::View) {
|
||||
use glam::Vec3;
|
||||
let cam = &mut *self.camera.borrow_mut();
|
||||
// view.target is the look-at point; view.direction is eye→target direction.
|
||||
cam.target = glam::DVec3::new(view.target.x, view.target.y, view.target.z);
|
||||
// direction in acadrust = from-target-to-eye (same as AutoCAD convention).
|
||||
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);
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// 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) -> acadrust::tables::View {
|
||||
use acadrust::types::Vector3;
|
||||
let cam = self.camera.borrow();
|
||||
let eye_dir = cam.rotation * glam::Vec3::Z;
|
||||
let height = cam.ortho_size() * 2.0;
|
||||
let width = height; // caller can adjust; rough square
|
||||
acadrust::tables::View {
|
||||
handle: acadrust::types::Handle::NULL,
|
||||
name: name.to_string(),
|
||||
center: Vector3 {
|
||||
x: cam.target.x as f64,
|
||||
y: cam.target.y as f64,
|
||||
z: 0.0,
|
||||
},
|
||||
target: Vector3 {
|
||||
x: cam.target.x as f64,
|
||||
y: cam.target.y as f64,
|
||||
z: cam.target.z as f64,
|
||||
},
|
||||
direction: Vector3 {
|
||||
x: eye_dir.x as f64,
|
||||
y: eye_dir.y as f64,
|
||||
z: eye_dir.z as f64,
|
||||
},
|
||||
height: height as f64,
|
||||
width: width as f64,
|
||||
lens_length: 50.0,
|
||||
front_clip: 0.0,
|
||||
back_clip: 0.0,
|
||||
twist_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
self.camera.borrow_mut().fit_to_bounds(min, max);
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// Apply camera state from an acadrust 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: &acadrust::tables::View,
|
||||
model_space: bool,
|
||||
) -> bool {
|
||||
let _ = model_space;
|
||||
let Some(cam) = self.camera_from_view(
|
||||
view.direction,
|
||||
view.target,
|
||||
acadrust::types::Vector2 {
|
||||
x: view.center.x,
|
||||
y: view.center.y,
|
||||
},
|
||||
view.height,
|
||||
view.twist_angle,
|
||||
) 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| v.name == "*Active") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Prefer our named View entry — survives DWG save without being overridden.
|
||||
let saved_view = self
|
||||
.document
|
||||
.views
|
||||
.iter()
|
||||
.find(|v| v.name == "OpenCADStudio_Camera_Model")
|
||||
.cloned();
|
||||
if let Some(view) = saved_view {
|
||||
return self.apply_camera_from_view_entry(&view, true);
|
||||
}
|
||||
let vp = match self.document.vports.iter().find(|v| v.name == "*Active") {
|
||||
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(
|
||||
&self,
|
||||
view_direction: acadrust::types::Vector3,
|
||||
view_target: acadrust::types::Vector3,
|
||||
view_center: acadrust::types::Vector2,
|
||||
view_height: f64,
|
||||
twist: f64,
|
||||
// Subtracted from `view_target` to reach wire-space. Model views pass
|
||||
// `[0.0_f64; 3]`; paper-space views (whose entities carry no
|
||||
// offset) pass `[0; 3]`.
|
||||
) -> 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 fov_y = 45.0_f32.to_radians();
|
||||
let distance = ((view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001);
|
||||
Some(Camera {
|
||||
target,
|
||||
rotation,
|
||||
distance,
|
||||
fov_y,
|
||||
projection: view::camera::Projection::Orthographic,
|
||||
yaw,
|
||||
pitch,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a VPort table entry (model-space tiled view) into a `Camera`.
|
||||
fn camera_from_vport(&self, vp: &acadrust::tables::VPort) -> Option<Camera> {
|
||||
self.camera_from_view(
|
||||
vp.view_direction,
|
||||
vp.view_target,
|
||||
vp.view_center,
|
||||
vp.view_height,
|
||||
vp.view_twist,
|
||||
)
|
||||
}
|
||||
|
||||
/// Reverse of `camera_from_vport`: write `cam`'s view target / direction
|
||||
/// / height onto a fresh VPort entry with the given `name` and screen
|
||||
/// rectangle (0..1 normalized, DXF bottom-left origin convention).
|
||||
fn vport_from_camera(
|
||||
&self,
|
||||
name: &str,
|
||||
cam: &Camera,
|
||||
lower_left: acadrust::types::Vector2,
|
||||
upper_right: acadrust::types::Vector2,
|
||||
) -> acadrust::tables::VPort {
|
||||
let view_dir = cam.rotation * glam::Vec3::Z;
|
||||
let view_height = cam.ortho_size() * 2.0;
|
||||
let target_wcs = acadrust::types::Vector3 {
|
||||
x: (cam.target.x as f64) + [0.0_f64; 3][0],
|
||||
y: (cam.target.y as f64) + [0.0_f64; 3][1],
|
||||
z: (cam.target.z as f64) + [0.0_f64; 3][2],
|
||||
};
|
||||
let mut entry = acadrust::tables::VPort::new(name);
|
||||
entry.lower_left = lower_left;
|
||||
entry.upper_right = upper_right;
|
||||
entry.view_target = target_wcs;
|
||||
entry.view_direction = acadrust::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;
|
||||
entry.view_center = acadrust::types::Vector2::ZERO;
|
||||
// Stored twist = -roll, matching the decoder (roll = -twist).
|
||||
entry.view_twist = -cam.roll() as f64;
|
||||
entry
|
||||
}
|
||||
|
||||
/// 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) -> (acadrust::types::Vector2, acadrust::types::Vector2) {
|
||||
let lower_left = acadrust::types::Vector2 {
|
||||
x: rect.x as f64,
|
||||
y: (1.0 - rect.y - rect.height) as f64,
|
||||
};
|
||||
let upper_right = acadrust::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: acadrust::types::Vector2, upper_right: acadrust::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 VPort entries that a previous save left in
|
||||
/// the document. Native AutoCAD tiled model-space layouts are represented
|
||||
/// by 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<acadrust::tables::VPort> = self
|
||||
.document
|
||||
.vports
|
||||
.iter()
|
||||
.filter(|v| v.name == "*Active")
|
||||
.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.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Persist `model_tiles` to the VPort table. Native AutoCAD tiled model
|
||||
/// viewports are written as duplicate `*Active` 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 preserved_vps: Vec<acadrust::tables::VPort> = self
|
||||
.document
|
||||
.vports
|
||||
.iter()
|
||||
.filter(|v| v.name != "*Active")
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut new_vports = acadrust::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());
|
||||
}
|
||||
}
|
||||
|
||||
for tile in ordered_tiles {
|
||||
let (ll, ur) = Self::tile_rect_to_vport(tile.rect);
|
||||
let mut entry = self.vport_from_camera("*Active", &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;
|
||||
entry.handle = self.document.allocate_handle();
|
||||
self.document.vports.add_allow_duplicate(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let sheet_vp = if layout_block.is_null() {
|
||||
None
|
||||
} else {
|
||||
self.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let EntityType::Viewport(vp) = e {
|
||||
Some(vp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.find(|vp| {
|
||||
vp.common.owner_handle == layout_block
|
||||
&& !self.is_content_viewport_in_layout(vp, layout_block)
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
|
||||
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(
|
||||
vp.view_direction,
|
||||
vp.view_target,
|
||||
acadrust::types::Vector2 {
|
||||
x: vp.view_center.x,
|
||||
y: vp.view_center.y,
|
||||
},
|
||||
vp.view_height,
|
||||
vp.twist_angle,
|
||||
) 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 = acadrust::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" {
|
||||
let target_wcs = acadrust::types::Vector3 {
|
||||
x: (cam.target.x as f64) + [0.0_f64; 3][0],
|
||||
y: (cam.target.y as f64) + [0.0_f64; 3][1],
|
||||
z: (cam.target.z as f64) + [0.0_f64; 3][2],
|
||||
};
|
||||
|
||||
// Write back to the *Active VPort entry (may be overridden by DWG writer).
|
||||
if let Some(vp) = self
|
||||
.document
|
||||
.vports
|
||||
.iter_mut()
|
||||
.find(|v| v.name == "*Active")
|
||||
{
|
||||
vp.view_target = target_wcs;
|
||||
vp.view_center = acadrust::types::Vector2::ZERO;
|
||||
vp.view_direction = vd3;
|
||||
vp.view_height = view_height as f64;
|
||||
vp.view_twist = twist;
|
||||
}
|
||||
|
||||
// Persist the tiled layout as duplicate `*Active` VPort entries.
|
||||
self.save_model_tiles_to_vports();
|
||||
|
||||
// Also write to View table — survives DWG save without override.
|
||||
self.write_camera_view_entry(
|
||||
"OpenCADStudio_Camera_Model",
|
||||
target_wcs,
|
||||
vd3,
|
||||
view_height,
|
||||
twist,
|
||||
);
|
||||
true
|
||||
} else {
|
||||
let target_wcs = acadrust::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 layout_block = self.current_layout_block_handle();
|
||||
if !layout_block.is_null() {
|
||||
let sheet_handle = self
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let EntityType::Viewport(vp) = e {
|
||||
Some(vp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.find(|vp| {
|
||||
vp.common.owner_handle == layout_block && !self.is_content_viewport_in_layout(vp, layout_block)
|
||||
})
|
||||
.map(|vp| vp.common.handle);
|
||||
|
||||
if let Some(handle) = sheet_handle {
|
||||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
|
||||
// AutoCAD stores the paper-space view position in
|
||||
// `view_center` (DCS) with `view_target` at the origin —
|
||||
// writing it the other way round shifts the layout and
|
||||
// crashes nothing but renders the sheet off-place. Paper
|
||||
// space is always a plan view, so DCS == WCS XY here.
|
||||
vp.view_center =
|
||||
acadrust::types::Vector3::new(target_wcs.x, target_wcs.y, 0.0);
|
||||
vp.view_target = acadrust::types::Vector3::ZERO;
|
||||
vp.view_direction = vd3;
|
||||
vp.view_height = view_height as f64;
|
||||
vp.twist_angle = twist;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsert a named View entry with the given camera fields.
|
||||
fn write_camera_view_entry(
|
||||
&mut self,
|
||||
name: &str,
|
||||
target: acadrust::types::Vector3,
|
||||
direction: acadrust::types::Vector3,
|
||||
height: f32,
|
||||
twist: f64,
|
||||
) {
|
||||
let existing_handle = self
|
||||
.document
|
||||
.views
|
||||
.iter()
|
||||
.find(|v| v.name == name)
|
||||
.map(|v| v.handle);
|
||||
let mut entry = acadrust::tables::View::new(name);
|
||||
entry.handle = existing_handle.unwrap_or_else(|| self.document.allocate_handle());
|
||||
entry.target = target;
|
||||
entry.direction = direction;
|
||||
entry.height = height as f64;
|
||||
entry.width = height as f64;
|
||||
entry.center = acadrust::types::Vector3::ZERO;
|
||||
entry.twist_angle = twist;
|
||||
self.document.views.add_or_replace(entry);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.fit_all();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fit_all(&mut self) {
|
||||
// Use the FULL, un-culled wire set — not `entity_wires()`, which is
|
||||
// frustum-culled to the current view. Culled input would fit only the
|
||||
// entities already on screen, so each call would zoom out a little and
|
||||
// reveal more, converging on the true extent only after several uses
|
||||
// (issue #51). `wpp = None` also tessellates at a fixed tolerance so
|
||||
// the bounds don't drift with zoom-adaptive curve sampling.
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let mut wires = self.wires_for_block_culled(layout_block, None, None, None, None);
|
||||
if self.current_layout != "Model" {
|
||||
wires.extend(self.viewport_content_wires(layout_block, None, None));
|
||||
}
|
||||
// 3D solids render as meshes, not wires, so collect their (offset-rel)
|
||||
// XY AABBs separately — a drawing of only solids has no wires to fit.
|
||||
let mesh_aabbs: Vec<[f32; 4]> = self
|
||||
.meshes
|
||||
.iter()
|
||||
.filter(|(h, _)| {
|
||||
self.document
|
||||
.get_entity(**h)
|
||||
.map(|e| e.common().owner_handle == layout_block)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(_, set)| set.world_aabb)
|
||||
.filter(|a| a[0].is_finite() && a[2].is_finite())
|
||||
.collect();
|
||||
if wires.is_empty() && mesh_aabbs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-wire centroid pass — used both for the absolute-magnitude reject
|
||||
// (`local_extent_max`) and for the IQR-based outlier reject below.
|
||||
// A wire whose centroid sits far outside the drawing's consensus
|
||||
// cluster is an orphan (block-defn entity that leaked into MSPACE,
|
||||
// bogus hatch boundary, Ray/XLine far point) and must not poison the
|
||||
// bounding box.
|
||||
struct WireCent {
|
||||
idx: usize,
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
}
|
||||
let lim = self.local_extent_max;
|
||||
let mut cents: Vec<WireCent> = Vec::with_capacity(wires.len());
|
||||
for (idx, wire) in wires.iter().enumerate() {
|
||||
let mut sx = 0.0_f64;
|
||||
let mut sy = 0.0_f64;
|
||||
let mut n = 0_usize;
|
||||
for &[x, y, _] in &wire.points {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
continue;
|
||||
}
|
||||
sx += x as f64;
|
||||
sy += y as f64;
|
||||
n += 1;
|
||||
}
|
||||
if n > 0 {
|
||||
cents.push(WireCent {
|
||||
idx,
|
||||
cx: (sx / n as f64) as f32,
|
||||
cy: (sy / n as f64) as f32,
|
||||
});
|
||||
}
|
||||
}
|
||||
if cents.is_empty() && mesh_aabbs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Robust drawing centre (median centroid). `lim` is a span RELATIVE to
|
||||
// this centre, so every reject below is distance-from-centre — geometry
|
||||
// now reaches fit_all as absolute coordinates (no world_offset), which
|
||||
// at UTM scale are ~5.7e6; an absolute `|x| > lim` test would reject the
|
||||
// entire drawing and make ZOOM Extents a no-op.
|
||||
let (mcx, mcy) = {
|
||||
let mut xs: Vec<f32> = cents.iter().map(|c| c.cx).collect();
|
||||
let mut ys: Vec<f32> = cents.iter().map(|c| c.cy).collect();
|
||||
if xs.is_empty() {
|
||||
(0.0_f32, 0.0_f32)
|
||||
} else {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
(xs[xs.len() / 2], ys[ys.len() / 2])
|
||||
}
|
||||
};
|
||||
|
||||
// IQR-based reject only kicks in with enough samples for the quartiles
|
||||
// to be meaningful. Below that, the centre-relative `lim` filter is the
|
||||
// only gate (legacy behavior).
|
||||
let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 {
|
||||
let mut xs: Vec<f32> = cents.iter().map(|c| c.cx).collect();
|
||||
let mut ys: Vec<f32> = cents.iter().map(|c| c.cy).collect();
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let q = |v: &[f32], frac: f32| v[((v.len() as f32 - 1.0) * frac) as usize];
|
||||
let q1x = q(&xs, 0.25);
|
||||
let q3x = q(&xs, 0.75);
|
||||
let q1y = q(&ys, 0.25);
|
||||
let q3y = q(&ys, 0.75);
|
||||
// k=10× the inter-quartile span is permissive enough to keep
|
||||
// legitimate sparse outlying geometry (annotation labels, scattered
|
||||
// dim leaders) but tight enough to drop a single wire stranded at
|
||||
// -world_offset. `max(1.0)` guards against a degenerate IQR=0
|
||||
// (e.g. all wires at the same centroid).
|
||||
const K: f32 = 10.0;
|
||||
let dx = (q3x - q1x).max(1.0) * K;
|
||||
let dy = (q3y - q1y).max(1.0) * K;
|
||||
(q1x - dx, q3x + dx, q1y - dy, q3y + dy)
|
||||
} else {
|
||||
(mcx - lim, mcx + lim, mcy - lim, mcy + lim)
|
||||
};
|
||||
|
||||
let mut min = glam::Vec3::splat(f32::MAX);
|
||||
let mut max = glam::Vec3::splat(f32::MIN);
|
||||
for c in ¢s {
|
||||
if c.cx < rx_lo || c.cx > rx_hi || c.cy < ry_lo || c.cy > ry_hi {
|
||||
continue;
|
||||
}
|
||||
let wire = &wires[c.idx];
|
||||
for &[x, y, z] in &wire.points {
|
||||
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if (x - mcx).abs() > lim || (y - mcy).abs() > lim {
|
||||
continue;
|
||||
}
|
||||
min = min.min(glam::Vec3::new(x, y, z));
|
||||
max = max.max(glam::Vec3::new(x, y, z));
|
||||
}
|
||||
}
|
||||
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
|
||||
for [ax, ay, bx, by] in &mesh_aabbs {
|
||||
min = min.min(glam::Vec3::new(*ax, *ay, 0.0));
|
||||
max = max.max(glam::Vec3::new(*bx, *by, 0.0));
|
||||
}
|
||||
// If no usable points found, leave the camera unchanged.
|
||||
if min.x > max.x {
|
||||
return;
|
||||
}
|
||||
if min == max {
|
||||
max += glam::Vec3::splat(1.0);
|
||||
}
|
||||
self.camera.borrow_mut().fit_to_bounds(min, max);
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, _dt: Duration) {}
|
||||
}
|
||||
Reference in new issue
Block a user