refactor: split four oversized files into submodule trees
Break up the four giant files (~29k lines total) into focused submodule
directories, one concern per file. Pure mechanical moves — behavior is
unchanged.
- app/update.rs (9650) -> app/update/{mod,viewport,command,style,file,
dynamic,dialog,util}. The 384-arm update_inner match stays in mod.rs
with thin delegating arms; the 66 fattest arm bodies move to topic
methods. Arm set preserved exactly (384 -> 384).
- scene/mod.rs (9017) -> scene/{mod,entity,tess,hittest,layout,paper,
mspace,project,selection,modify,group_layer,preview}. The impl Scene
body is split across files via inherent-impl-per-file; struct, ctor,
fields and load-time helpers stay in the root. fn count 123 -> 123.
- app/commands.rs (6082) -> app/commands/* grouped by command family.
The 233-arm dispatch match becomes source-ordered per-family
dispatch_* handlers, preserving first-match precedence (233 -> 233).
- app/view.rs (4686) -> app/view/{mod,overlay,modal,viewcube,controls}.
Largest file drops from 9650 to 3343. Builds clean; app launches and
loads plugins with no panic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2baabdf4a0
commit
bddc050b3e
39 changed files with 26678 additions and 26117 deletions
1192
src/scene/entity.rs
Normal file
1192
src/scene/entity.rs
Normal file
File diff suppressed because it is too large
Load diff
98
src/scene/group_layer.rs
Normal file
98
src/scene/group_layer.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Group helpers ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn groups(&self) -> impl Iterator<Item = &acadrust::objects::Group> {
|
||||
self.document.objects.values().filter_map(|obj| match obj {
|
||||
ObjectType::Group(g) => Some(g),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the names of all groups that contain `handle`.
|
||||
pub fn group_names_for_entity(&self, handle: Handle) -> Vec<String> {
|
||||
self.groups()
|
||||
.filter(|g| g.contains(handle))
|
||||
.map(|g| g.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Creates a named group from the given handles and registers it in the group dictionary.
|
||||
pub fn create_group(&mut self, name: String, handles: Vec<Handle>) -> Handle {
|
||||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||||
let mut group = acadrust::objects::Group::new(&name);
|
||||
group.handle = self.document.allocate_handle();
|
||||
group.owner = group_dict_handle;
|
||||
group.add_entities(handles);
|
||||
let gh = group.handle;
|
||||
self.document.objects.insert(gh, ObjectType::Group(group));
|
||||
if let Some(ObjectType::Dictionary(dict)) =
|
||||
self.document.objects.get_mut(&group_dict_handle)
|
||||
{
|
||||
dict.add_entry(&name, gh);
|
||||
}
|
||||
gh
|
||||
}
|
||||
|
||||
/// Dissolves all groups that contain any of the given handles.
|
||||
/// Returns the number of groups removed.
|
||||
pub fn delete_groups_containing(&mut self, handles: &[Handle]) -> usize {
|
||||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||||
let to_delete: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|obj| match obj {
|
||||
ObjectType::Group(g) if handles.iter().any(|h| g.contains(*h)) => Some(g.handle),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let count = to_delete.len();
|
||||
for gh in &to_delete {
|
||||
if let Some(ObjectType::Dictionary(dict)) =
|
||||
self.document.objects.get_mut(&group_dict_handle)
|
||||
{
|
||||
dict.entries.retain(|(_, h)| h != gh);
|
||||
}
|
||||
self.document.objects.remove(gh);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// If `handle` belongs to any selectable groups, also select all other members of those groups.
|
||||
pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) {
|
||||
let to_add: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|obj| match obj {
|
||||
ObjectType::Group(g) if g.selectable && handles.iter().any(|h| g.contains(*h)) => {
|
||||
Some(g.entities.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
for h in to_add {
|
||||
self.selected.insert(h);
|
||||
}
|
||||
self.bump_selection();
|
||||
}
|
||||
|
||||
// ── Layer helpers ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn toggle_layer_visibility(&mut self, name: &str) {
|
||||
if let Some(layer) = self.document.layers.get_mut(name) {
|
||||
layer.flags.off = !layer.flags.off;
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn toggle_layer_lock(&mut self, name: &str) {
|
||||
if let Some(layer) = self.document.layers.get_mut(name) {
|
||||
layer.flags.locked = !layer.flags.locked;
|
||||
}
|
||||
}
|
||||
}
|
||||
801
src/scene/hittest.rs
Normal file
801
src/scene/hittest.rs
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
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) {}
|
||||
}
|
||||
622
src/scene/layout.rs
Normal file
622
src/scene/layout.rs
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Layout management ─────────────────────────────────────────────────
|
||||
|
||||
/// Rename a paper-space layout. Updates the Layout object name in the document.
|
||||
pub fn rename_layout(&mut self, old_name: &str, new_name: &str) {
|
||||
for obj in self.document.objects.values_mut() {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == old_name {
|
||||
l.name = new_name.to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a paper-space layout and all entities owned by it.
|
||||
/// Returns `false` if the layout was not found or is "Model".
|
||||
pub fn delete_layout(&mut self, name: &str) -> bool {
|
||||
if name == "Model" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let layout_info = self.document.objects.values().find_map(|obj| {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == name {
|
||||
return Some((l.handle, l.block_record));
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
let (layout_handle, block_handle) = match layout_info {
|
||||
Some(info) => info,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Remove all entities that belong to this layout's block record.
|
||||
let to_remove: Vec<Handle> = self
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| e.common().owner_handle == block_handle)
|
||||
.map(|e| e.common().handle)
|
||||
.collect();
|
||||
for h in &to_remove {
|
||||
self.hatches.remove(h);
|
||||
self.meshes.remove(h);
|
||||
self.solid_models.remove(h);
|
||||
self.document.remove_entity(*h);
|
||||
}
|
||||
|
||||
// Remove the Layout object itself.
|
||||
self.document.objects.remove(&layout_handle);
|
||||
|
||||
// Drop the layout's entry from the ACAD_LAYOUT dictionary so it does not
|
||||
// dangle (and so AutoCAD doesn't try to recover a now-missing layout).
|
||||
let dict_handle = self.document.header.acad_layout_dict_handle;
|
||||
if let Some(ObjectType::Dictionary(d)) = self.document.objects.get_mut(&dict_handle) {
|
||||
d.entries.retain(|(k, _)| k != name);
|
||||
}
|
||||
|
||||
// Remove the now-empty paper-space block record.
|
||||
let block_name = self
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|b| b.handle == block_handle)
|
||||
.map(|b| b.name.clone());
|
||||
if let Some(bn) = block_name {
|
||||
self.document.block_records.remove(&bn);
|
||||
}
|
||||
|
||||
// Drop any standalone PlotSettings page setup tied to this layout.
|
||||
let ps_handles: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
ObjectType::PlotSettings(ps) if ps.page_name == name => Some(*h),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for h in ps_handles {
|
||||
self.document.objects.remove(&h);
|
||||
}
|
||||
|
||||
// If the deleted layout was active, fall back to Model space.
|
||||
if self.current_layout == name {
|
||||
self.current_layout = "Model".to_string();
|
||||
}
|
||||
|
||||
self.bump_geometry();
|
||||
true
|
||||
}
|
||||
|
||||
/// Swap the `tab_order` of two paper layouts so they appear in swapped order.
|
||||
pub fn swap_layout_order(&mut self, name_a: &str, name_b: &str) {
|
||||
let mut order_a: Option<i16> = None;
|
||||
let mut order_b: Option<i16> = None;
|
||||
for obj in self.document.objects.values() {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == name_a {
|
||||
order_a = Some(l.tab_order);
|
||||
}
|
||||
if l.name == name_b {
|
||||
order_b = Some(l.tab_order);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(oa), Some(ob)) = (order_a, order_b) {
|
||||
for obj in self.document.objects.values_mut() {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == name_a {
|
||||
l.tab_order = ob;
|
||||
} else if l.name == name_b {
|
||||
l.tab_order = oa;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Discover the inner divider edges between Model tiles. Each entry
|
||||
/// is one draggable horizontal or vertical edge, with the span along
|
||||
/// the perpendicular axis that the edge actually covers (the union
|
||||
/// of touching tiles' extents). Coordinates are in normalized 0..1
|
||||
/// canvas space. Returns an empty list outside Model or for a
|
||||
/// single-tile layout.
|
||||
pub fn model_tile_edges(&self) -> Vec<TileEdge> {
|
||||
if self.current_layout != "Model" {
|
||||
return vec![];
|
||||
}
|
||||
let tiles = self.model_tiles.borrow();
|
||||
if tiles.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
// Collect candidate inner x's: any tile edge that's strictly
|
||||
// inside (0, 1). Dedup by epsilon.
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut ys: Vec<f32> = Vec::new();
|
||||
for t in tiles.iter() {
|
||||
for x in [t.rect.x, t.rect.x + t.rect.width] {
|
||||
if x > TILE_EPS && x < 1.0 - TILE_EPS {
|
||||
xs.push(x);
|
||||
}
|
||||
}
|
||||
for y in [t.rect.y, t.rect.y + t.rect.height] {
|
||||
if y > TILE_EPS && y < 1.0 - TILE_EPS {
|
||||
ys.push(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
xs.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS);
|
||||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
ys.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS);
|
||||
for x in xs {
|
||||
let mut y0 = f32::INFINITY;
|
||||
let mut y1 = f32::NEG_INFINITY;
|
||||
let mut has_left = false;
|
||||
let mut has_right = false;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.x + t.rect.width) - x).abs() < TILE_EPS {
|
||||
has_left = true;
|
||||
y0 = y0.min(t.rect.y);
|
||||
y1 = y1.max(t.rect.y + t.rect.height);
|
||||
}
|
||||
if (t.rect.x - x).abs() < TILE_EPS {
|
||||
has_right = true;
|
||||
y0 = y0.min(t.rect.y);
|
||||
y1 = y1.max(t.rect.y + t.rect.height);
|
||||
}
|
||||
}
|
||||
if has_left && has_right && y1 > y0 {
|
||||
out.push(TileEdge {
|
||||
orient: TileEdgeOrient::Vertical,
|
||||
coord: x,
|
||||
span: (y0, y1),
|
||||
});
|
||||
}
|
||||
}
|
||||
for y in ys {
|
||||
let mut x0 = f32::INFINITY;
|
||||
let mut x1 = f32::NEG_INFINITY;
|
||||
let mut has_top = false;
|
||||
let mut has_bot = false;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.y + t.rect.height) - y).abs() < TILE_EPS {
|
||||
has_top = true;
|
||||
x0 = x0.min(t.rect.x);
|
||||
x1 = x1.max(t.rect.x + t.rect.width);
|
||||
}
|
||||
if (t.rect.y - y).abs() < TILE_EPS {
|
||||
has_bot = true;
|
||||
x0 = x0.min(t.rect.x);
|
||||
x1 = x1.max(t.rect.x + t.rect.width);
|
||||
}
|
||||
}
|
||||
if has_top && has_bot && x1 > x0 {
|
||||
out.push(TileEdge {
|
||||
orient: TileEdgeOrient::Horizontal,
|
||||
coord: y,
|
||||
span: (x0, x1),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hit-test the inner Model-tile dividers against a pixel cursor.
|
||||
/// `bounds` is the canvas pixel rectangle (origin = canvas top-left).
|
||||
/// Returns the closest edge within `tolerance_px` pixels of the cursor
|
||||
/// along its perpendicular axis, also requiring the cursor to lie
|
||||
/// within the edge's actual span.
|
||||
pub fn hit_model_tile_edge(
|
||||
&self,
|
||||
cursor_px: iced::Point,
|
||||
bounds: iced::Rectangle,
|
||||
tolerance_px: f32,
|
||||
) -> Option<TileEdge> {
|
||||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let cx = cursor_px.x - bounds.x;
|
||||
let cy = cursor_px.y - bounds.y;
|
||||
let nx = cx / bounds.width;
|
||||
let ny = cy / bounds.height;
|
||||
let tol_nx = tolerance_px / bounds.width;
|
||||
let tol_ny = tolerance_px / bounds.height;
|
||||
let mut best: Option<(f32, TileEdge)> = None;
|
||||
for e in self.model_tile_edges() {
|
||||
let (dist, in_span) = match e.orient {
|
||||
TileEdgeOrient::Vertical => (
|
||||
(e.coord - nx).abs() / tol_nx.max(1e-9),
|
||||
ny >= e.span.0 && ny <= e.span.1,
|
||||
),
|
||||
TileEdgeOrient::Horizontal => (
|
||||
(e.coord - ny).abs() / tol_ny.max(1e-9),
|
||||
nx >= e.span.0 && nx <= e.span.1,
|
||||
),
|
||||
};
|
||||
if in_span && dist <= 1.0 {
|
||||
if best.as_ref().map_or(true, |(d, _)| dist < *d) {
|
||||
best = Some((dist, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, e)| e)
|
||||
}
|
||||
|
||||
/// Move the inner divider edge from `old_coord` to `new_coord`, both
|
||||
/// in normalized 0..1 space. Adjusts every tile that touches the
|
||||
/// edge on either side. `min_size` clamps the new coordinate so no
|
||||
/// tile on either side can shrink below the minimum — dragging a
|
||||
/// divider to the screen edge stops at that minimum instead of
|
||||
/// closing the pane (use the close button for that).
|
||||
pub fn move_model_tile_edge(
|
||||
&self,
|
||||
orient: TileEdgeOrient,
|
||||
old_coord: f32,
|
||||
new_coord: f32,
|
||||
min_size: f32,
|
||||
) {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
// Clamp the new coordinate so no tile becomes ≤ 0 wide / tall.
|
||||
// (Sub-`min_size` results are still allowed — the collapse pass
|
||||
// handles those.)
|
||||
let new_coord = match orient {
|
||||
TileEdgeOrient::Vertical => {
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = 1.0_f32;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
|
||||
lo = lo.max(t.rect.x + min_size);
|
||||
}
|
||||
if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||||
hi = hi.min(t.rect.x + t.rect.width - min_size);
|
||||
}
|
||||
}
|
||||
new_coord.clamp(lo, hi.max(lo))
|
||||
}
|
||||
TileEdgeOrient::Horizontal => {
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = 1.0_f32;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
|
||||
lo = lo.max(t.rect.y + min_size);
|
||||
}
|
||||
if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||||
hi = hi.min(t.rect.y + t.rect.height - min_size);
|
||||
}
|
||||
}
|
||||
new_coord.clamp(lo, hi.max(lo))
|
||||
}
|
||||
};
|
||||
for t in tiles.iter_mut() {
|
||||
match orient {
|
||||
TileEdgeOrient::Vertical => {
|
||||
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
|
||||
t.rect.width = (new_coord - t.rect.x).max(0.0);
|
||||
} else if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||||
let old_right = t.rect.x + t.rect.width;
|
||||
t.rect.x = new_coord;
|
||||
t.rect.width = (old_right - new_coord).max(0.0);
|
||||
}
|
||||
}
|
||||
TileEdgeOrient::Horizontal => {
|
||||
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
|
||||
t.rect.height = (new_coord - t.rect.y).max(0.0);
|
||||
} else if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||||
let old_bottom = t.rect.y + t.rect.height;
|
||||
t.rect.y = new_coord;
|
||||
t.rect.height = (old_bottom - new_coord).max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the active Model tile, absorbing its area into the
|
||||
/// neighbour that shares the longest contact edge and rebinding the
|
||||
/// live camera to that neighbour. No-op with fewer than two tiles.
|
||||
pub fn close_active_model_tile(&self) {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
if tiles.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let idx = self.active_model_tile.get().min(tiles.len() - 1);
|
||||
self.absorb_model_tile(&mut tiles, idx);
|
||||
}
|
||||
|
||||
/// Drop tile `idx`, growing the neighbour with the longest shared
|
||||
/// contact edge to cover the vacated area. Fixes up
|
||||
/// `active_model_tile` so the live camera stays bound to a real tile
|
||||
/// (preferring the neighbour that absorbed it). Falls back to
|
||||
/// stretching the first remaining tile to fill the canvas if the
|
||||
/// tile has no axis-aligned neighbour.
|
||||
fn absorb_model_tile(&self, tiles: &mut Vec<ModelTile>, idx: usize) {
|
||||
let removed = tiles[idx].rect;
|
||||
// Find the neighbour with the longest shared contact edge.
|
||||
let mut best: Option<(usize, f32, ContactSide)> = None;
|
||||
for (j, t) in tiles.iter().enumerate() {
|
||||
if j == idx {
|
||||
continue;
|
||||
}
|
||||
let probes = [
|
||||
(
|
||||
ContactSide::Left,
|
||||
((t.rect.x + t.rect.width) - removed.x).abs() < TILE_EPS,
|
||||
overlap_len(
|
||||
(t.rect.y, t.rect.y + t.rect.height),
|
||||
(removed.y, removed.y + removed.height),
|
||||
),
|
||||
),
|
||||
(
|
||||
ContactSide::Right,
|
||||
(t.rect.x - (removed.x + removed.width)).abs() < TILE_EPS,
|
||||
overlap_len(
|
||||
(t.rect.y, t.rect.y + t.rect.height),
|
||||
(removed.y, removed.y + removed.height),
|
||||
),
|
||||
),
|
||||
(
|
||||
ContactSide::Top,
|
||||
((t.rect.y + t.rect.height) - removed.y).abs() < TILE_EPS,
|
||||
overlap_len(
|
||||
(t.rect.x, t.rect.x + t.rect.width),
|
||||
(removed.x, removed.x + removed.width),
|
||||
),
|
||||
),
|
||||
(
|
||||
ContactSide::Bottom,
|
||||
(t.rect.y - (removed.y + removed.height)).abs() < TILE_EPS,
|
||||
overlap_len(
|
||||
(t.rect.x, t.rect.x + t.rect.width),
|
||||
(removed.x, removed.x + removed.width),
|
||||
),
|
||||
),
|
||||
];
|
||||
for (side, touches, c) in probes {
|
||||
if touches && c > 0.0 {
|
||||
if best.map_or(true, |(_, len, _)| c > len) {
|
||||
best = Some((j, c, side));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((nbr_idx, _, side)) = best {
|
||||
match side {
|
||||
ContactSide::Left => {
|
||||
tiles[nbr_idx].rect.width =
|
||||
(removed.x + removed.width) - tiles[nbr_idx].rect.x;
|
||||
}
|
||||
ContactSide::Right => {
|
||||
let old_right =
|
||||
tiles[nbr_idx].rect.x + tiles[nbr_idx].rect.width;
|
||||
tiles[nbr_idx].rect.x = removed.x;
|
||||
tiles[nbr_idx].rect.width = old_right - removed.x;
|
||||
}
|
||||
ContactSide::Top => {
|
||||
tiles[nbr_idx].rect.height =
|
||||
(removed.y + removed.height) - tiles[nbr_idx].rect.y;
|
||||
}
|
||||
ContactSide::Bottom => {
|
||||
let old_bottom =
|
||||
tiles[nbr_idx].rect.y + tiles[nbr_idx].rect.height;
|
||||
tiles[nbr_idx].rect.y = removed.y;
|
||||
tiles[nbr_idx].rect.height = old_bottom - removed.y;
|
||||
}
|
||||
}
|
||||
let active = self.active_model_tile.get();
|
||||
let new_active = if active == idx {
|
||||
if nbr_idx > idx { nbr_idx - 1 } else { nbr_idx }
|
||||
} else if active > idx {
|
||||
active - 1
|
||||
} else {
|
||||
active
|
||||
};
|
||||
tiles.remove(idx);
|
||||
self.active_model_tile
|
||||
.set(new_active.min(tiles.len().saturating_sub(1)));
|
||||
} else {
|
||||
// Isolated tile (shouldn't happen with axis-aligned
|
||||
// splits) — drop it and stretch the first remaining
|
||||
// tile to fill the canvas so we don't leave a hole.
|
||||
tiles.remove(idx);
|
||||
let active = self.active_model_tile.get();
|
||||
self.active_model_tile
|
||||
.set(active.saturating_sub(if active > idx { 1 } else { 0 }).min(tiles.len().saturating_sub(1)));
|
||||
if let Some(first) = tiles.first_mut() {
|
||||
first.rect = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the active Model tile in two. `horizontal` → a horizontal
|
||||
/// divider (top / bottom halves); otherwise a vertical divider (left /
|
||||
/// right). Both halves inherit the active tile's current camera; the
|
||||
/// active tile stays the first half. No-op outside the Model layout.
|
||||
pub fn split_active_model_tile(&self, horizontal: bool) {
|
||||
if self.current_layout != "Model" {
|
||||
return;
|
||||
}
|
||||
let cam_now = 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));
|
||||
let r = tiles[active].rect;
|
||||
let (a, b) = if horizontal {
|
||||
(
|
||||
iced::Rectangle { height: r.height / 2.0, ..r },
|
||||
iced::Rectangle {
|
||||
y: r.y + r.height / 2.0,
|
||||
height: r.height / 2.0,
|
||||
..r
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
iced::Rectangle { width: r.width / 2.0, ..r },
|
||||
iced::Rectangle {
|
||||
x: r.x + r.width / 2.0,
|
||||
width: r.width / 2.0,
|
||||
..r
|
||||
},
|
||||
)
|
||||
};
|
||||
let mode = tiles[active].render_mode;
|
||||
let (grid_on, snap_on) = (tiles[active].grid_on, tiles[active].snap_on);
|
||||
tiles[active] = ModelTile {
|
||||
rect: a,
|
||||
camera: cam_now.clone(),
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
};
|
||||
tiles.insert(
|
||||
active + 1,
|
||||
ModelTile {
|
||||
rect: b,
|
||||
camera: cam_now,
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Make the Model tile containing normalized point `(nx, ny)` active,
|
||||
/// swapping cameras so the live `Scene::camera` follows the new tile.
|
||||
/// Returns `true` when the active tile changed. No-op outside Model.
|
||||
pub fn set_active_model_tile_at(&self, nx: f32, ny: f32) -> bool {
|
||||
if self.current_layout != "Model" {
|
||||
return false;
|
||||
}
|
||||
let new = {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
tiles.iter().position(|t| {
|
||||
nx >= t.rect.x
|
||||
&& nx < t.rect.x + t.rect.width
|
||||
&& ny >= t.rect.y
|
||||
&& ny < t.rect.y + t.rect.height
|
||||
})
|
||||
};
|
||||
let Some(new) = new else { return false };
|
||||
let old = self.active_model_tile.get();
|
||||
if new == old {
|
||||
return false;
|
||||
}
|
||||
// Stash the live camera into the outgoing tile, load the incoming.
|
||||
let incoming = {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
if let Some(t) = tiles.get_mut(old) {
|
||||
t.camera = self.camera.borrow().clone();
|
||||
}
|
||||
tiles.get(new).map(|t| t.camera.clone())
|
||||
};
|
||||
if let Some(cam) = incoming {
|
||||
*self.camera.borrow_mut() = cam;
|
||||
}
|
||||
self.active_model_tile.set(new);
|
||||
// Caller bumps camera_generation (it needs &mut Scene).
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the Model tiled layout with the given normalized rectangles
|
||||
/// (each in 0..1). Every tile inherits the current camera; the first
|
||||
/// tile becomes active. Used by VPORTS presets and `reset_model_tiles`.
|
||||
pub fn set_model_tile_layout(&self, rects: Vec<iced::Rectangle>) {
|
||||
let cam_now = self.camera.borrow().clone();
|
||||
// Every new pane inherits the active tile's current visual style.
|
||||
let (mode, grid_on, snap_on) = {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
tiles
|
||||
.get(active)
|
||||
.map(|t| (t.render_mode, t.grid_on, t.snap_on))
|
||||
.unwrap_or((
|
||||
acadrust::entities::ViewportRenderMode::Wireframe2D,
|
||||
false,
|
||||
false,
|
||||
))
|
||||
};
|
||||
let tiles: Vec<ModelTile> = rects
|
||||
.into_iter()
|
||||
.map(|rect| ModelTile {
|
||||
rect,
|
||||
camera: cam_now.clone(),
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
})
|
||||
.collect();
|
||||
*self.model_tiles.borrow_mut() = if tiles.is_empty() {
|
||||
vec![ModelTile {
|
||||
rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
camera: cam_now,
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
}]
|
||||
} else {
|
||||
tiles
|
||||
};
|
||||
self.active_model_tile.set(0);
|
||||
}
|
||||
|
||||
/// Screen-pixel rectangle of the active Model tile within a canvas of
|
||||
/// `(vw, vh)`. Full canvas outside the Model layout or for a single
|
||||
/// tile. Used to map cursor coordinates into the active tile so pick /
|
||||
/// pan / ViewCube work per-pane in a tiled layout.
|
||||
/// Canvas bounds + camera for every Model tile whose grid display is on.
|
||||
/// Each pane renders its own grid independently of which tile is active or
|
||||
/// hovered, so the grid never flickers as the cursor crosses panes. The
|
||||
/// active tile uses the live camera (mid-orbit/pan); others use their
|
||||
/// stored camera. (#121)
|
||||
/// Screen rect + camera for every grid-on sub-view in the current layout —
|
||||
/// model tiles in model space, the sheet plus each floating viewport
|
||||
/// (clipped to its rectangle) in paper space. Derived from the same
|
||||
/// `active_viewports` enumeration the renderer uses, so the grid overlay can
|
||||
/// never drift from the views actually on screen (issue #121). The grid
|
||||
|
||||
pub fn active_model_tile_bounds(&self, vw: f32, vh: f32) -> iced::Rectangle {
|
||||
if self.current_layout != "Model" {
|
||||
return iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh };
|
||||
}
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
match tiles.get(active) {
|
||||
Some(t) => iced::Rectangle {
|
||||
x: t.rect.x * vw,
|
||||
y: t.rect.y * vh,
|
||||
width: (t.rect.width * vw).max(1.0),
|
||||
height: (t.rect.height * vh).max(1.0),
|
||||
},
|
||||
None => iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
enum ContactSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
fn overlap_len(a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||
(a.1.min(b.1) - a.0.max(b.0)).max(0.0)
|
||||
}
|
||||
5716
src/scene/mod.rs
5716
src/scene/mod.rs
File diff suppressed because it is too large
Load diff
319
src/scene/modify.rs
Normal file
319
src/scene/modify.rs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Modify (transform / copy) ─────────────────────────────────────────
|
||||
|
||||
pub fn transform_entities(&mut self, handles: &[Handle], t: &EntityTransform) {
|
||||
// MIRRTEXT (header.mirror_text): when false AutoCAD positions text /
|
||||
// mtext / shape by the mirror but keeps the original rotation +
|
||||
// oblique so the text stays right-reading. Capture before the
|
||||
// transform and re-apply afterwards.
|
||||
let preserve_text_orientation =
|
||||
matches!(t, EntityTransform::Mirror { .. }) && !self.document.header.mirror_text;
|
||||
let mut text_orient_backup: Vec<(Handle, f64, f64, f64)> = Vec::new();
|
||||
if preserve_text_orientation {
|
||||
for &h in handles {
|
||||
if let Some(entity) = self.document.get_entity(h) {
|
||||
match entity {
|
||||
EntityType::Text(t) => {
|
||||
text_orient_backup.push((h, t.rotation, t.oblique_angle, 0.0))
|
||||
}
|
||||
EntityType::MText(m) => {
|
||||
text_orient_backup.push((h, m.rotation, 0.0, 0.0))
|
||||
}
|
||||
EntityType::Shape(s) => text_orient_backup.push((
|
||||
h,
|
||||
s.rotation,
|
||||
s.oblique_angle,
|
||||
s.relative_x_scale,
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A dimension's final geometry is baked into a per-instance `*D`
|
||||
// block, and the render draws those sub-entities directly (not the
|
||||
// definition points). Transform them with the dimension, or it would
|
||||
// stay drawn in place while only its def points move.
|
||||
let dim_block_subs: Vec<Handle> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| match self.document.get_entity(h) {
|
||||
Some(EntityType::Dimension(d)) => {
|
||||
let bn = d.base().block_name.clone();
|
||||
if bn.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bn)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.filter_map(|bn| {
|
||||
self.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.name.eq_ignore_ascii_case(&bn))
|
||||
.map(|br| br.entity_handles.clone())
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
for &h in handles {
|
||||
if let Some(entity) = self.document.get_entity_mut(h) {
|
||||
view::dispatch::apply_transform(entity, t);
|
||||
}
|
||||
if self.hatches.contains_key(&h) {
|
||||
let existing_color = self.hatches[&h].color;
|
||||
let new_model = match self.document.get_entity(h) {
|
||||
Some(EntityType::Hatch(dxf)) => {
|
||||
Self::hatch_model_from_dxf(dxf, existing_color)
|
||||
}
|
||||
// A DXF SOLID renders as a solid-fill hatch; rebuild it from
|
||||
// the moved corners so the fill follows the transform.
|
||||
Some(EntityType::Solid(s)) => {
|
||||
Some(Self::solid_hatch_model(s, existing_color))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(model) = new_model {
|
||||
self.hatches.insert(h, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
if preserve_text_orientation {
|
||||
for (h, rot, oblique, x_scale) in text_orient_backup {
|
||||
if let Some(entity) = self.document.get_entity_mut(h) {
|
||||
match entity {
|
||||
EntityType::Text(t) => {
|
||||
t.rotation = rot;
|
||||
t.oblique_angle = oblique;
|
||||
}
|
||||
EntityType::MText(m) => {
|
||||
m.rotation = rot;
|
||||
}
|
||||
EntityType::Shape(s) => {
|
||||
s.rotation = rot;
|
||||
s.oblique_angle = oblique;
|
||||
s.relative_x_scale = x_scale;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Move the baked dimension-block sub-entities too (collected above).
|
||||
for h in &dim_block_subs {
|
||||
if let Some(entity) = self.document.get_entity_mut(*h) {
|
||||
view::dispatch::apply_transform(entity, t);
|
||||
}
|
||||
}
|
||||
// Only the transformed entities changed (a top-level move/rotate/scale/
|
||||
// mirror never edits a block definition) — re-tessellate just those and
|
||||
// keep the block cache + every other entity's memoized wires.
|
||||
for &h in handles {
|
||||
self.mark_entity_dirty(h);
|
||||
}
|
||||
self.bump_geometry_no_blocks();
|
||||
}
|
||||
|
||||
/// Give a freshly-cloned entity brand-new handles for every *inline*
|
||||
/// sub-entity that stores one (INSERT attributes, 3D-polyline vertices).
|
||||
/// `document.add_entity` only assigns the top-level handle, so without this
|
||||
/// a copy keeps its source's sub-handles — duplicate handles that corrupt
|
||||
/// the saved DWG (file won't reopen in other CAD apps). Vertices that don't
|
||||
/// store a handle (LwPolyline / heavy 2D polyline) get one from the writer,
|
||||
/// so they need no fix-up here. (#129)
|
||||
pub(super) fn reset_clone_subhandles(doc: &mut acadrust::CadDocument, entity: &mut EntityType) {
|
||||
match entity {
|
||||
EntityType::Insert(ins) => {
|
||||
for att in ins.attributes.iter_mut() {
|
||||
att.common.handle = doc.allocate_handle();
|
||||
}
|
||||
}
|
||||
EntityType::Polyline3D(p) => {
|
||||
for v in p.vertices.iter_mut() {
|
||||
v.handle = doc.allocate_handle();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a freshly-cloned entity, allocating a new handle for it *and* every
|
||||
/// inline sub-entity so the copy never shares a handle with its source.
|
||||
/// Use this (not `add_entity`) whenever inserting a duplicate. (#129)
|
||||
pub fn add_entity_clone(&mut self, mut entity: EntityType) -> Handle {
|
||||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
self.add_entity(entity)
|
||||
}
|
||||
|
||||
/// Duplicate the anonymous block `src_name`, transforming every sub-entity
|
||||
/// by `t`, and return the new block's name. A dimension's drawn geometry
|
||||
/// lives in such a baked `*D` block, so a copied dimension needs its own
|
||||
/// transformed block — otherwise it still references the source block and
|
||||
/// renders on top of the original instead of at the copy. Returns None when
|
||||
/// the source block is missing or empty. (#161)
|
||||
fn clone_transformed_block(&mut self, src_name: &str, t: &EntityTransform) -> Option<String> {
|
||||
let sub_handles = self
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.name.eq_ignore_ascii_case(src_name))
|
||||
.map(|br| br.entity_handles.clone())?;
|
||||
if sub_handles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Smallest free `*D<n>` anonymous name.
|
||||
let mut n = 0u64;
|
||||
let new_name = loop {
|
||||
let cand = format!("*D{n}");
|
||||
if self.document.block_records.get(&cand).is_none() {
|
||||
break cand;
|
||||
}
|
||||
n += 1;
|
||||
};
|
||||
let next = self.document.next_handle();
|
||||
let br_handle = Handle::new(next);
|
||||
let block_handle = Handle::new(next + 1);
|
||||
let end_handle = Handle::new(next + 2);
|
||||
let mut br = acadrust::tables::BlockRecord::new(&new_name);
|
||||
br.handle = br_handle;
|
||||
br.block_entity_handle = block_handle;
|
||||
br.block_end_handle = end_handle;
|
||||
self.document.block_records.add(br).ok()?;
|
||||
let mut block = Block::new(&new_name, acadrust::types::Vector3::ZERO);
|
||||
block.common.handle = block_handle;
|
||||
block.common.owner_handle = br_handle;
|
||||
self.document.add_entity(EntityType::Block(block)).ok()?;
|
||||
let mut block_end = BlockEnd::new();
|
||||
block_end.common.handle = end_handle;
|
||||
block_end.common.owner_handle = br_handle;
|
||||
self.document.add_entity(EntityType::BlockEnd(block_end)).ok()?;
|
||||
for sh in sub_handles {
|
||||
if let Some(mut sub) = self.document.get_entity(sh).cloned() {
|
||||
view::dispatch::apply_transform(&mut sub, t);
|
||||
Self::reset_clone_subhandles(&mut self.document, &mut sub);
|
||||
sub.common_mut().handle = Handle::NULL;
|
||||
sub.common_mut().owner_handle = br_handle;
|
||||
let _ = self.document.add_entity(sub);
|
||||
}
|
||||
}
|
||||
Some(new_name)
|
||||
}
|
||||
|
||||
pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec<Handle> {
|
||||
let clones: Vec<EntityType> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| self.document.get_entity(h).cloned())
|
||||
.collect();
|
||||
let mut new_handles = Vec::with_capacity(clones.len());
|
||||
for mut entity in clones {
|
||||
view::dispatch::apply_transform(&mut entity, t);
|
||||
// A dimension draws from its baked `*D` block; give the copy its own
|
||||
// transformed block so it lands at the copy position rather than
|
||||
// rendering on top of the source. (#161)
|
||||
if let EntityType::Dimension(d) = &entity {
|
||||
let bn = d.base().block_name.clone();
|
||||
if !bn.trim().is_empty() {
|
||||
if let Some(new_bn) = self.clone_transformed_block(&bn, t) {
|
||||
if let EntityType::Dimension(d) = &mut entity {
|
||||
d.base_mut().block_name = new_bn;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
let h = self.document.add_entity(entity).unwrap_or(Handle::NULL);
|
||||
if !h.is_null() {
|
||||
let new_model = match self.document.get_entity(h) {
|
||||
Some(EntityType::Hatch(dxf)) => {
|
||||
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
||||
Self::hatch_model_from_dxf(dxf, color)
|
||||
}
|
||||
Some(EntityType::Solid(s)) => {
|
||||
let color = convert::tess_util::aci_to_rgba(&s.common.color);
|
||||
Some(Self::solid_hatch_model(s, color))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(model) = new_model {
|
||||
self.hatches.insert(h, model);
|
||||
}
|
||||
}
|
||||
new_handles.push(h);
|
||||
}
|
||||
// The copies are new handles (natural memo misses, tessellated fresh)
|
||||
// and reference only already-cached blocks — no block defn changes.
|
||||
self.bump_geometry_no_blocks();
|
||||
new_handles
|
||||
}
|
||||
|
||||
// ── Grip editing ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn apply_grip(&mut self, handle: Handle, grip_id: usize, apply: GripApply) {
|
||||
// For Solid3D / Region / Body, record the old point_of_reference so we
|
||||
// can translate the pre-tessellated MeshModel by the same delta after
|
||||
// the grip is applied (the ACIS data itself is not modified).
|
||||
let old_por: Option<[f64; 3]> = self
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.and_then(crate::entities::solid3d::point_of_reference)
|
||||
.map(|p| [p.x, p.y, p.z]);
|
||||
|
||||
if let Some(entity) = self.document.get_entity_mut(handle) {
|
||||
view::dispatch::apply_grip(entity, grip_id, apply);
|
||||
}
|
||||
|
||||
// Translate MeshModel vertices by the same delta the grip applied.
|
||||
if let Some(old) = old_por {
|
||||
let new_por: Option<[f64; 3]> = self
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.and_then(crate::entities::solid3d::point_of_reference)
|
||||
.map(|p| [p.x, p.y, p.z]);
|
||||
if let Some(new) = new_por {
|
||||
let dx = (new[0] - old[0]) as f32;
|
||||
let dy = (new[1] - old[1]) as f32;
|
||||
let dz = (new[2] - old[2]) as f32;
|
||||
if let Some(set) = self.meshes.get_mut(&handle) {
|
||||
for lod in &mut set.lods {
|
||||
for v in &mut lod.verts {
|
||||
v[0] += dx;
|
||||
v[1] += dy;
|
||||
v[2] += dz;
|
||||
}
|
||||
}
|
||||
set.world_aabb[0] += dx;
|
||||
set.world_aabb[1] += dy;
|
||||
set.world_aabb[2] += dx;
|
||||
set.world_aabb[3] += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild GPU hatch/solid model when a boundary vertex or corner moves.
|
||||
match self.document.get_entity(handle) {
|
||||
Some(EntityType::Hatch(dxf)) => {
|
||||
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
||||
if let Some(model) = Self::hatch_model_from_dxf(dxf, color) {
|
||||
self.hatches.insert(handle, model);
|
||||
} else {
|
||||
self.hatches.remove(&handle);
|
||||
}
|
||||
}
|
||||
Some(EntityType::Solid(solid)) => {
|
||||
let color = convert::tess_util::aci_to_rgba(&solid.common.color);
|
||||
self.hatches
|
||||
.insert(handle, Self::solid_hatch_model(solid, color));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// NOTE: no `bump_geometry()` here. The grip-drag caller hides the
|
||||
// edited entity and previews it as an overlay during the drag (so a
|
||||
// move doesn't re-tessellate the whole model), then bumps once on
|
||||
// commit. Any other caller must bump geometry itself.
|
||||
}
|
||||
}
|
||||
481
src/scene/mspace.rs
Normal file
481
src/scene/mspace.rs
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── MSPACE helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Convert a **paper-space** world coordinate to **model-space** using the
|
||||
/// geometry of the currently active viewport. Returns the input unchanged
|
||||
/// when there is no active viewport.
|
||||
/// Convert a paper-space point to model space (precise at UTM scale).
|
||||
pub fn paper_to_model(&self, paper_pt: glam::DVec3) -> glam::DVec3 {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return paper_pt,
|
||||
};
|
||||
let vp = match self.document.get_entity(vp_handle) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => vp,
|
||||
_ => return paper_pt,
|
||||
};
|
||||
// Uses the viewport's own `view_target` — kept valid by
|
||||
// `normalize_active_viewport_view` on entry, which folds a stale UTM
|
||||
// saved view onto the auto-fit centre so the display, pan/zoom and this
|
||||
// inverse all agree. Cheap (no per-call camera rebuild).
|
||||
let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||||
if scale.abs() < 1e-9 {
|
||||
return paper_pt;
|
||||
}
|
||||
let tx = vp.view_target.x;
|
||||
let ty = vp.view_target.y;
|
||||
let pcx = vp.center.x;
|
||||
let pcy = vp.center.y;
|
||||
glam::DVec3::new(
|
||||
(paper_pt.x - pcx) / scale + tx,
|
||||
(paper_pt.y - pcy) / scale + ty,
|
||||
paper_pt.z,
|
||||
)
|
||||
}
|
||||
|
||||
/// Inverse of [`paper_to_model`]: map a model-space point to the paper
|
||||
/// sheet through the active viewport. Returns the input unchanged when
|
||||
/// there is no active viewport. Kept as the inverse companion to
|
||||
/// `paper_to_model`; in-viewport overlays now project via the viewport
|
||||
/// camera ([`viewport_edit_frame`]) rather than mapping onto the sheet.
|
||||
#[allow(dead_code)]
|
||||
pub fn model_to_paper(&self, model_pt: glam::DVec3) -> glam::DVec3 {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return model_pt,
|
||||
};
|
||||
let vp = match self.document.get_entity(vp_handle) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => vp,
|
||||
_ => return model_pt,
|
||||
};
|
||||
let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||||
glam::DVec3::new(
|
||||
(model_pt.x - vp.view_target.x) * scale + vp.center.x,
|
||||
(model_pt.y - vp.view_target.y) * scale + vp.center.y,
|
||||
model_pt.z,
|
||||
)
|
||||
}
|
||||
|
||||
/// In-viewport (MSPACE) editing frame: the active floating viewport's own
|
||||
/// camera — *exactly* the one the GPU renders its content with
|
||||
/// ([`camera_for_viewport`]) — together with the viewport's full screen
|
||||
/// rectangle in canvas pixels ([`viewport_screen_rect`]).
|
||||
///
|
||||
/// This is the unified editing adapter (the "süzgeç"). Inside a viewport,
|
||||
/// editing IS model-space: treat the returned camera as *the* camera, the
|
||||
/// returned rect as *the* pane, and the cursor relative to that rect — then
|
||||
/// the existing model-space snap / hit-test / grip / preview / plane-pick
|
||||
/// code runs unchanged and lands on the same pixels the GPU draws. Results
|
||||
/// come back as model coordinates directly (no paper round-trip).
|
||||
///
|
||||
/// Because the camera is the real GPU camera, this tracks the viewport's
|
||||
/// pan / zoom / twist / oblique view correctly — unlike a linear
|
||||
/// paper-projection, whose auto-fit / saved-view / crop divergence left the
|
||||
/// snap stale after pan/zoom. Returns `None` when not editing inside a
|
||||
/// floating viewport, or the camera / rect cannot be derived.
|
||||
pub fn viewport_edit_frame(
|
||||
&self,
|
||||
canvas_px: (f32, f32),
|
||||
) -> Option<(view::camera::Camera, iced::Rectangle)> {
|
||||
let vp_handle = self.active_viewport?;
|
||||
let cam = self.camera_for_viewport(vp_handle)?;
|
||||
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
|
||||
Some((cam, full))
|
||||
}
|
||||
|
||||
/// Fold the active viewport's saved view onto the effective camera (the
|
||||
/// auto-fit centre for stale UTM views) and persist it into `view_target` /
|
||||
/// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the
|
||||
/// rendered content all share one valid view — otherwise a stale `(0,0,0)`
|
||||
/// target left the camera auto-fitting to the model centre while the cursor
|
||||
/// math used the origin, and pan toggled the two (jitter).
|
||||
pub fn normalize_active_viewport_view(&mut self) {
|
||||
let Some(vp_handle) = self.active_viewport else {
|
||||
return;
|
||||
};
|
||||
let Some(cam) = self.camera_for_viewport(vp_handle) else {
|
||||
return;
|
||||
};
|
||||
let eff_h = cam.ortho_size() as f64 * 2.0;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
vp.view_target.x = cam.target.x;
|
||||
vp.view_target.y = cam.target.y;
|
||||
vp.view_center.x = 0.0;
|
||||
vp.view_center.y = 0.0;
|
||||
if eff_h > 1e-9 {
|
||||
vp.view_height = eff_h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pan the active viewport's model-space view by `(screen_dx, screen_dy)` pixels.
|
||||
/// The delta is converted to model-space units using the camera and viewport scale.
|
||||
/// No-op when there is no active viewport.
|
||||
pub fn pan_active_viewport(&mut self, screen_dx: f32, screen_dy: f32, bounds: iced::Rectangle) {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
// Use the viewport's own camera for the pan axes (matches 3-D view orientation).
|
||||
let vp_cam = match self.camera_for_viewport(vp_handle) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Read viewport dims (immutable borrow ends here).
|
||||
let (view_height, vp_height, locked) = match self.document.get_entity(vp_handle) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => {
|
||||
(vp.view_height as f32, vp.height as f32, vp.status.locked)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if locked {
|
||||
return;
|
||||
}
|
||||
|
||||
// Correct pan speed: how many model units correspond to one screen pixel.
|
||||
//
|
||||
// The paper camera's ortho_size() gives the visible paper-space half-height
|
||||
// (in paper mm). One screen pixel = 2*half_h / canvas_height paper mm.
|
||||
// Inside the viewport, one paper mm = view_height / vp_height model units.
|
||||
// Together: model_per_pixel = (2*half_h / canvas_height) * (view_height / vp_height)
|
||||
let paper_half_h = self.camera.borrow().ortho_size();
|
||||
let speed = if bounds.height > 0.0 && paper_half_h > 1e-6 && vp_height > 1e-6 {
|
||||
(2.0 * paper_half_h / bounds.height) * (view_height / vp_height)
|
||||
} else {
|
||||
vp_cam.distance * 0.001
|
||||
};
|
||||
|
||||
let cam_right = vp_cam.rotation * glam::Vec3::X;
|
||||
let cam_up = vp_cam.rotation * glam::Vec3::Y;
|
||||
let model_delta = -(cam_right * screen_dx * speed) + (cam_up * screen_dy * speed);
|
||||
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
vp.view_target.x += model_delta.x as f64;
|
||||
vp.view_target.y += model_delta.y as f64;
|
||||
vp.view_target.z += model_delta.z as f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom the active viewport's model-space view by `steps` notches.
|
||||
/// Positive = zoom in (increase detail), negative = zoom out.
|
||||
/// `cursor_paper`: optional paper-space XY of the cursor; when supplied the
|
||||
/// model point under the cursor is kept stationary (AutoCAD-style zoom).
|
||||
/// No-op when there is no active viewport.
|
||||
pub fn zoom_active_viewport(&mut self, steps: f32, cursor_paper: Option<glam::Vec2>) {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return;
|
||||
}
|
||||
// Zoom in = shrink view_height → higher scale → objects appear larger.
|
||||
let factor = (1.0_f64 - 0.15 * steps as f64).clamp(0.1, 10.0);
|
||||
|
||||
if let Some(cp) = cursor_paper {
|
||||
// Compute the model-space point under the cursor before zoom.
|
||||
let scale_before =
|
||||
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height) as f32;
|
||||
let cx = vp.center.x as f32;
|
||||
let cy = vp.center.y as f32;
|
||||
let tx = vp.view_target.x as f32;
|
||||
let ty = vp.view_target.y as f32;
|
||||
let mx = (cp.x - cx) / scale_before + tx;
|
||||
let my = (cp.y - cy) / scale_before + ty;
|
||||
|
||||
// Apply zoom.
|
||||
vp.view_height = (vp.view_height * factor).max(1e-6);
|
||||
if vp.view_height.abs() > 1e-9 {
|
||||
vp.custom_scale = vp.height / vp.view_height;
|
||||
}
|
||||
let scale_after = vp.custom_scale as f32;
|
||||
|
||||
// Adjust view_target so the model point under cursor stays there.
|
||||
let mx_after = (cp.x - cx) / scale_after + vp.view_target.x as f32;
|
||||
let my_after = (cp.y - cy) / scale_after + vp.view_target.y as f32;
|
||||
vp.view_target.x += (mx - mx_after) as f64;
|
||||
vp.view_target.y += (my - my_after) as f64;
|
||||
} else {
|
||||
vp.view_height = (vp.view_height * factor).max(1e-6);
|
||||
if vp.view_height.abs() > 1e-9 {
|
||||
vp.custom_scale = vp.height / vp.view_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit the active viewport's view direction by the given screen-pixel delta.
|
||||
/// No-op when there is no active viewport or it is locked.
|
||||
pub fn orbit_active_viewport(&mut self, delta_x: f32, delta_y: f32) {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
let mut cam = match self.camera_for_viewport(vp_handle) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
cam.orbit(delta_x, delta_y);
|
||||
// yaw_pitch_to_quat(y,p)*Z = (cos(p)*sin(y), -cos(p)*cos(y), sin(p))
|
||||
// `camera_for_viewport` reconstructs the rotation so that
|
||||
// `rotation * Z == view_direction` exactly (its `yaw = atan2(x, -y)`
|
||||
// cancels the sign). Store `eye` directly so the orbit round-trips —
|
||||
// negating Y here made each drag step read back a Y-mirrored camera,
|
||||
// flipping the model between a rotation and its opposite every frame.
|
||||
let eye = cam.rotation * glam::Vec3::Z;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return;
|
||||
}
|
||||
vp.view_direction.x = eye.x as f64;
|
||||
vp.view_direction.y = eye.y as f64;
|
||||
vp.view_direction.z = eye.z as f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap the active viewport's view direction to `eye_dir` (unit
|
||||
/// vector from target toward camera). Twist angle is left at its
|
||||
/// current value so the up-sense is preserved across successive
|
||||
/// snaps. No-op when there is no active viewport or it is locked.
|
||||
pub fn snap_active_viewport_to_direction(&mut self, eye_dir: glam::Vec3, ucs: glam::Mat4) {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
// Build the full UCS-aligned orientation exactly as the model snap does
|
||||
// (snap_to_direction picks the in-plane roll from the UCS axes), seeded
|
||||
// from the viewport's current camera so the "best up" stays stable, then
|
||||
// decode it back to the stored (view_direction, twist_angle). Writing
|
||||
// only view_direction loses the roll and the rebuilt camera snaps to
|
||||
// WCS-up instead of the UCS the clicked cube was drawn in.
|
||||
let mut tmp = self.camera_for_viewport(vp_handle).unwrap_or_default();
|
||||
tmp.snap_to_direction(eye_dir, ucs);
|
||||
let dir = (tmp.rotation * glam::Vec3::Z).normalize_or(glam::Vec3::Z);
|
||||
let desired_up = (tmp.rotation * glam::Vec3::Y).normalize_or(glam::Vec3::Y);
|
||||
|
||||
// camera_from_view rebuilds the rotation with its OWN yaw convention
|
||||
// (atan2(x, -y)) and applies roll = -twist, which is *not* the camera's
|
||||
// internal yaw/roll convention — so `-tmp.roll()` does not round-trip.
|
||||
// Instead reproduce the decoder's zero-twist basis here, then measure
|
||||
// the signed roll about the view axis that carries its up onto the
|
||||
// desired UCS up. Store twist = -roll (the decoder negates it back).
|
||||
let pitch = dir.z.clamp(-1.0, 1.0).asin();
|
||||
let yaw = if dir.x.abs() < 1e-6 && dir.y.abs() < 1e-6 {
|
||||
0.0
|
||||
} else {
|
||||
dir.x.atan2(-dir.y)
|
||||
};
|
||||
let up0 = (view::camera::yaw_pitch_to_quat(yaw, pitch, 0.0) * glam::Vec3::Y)
|
||||
.normalize_or(glam::Vec3::Y);
|
||||
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
||||
let twist = -roll as f64;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return;
|
||||
}
|
||||
vp.view_direction.x = dir.x as f64;
|
||||
vp.view_direction.y = dir.y as f64;
|
||||
vp.view_direction.z = dir.z as f64;
|
||||
vp.twist_angle = twist;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutate the active viewport's camera through a closure, then re-encode the
|
||||
/// result to the stored `(view_direction, twist_angle)` — the same decode
|
||||
/// the ViewCube snap uses. Lets the home / roll / nudge controls drive a
|
||||
/// floating viewport just like the model camera. Returns `false` if there is
|
||||
/// no active (unlocked) viewport.
|
||||
pub fn mutate_active_viewport_camera(
|
||||
&mut self,
|
||||
f: impl FnOnce(&mut view::camera::Camera),
|
||||
) -> bool {
|
||||
let Some(vp_handle) = self.active_viewport else {
|
||||
return false;
|
||||
};
|
||||
let mut tmp = self.camera_for_viewport(vp_handle).unwrap_or_default();
|
||||
f(&mut tmp);
|
||||
let dir = (tmp.rotation * glam::Vec3::Z).normalize_or(glam::Vec3::Z);
|
||||
let desired_up = (tmp.rotation * glam::Vec3::Y).normalize_or(glam::Vec3::Y);
|
||||
let pitch = dir.z.clamp(-1.0, 1.0).asin();
|
||||
let yaw = if dir.x.abs() < 1e-6 && dir.y.abs() < 1e-6 {
|
||||
0.0
|
||||
} else {
|
||||
dir.x.atan2(-dir.y)
|
||||
};
|
||||
let up0 = (view::camera::yaw_pitch_to_quat(yaw, pitch, 0.0) * glam::Vec3::Y)
|
||||
.normalize_or(glam::Vec3::Y);
|
||||
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
||||
let twist = -roll as f64;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return false;
|
||||
}
|
||||
vp.view_direction.x = dir.x as f64;
|
||||
vp.view_direction.y = dir.y as f64;
|
||||
vp.view_direction.z = dir.z as f64;
|
||||
vp.twist_angle = twist;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Render mode of the active paper-space viewport, or `None` when no
|
||||
/// viewport is active (PSPACE / model layout).
|
||||
pub fn active_viewport_render_mode(
|
||||
&self,
|
||||
) -> Option<acadrust::entities::ViewportRenderMode> {
|
||||
let h = self.active_viewport?;
|
||||
match self.document.get_entity(h) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => Some(vp.render_mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active paper-space viewport's render mode. Returns `true`
|
||||
/// when a viewport was active and updated; `false` (no-op) otherwise,
|
||||
/// so the caller can fall back to the model-layout render mode.
|
||||
pub fn set_active_viewport_render_mode(
|
||||
&mut self,
|
||||
mode: acadrust::entities::ViewportRenderMode,
|
||||
) -> bool {
|
||||
let Some(h) = self.active_viewport else {
|
||||
return false;
|
||||
};
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(h) {
|
||||
vp.render_mode = mode;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual style of the active Model tile (for the render-mode picker).
|
||||
pub fn active_model_tile_render_mode(
|
||||
&self,
|
||||
) -> acadrust::entities::ViewportRenderMode {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
tiles
|
||||
.get(active)
|
||||
.map(|t| t.render_mode)
|
||||
.unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D)
|
||||
}
|
||||
|
||||
/// Set only the active Model tile's render mode. Other tiles keep theirs.
|
||||
pub fn set_active_model_tile_render_mode(
|
||||
&self,
|
||||
mode: acadrust::entities::ViewportRenderMode,
|
||||
) {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
if let Some(t) = tiles.get_mut(active) {
|
||||
t.render_mode = mode;
|
||||
}
|
||||
}
|
||||
|
||||
/// Current gaze direction (canonical +Z eye dir, world space) of whichever
|
||||
/// camera owns the ViewCube — the active floating viewport in MSPACE, else
|
||||
/// the main camera. Used by the ViewCube "already there → flip to opposite"
|
||||
/// check, which must test the camera the cube actually reflects (the paper
|
||||
/// camera always looks straight down, so testing it flipped every snap).
|
||||
pub fn active_gaze_dir(&self) -> glam::Vec3 {
|
||||
if let Some(h) = self.active_viewport {
|
||||
if let Some(cam) = self.camera_for_viewport(h) {
|
||||
return cam.rotation * glam::Vec3::Z;
|
||||
}
|
||||
}
|
||||
self.camera.borrow().rotation * glam::Vec3::Z
|
||||
}
|
||||
|
||||
/// View-rotation matrix for the active viewport (MSPACE), or the
|
||||
/// paper-space camera's matrix when not in MSPACE.
|
||||
/// Used by ViewCube hit-testing so clicks map to the correct camera.
|
||||
pub fn active_view_rotation_mat(&self) -> glam::Mat4 {
|
||||
// Must match exactly what the drawn cube uses (see ViewportData's
|
||||
// `cam_rotation`): the active context's camera composed with the
|
||||
// ViewCube UCS. Inside a floating viewport that's the viewport's own
|
||||
// camera; the UCS factor applies in both cases.
|
||||
if let Some(h) = self.active_viewport {
|
||||
if let Some(cam) = self.camera_for_viewport(h) {
|
||||
return cam.view_rotation_mat() * self.viewcube_ucs_mat();
|
||||
}
|
||||
}
|
||||
self.camera.borrow().view_rotation_mat() * self.viewcube_ucs_mat()
|
||||
}
|
||||
|
||||
/// The UCS→world rotation the ViewCube should compose with the camera —
|
||||
/// the active UCS in model space, identity everywhere else. Render,
|
||||
/// hit-test, and click-snap all go through this so they stay in lock-step.
|
||||
pub fn viewcube_ucs_mat(&self) -> glam::Mat4 {
|
||||
// UCS applies in model space and inside a floating viewport (MSPACE);
|
||||
// plain paper space stays WCS.
|
||||
if self.current_layout == "Model" || self.active_viewport.is_some() {
|
||||
self.viewcube_ucs
|
||||
} else {
|
||||
glam::Mat4::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the handle of the user viewport whose *visible* on-screen
|
||||
/// rectangle (clamped to the canvas) contains the given screen-pixel point.
|
||||
/// Viewport activation goes through this so a click only enters a viewport
|
||||
/// when it lands on the part the user can actually see — clicking the empty
|
||||
/// area beside a viewport that runs off-screen no longer matches its full
|
||||
/// (partly off-canvas) paper rect and switches to it by mistake.
|
||||
pub fn viewport_at_screen_point(
|
||||
&self,
|
||||
px: f32,
|
||||
py: f32,
|
||||
canvas: (f32, f32),
|
||||
) -> Option<Handle> {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
self.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
let EntityType::Viewport(vp) = e else {
|
||||
return None;
|
||||
};
|
||||
if !self.is_content_viewport_in_layout(vp, layout_block) || !vp.status.is_on {
|
||||
return None;
|
||||
}
|
||||
let rect = self.viewport_screen_rect(vp.common.handle, canvas)?;
|
||||
let x0 = rect.x.max(0.0);
|
||||
let y0 = rect.y.max(0.0);
|
||||
let x1 = (rect.x + rect.width).min(canvas.0);
|
||||
let y1 = (rect.y + rect.height).min(canvas.1);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None; // fully off-canvas → nothing to click
|
||||
}
|
||||
if px >= x0 && px <= x1 && py >= y0 && py <= y1 {
|
||||
Some((vp.common.handle, (x1 - x0) * (y1 - y0)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(h, _)| h)
|
||||
}
|
||||
|
||||
/// Return the handle of the first active user viewport in the current layout,
|
||||
/// or `None` if there are none. Used by the MS command.
|
||||
pub fn first_user_viewport(&self) -> Option<Handle> {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
self.document.entities().find_map(|e| {
|
||||
let EntityType::Viewport(vp) = e else {
|
||||
return None;
|
||||
};
|
||||
if self.is_content_viewport_in_layout(vp, layout_block)
|
||||
&& vp.status.is_on
|
||||
{
|
||||
Some(vp.common.handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
481
src/scene/paper.rs
Normal file
481
src/scene/paper.rs
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> {
|
||||
self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D)
|
||||
.into_iter()
|
||||
.filter(|inst| inst.grid_on)
|
||||
.map(|inst| (inst.screen_rect, inst.camera, inst.handle))
|
||||
.collect()
|
||||
}
|
||||
/// The viewports to render this frame, one entry per scissor pass.
|
||||
///
|
||||
/// - **Model layout**: a single full-canvas instance driven by the
|
||||
/// scene camera (tiled splits will append more later). `model_mode`
|
||||
/// supplies its render mode (held on the tab, not the scene).
|
||||
/// - **Paper layout**: one instance per content viewport entity
|
||||
/// (`id > 1`, owned by the current layout block, switched on),
|
||||
/// using each viewport's own camera and render mode.
|
||||
pub fn active_viewports(
|
||||
&self,
|
||||
canvas_w: f32,
|
||||
canvas_h: f32,
|
||||
model_mode: acadrust::entities::ViewportRenderMode,
|
||||
) -> Vec<ViewportInstance> {
|
||||
if self.current_layout == "Model" {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
return tiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tile)| {
|
||||
// The active tile renders the live camera (orbit/pan act
|
||||
// on it); inactive tiles use their stored snapshot.
|
||||
let camera = if i == active {
|
||||
self.camera.borrow().clone()
|
||||
} else {
|
||||
tile.camera.clone()
|
||||
};
|
||||
ViewportInstance {
|
||||
handle: Handle::NULL,
|
||||
tile_idx: Some(i),
|
||||
screen_rect: iced::Rectangle {
|
||||
x: tile.rect.x * canvas_w,
|
||||
y: tile.rect.y * canvas_h,
|
||||
width: tile.rect.width * canvas_w,
|
||||
height: tile.rect.height * canvas_h,
|
||||
},
|
||||
camera,
|
||||
// The active tile shows the live mode the picker
|
||||
// drives; every other tile keeps its own stored
|
||||
// style so editing one never disturbs the rest.
|
||||
render_mode: if i == active { model_mode } else { tile.render_mode },
|
||||
active: i == active,
|
||||
grid_on: tile.grid_on,
|
||||
paper_sheet: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let mut out: Vec<ViewportInstance> = Vec::new();
|
||||
// The full-canvas sheet viewport renders the paper-space entities
|
||||
// themselves — the layout's own view, drawn first so the floating
|
||||
// content viewports overlay it. Its camera keeps the paper pan/zoom
|
||||
// (target + ortho size) but is LOCKED to the top/plan orientation:
|
||||
// paper is 2-D, so the sheet never orbits.
|
||||
let mut sheet_cam = self.camera.borrow().clone();
|
||||
sheet_cam.yaw = 0.0;
|
||||
sheet_cam.pitch = std::f32::consts::FRAC_PI_2;
|
||||
sheet_cam.rotation = view::camera::yaw_pitch_to_quat(0.0, std::f32::consts::FRAC_PI_2, 0.0);
|
||||
sheet_cam.projection = view::camera::Projection::Orthographic;
|
||||
let sheet_grid_on = match self
|
||||
.document
|
||||
.get_entity(self.current_layout_sheet_viewport_handle())
|
||||
{
|
||||
Some(EntityType::Viewport(vp)) => vp.status.grid_on,
|
||||
_ => false,
|
||||
};
|
||||
out.push(ViewportInstance {
|
||||
handle: Handle::NULL,
|
||||
tile_idx: None,
|
||||
screen_rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: canvas_w,
|
||||
height: canvas_h,
|
||||
},
|
||||
camera: sheet_cam,
|
||||
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
|
||||
active: false,
|
||||
grid_on: sheet_grid_on,
|
||||
paper_sheet: true,
|
||||
});
|
||||
for e in self.document.entities() {
|
||||
let EntityType::Viewport(vp) = e else {
|
||||
continue;
|
||||
};
|
||||
if !self.is_content_viewport_in_layout(vp, layout_block)
|
||||
|| !vp.status.is_on
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let h = vp.common.handle;
|
||||
let (Some(screen_rect), Some(camera)) = (
|
||||
self.viewport_screen_rect(h, (canvas_w, canvas_h)),
|
||||
self.camera_for_viewport(h),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
out.push(ViewportInstance {
|
||||
handle: h,
|
||||
tile_idx: None,
|
||||
screen_rect,
|
||||
camera,
|
||||
render_mode: vp.render_mode,
|
||||
active: self.active_viewport == Some(h),
|
||||
grid_on: vp.status.grid_on,
|
||||
paper_sheet: false,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a paper-space Viewport entity's position/size into a pixel
|
||||
/// `Rectangle` relative to the top-left of the canvas.
|
||||
///
|
||||
/// Uses the same top-down ortho transform as the GPU sheet viewport so the
|
||||
/// overlay lands exactly over the drawn viewport border regardless of zoom
|
||||
/// or pan level.
|
||||
pub fn viewport_screen_rect(
|
||||
&self,
|
||||
vp_handle: Handle,
|
||||
canvas_px: (f32, f32),
|
||||
) -> Option<iced::Rectangle> {
|
||||
let vp = match self.document.get_entity(vp_handle) {
|
||||
Some(EntityType::Viewport(vp)) => vp,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let (canvas_w, canvas_h) = canvas_px;
|
||||
if canvas_w < 1.0 || canvas_h < 1.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cam = self.camera.borrow();
|
||||
let aspect = canvas_w / canvas_h;
|
||||
let half_h = cam.ortho_size();
|
||||
let half_w = half_h * aspect;
|
||||
let tx = cam.target.x as f32;
|
||||
let ty = cam.target.y as f32;
|
||||
drop(cam);
|
||||
|
||||
// Top-down ortho mapping matching the GPU sheet viewport's camera.
|
||||
let to_px = |wx: f32, wy: f32| -> (f32, f32) {
|
||||
let x = (wx - tx + half_w) / (2.0 * half_w) * canvas_w;
|
||||
let y = (ty + half_h - wy) / (2.0 * half_h) * canvas_h;
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let cx = vp.center.x as f32;
|
||||
let cy = vp.center.y as f32;
|
||||
let hw = (vp.width / 2.0) as f32;
|
||||
let hh = (vp.height / 2.0) as f32;
|
||||
|
||||
let (x0, y0) = to_px(cx - hw, cy + hh); // top-left in screen
|
||||
let (x1, y1) = to_px(cx + hw, cy - hh); // bottom-right in screen
|
||||
|
||||
let w = (x1 - x0).max(1.0);
|
||||
let h = (y1 - y0).max(1.0);
|
||||
|
||||
Some(iced::Rectangle {
|
||||
x: x0,
|
||||
y: y0,
|
||||
width: w,
|
||||
height: h,
|
||||
})
|
||||
}
|
||||
// ── Paper-space helpers ───────────────────────────────────────────────
|
||||
|
||||
/// Paper-layout hatch fills, restricted to the active layout block (used by
|
||||
/// paper-space hatch hit-testing / export). The GPU-rendered
|
||||
/// content viewports already draw model-block hatches inside their
|
||||
/// own scissor; including those here would also draw them on the
|
||||
/// paper sheet through the paper camera (huge / off-position), so
|
||||
/// restrict the canvas list to entities owned by the active paper
|
||||
/// layout block. Iterates the source `self.hatches` map (keyed by
|
||||
/// entity handle) rather than the already-flattened arc — the
|
||||
/// flattened arc carries pattern names, not handles, so filtering
|
||||
/// there is unreliable.
|
||||
pub fn paper_canvas_hatches(&self) -> Arc<Vec<HatchModel>> {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let layer_hidden = |layer: &str| {
|
||||
self.document
|
||||
.layers
|
||||
.get(layer)
|
||||
.map(|l| l.flags.off || l.flags.frozen)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut models: Vec<HatchModel> = Vec::new();
|
||||
for (&handle, model) in self.hatches.iter() {
|
||||
let Some(entity) = self.document.get_entity(handle) else {
|
||||
continue;
|
||||
};
|
||||
let c = entity.common();
|
||||
if c.invisible || layer_hidden(&c.layer) {
|
||||
continue;
|
||||
}
|
||||
if !self.belongs_to_visible_block(handle, c.owner_handle, layout_block) {
|
||||
continue;
|
||||
}
|
||||
let mut m = model.clone();
|
||||
m.color = self.render_style(entity).0;
|
||||
if let EntityType::Hatch(dxf) = entity {
|
||||
if let model::hatch_model::HatchPattern::Pattern(_) = &m.pattern {
|
||||
m.angle_offset = dxf.pattern_angle as f32;
|
||||
m.scale = dxf.pattern_scale as f32;
|
||||
}
|
||||
}
|
||||
if self.selected.contains(&handle) {
|
||||
m.color = [0.15, 0.55, 1.00, m.color[3]];
|
||||
}
|
||||
models.push(m);
|
||||
}
|
||||
Arc::new(models)
|
||||
}
|
||||
|
||||
/// Paper-layout wipeout fills (paper hit-testing / export). Same rationale as
|
||||
/// `paper_canvas_hatches` — only include wipeouts owned by the
|
||||
/// active paper layout block, so model wipeouts (drawn through their
|
||||
/// content viewport's GPU pipeline) don't get a second mis-projected
|
||||
/// copy on the paper sheet.
|
||||
pub fn paper_canvas_wipeouts(&self) -> Arc<Vec<HatchModel>> {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let bg_color = self.paper_bg_color;
|
||||
let mut models = Vec::new();
|
||||
for entity in self.document.entities() {
|
||||
let EntityType::Wipeout(wo) = entity else {
|
||||
continue;
|
||||
};
|
||||
if wo.common.invisible {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.document
|
||||
.layers
|
||||
.get(&wo.common.layer)
|
||||
.map(|l| l.flags.off || l.flags.frozen)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !self.belongs_to_visible_block(wo.common.handle, wo.common.owner_handle, layout_block)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Paper-block wipeouts live in paper coords — no `world_offset`.
|
||||
let (fill_origin, boundary) = Self::wipeout_boundary_2d(wo);
|
||||
if boundary.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let mut fill_color = bg_color;
|
||||
if self.selected.contains(&wo.common.handle) {
|
||||
fill_color = [0.15, 0.55, 1.00, 0.35];
|
||||
}
|
||||
models.push(HatchModel {
|
||||
boundary: Arc::new(boundary),
|
||||
pattern: model::hatch_model::HatchPattern::Solid,
|
||||
name: "WIPEOUT_FILL".into(),
|
||||
color: fill_color,
|
||||
angle_offset: 0.0,
|
||||
scale: 1.0,
|
||||
world_origin: fill_origin,
|
||||
vp_scissor: None,
|
||||
draw_depth: 0.0,
|
||||
});
|
||||
}
|
||||
Arc::new(models)
|
||||
}
|
||||
|
||||
/// Build a Camera oriented and scaled to match a paper-space Viewport entity.
|
||||
/// Used by `active_viewports` to render model-space content through each
|
||||
/// content viewport's own view direction and scale.
|
||||
pub(super) fn camera_for_viewport(&self, vp_handle: Handle) -> Option<view::camera::Camera> {
|
||||
let vp = match self.document.get_entity(vp_handle) {
|
||||
Some(EntityType::Viewport(vp)) => vp,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Floating-viewport–specific step: decide saved-view vs auto-fit, then
|
||||
// hand the effective view to the shared `camera_from_view` decoder so
|
||||
// twist / view_center / distance behave identically to a model VPORT.
|
||||
//
|
||||
// UTM / coordinate-shifted drawings often arrive with
|
||||
// `view_target = (0, 0, 0)` and a stale `view_center` from before the
|
||||
// file was geo-referenced; the saved view points at empty WCS while the
|
||||
// actual model sits ~`world_offset` away. Decode the saved view first
|
||||
// and keep it only if its target actually frames the model cluster.
|
||||
//
|
||||
// The overlap test runs on the *decoded* target (wire-space, so the
|
||||
// cluster is `±cluster_half` about the origin), NOT a raw
|
||||
// `view_target + view_center` sum: under a view twist `view_center` is a
|
||||
// DCS offset, so the raw sum lands far from the real WCS centre and
|
||||
// would wrongly trip the auto-fit — replacing the saved view_height with
|
||||
// the whole-cluster fit and rendering the content at the wrong zoom.
|
||||
let saved_h = vp.view_height.abs();
|
||||
let aspect_d = (vp.width / vp.height.max(1.0)).max(1e-9);
|
||||
let cluster_half = self.local_extent_max.max(1.0) as f64;
|
||||
// Absolute drawing centre. Geometry now reaches the scene at absolute
|
||||
// (UTM) coordinates — the old code centred the overlap test and the
|
||||
// auto-fit on the origin, which was right only while world_offset
|
||||
// re-centred the model there. Without it a UTM drawing sits ~5.7e6 away,
|
||||
// so a stale `(0,0,0)` saved view failed the overlap test AND the
|
||||
// auto-fit aimed at empty origin → blank viewports.
|
||||
// Frame the overlap test / auto-fit on the robust cluster centre (median
|
||||
// of entity centroids), NOT the raw extents centre: a drawing with a
|
||||
// far second cluster (e.g. a small-coordinate legend beside a UTM survey)
|
||||
// has an extents centre in the empty gap, which would reject a valid
|
||||
// saved view and then auto-fit onto blank space. Fall back to the extents
|
||||
// centre only when no cluster centre was computed.
|
||||
let (cx, cy) = if self.local_center != [0.0, 0.0] {
|
||||
(self.local_center[0], self.local_center[1])
|
||||
} else {
|
||||
self.model_space_extents()
|
||||
.map(|(mn, mx)| {
|
||||
(((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)
|
||||
})
|
||||
.unwrap_or((0.0, 0.0))
|
||||
};
|
||||
|
||||
if 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,
|
||||
},
|
||||
saved_h,
|
||||
vp.twist_angle,
|
||||
) {
|
||||
let half_h = saved_h * 0.5;
|
||||
let half_w = half_h * aspect_d;
|
||||
let (tx, ty) = (cam.target.x as f64, cam.target.y as f64);
|
||||
let overlaps = tx + half_w >= cx - cluster_half
|
||||
&& tx - half_w <= cx + cluster_half
|
||||
&& ty + half_h >= cy - cluster_half
|
||||
&& ty - half_h <= cy + cluster_half;
|
||||
if overlaps {
|
||||
return Some(cam);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fit: aim at the content cluster centre, drop the stale view_center.
|
||||
let fit_h = cluster_half * 2.0 * 1.05;
|
||||
let tgt = acadrust::types::Vector3 {
|
||||
x: cx,
|
||||
y: cy,
|
||||
z: vp.view_target.z,
|
||||
};
|
||||
self.camera_from_view(
|
||||
vp.view_direction,
|
||||
tgt,
|
||||
acadrust::types::Vector2::ZERO,
|
||||
fit_h,
|
||||
vp.twist_angle,
|
||||
)
|
||||
}
|
||||
|
||||
/// Collect model-space WireModels visible through `vp_handle`, respecting
|
||||
/// global layer visibility, the viewport's per-viewport layer freeze list,
|
||||
/// and the per-viewport frustum + LOD cull derived from
|
||||
/// `screen_height_px` (the on-paper pixel height of this viewport).
|
||||
fn model_wires_for_viewport(
|
||||
&self,
|
||||
vp_handle: Handle,
|
||||
screen_height_px: f32,
|
||||
) -> Vec<WireModel> {
|
||||
use rustc_hash::FxHashSet as HSet;
|
||||
|
||||
let (frozen, vp_anno_scale, vp_aspect) = match self.document.get_entity(vp_handle) {
|
||||
Some(EntityType::Viewport(vp)) => {
|
||||
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
|
||||
let vp_scale =
|
||||
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||||
let anno = if vp_scale > 1e-9 {
|
||||
(1.0 / vp_scale) as f32
|
||||
} else {
|
||||
1.0_f32
|
||||
};
|
||||
let aspect = if vp.height > 1e-9 {
|
||||
(vp.width / vp.height) as f32
|
||||
} else {
|
||||
1.0_f32
|
||||
};
|
||||
(f, anno, aspect)
|
||||
}
|
||||
_ => (HSet::default(), 1.0_f32, 1.0_f32),
|
||||
};
|
||||
|
||||
// Drive the per-viewport view_aabb / wpp from the *effective* camera
|
||||
// `camera_for_viewport` produces — it folds in the auto-fit
|
||||
// fallback for UTM-style files whose saved `view_target` sits at
|
||||
// empty WCS. Without that, the GPU pass would frustum-cull every
|
||||
// entity (saved-view rect doesn't overlap the offset-subtracted
|
||||
// model cluster) and the viewport would render blank.
|
||||
let Some(cam) = self.camera_for_viewport(vp_handle) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let vp_ortho_h = cam.ortho_size();
|
||||
// Rotation-aware cull box: a twisted/rotated viewport sees a rotated
|
||||
// rectangle in world XY, so derive the box from the camera basis.
|
||||
// `None` (tilted view) disables the cull — render everything.
|
||||
let view_aabb = view_cull_aabb(&cam, vp_aspect, 1.25);
|
||||
// World units per on-screen pixel for LOD substitution + curve
|
||||
// tolerance. Tracks the paper-zoom-driven pixel height the
|
||||
// viewport currently occupies.
|
||||
let wpp = if screen_height_px > 1.0 {
|
||||
Some((2.0 * vp_ortho_h) / screen_height_px)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
self.wires_for_block_culled(
|
||||
self.model_space_block_handle(),
|
||||
view_aabb,
|
||||
wpp,
|
||||
Some(&frozen),
|
||||
Some(vp_anno_scale),
|
||||
)
|
||||
}
|
||||
|
||||
/// Cached per-paper-viewport tessellation. Each viewport's wpp tracks
|
||||
/// the on-paper pixel height (paper-zoom dependent), so the cache key
|
||||
/// includes a quantized form of that height in addition to the
|
||||
/// geometry epoch — every paper zoom step that actually changes the
|
||||
/// LOD bucket invalidates this viewport's entry.
|
||||
pub(crate) fn model_wires_for_viewport_arc(
|
||||
&self,
|
||||
vp_handle: Handle,
|
||||
screen_height_px: f32,
|
||||
) -> Arc<Vec<WireModel>> {
|
||||
// Drop sub-pixel noise so trivial paper-zoom jitter does not
|
||||
// re-tessellate a 100k-entity drawing every frame; round to an
|
||||
// integer pixel.
|
||||
let height_key = screen_height_px.max(1.0).round() as u32;
|
||||
// Hash the viewport's own view (pan + zoom + orbit) into the key.
|
||||
// Editing inside the viewport (MSPACE) changes its frustum but does NOT
|
||||
// bump geometry_epoch, so without this the stale frustum-culled subset
|
||||
// is returned and newly-revealed lines stay invisible until the layout
|
||||
// re-tessellates. Quantize to ~1 px / fine steps to ignore jitter.
|
||||
let view_key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) {
|
||||
let vh = vp.view_height.abs().max(1e-6);
|
||||
let q = vh / (screen_height_px.max(1.0) as f64); // model units / px
|
||||
(((vp.view_target.x + vp.view_center.x) / q).round() as i64).hash(&mut h);
|
||||
(((vp.view_target.y + vp.view_center.y) / q).round() as i64).hash(&mut h);
|
||||
((vh * 1000.0).round() as i64).hash(&mut h);
|
||||
((vp.view_direction.x * 1000.0).round() as i64).hash(&mut h);
|
||||
((vp.view_direction.y * 1000.0).round() as i64).hash(&mut h);
|
||||
((vp.view_direction.z * 1000.0).round() as i64).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
};
|
||||
let key = (self.geometry_epoch, height_key, view_key);
|
||||
{
|
||||
let cache = self.viewport_wire_cache.borrow();
|
||||
if let Some((cached_key, ref arc)) = cache.get(&vp_handle) {
|
||||
if *cached_key == key {
|
||||
return Arc::clone(arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
let arc = Arc::new(self.model_wires_for_viewport(vp_handle, screen_height_px));
|
||||
self.viewport_wire_cache
|
||||
.borrow_mut()
|
||||
.insert(vp_handle, (key, Arc::clone(&arc)));
|
||||
arc
|
||||
}
|
||||
}
|
||||
87
src/scene/preview.rs
Normal file
87
src/scene/preview.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Preview wire ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn set_preview_wires(&mut self, wires: Vec<WireModel>) {
|
||||
// Preview wires are an overlay appended to the cached base wire set in
|
||||
// `build_primitive`; they are NOT part of the tessellation cache. So a
|
||||
// preview update must NOT bump `geometry_epoch` — that would re-
|
||||
// tessellate the whole model on every rubber-band frame. The overlay
|
||||
// forces a GPU wire re-upload on its own (the `has_overlay` content-id
|
||||
// path), and iced redraws after the message that set the preview.
|
||||
self.preview_wires = wires;
|
||||
}
|
||||
|
||||
pub fn clear_preview_wire(&mut self) {
|
||||
// No geometry bump — see `set_preview_wires`. Dropping the overlay
|
||||
// flips the wire content id back to the base tessellation id, which
|
||||
// re-uploads the base wires (without the preview) on the next frame.
|
||||
self.preview_wires = vec![];
|
||||
self.interim_wire = None;
|
||||
}
|
||||
|
||||
pub fn wire_models_for(&self, handles: &[acadrust::Handle]) -> Vec<WireModel> {
|
||||
handles
|
||||
.iter()
|
||||
.flat_map(|h| {
|
||||
match self.document.entities().find(|e| e.common().handle == *h) {
|
||||
// Hatches carry no outline in the normal wire set, but an
|
||||
// edit preview (move / copy / array / grip-drag) needs to
|
||||
// show the shape following the cursor. Build a live boundary
|
||||
// from the current HatchModel — `apply_grip` keeps it in
|
||||
// step, so the preview tracks a dragged grip in real time.
|
||||
Some(EntityType::Hatch(_)) => {
|
||||
self.hatch_outline_wire(*h).into_iter().collect()
|
||||
}
|
||||
Some(e) => self.tessellate_one(e),
|
||||
None => Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Boundary outline wire for a hatch, reconstructed from its cached
|
||||
/// `HatchModel` (offsets from `world_origin`). Used only for edit previews —
|
||||
/// the normal render shows the fill, not this outline.
|
||||
fn hatch_outline_wire(&self, handle: Handle) -> Option<WireModel> {
|
||||
let m = self.hatches.get(&handle)?;
|
||||
let (wx, wy) = (m.world_origin[0], m.world_origin[1]);
|
||||
let pts: Vec<[f64; 3]> = m
|
||||
.boundary
|
||||
.iter()
|
||||
.map(|&[x, y]| {
|
||||
if x.is_finite() && y.is_finite() {
|
||||
[wx + x as f64, wy + y as f64, 0.0]
|
||||
} else {
|
||||
[f64::NAN; 3]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(WireModel::solid_f64(
|
||||
handle.value().to_string(),
|
||||
pts,
|
||||
m.color,
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
/// Build wire models for an arbitrary slice of entities (e.g. clipboard contents).
|
||||
/// Entities need not be in the document — they are tessellated directly.
|
||||
pub fn wires_for_entities(&self, entities: &[acadrust::EntityType]) -> Vec<WireModel> {
|
||||
entities
|
||||
.iter()
|
||||
.flat_map(|e| self.tessellate_one(e))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn set_interim_wire(&mut self, w: WireModel) {
|
||||
// Overlay wire — same reasoning as `set_preview_wires`: no geometry
|
||||
// bump, so the model isn't re-tessellated on every interim update.
|
||||
self.interim_wire = Some(w);
|
||||
}
|
||||
}
|
||||
440
src/scene/project.rs
Normal file
440
src/scene/project.rs
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
/// Collect model-space wires projected into paper space for all (or one specific)
|
||||
/// user viewports. `only_vp = Some(h)` restricts output to that viewport.
|
||||
pub(super) fn viewport_content_wires(
|
||||
&self,
|
||||
paper_block: Handle,
|
||||
only_vp: Option<Handle>,
|
||||
exclude_vp: Option<Handle>,
|
||||
) -> Vec<WireModel> {
|
||||
use acadrust::entities::Viewport;
|
||||
|
||||
let viewports: Vec<&Viewport> = self
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let EntityType::Viewport(vp) = e {
|
||||
Some(vp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.filter(|vp| {
|
||||
self.is_content_viewport_in_layout(vp, paper_block)
|
||||
&& vp.status.is_on
|
||||
&& only_vp.map_or(true, |h| vp.common.handle == h)
|
||||
&& exclude_vp.map_or(true, |h| vp.common.handle != h)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if viewports.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for vp in viewports {
|
||||
let vp_handle = vp.common.handle;
|
||||
|
||||
// ── Fast path: return cached projected wires ──────────────────
|
||||
{
|
||||
let cache = self.paper_projected_cache.borrow();
|
||||
if let Some((cached_epoch, ref wires)) = cache.get(&vp_handle) {
|
||||
if *cached_epoch == self.geometry_epoch {
|
||||
result.extend_from_slice(wires);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cache miss: compute projection ────────────────────────────
|
||||
|
||||
// Use camera_for_viewport so the axes match the GPU renderer exactly.
|
||||
let cam_frame = match self.camera_for_viewport(vp_handle) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let view_right = cam_frame.rotation * glam::Vec3::X;
|
||||
let view_up = cam_frame.rotation * glam::Vec3::Y;
|
||||
|
||||
// Scale (paper units per model unit) comes straight from the camera
|
||||
// the GPU uses: the model height shown is `2 * ortho_size`, mapped
|
||||
// onto `vp.height` of paper. `camera_for_viewport` already made the
|
||||
// saved-view-vs-auto-fit decision (with the twist-correct overlap
|
||||
// test), so deriving scale from it keeps the CPU projection (used
|
||||
// for hit-test / snap / fit) locked to the GPU render — no second,
|
||||
// independently-computed scale that could disagree under a twist.
|
||||
let view_height_eff = (cam_frame.ortho_size() * 2.0) as f64;
|
||||
let scale = if view_height_eff > 1e-9 {
|
||||
(vp.height / view_height_eff) as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let pcx = vp.center.x as f32;
|
||||
let pcy = vp.center.y as f32;
|
||||
let pcz = vp.center.z as f32;
|
||||
let hw = (vp.width / 2.0) as f32;
|
||||
let hh = (vp.height / 2.0) as f32;
|
||||
|
||||
// ── Use cached tessellation (model_wires_for_viewport_arc) ────
|
||||
// This eliminates the per-frame tessellate_one() loop that was here
|
||||
// previously; tessellation is now O(1) on navigation frames.
|
||||
// Pass 0.0 for screen height — the CPU-projection / hit-test
|
||||
// path wants the full-fidelity (no-LOD-stub) wire list,
|
||||
// regardless of paper zoom.
|
||||
let model_wires = self.model_wires_for_viewport_arc(vp_handle, 0.0);
|
||||
|
||||
// ── Project and clip wires into viewport ──────────────────────
|
||||
let vp_x0 = pcx - hw;
|
||||
let vp_x1 = pcx + hw;
|
||||
let vp_y0 = pcy - hh;
|
||||
let vp_y1 = pcy + hh;
|
||||
|
||||
// camera_dist: how far the camera is from the target plane.
|
||||
let use_perspective = vp.status.perspective && vp.lens_length > 1.0;
|
||||
let camera_dist = if use_perspective {
|
||||
(vp.view_height as f32 * vp.lens_length as f32 / 24.0).max(0.001)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let mut projected: Vec<WireModel> = Vec::new();
|
||||
|
||||
// Precompute precision-stable WCS-space projection inputs in
|
||||
// f64. The previous f32 inner loop suffered catastrophic
|
||||
// cancellation on UTM-scale drawings: `(wire_offset_rel -
|
||||
// target_offset_rel).dot(view_right) - view_center` is a
|
||||
// small paper offset computed by subtracting two values at
|
||||
// ~5e6 magnitude — f32 ULP there is ~0.5 m, so paper output
|
||||
// jittered by cm even when the actual model was clean.
|
||||
//
|
||||
// Do everything WCS-relative in f64; cast to f32 only at the
|
||||
// final paper position.
|
||||
// Display centre = the camera's target, in WCS. `camera_for_viewport`
|
||||
// already folded view_center through the (twisted) view basis and
|
||||
// applied the empty-WCS auto-fit, so taking its target keeps the CPU
|
||||
// projection identical to the GPU renderer under any twist.
|
||||
let display_center_x = cam_frame.target.x as f64 + [0.0_f64; 3][0];
|
||||
let display_center_y = cam_frame.target.y as f64 + [0.0_f64; 3][1];
|
||||
let display_center_z = cam_frame.target.z as f64 + [0.0_f64; 3][2];
|
||||
let view_right_d = (
|
||||
view_right.x as f64,
|
||||
view_right.y as f64,
|
||||
view_right.z as f64,
|
||||
);
|
||||
let view_up_d = (view_up.x as f64, view_up.y as f64, view_up.z as f64);
|
||||
let view_fwd = cam_frame.rotation * glam::Vec3::Z;
|
||||
let view_fwd_d = (view_fwd.x as f64, view_fwd.y as f64, view_fwd.z as f64);
|
||||
let camera_dist_d = camera_dist as f64;
|
||||
let scale_d = scale as f64;
|
||||
let pcx_d = pcx as f64;
|
||||
let pcy_d = pcy as f64;
|
||||
// Project one ABSOLUTE-WCS model point (f64) onto the paper sheet.
|
||||
// Shared by the polyline points, snap points and key vertices so the
|
||||
// hit-test / snap geometry lands in the same paper frame the wire is
|
||||
// drawn in — otherwise snaps and the click-AABB stay in model (UTM)
|
||||
// space and the cursor never reaches them.
|
||||
let proj_abs = |ax: f64, ay: f64, az: f64| -> [f32; 3] {
|
||||
let mp_x = ax - display_center_x;
|
||||
let mp_y = ay - display_center_y;
|
||||
let mp_z = az - display_center_z;
|
||||
let u = mp_x * view_right_d.0 + mp_y * view_right_d.1 + mp_z * view_right_d.2;
|
||||
let v = mp_x * view_up_d.0 + mp_y * view_up_d.1 + mp_z * view_up_d.2;
|
||||
if use_perspective {
|
||||
let d_vd = mp_x * view_fwd_d.0 + mp_y * view_fwd_d.1 + mp_z * view_fwd_d.2;
|
||||
let fwd = camera_dist_d - d_vd;
|
||||
if fwd <= 0.001 {
|
||||
return [f32::NAN; 3];
|
||||
}
|
||||
let factor = camera_dist_d / fwd;
|
||||
[
|
||||
(pcx_d + u * factor * scale_d) as f32,
|
||||
(pcy_d + v * factor * scale_d) as f32,
|
||||
pcz,
|
||||
]
|
||||
} else {
|
||||
[(pcx_d + u * scale_d) as f32, (pcy_d + v * scale_d) as f32, pcz]
|
||||
}
|
||||
};
|
||||
let in_vp = |x: f32, y: f32| x >= vp_x0 && x <= vp_x1 && y >= vp_y0 && y <= vp_y1;
|
||||
|
||||
for wire in model_wires.iter() {
|
||||
let projected_pts: Vec<[f32; 3]> = wire
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(pi, &[mx, my, mz])| {
|
||||
if mx.is_nan() || my.is_nan() || mz.is_nan() {
|
||||
return [f32::NAN; 3];
|
||||
}
|
||||
// Reconstruct absolute WCS from the double-single high
|
||||
// (`points`) + low (`points_low`) pair — the high f32
|
||||
// alone is ~0.5 m off at UTM scale.
|
||||
let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]);
|
||||
proj_abs(mx as f64 + lo[0] as f64, my as f64 + lo[1] as f64, mz as f64 + lo[2] as f64)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fast AABB pre-reject.
|
||||
let any_near = projected_pts.iter().any(|&[x, y, _]| {
|
||||
x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& x >= vp_x0 - 1.0
|
||||
&& x <= vp_x1 + 1.0
|
||||
&& y >= vp_y0 - 1.0
|
||||
&& y <= vp_y1 + 1.0
|
||||
});
|
||||
let (min_x, max_x, min_y, max_y) =
|
||||
projected_pts.iter().filter(|p| p[0].is_finite()).fold(
|
||||
(
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
),
|
||||
|(mnx, mxx, mny, mxy), &[x, y, _]| {
|
||||
(mnx.min(x), mxx.max(x), mny.min(y), mxy.max(y))
|
||||
},
|
||||
);
|
||||
let aabb_hits =
|
||||
max_x >= vp_x0 && min_x <= vp_x1 && max_y >= vp_y0 && min_y <= vp_y1;
|
||||
if !any_near && !aabb_hits {
|
||||
continue;
|
||||
}
|
||||
|
||||
let clipped =
|
||||
clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz);
|
||||
if clipped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paper-space AABB of the clipped polyline — the cloned model
|
||||
// (UTM) AABB would make click_hit's screen-projected pre-reject
|
||||
// discard the wire (box selection has no pre-reject, which is why
|
||||
// it kept working while picking didn't).
|
||||
let mut pmnx = f32::INFINITY;
|
||||
let mut pmny = f32::INFINITY;
|
||||
let mut pmxx = f32::NEG_INFINITY;
|
||||
let mut pmxy = f32::NEG_INFINITY;
|
||||
for &[x, y, _] in clipped.iter().filter(|p| p[0].is_finite()) {
|
||||
pmnx = pmnx.min(x);
|
||||
pmny = pmny.min(y);
|
||||
pmxx = pmxx.max(x);
|
||||
pmxy = pmxy.max(y);
|
||||
}
|
||||
|
||||
// Project snap points + key vertices into the same paper frame,
|
||||
// keeping only those inside the viewport rect, so endpoint /
|
||||
// midpoint / centre snaps land on the visible sheet geometry
|
||||
// instead of the model's UTM coordinates.
|
||||
let snap_pts: Vec<(glam::DVec3, model::wire_model::SnapHint)> = wire
|
||||
.snap_pts
|
||||
.iter()
|
||||
.filter_map(|(w, h)| {
|
||||
let p = proj_abs(w.x, w.y, w.z);
|
||||
(p[0].is_finite() && in_vp(p[0], p[1]))
|
||||
.then(|| (glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), *h))
|
||||
})
|
||||
.collect();
|
||||
let key_vertices: Vec<[f64; 3]> = wire
|
||||
.key_vertices
|
||||
.iter()
|
||||
.filter_map(|&[kx, ky, kz]| {
|
||||
let p = proj_abs(kx, ky, kz);
|
||||
(p[0].is_finite() && in_vp(p[0], p[1]))
|
||||
.then(|| [p[0] as f64, p[1] as f64, p[2] as f64])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color);
|
||||
let [r, g, b, a] = adapted;
|
||||
let mut out = wire.clone();
|
||||
out.points = clipped;
|
||||
// Paper coordinates are small sheet units — no relative-to-eye
|
||||
// residual is needed, and keeping the model wire's points_low
|
||||
// here would add a model-scale offset to the paper points.
|
||||
out.points_low = Vec::new();
|
||||
out.snap_pts = snap_pts;
|
||||
out.key_vertices = key_vertices;
|
||||
// Tangent geometry is in model space and can't be trivially
|
||||
// re-expressed in paper coords — drop it (no tangent snap on
|
||||
// projected viewport content) rather than snap to UTM.
|
||||
out.tangent_geoms = Vec::new();
|
||||
out.aabb = if pmnx.is_finite() {
|
||||
[pmnx, pmny, pmxx, pmxy]
|
||||
} else {
|
||||
WireModel::UNBOUNDED_AABB
|
||||
};
|
||||
out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
|
||||
out.line_weight_px = wire.line_weight_px;
|
||||
// Wire's pattern was sized for model-space coords during
|
||||
// tessellation; we just projected points into paper coords
|
||||
// (× scale), so rescale the dash pattern by the same factor
|
||||
// to keep dimensional consistency in the GPU shader.
|
||||
out.pattern_length = wire.pattern_length * scale;
|
||||
out.pattern = wire.pattern.map(|v| v * scale);
|
||||
out.vp_scissor = Some([vp_x0, vp_y0, vp_x1, vp_y1]);
|
||||
projected.push(out);
|
||||
}
|
||||
|
||||
// Store in cache, then extend result.
|
||||
self.paper_projected_cache
|
||||
.borrow_mut()
|
||||
.insert(vp_handle, (self.geometry_epoch, projected.clone()));
|
||||
result.extend(projected);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ── Paper boundary wire ────────────────────────────────────────────────────
|
||||
|
||||
// ── Cohen-Sutherland line clipping ───────────────────────────────────────
|
||||
|
||||
/// Clip a single segment (x0,y0)→(x1,y1) against the axis-aligned rectangle
|
||||
/// [xmin,xmax]×[ymin,ymax]. Returns the clipped endpoints or `None` if the
|
||||
/// segment is entirely outside.
|
||||
|
||||
fn cs_clip(
|
||||
mut x0: f32,
|
||||
mut y0: f32,
|
||||
mut x1: f32,
|
||||
mut y1: f32,
|
||||
xmin: f32,
|
||||
ymin: f32,
|
||||
xmax: f32,
|
||||
ymax: f32,
|
||||
) -> Option<(f32, f32, f32, f32)> {
|
||||
const LEFT: u8 = 1;
|
||||
const RIGHT: u8 = 2;
|
||||
const BOTTOM: u8 = 4;
|
||||
const TOP: u8 = 8;
|
||||
|
||||
let code = |x: f32, y: f32| -> u8 {
|
||||
let mut c = 0u8;
|
||||
if x < xmin {
|
||||
c |= LEFT;
|
||||
} else if x > xmax {
|
||||
c |= RIGHT;
|
||||
}
|
||||
if y < ymin {
|
||||
c |= BOTTOM;
|
||||
} else if y > ymax {
|
||||
c |= TOP;
|
||||
}
|
||||
c
|
||||
};
|
||||
|
||||
let mut c0 = code(x0, y0);
|
||||
let mut c1 = code(x1, y1);
|
||||
|
||||
loop {
|
||||
if c0 | c1 == 0 {
|
||||
return Some((x0, y0, x1, y1));
|
||||
}
|
||||
if c0 & c1 != 0 {
|
||||
return None;
|
||||
}
|
||||
let cout = if c0 != 0 { c0 } else { c1 };
|
||||
let (x, y);
|
||||
if cout & TOP != 0 {
|
||||
x = x0 + (x1 - x0) * (ymax - y0) / (y1 - y0);
|
||||
y = ymax;
|
||||
} else if cout & BOTTOM != 0 {
|
||||
x = x0 + (x1 - x0) * (ymin - y0) / (y1 - y0);
|
||||
y = ymin;
|
||||
} else if cout & RIGHT != 0 {
|
||||
y = y0 + (y1 - y0) * (xmax - x0) / (x1 - x0);
|
||||
x = xmax;
|
||||
} else {
|
||||
y = y0 + (y1 - y0) * (xmin - x0) / (x1 - x0);
|
||||
x = xmin;
|
||||
}
|
||||
if cout == c0 {
|
||||
x0 = x;
|
||||
y0 = y;
|
||||
c0 = code(x0, y0);
|
||||
} else {
|
||||
x1 = x;
|
||||
y1 = y;
|
||||
c1 = code(x1, y1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip a projected polyline (NaN-separated segments) to the viewport rectangle.
|
||||
/// Returns a new points vec with proper NaN separators at clip boundaries.
|
||||
fn clip_polyline_to_rect(
|
||||
pts: &[[f32; 3]],
|
||||
xmin: f32,
|
||||
ymin: f32,
|
||||
xmax: f32,
|
||||
ymax: f32,
|
||||
z: f32,
|
||||
) -> Vec<[f32; 3]> {
|
||||
const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN];
|
||||
let mut result: Vec<[f32; 3]> = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < pts.len() {
|
||||
// Skip NaN separators.
|
||||
if pts[i][0].is_nan() || pts[i][1].is_nan() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Gather contiguous run of finite points.
|
||||
let start = i;
|
||||
while i < pts.len() && pts[i][0].is_finite() && pts[i][1].is_finite() {
|
||||
i += 1;
|
||||
}
|
||||
let seg = &pts[start..i];
|
||||
if seg.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clip each edge and track pen state to insert NaN on lift.
|
||||
let mut pen_down = false;
|
||||
for j in 0..seg.len() - 1 {
|
||||
let [x0, y0, _] = seg[j];
|
||||
let [x1, y1, _] = seg[j + 1];
|
||||
match cs_clip(x0, y0, x1, y1, xmin, ymin, xmax, ymax) {
|
||||
None => {
|
||||
pen_down = false;
|
||||
}
|
||||
Some((cx0, cy0, cx1, cy1)) => {
|
||||
if !pen_down {
|
||||
if !result.is_empty() {
|
||||
result.push(NAN3);
|
||||
}
|
||||
result.push([cx0, cy0, z]);
|
||||
pen_down = true;
|
||||
} else if let Some(&[lx, ly, _]) = result.last() {
|
||||
if (lx - cx0).abs() > 1e-4 || (ly - cy0).abs() > 1e-4 {
|
||||
result.push(NAN3);
|
||||
result.push([cx0, cy0, z]);
|
||||
}
|
||||
}
|
||||
result.push([cx1, cy1, z]);
|
||||
// If the exit point was clipped, lift pen.
|
||||
if (cx1 - x1).abs() > 1e-4 || (cy1 - y1).abs() > 1e-4 {
|
||||
pen_down = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove trailing NaN.
|
||||
while result
|
||||
.last()
|
||||
.map(|p: &[f32; 3]| p[0].is_nan())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
result.pop();
|
||||
}
|
||||
result
|
||||
}
|
||||
355
src/scene/selection.rs
Normal file
355
src/scene/selection.rs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Selection ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
|
||||
if exclusive {
|
||||
self.selected.clear();
|
||||
}
|
||||
self.selected.insert(handle);
|
||||
self.bump_selection();
|
||||
}
|
||||
|
||||
pub fn deselect_all(&mut self) {
|
||||
self.selected.clear();
|
||||
self.bump_selection();
|
||||
}
|
||||
|
||||
/// Remove a single entity from the selection (Shift+click subtractive pick).
|
||||
pub fn deselect_entity(&mut self, handle: Handle) {
|
||||
if self.selected.remove(&handle) {
|
||||
self.bump_selection();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_entities(&self) -> Vec<(Handle, &EntityType)> {
|
||||
self.selected
|
||||
.iter()
|
||||
.filter_map(|&h| self.document.get_entity(h).map(|e| (h, e)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Iterates every entity owned by the current layout's block-record.
|
||||
/// Returns an empty vec when the block-record is missing or holds no
|
||||
/// entity handles (legacy DXF without group-code 330 — we err on the
|
||||
/// side of "no candidates" instead of scanning the whole document, so
|
||||
/// model-block entities don't leak into a paper-layout selection).
|
||||
fn current_layout_entity_handles(&self) -> Vec<Handle> {
|
||||
let block = self.current_layout_block_handle();
|
||||
self.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.handle == block)
|
||||
.map(|br| br.entity_handles.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extends the current selection with every entity in the active
|
||||
/// layout that matches one of the selected entities by `(variant,
|
||||
/// layer)`. The seed selection stays selected. No-op when nothing is
|
||||
/// selected. Returns the number of newly-added entities.
|
||||
pub fn select_similar(&mut self) -> usize {
|
||||
use crate::entities::traits::entity_type_name;
|
||||
if self.selected.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let pairs: rustc_hash::FxHashSet<(&'static str, String)> = self
|
||||
.selected
|
||||
.iter()
|
||||
.filter_map(|h| self.document.get_entity(*h))
|
||||
.map(|e| (entity_type_name(e), e.as_entity().layer().to_string()))
|
||||
.collect();
|
||||
let handles = self.current_layout_entity_handles();
|
||||
let mut added = 0;
|
||||
for h in handles {
|
||||
if self.selected.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = self.document.get_entity(h) {
|
||||
let key = (entity_type_name(e), e.as_entity().layer().to_string());
|
||||
if pairs.contains(&key) {
|
||||
self.selected.insert(h);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added > 0 {
|
||||
self.bump_selection();
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Replace the selection with its complement: every selectable object
|
||||
/// in the active layout that isn't currently selected. The candidate
|
||||
/// set is the visible wire set, so objects on off/frozen layers (which
|
||||
/// can't be picked anyway) are excluded. Returns the new count.
|
||||
pub fn invert_selection(&mut self) -> usize {
|
||||
let prev: rustc_hash::FxHashSet<Handle> = self.selected.iter().copied().collect();
|
||||
let all: Vec<Handle> = self
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| Self::handle_from_wire_name(&w.name))
|
||||
.collect();
|
||||
self.selected.clear();
|
||||
for h in all {
|
||||
if !prev.contains(&h) {
|
||||
self.selected.insert(h);
|
||||
}
|
||||
}
|
||||
self.bump_selection();
|
||||
self.selected.len()
|
||||
}
|
||||
|
||||
/// Replaces (or extends, when `append` is true) the current
|
||||
/// selection with every entity in the active layout that matches
|
||||
/// the filter. Returns the number of newly-matching entities.
|
||||
///
|
||||
/// `type_name` of `None` means "any type". `property_field` of
|
||||
/// `None` skips the property test (only the type filter applies).
|
||||
/// The operator's `Any` variant also skips the property test.
|
||||
/// Numeric operators (`Gt` / `Lt`) parse both sides as `f64` and
|
||||
/// reject anything non-numeric.
|
||||
pub fn qselect(
|
||||
&mut self,
|
||||
type_name: Option<&str>,
|
||||
property_field: Option<&str>,
|
||||
op: crate::app::QSelectOp,
|
||||
value: &str,
|
||||
append: bool,
|
||||
) -> usize {
|
||||
use crate::app::QSelectOp;
|
||||
use crate::entities::traits::entity_type_name;
|
||||
if !append {
|
||||
self.selected.clear();
|
||||
}
|
||||
let handles = self.current_layout_entity_handles();
|
||||
let mut matched = 0;
|
||||
for h in handles {
|
||||
let Some(e) = self.document.get_entity(h) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(t) = type_name {
|
||||
if entity_type_name(e) != t {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let prop_ok = match (property_field, op) {
|
||||
(None, _) | (_, QSelectOp::Any) => true,
|
||||
(Some(field), op) => {
|
||||
let Some(actual) = self.entity_property_value(e, field) else {
|
||||
continue;
|
||||
};
|
||||
match op {
|
||||
QSelectOp::Eq => actual.eq_ignore_ascii_case(value),
|
||||
QSelectOp::Neq => !actual.eq_ignore_ascii_case(value),
|
||||
QSelectOp::Gt | QSelectOp::Lt => {
|
||||
let (Ok(a), Ok(b)) =
|
||||
(actual.parse::<f64>(), value.parse::<f64>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(op, QSelectOp::Gt) {
|
||||
a > b
|
||||
} else {
|
||||
a < b
|
||||
}
|
||||
}
|
||||
QSelectOp::Any => true,
|
||||
}
|
||||
}
|
||||
};
|
||||
if prop_ok {
|
||||
self.selected.insert(h);
|
||||
matched += 1;
|
||||
}
|
||||
}
|
||||
self.bump_selection();
|
||||
matched
|
||||
}
|
||||
|
||||
/// Returns the sorted set of entity-type names present in the active
|
||||
/// layout. Used to populate the Quick Select "Object type" dropdown
|
||||
/// with only the types that actually exist in the drawing.
|
||||
pub fn entity_type_names_in_layout(&self) -> Vec<&'static str> {
|
||||
use crate::entities::traits::entity_type_name;
|
||||
let mut names: std::collections::BTreeSet<&'static str> =
|
||||
std::collections::BTreeSet::new();
|
||||
for h in self.current_layout_entity_handles() {
|
||||
if let Some(e) = self.document.get_entity(h) {
|
||||
names.insert(entity_type_name(e));
|
||||
}
|
||||
}
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
/// True when `handle`'s entity type is allowed by the selection filter.
|
||||
/// The filter stores excluded type names; empty = everything allowed.
|
||||
pub fn passes_selection_filter(&self, handle: Handle) -> bool {
|
||||
if self.selection_filter.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match self.document.get_entity(handle) {
|
||||
Some(e) => !self
|
||||
.selection_filter
|
||||
.contains(crate::entities::traits::entity_type_name(e)),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the selection filter is excluding at least one type.
|
||||
pub fn selection_filter_active(&self) -> bool {
|
||||
!self.selection_filter.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the list of `(field, label)` pairs the Quick Select
|
||||
/// "Properties" dropdown should show given the current type filter:
|
||||
///
|
||||
/// * Common properties (Layer, Color, Linetype, Lineweight) are
|
||||
/// always included.
|
||||
/// * When `type_name` names a specific entity type present in the
|
||||
/// active layout, the first entity of that type contributes its
|
||||
/// `geometry_properties()` rows (Start X, Length, Radius, …) so
|
||||
/// type-specific filtering works.
|
||||
pub fn qselect_properties(
|
||||
&self,
|
||||
type_name: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
use crate::entities::traits::{entity_type_name, EntityTypeOps};
|
||||
let mut out: Vec<(String, String)> = vec![
|
||||
("layer".to_string(), "Layer".to_string()),
|
||||
("color".to_string(), "Color".to_string()),
|
||||
("linetype".to_string(), "Linetype".to_string()),
|
||||
("lineweight".to_string(), "Lineweight".to_string()),
|
||||
];
|
||||
if let Some(t) = type_name {
|
||||
let text_style_names: Vec<String> = self
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
let sample = self
|
||||
.current_layout_entity_handles()
|
||||
.into_iter()
|
||||
.filter_map(|h| self.document.get_entity(h))
|
||||
.find(|e| entity_type_name(e) == t);
|
||||
if let Some(sample) = sample {
|
||||
if let Some(section) = sample.geometry_properties(&text_style_names) {
|
||||
for prop in section.props {
|
||||
// Skip rows that don't sensibly compare via
|
||||
// `entity_property_value` (read-only labels are
|
||||
// fine — users can still match against them).
|
||||
out.push((prop.field.to_string(), prop.label.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads a property value from an entity for QSELECT comparison.
|
||||
/// Returns the canonical string used as the left-hand side of the
|
||||
/// operator test. Common properties have hand-rolled formatting so
|
||||
/// `"ByLayer"` / `"7"` / `"0.30mm"` are stable; everything else
|
||||
/// goes through `geometry_properties()` and pulls the matching
|
||||
/// row's value out.
|
||||
pub fn entity_property_value(
|
||||
&self,
|
||||
entity: &acadrust::EntityType,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
use crate::scene::model::object::PropValue;
|
||||
match field {
|
||||
"layer" => Some(entity.common().layer.clone()),
|
||||
"color" => Some(Self::format_color(entity.common().color)),
|
||||
"linetype" => Some(entity.common().linetype.clone()),
|
||||
"lineweight" => Some(Self::format_lineweight(entity.common().line_weight)),
|
||||
_ => {
|
||||
let text_style_names: Vec<String> = self
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
let section = entity.geometry_properties(&text_style_names)?;
|
||||
let prop = section.props.into_iter().find(|p| p.field == field)?;
|
||||
Some(match prop.value {
|
||||
PropValue::ReadOnly(s) | PropValue::EditText(s) => s,
|
||||
PropValue::LayerChoice(s) => s,
|
||||
PropValue::Choice { selected, .. } => selected,
|
||||
PropValue::ColorChoice(c) => Self::format_color(c),
|
||||
PropValue::LwChoice(lw) => Self::format_lineweight(lw),
|
||||
PropValue::LinetypeChoice(s) => s,
|
||||
PropValue::HatchPatternChoice(s) => s,
|
||||
PropValue::BoolToggle { value, .. } => value.to_string(),
|
||||
PropValue::ColorVaries | PropValue::LwVaries => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_color(c: acadrust::types::Color) -> String {
|
||||
use acadrust::types::Color;
|
||||
match c {
|
||||
Color::ByLayer => "ByLayer".to_string(),
|
||||
Color::ByBlock => "ByBlock".to_string(),
|
||||
Color::Index(i) => i.to_string(),
|
||||
Color::Rgb { r, g, b } => format!("{},{},{}", r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_lineweight(lw: acadrust::types::LineWeight) -> String {
|
||||
use acadrust::types::LineWeight;
|
||||
match lw {
|
||||
LineWeight::ByLayer => "ByLayer".to_string(),
|
||||
LineWeight::ByBlock => "ByBlock".to_string(),
|
||||
LineWeight::Default => "Default".to_string(),
|
||||
LineWeight::Value(v) => format!("{:.2}mm", v as f64 / 100.0),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Erase ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn erase_entities(&mut self, handles: &[Handle]) {
|
||||
for &h in handles {
|
||||
self.document.remove_entity(h);
|
||||
self.selected.remove(&h);
|
||||
self.hatches.remove(&h);
|
||||
self.meshes.remove(&h);
|
||||
self.solid_models.remove(&h);
|
||||
self.mark_entity_dirty(h);
|
||||
}
|
||||
// Remove erased handles from all groups; delete groups that become empty.
|
||||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||||
let to_remove: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.values_mut()
|
||||
.filter_map(|obj| match obj {
|
||||
ObjectType::Group(g) => {
|
||||
g.entities.retain(|h| !handles.contains(h));
|
||||
if g.entities.is_empty() {
|
||||
Some(g.handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for gh in &to_remove {
|
||||
if let Some(ObjectType::Dictionary(dict)) =
|
||||
self.document.objects.get_mut(&group_dict_handle)
|
||||
{
|
||||
dict.entries.retain(|(_, h)| h != gh);
|
||||
}
|
||||
self.document.objects.remove(gh);
|
||||
}
|
||||
// Deleting top-level entities/inserts leaves block definitions intact;
|
||||
// the erased handles were already dropped from the memo above.
|
||||
self.bump_geometry_no_blocks();
|
||||
}
|
||||
}
|
||||
859
src/scene/tess.rs
Normal file
859
src/scene/tess.rs
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
// ── Parallel tessellation free function ──────────────────────────────────────
|
||||
//
|
||||
// Takes only the `Send + Sync` data needed for tessellation so that
|
||||
// `wires_for_block` can dispatch work across rayon's thread pool without
|
||||
// requiring `Scene` (which contains `Rc<RefCell<...>>` and is `!Send`) to
|
||||
// cross thread boundaries.
|
||||
|
||||
|
||||
/// Tessellate a synthesised dimension-text entity through `tessellate_entity`
|
||||
/// so it picks up the standard text LOD ladder (baseline / greek / full),
|
||||
/// then re-color the returned wires with the dimension's resolved text colour
|
||||
/// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults).
|
||||
pub(crate) fn tessellate_entity_dim_text(
|
||||
document: &acadrust::CadDocument,
|
||||
selected: &HashSet<Handle>,
|
||||
active_viewport: Option<Handle>,
|
||||
bg_color: [f32; 4],
|
||||
anno_scale: f32,
|
||||
e: &EntityType,
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
text_color: [f32; 4],
|
||||
) -> Vec<WireModel> {
|
||||
let mut wires = tessellate_entity(
|
||||
document, selected, active_viewport, bg_color,
|
||||
anno_scale, e, None, view_aabb, world_per_pixel,
|
||||
);
|
||||
for w in &mut wires {
|
||||
// Synth dim text carries no real entity colour — paint everything
|
||||
// (including greek-LOD fill tris which read `wire.color`) with the
|
||||
// dim's text colour. Selection highlight already baked in by
|
||||
// tessellate_entity, so leave that alone.
|
||||
if !w.selected {
|
||||
w.color = text_color;
|
||||
}
|
||||
}
|
||||
wires
|
||||
}
|
||||
|
||||
pub(crate) fn tessellate_entity(
|
||||
document: &acadrust::CadDocument,
|
||||
selected: &HashSet<Handle>,
|
||||
active_viewport: Option<Handle>,
|
||||
bg_color: [f32; 4],
|
||||
anno_scale: f32,
|
||||
e: &EntityType,
|
||||
block_cache: Option<&cache::block_cache::BlockCache>,
|
||||
// World-space XY view AABB (post `world_offset` subtraction). When
|
||||
// `Some`, entities whose AABB doesn't intersect this rect are skipped.
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
// World units per screen pixel for LOD culling. `None` = no LOD.
|
||||
world_per_pixel: Option<f32>,
|
||||
) -> Vec<WireModel> {
|
||||
let h = e.common().handle;
|
||||
let sel = selected.contains(&h);
|
||||
|
||||
// Frustum + LOD cull for non-Insert, non-Viewport entities. Insert is
|
||||
// handled separately (its WCS bbox depends on the block defn AABB ×
|
||||
// Insert transform — done inside expand_insert). Viewports always emit
|
||||
// so the viewport frame stays visible regardless of zoom.
|
||||
let needs_cull = view_aabb.is_some() || world_per_pixel.is_some();
|
||||
if needs_cull {
|
||||
match e {
|
||||
EntityType::Viewport(_) | EntityType::Insert(_) => {}
|
||||
_ => {
|
||||
let ab = entity_aabb(e);
|
||||
if ab != WireModel::UNBOUNDED_AABB {
|
||||
if let Some(view) = view_aabb {
|
||||
if cache::block_cache::aabb_disjoint_xy(ab, view) {
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
if let Some(wpp) = world_per_pixel {
|
||||
let w_px = (ab[2] - ab[0]).abs();
|
||||
let h_px = (ab[3] - ab[1]).abs();
|
||||
// Keep in sync with `cache::block_cache::MIN_PIXEL_SIZE`.
|
||||
// Text/MText have their own LOD ladder below
|
||||
// (baseline-line / greek / full) and must reach it
|
||||
// even when projected size is sub-5 px.
|
||||
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
|
||||
// Face3D is exempt from the sub-pixel stub: it is trivially
|
||||
// cheap to tessellate (4 corners → 2 tris), so there is no
|
||||
// cost to draw it full at any zoom, and the cube-stub
|
||||
// otherwise pops/coarsens flat faces across the threshold.
|
||||
let is_face3d = matches!(e, EntityType::Face3D(_));
|
||||
let is_3d_entity = matches!(
|
||||
e,
|
||||
EntityType::Solid3D(_)
|
||||
| EntityType::Mesh(_)
|
||||
| EntityType::PolyfaceMesh(_)
|
||||
| EntityType::PolygonMesh(_)
|
||||
| EntityType::Body(_)
|
||||
| EntityType::Region(_)
|
||||
| EntityType::Surface(_)
|
||||
);
|
||||
if !is_text && !is_face3d && w_px.max(h_px) / wpp < 5.0 {
|
||||
// Sub-pixel entity: emit a stub instead of
|
||||
// nothing so it stays visible / selectable /
|
||||
// hit-test'able at any zoom. 2-D entities
|
||||
// get the cheap diagonal segment; 3-D
|
||||
// entities get an AABB cube so their
|
||||
// footprint doesn't drift when the camera
|
||||
// crosses the LOD threshold. See #19.
|
||||
let (entity_color, _, _, _, aci_idx) =
|
||||
view::render::render_style_for(document, e);
|
||||
let entity_color = view::render::adapt_to_bg(entity_color, bg_color);
|
||||
if is_3d_entity {
|
||||
// `ab` is already in the local frame
|
||||
// (entity_aabb subtracted world_offset
|
||||
// XY). The bbox z fields are still in
|
||||
// WCS, so subtract `world_offset[2]` to
|
||||
// match — otherwise the stub sits at a
|
||||
// different z than the full tessellation
|
||||
// and the geometry visibly shifts when
|
||||
// the camera crosses the LOD threshold.
|
||||
let bbox = e.as_entity().bounding_box();
|
||||
let oz = 0.0_f64;
|
||||
let z_min = (bbox.min.z - oz) as f32;
|
||||
let z_max = (bbox.max.z - oz) as f32;
|
||||
return vec![lod_stub_wire_3d(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci_idx,
|
||||
ab,
|
||||
z_min,
|
||||
z_max,
|
||||
)];
|
||||
}
|
||||
return vec![lod_stub_wire(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci_idx,
|
||||
ab,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let EntityType::Viewport(vp) = e {
|
||||
// The sheet viewport (overall/id=1) is never shown — it represents the
|
||||
// paper boundary, not a user-defined content window.
|
||||
if !Scene::is_content_viewport(vp) {
|
||||
return vec![];
|
||||
}
|
||||
let is_active = active_viewport == Some(h);
|
||||
let is_locked = vp.status.locked;
|
||||
let color = if sel {
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
} else if is_active {
|
||||
[1.0, 0.90, 0.20, 1.0]
|
||||
} else if is_locked {
|
||||
[0.90, 0.55, 0.10, 1.0]
|
||||
} else {
|
||||
[0.0, 0.75, 0.75, 1.0]
|
||||
};
|
||||
let (pattern_length, pattern) = if is_active {
|
||||
(1.5_f32, [0.8, -0.4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0_f32])
|
||||
} else {
|
||||
(0.0_f32, [0.0f32; 8])
|
||||
};
|
||||
let mut wires = convert::tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
e,
|
||||
sel,
|
||||
color,
|
||||
pattern_length,
|
||||
pattern,
|
||||
1.5,
|
||||
1.0,
|
||||
world_per_pixel,
|
||||
);
|
||||
let ab = entity_aabb(e);
|
||||
for w in &mut wires {
|
||||
w.aabb = ab;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
|
||||
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
|
||||
view::render::render_style_for(document, e);
|
||||
let entity_color = view::render::adapt_to_bg(entity_color, bg_color);
|
||||
let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32;
|
||||
let lt_name = view::render::linetype_name_for(document, e);
|
||||
// PSLTSCALE: scale linetype dashes by viewport anno_scale so they appear uniform in paper space.
|
||||
let pslt_factor = if document.header.paper_space_linetype_scaling {
|
||||
anno_scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let pattern_length = pattern_length * pslt_factor;
|
||||
let pattern = pattern.map(|v| v * pslt_factor);
|
||||
|
||||
// ── Dimension baked-block fast path ─────────────────────────────────────
|
||||
//
|
||||
// AutoCAD bakes each dimension's final geometry (extension lines, dim
|
||||
// line, arrows, text MText) into a per-instance block — usually
|
||||
// `*D<n>`, but custom names like `DIMBLOCK###-4NP` also occur. When the
|
||||
// block exists we render its contents through `tessellate_entity` so
|
||||
// sub-Text/MText get the standard baseline/greek/full LOD ladder, and
|
||||
// DIMTXT × DIMSCALE isn't re-applied on already-baked geometry.
|
||||
if let EntityType::Dimension(dim) = e {
|
||||
let block_name = &dim.base().block_name;
|
||||
if !block_name.trim().is_empty() {
|
||||
if let Some(br) = document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.name.eq_ignore_ascii_case(block_name))
|
||||
{
|
||||
if !br.entity_handles.is_empty() {
|
||||
let mut wires: Vec<WireModel> =
|
||||
Vec::with_capacity(br.entity_handles.len());
|
||||
for &eh in &br.entity_handles {
|
||||
let Some(sub) = document.get_entity(eh) else { continue };
|
||||
// Sub-entities inside *D### / DIMBLOCK## blocks
|
||||
// typically use ByBlock color/linetype/lineweight —
|
||||
// they should inherit from the Dimension entity.
|
||||
let sub_color_is_byblock =
|
||||
sub.common().color == acadrust::types::Color::ByBlock;
|
||||
let sub_wires = tessellate_entity(
|
||||
document, selected, active_viewport, bg_color,
|
||||
// Block contents are baked at the final WCS size —
|
||||
// don't let downstream paths re-apply anno_scale.
|
||||
1.0, sub, block_cache, view_aabb, world_per_pixel,
|
||||
);
|
||||
for mut w in sub_wires {
|
||||
w.name = h.value().to_string();
|
||||
// Override ByBlock colour with the dim's resolved
|
||||
// colour so text matches `DIMCLRT`-style behaviour
|
||||
// (or layer colour) instead of the raw ByBlock
|
||||
// fallback that render_style_for produces.
|
||||
if sub_color_is_byblock {
|
||||
w.color = if sel { WireModel::SELECTED } else { entity_color };
|
||||
w.aci = aci;
|
||||
}
|
||||
wires.push(w);
|
||||
}
|
||||
}
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
for w in &mut wires {
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall through to the synthesis path below when no block is attached.
|
||||
}
|
||||
|
||||
if let EntityType::Dimension(dim) = e {
|
||||
let aabb = entity_aabb(e);
|
||||
use crate::entities::dimension::DimensionTess;
|
||||
let mut wires = dim.tessellate(
|
||||
document,
|
||||
h,
|
||||
sel,
|
||||
entity_color,
|
||||
line_weight_px,
|
||||
anno_scale,
|
||||
selected,
|
||||
active_viewport,
|
||||
bg_color,
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
);
|
||||
for w in &mut wires {
|
||||
w.aci = aci;
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
|
||||
if let EntityType::MultiLeader(ml) = e {
|
||||
let aabb = entity_aabb(e);
|
||||
use crate::entities::multileader::MultiLeaderTess;
|
||||
let mut wires = ml.tessellate(
|
||||
document,
|
||||
h,
|
||||
sel,
|
||||
entity_color,
|
||||
line_weight_px,
|
||||
anno_scale,
|
||||
world_per_pixel,
|
||||
);
|
||||
for w in &mut wires {
|
||||
w.aci = aci;
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
|
||||
// ── Table baked-block fast path ─────────────────────────────────────────
|
||||
//
|
||||
// AutoCAD bakes a Table's final rendered geometry (cell text, gridlines,
|
||||
// fill) into a per-instance block (usually `*T###`) referenced through
|
||||
// `table.block_record_handle`. The block's text uses the *displayed*
|
||||
// height; synthesising cells from `self.rows + TableStyle` instead would
|
||||
// re-apply the table's scale factor on top of already-baked geometry.
|
||||
// When the block exists we render it directly. Same pattern as
|
||||
// Dimension's `block_name`.
|
||||
if let EntityType::Table(tab) = e {
|
||||
if let Some(br_h) = tab.block_record_handle {
|
||||
if let Some(br) = document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.handle == br_h)
|
||||
{
|
||||
if !br.entity_handles.is_empty() {
|
||||
let mut wires: Vec<WireModel> =
|
||||
Vec::with_capacity(br.entity_handles.len());
|
||||
for &eh in &br.entity_handles {
|
||||
let Some(sub) = document.get_entity(eh) else { continue };
|
||||
let sub_color_is_byblock =
|
||||
sub.common().color == acadrust::types::Color::ByBlock;
|
||||
let sub_wires = tessellate_entity(
|
||||
document, selected, active_viewport, bg_color,
|
||||
anno_scale, sub, block_cache, view_aabb, world_per_pixel,
|
||||
);
|
||||
for mut w in sub_wires {
|
||||
w.name = h.value().to_string();
|
||||
if sub_color_is_byblock {
|
||||
w.color = if sel { WireModel::SELECTED } else { entity_color };
|
||||
w.aci = aci;
|
||||
}
|
||||
wires.push(w);
|
||||
}
|
||||
}
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
for w in &mut wires {
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No baked block (e.g. a table created in-app) — synthesise coloured
|
||||
// geometry from the rows + TableStyle so fills/colours/borders/margins
|
||||
// are honoured instead of the monochrome fallback.
|
||||
let mut wires = crate::entities::table::tessellate_table(
|
||||
tab, document, sel, entity_color, line_weight_px,
|
||||
);
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
for w in &mut wires {
|
||||
w.aci = aci;
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
|
||||
if let EntityType::Insert(ins) = e {
|
||||
// Resolve the INSERT's own style so ByBlock sub-entities can inherit it.
|
||||
let (ins_color, ins_pat_len, ins_pat, ins_lw_px, _) = view::render::render_style_for(document, e);
|
||||
let ins_color = view::render::adapt_to_bg(ins_color, bg_color);
|
||||
let ip = glam::Vec3::new(
|
||||
(ins.insert_point.x) as f32,
|
||||
(ins.insert_point.y) as f32,
|
||||
(ins.insert_point.z) as f32,
|
||||
);
|
||||
let marker = WireModel {
|
||||
name: h.value().to_string(),
|
||||
points: vec![],
|
||||
points_low: Vec::new(),
|
||||
color: entity_color,
|
||||
selected: sel,
|
||||
aci: 0,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![(ip.as_dvec3(), model::wire_model::SnapHint::Insertion)],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
};
|
||||
|
||||
if let Some(cache) = block_cache {
|
||||
// Xrefs render with the same hue but faded toward `bg_color` so
|
||||
// the user can recognise external-reference geometry at a glance.
|
||||
let is_xref = document
|
||||
.block_records
|
||||
.get(&ins.block_name)
|
||||
.map(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
.unwrap_or(false);
|
||||
if let Some(mut wires) = cache::block_cache::expand_insert(
|
||||
cache,
|
||||
ins,
|
||||
h,
|
||||
ins_color,
|
||||
ins_pat_len,
|
||||
ins_pat,
|
||||
ins_lw_px,
|
||||
sel,
|
||||
pslt_factor,
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
is_xref,
|
||||
bg_color,
|
||||
) {
|
||||
// XCLIP: if this INSERT carries an enabled spatial filter,
|
||||
// clip the expanded block geometry to the boundary polygon so
|
||||
// only the portion inside the clip is drawn.
|
||||
if let Some(sf) = pick::xclip::insert_spatial_filter(document, ins) {
|
||||
let poly = pick::xclip::world_clip_polygon_f64(sf, ins);
|
||||
pick::xclip::clip_wires(&mut wires, &poly);
|
||||
}
|
||||
|
||||
// Per-INSERT attribute values. The block defn carries the
|
||||
// AttributeDefinitions (templates) which expand_insert skips;
|
||||
// the AttributeEntity instances live on the Insert itself in
|
||||
// WCS and need their own tessellation so the user sees the
|
||||
// values they actually filled in. See #20.
|
||||
crate::entities::insert::append_insert_attribute_wires(
|
||||
&mut wires,
|
||||
document,
|
||||
ins,
|
||||
h,
|
||||
sel,
|
||||
ins_color,
|
||||
ins_pat_len,
|
||||
ins_pat,
|
||||
ins_lw_px,
|
||||
bg_color,
|
||||
is_xref,
|
||||
pslt_factor,
|
||||
anno_scale,
|
||||
);
|
||||
wires.push(marker);
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss / unavailable: fall back to the original explode path.
|
||||
// The block_cache primary path covers all typical Inserts; this
|
||||
// branch only fires for pathological cache failures.
|
||||
let br = document.block_records.get(&ins.block_name);
|
||||
let is_xref = br
|
||||
.map(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
.unwrap_or(false);
|
||||
let mut wires: Vec<WireModel> = ins
|
||||
.explode_from_document(document)
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(crate::modules::draw::modify::explode::normalize_insert_entity)
|
||||
.flat_map(|sub| {
|
||||
let (sub_color, sub_pattern_length, sub_pattern, sub_line_weight_px, sub_aci) =
|
||||
view::render::render_style_for_block_sub(
|
||||
document,
|
||||
&sub,
|
||||
ins_color,
|
||||
ins_pat_len,
|
||||
ins_pat,
|
||||
ins_lw_px,
|
||||
);
|
||||
let sub_color = view::render::adapt_to_bg(sub_color, bg_color);
|
||||
let sub_color = if is_xref && !sel {
|
||||
cache::block_cache::fade_toward_bg(sub_color, bg_color)
|
||||
} else {
|
||||
sub_color
|
||||
};
|
||||
let sub_aabb = entity_aabb(&sub);
|
||||
let sub_pattern_length = sub_pattern_length * pslt_factor;
|
||||
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
|
||||
let mut wires = convert::tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
&sub,
|
||||
sel,
|
||||
sub_color,
|
||||
sub_pattern_length,
|
||||
sub_pattern,
|
||||
sub_line_weight_px,
|
||||
anno_scale,
|
||||
world_per_pixel,
|
||||
);
|
||||
for w in &mut wires {
|
||||
w.name = h.value().to_string();
|
||||
w.aci = sub_aci;
|
||||
w.aabb = sub_aabb;
|
||||
}
|
||||
wires
|
||||
})
|
||||
.collect();
|
||||
crate::entities::insert::append_insert_attribute_wires(
|
||||
&mut wires,
|
||||
document,
|
||||
ins,
|
||||
h,
|
||||
sel,
|
||||
ins_color,
|
||||
ins_pat_len,
|
||||
ins_pat,
|
||||
ins_lw_px,
|
||||
bg_color,
|
||||
is_xref,
|
||||
pslt_factor,
|
||||
anno_scale,
|
||||
);
|
||||
wires.push(marker);
|
||||
return wires;
|
||||
}
|
||||
|
||||
let aabb = entity_aabb(e);
|
||||
|
||||
// Text-specific LOD ladder, keyed off the entity's glyph height in
|
||||
// pixels (anno-scaled):
|
||||
// < 1 px → baseline line in the text's color (text-here hint)
|
||||
// 1–5 px → greeked OBB rect in the text's color
|
||||
// ≥ 5 px → full per-glyph stroke tessellation
|
||||
//
|
||||
// Applies to every entity that is "primarily a piece of text" — Text,
|
||||
// MText, ATTDEF, ATTRIB, Tolerance — so far-out drawings don't pay the
|
||||
// full glyph-tessellation cost. Composite entities (Dimension, Table,
|
||||
// MultiLeader) carry non-text geometry and have their own LOD paths.
|
||||
if let Some(wpp) = world_per_pixel {
|
||||
let text_height: Option<f64> = match e {
|
||||
EntityType::Text(t) => Some(t.height * anno_scale as f64),
|
||||
EntityType::MText(m) => Some(m.height * anno_scale as f64),
|
||||
EntityType::AttributeDefinition(a) => Some(a.height * anno_scale as f64),
|
||||
EntityType::AttributeEntity(a) => Some(a.height * anno_scale as f64),
|
||||
EntityType::Tolerance(t) => {
|
||||
// Tolerance text_height defaults to 0.18 from creation; treat
|
||||
// 0 as missing and fall back to the AutoCAD default so the
|
||||
// pixel check still kicks in for legitimately tiny dimensions.
|
||||
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
|
||||
Some(raw * anno_scale as f64)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(h_world) = text_height {
|
||||
let h_px = (h_world as f32) / wpp;
|
||||
// Wrap-expanded line count for MText (Text = 1).
|
||||
let n_lines = match e {
|
||||
EntityType::MText(m) => {
|
||||
crate::entities::text_support::mtext_line_count(m, document, anno_scale)
|
||||
}
|
||||
_ => 1,
|
||||
};
|
||||
if h_px < 1.0 {
|
||||
let pts = crate::entities::text_support::text_baseline_points(e, anno_scale, n_lines);
|
||||
if pts.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
// Skip the baseline too if the line itself projects under
|
||||
// 2 px (e.g. a 1-char text seen edge-on). All wrap lines
|
||||
// share the same baseline length, so the first segment is
|
||||
// a representative sample.
|
||||
let dx = pts[1][0] - pts[0][0];
|
||||
let dy = pts[1][1] - pts[0][1];
|
||||
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
|
||||
if len_px < 2.0 {
|
||||
// Text projects to under 2 px — fall back to the
|
||||
// generic LOD stub so the entity stays visible /
|
||||
// selectable. #19. Text is 2-D in the XY plane so
|
||||
// z_min = z_max = 0 keeps the historical behaviour.
|
||||
return vec![lod_stub_wire(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci,
|
||||
aabb,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
}
|
||||
return vec![WireModel {
|
||||
name: h.value().to_string(),
|
||||
points: pts,
|
||||
points_low: Vec::new(),
|
||||
color: entity_color,
|
||||
selected: sel,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
}
|
||||
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
|
||||
let fill_tris = crate::entities::text_support::text_greek_obb_tris(e, anno_scale, n_lines);
|
||||
if fill_tris.is_empty() {
|
||||
// Text greek fallback: also 2-D, keep stub at z=0.
|
||||
return vec![lod_stub_wire(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci,
|
||||
aabb,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
}
|
||||
// Greek text renders via the face3d fill batch, which colours
|
||||
// each tri with `wire.color`. Bake the selected colour in so
|
||||
// a selected text stays highlighted across the LOD boundary.
|
||||
// hit_test's AABB fallback handles window / crossing. #19.
|
||||
let fill_color = if sel { WireModel::SELECTED } else { entity_color };
|
||||
return vec![WireModel {
|
||||
name: h.value().to_string(),
|
||||
points: vec![],
|
||||
points_low: Vec::new(),
|
||||
color: fill_color,
|
||||
selected: sel,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris,
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut bases = convert::tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
e,
|
||||
sel,
|
||||
entity_color,
|
||||
pattern_length,
|
||||
pattern,
|
||||
line_weight_px,
|
||||
anno_scale,
|
||||
world_per_pixel,
|
||||
);
|
||||
for b in &mut bases {
|
||||
b.aci = aci;
|
||||
b.aabb = aabb;
|
||||
}
|
||||
|
||||
// Complex linetypes (with embedded shapes / text) expand the *base*
|
||||
// polyline along its tangent. Text-type entities never have a complex
|
||||
// linetype assigned, so we only consult the first wire here — multi-wire
|
||||
// returns come exclusively from MTEXT colour splits which can't trigger
|
||||
// this path.
|
||||
if let Some(clt) = crate::linetypes::complex_lt(lt_name) {
|
||||
if let Some(base) = bases.first() {
|
||||
let mut wires = text::complex_lt::apply_along(
|
||||
&base.name,
|
||||
&base.points,
|
||||
clt,
|
||||
(lt_scale * pslt_factor).max(1e-4),
|
||||
entity_color,
|
||||
sel,
|
||||
base.line_weight_px,
|
||||
);
|
||||
if !wires.is_empty() {
|
||||
for w in &mut wires {
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bases
|
||||
}
|
||||
|
||||
/// Build the 4 OBB corners (CCW: bl, br, tr, tl) of a Text / MText entity
|
||||
/// in its **native frame** — for top-level entities this is world coords,
|
||||
/// for block-defn subs it's block-local. No offset/transform applied.
|
||||
/// Width is approximated from glyph height × character count (TEXT) or
|
||||
/// from `rectangle_width` (MTEXT). Returns `None` for non-text entities.
|
||||
///
|
||||
/// `mtext_lines_override` lets the caller plug in a wrap-aware line count
|
||||
/// (from `text_support::mtext_line_count`). Without it, MText's OBB
|
||||
/// height collapses to a single line when the file omits `rectangle_height`,
|
||||
/// which makes downstream per-line LOD math degenerate.
|
||||
|
||||
/// Build a "low-LOD stub" wire for an entity that would otherwise be culled
|
||||
/// to nothing — the entity's AABB diagonal as a 2-point segment, plus the
|
||||
/// AABB itself so window / crossing selection picks the entity up. The
|
||||
/// stored `selected` flag tracks across zoom levels so highlight visuals
|
||||
/// don't disappear when the LOD level changes. See #19.
|
||||
fn lod_stub_wire(
|
||||
name: String,
|
||||
color: [f32; 4],
|
||||
selected: bool,
|
||||
aci: u8,
|
||||
aabb: [f32; 4],
|
||||
z_min: f32,
|
||||
z_max: f32,
|
||||
) -> WireModel {
|
||||
let [ax, ay, bx, by] = aabb;
|
||||
let cx = (ax + bx) * 0.5;
|
||||
let cy = (ay + by) * 0.5;
|
||||
let cz = (z_min + z_max) * 0.5;
|
||||
// Mirror what tessellate.rs does for the non-stub paths: bake the
|
||||
// selection-highlight colour into the wire so a re-tessellate triggered
|
||||
// by a zoom-induced LOD change keeps the entity highlighted. Without
|
||||
// this swap the wire's `selected` flag is true but its colour stays at
|
||||
// the entity's own hue, so the user sees the highlight vanish at the
|
||||
// LOD boundary. #19.
|
||||
let stored_color = if selected { WireModel::SELECTED } else { color };
|
||||
WireModel {
|
||||
name,
|
||||
// Diagonal of the entity's 3D AABB so depth tests against
|
||||
// shaded / hidden-line geometry are correct — the stub doesn't
|
||||
// flatten to z=0 and pop in front of objects that sit at a
|
||||
// different elevation. 2D entities (text fallbacks) pass
|
||||
// z_min = z_max = 0 to keep the historical behaviour.
|
||||
points: vec![[ax, ay, z_min], [bx, by, z_max]],
|
||||
points_low: Vec::new(),
|
||||
color: stored_color,
|
||||
selected,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![[cx as f64, cy as f64, cz as f64]],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sub-pixel LOD stub for 3D entities. Emits the entity's 3D AABB as a
|
||||
/// 12-edge cube so the geometry occupies the same screen footprint and
|
||||
/// depth range as the full tessellation, just with a tiny constant cost
|
||||
/// (12 line segments). Without this, the diagonal stub used by
|
||||
/// `lod_stub_wire` cuts off at two opposite bbox corners and drifts
|
||||
/// visibly when the camera crosses the LOD threshold.
|
||||
fn lod_stub_wire_3d(
|
||||
name: String,
|
||||
color: [f32; 4],
|
||||
selected: bool,
|
||||
aci: u8,
|
||||
aabb: [f32; 4],
|
||||
z_min: f32,
|
||||
z_max: f32,
|
||||
) -> WireModel {
|
||||
let [x0, y0, x1, y1] = aabb;
|
||||
let (z0, z1) = if z_min <= z_max { (z_min, z_max) } else { (z_max, z_min) };
|
||||
let p = [
|
||||
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
|
||||
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
|
||||
];
|
||||
// 12 edges = 4 bottom-face + 4 top-face + 4 vertical connectors.
|
||||
const EDGES: [(usize, usize); 12] = [
|
||||
(0, 1), (1, 2), (2, 3), (3, 0),
|
||||
(4, 5), (5, 6), (6, 7), (7, 4),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7),
|
||||
];
|
||||
let mut points: Vec<[f32; 3]> = Vec::with_capacity(EDGES.len() * 3);
|
||||
for (a, b) in EDGES {
|
||||
if !points.is_empty() {
|
||||
points.push([f32::NAN; 3]);
|
||||
}
|
||||
points.push(p[a]);
|
||||
points.push(p[b]);
|
||||
}
|
||||
let stored_color = if selected { WireModel::SELECTED } else { color };
|
||||
WireModel {
|
||||
name,
|
||||
points,
|
||||
points_low: Vec::new(),
|
||||
color: stored_color,
|
||||
selected,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
// No `key_vertices` — Face3DGpu requires 4 corners to emit a
|
||||
// fill quad, and we don't want this stub painted as a solid
|
||||
// face. The wire pass still draws its 12 edges.
|
||||
key_vertices: vec![],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tessellate each visible AttributeEntity attached to an Insert and append
|
||||
/// the resulting wires. AttributeEntity positions are already in WCS — the
|
||||
/// INSERT only stamps the geometry once, attribute text sits at the world
|
||||
/// position recorded on each ATTRIB. See #20.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] {
|
||||
let bbox = e.as_entity().bounding_box();
|
||||
let min_x = (bbox.min.x) as f32;
|
||||
let min_y = (bbox.min.y) as f32;
|
||||
let max_x = (bbox.max.x) as f32;
|
||||
let max_y = (bbox.max.y) as f32;
|
||||
// A degenerate box (min == max == 0) means bounding_box() returned Default —
|
||||
// use UNBOUNDED so the wire is never wrongly pre-rejected.
|
||||
if min_x == max_x && min_y == max_y {
|
||||
return WireModel::UNBOUNDED_AABB;
|
||||
}
|
||||
[min_x, min_y, max_x, max_y]
|
||||
}
|
||||
|
||||
/// AABB of `e` in WCS f64 (no world_offset subtraction). `None` for
|
||||
/// entities whose `bounding_box()` returned the degenerate default
|
||||
/// (which `entity_aabb` collapses to `UNBOUNDED_AABB`). Quadtree
|
||||
/// indexing uses this so changing `world_offset` doesn't invalidate
|
||||
/// the index.
|
||||
pub(crate) fn entity_world_aabb_f64(e: &acadrust::EntityType) -> Option<[f64; 4]> {
|
||||
let bbox = e.as_entity().bounding_box();
|
||||
let (xmin, ymin, xmax, ymax) = (bbox.min.x, bbox.min.y, bbox.max.x, bbox.max.y);
|
||||
if xmin == xmax && ymin == ymax {
|
||||
return None;
|
||||
}
|
||||
if !xmin.is_finite() || !ymin.is_finite() || !xmax.is_finite() || !ymax.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some([xmin, ymin, xmax, ymax])
|
||||
}
|
||||
|
||||
/// True if `e` is a type the quadtree should skip. `Insert` and
|
||||
/// `Viewport` are sized only after extra transformation; tessellation
|
||||
/// already handles them via dedicated code paths. `Block`/`BlockEnd`
|
||||
/// are block-defn sentinels with no geometry.
|
||||
pub(crate) fn is_unindexable_entity(e: &acadrust::EntityType) -> bool {
|
||||
use acadrust::EntityType as E;
|
||||
matches!(
|
||||
e,
|
||||
E::Insert(_) | E::Viewport(_) | E::Block(_) | E::BlockEnd(_)
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue