// Scene modules grouped by role: // convert — DXF/ACIS entities → solids & tessellated geometry // text — LFF stroke + TrueType font engines and shaping // model — per-entity GPU render models (wire, hatch, mesh, image, object) // pick — hit-testing, selection, grips, spatial index, xclip // view — camera, transforms, viewport, render pipeline driver // cache — block-definition and property caches pub mod annotative; pub mod assoc_array; pub(crate) mod block_metadata; pub mod block_service; pub mod block_visibility; pub mod cache; pub mod compare; pub mod convert; pub mod creation_style; pub(crate) mod frame; pub mod model; pub mod pick; pub mod pipeline; pub(crate) mod render_graph; pub mod section_cull; pub mod sub_select; pub mod text; pub mod view; #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] pub enum BlockScalePolicy { #[default] FromInsert, Applied, } // Topic submodules split out of this root (each contributes `impl Scene` // blocks and/or free functions). Pure text-move from the original mod.rs. #[cfg(test)] mod block_base_point_tests; mod boundary; mod camera_ops; #[cfg(test)] mod reference_only_tests; pub use camera_ops::{add_background, copy_background, erase_background}; pub(crate) mod centerline; pub(crate) mod centermark; pub(crate) mod dimension_assoc; pub(crate) mod dimension_assoc_chain; pub use dimension_assoc::{ReferenceStatus, ResolvedReference}; pub use page_setup::{apply_default_page_setup, document_page_setups, rotated_margins}; mod dwg_native_constraints; mod entity; pub use camera_ops::named_view_center; pub(crate) use entity::gradient_tint_color; pub use entity::CreateBlockOptions; mod group_layer; #[cfg(test)] mod hatch_boundary; mod layout; mod limits; mod modify; pub(crate) use modify::remap_copied_reactors; mod mspace; pub mod named_parameters; mod page_setup; mod paper; mod preview; mod project; pub mod reference_only; mod scene_markers; mod selection; pub mod viewport_dimension_pick; pub mod viewport_ref; pub(crate) use selection::{pe_url_description_of, pe_url_of}; pub mod parametric_constraints; mod parametric_solve; pub(crate) use boundary::{ boundary_entities, boundary_entities_from_sources, boundary_faces, boundary_polyline_entities, exact_hatch_paths, hatch_boundary_rings, hatch_path_directions, hatch_path_ring, remap_hatch_associations, ring_source_handles, separated_hatch_path_groups, BoundarySource, }; // Parallel tessellation free functions live in `convert::tess` (alongside the // other tessellation code); re-exported here so this root and sibling topic // modules (each does `use super::*`) keep referencing them unqualified. pub(crate) use convert::tess::{ entity_aabb, entity_world_aabb_f64, is_unindexable_entity, tessellate_entity_dim_text, }; /// Keep construction-history curves in the same per-entity tessellation memo /// as their parent, so navigation never rebuilds them on every frame. /// /// `reference_only` is the scene's designation set: its membership decides /// whether the produced wires carry the `WireModel::reference_only` click- /// through stamp (see `scene::reference_only`). #[allow(clippy::too_many_arguments)] pub(crate) fn tessellate_entity( document: &CadDocument, selected: &HashSet, active_viewport: Option, bg_color: [f32; 4], anno_scale: f32, annotation_scale_handle: Option, entity: &EntityType, block_cache: Option<&cache::block_cache::BlockCache>, view_aabb: Option<[f32; 4]>, world_per_pixel: Option, paper_space: bool, reference_only: &HashSet, ) -> Vec { let mut wires = convert::tess::tessellate_entity( document, selected, active_viewport, bg_color, anno_scale, annotation_scale_handle, entity, block_cache, view_aabb, world_per_pixel, paper_space, reference_only, ); if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { let handle = entity.common().handle; for source in model::solid_history::loft_visible_history_entities(document, handle) { // Call the raw converter, not this wrapper: sources must not expand // the parent's history again through their shared selection handle. let mut construction = if let EntityType::Region(region) = &source { // Regions normally render through the mesh layer, so the raw // wire converter emits nothing. Decode every exact boundary, // including holes, then sample only for this cached display. let mut boundaries = Vec::new(); if let Ok(kernel::brep::LoftSection::Profile { plane, wires, .. }) = kernel::acis::loft_section_geometry(&[codec::entities::EmbeddedEntity::Region( region.clone(), )]) { let (color, _, _, line_weight_px, _) = view::render::render_style_for_viewport(document, &source, active_viewport); for boundary in wires { for curve in boundary { let points = curve .tessellate(64.0 / std::f64::consts::TAU) .into_iter() .map(|point| plane.point_at(point)); let (points, points_low) = convert::tessellate::points_to_ds(points); let mut wire = WireModel::solid( handle.value().to_string(), points, color, selected.contains(&handle), ); wire.points_low = points_low; wire.line_weight_px = line_weight_px; boundaries.push(wire); } } } boundaries } else { convert::tess::tessellate_entity( document, selected, active_viewport, bg_color, anno_scale, annotation_scale_handle, &source, block_cache, view_aabb, world_per_pixel, paper_space, reference_only, ) }; for wire in &mut construction { wire.name = handle.value().to_string(); if !wire.selected { wire.color = [1.0, 0.25, 0.25, wire.color[3]]; } wire.fill_tris.clear(); wire.fill_tris_low.clear(); } wires.extend(construction); } for points in model::solid_history::composite_history_edges(document, handle) { let selected = selected.contains(&handle); let color = [1.0, 0.25, 0.25, 1.0]; wires.push(WireModel::solid_f64( handle.value().to_string(), points, color, selected, )); } } wires } impl Scene { /// True for the modeler solid families whose wire path is an empty shell /// (their geometry lives in the mesh pipeline): B-rep corners for those /// come from the mesh edge list instead of wire points. fn is_solid_shell_entity(entity: &EntityType) -> bool { matches!( entity, EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) ) } /// B-rep snap points for a solid: edge endpoints as `Vertex` hints and /// edge centres as `EdgeMidpoint` hints (both from the tessellated edge /// list), plus face centres as `FaceCenter` hints (from the kernel B-rep /// faces). Empty for non-solids and for solids with no usable data. pub(crate) fn solid_snap_points( &self, handle: Handle, ) -> Vec<(glam::DVec3, crate::scene::model::wire_model::SnapHint)> { use crate::scene::model::wire_model::SnapHint; let Some(entity) = self.document.get_entity(handle) else { return Vec::new(); }; if !Self::is_solid_shell_entity(entity) { return Vec::new(); } let mut out = Vec::new(); if let Some(set) = self .meshes .get(&handle) .or_else(|| self.block_meshes.get(&handle)) { let (edges, lows) = set.geometry_edges(); let mut seen = HashSet::default(); for (a, b) in crate::scene::model::mesh_model::solid_edge_segments(edges, lows) { for (point, hint) in [(a, SnapHint::Vertex), (b, SnapHint::Vertex)] { let key = [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()]; if seen.insert(key) { out.push((point, hint)); } } out.push(((a + b) * 0.5, SnapHint::EdgeMidpoint)); } } out.extend( Self::solid_face_centers(entity, self.solid_models.get(&handle)) .into_iter() .map(|p| (p, SnapHint::FaceCenter)), ); out } /// Face centres for a solid entity: kernel B-rep faces averaged over /// their boundary loops. Prefers the cached kernel body (no re-parse); /// falls back to parsing the entity's ACIS data, which shares the cost /// profile of the mesh build at the same epoch. No body ⇒ no centres. fn solid_face_centers( entity: &EntityType, cached: Option<&kernel::brep::Body>, ) -> Vec { use crate::scene::convert::solid3d_tess::{kernel_body, kernel_region_body}; use crate::scene::model::solid_model::face_centers; if let Some(body) = cached { return face_centers(body) .into_iter() .map(glam::DVec3::from_array) .collect(); } match entity { EntityType::Solid3D(s) => kernel_body(s), EntityType::Region(r) => kernel_region_body(r), _ => None, } .map(|body| face_centers(&body)) .unwrap_or_default() .into_iter() .map(glam::DVec3::from_array) .collect() } /// Attach solid B-rep snaps to the entity's shell wire (the empty wire /// carrying only the insertion snap). History/construction wires keep /// their own points and are left alone; non-solids are untouched. /// Idempotent: a wire already carrying 3D snaps is skipped, so shared /// memo entries can pass through every assembly path safely. pub(crate) fn attach_solid_snaps(&self, handle: Handle, wires: &mut Vec) { use crate::scene::model::wire_model::SnapHint; let points = self.solid_snap_points(handle); if points.is_empty() { return; } for wire in wires.iter_mut() { if !wire.points.is_empty() { continue; } if wire.snap_pts.iter().any(|(_, hint)| { matches!( hint, SnapHint::Vertex | SnapHint::EdgeMidpoint | SnapHint::FaceCenter | SnapHint::Knot ) }) { continue; } wire.snap_pts.extend(points.iter().copied()); } } /// Live solid-face snaps for a command point: Nearest-to-face (screen /// nearest point on a mesh triangle) and Perpendicular-to-face (foot from /// `base` onto a triangle). Mesh triangles carry no B-rep face grouping, /// so faces are individual triangles here — the pre-baked `FaceCenter` /// hints (true B-rep faces) are the separate discrete counterpart. /// /// Top-level model meshes only: block-local sets live in a different /// space, and xclipped views are not honored (the wire channel the /// pre-baked 3D hints ride is clipped; this direct mesh read is not). /// Worst case in those views is a snap with no geometry behind it — /// never a corrupt point. pub(crate) fn solid_face_snaps( &self, cursor_screen: iced::Point, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, aperture_px: f32, base: Option, want_perp: bool, want_nearest: bool, ) -> Option { use crate::snap::{ closest_point_on_tri_2d, foot_on_triangle, snap_better, snap_priority, snap_tier, SnapType, }; if !want_perp && !want_nearest { return None; } if !(aperture_px.is_finite() && aperture_px > 0.0) { return None; } let perp_base = want_perp.then(|| base).flatten(); let project = |world: glam::DVec3| { let ndc = view_rot.project_point3((world - eye).as_vec3()); [ (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, ] }; let finite_screen = |s: [f32; 2]| s[0].is_finite() && s[1].is_finite(); let in_bounds = |s: [f32; 2]| { s[0] >= 0.0 && s[0] <= bounds.width && s[1] >= 0.0 && s[1] <= bounds.height }; let radius2 = aperture_px * aperture_px; let cursor = [cursor_screen.x, cursor_screen.y]; // (tier, d2, sub, eye-depth², handle, hit): engine ordering, then // nearer-to-eye (coincident faces share pixels — the top face must // beat the bottom one), then the handle as a final deterministic // tie-break (mesh iteration order is not stable). let mut best: Option<(u8, f32, u8, f64, u64, crate::snap::SnapResult)> = None; let mut consider = |snap_type: SnapType, world: glam::DVec3, screen: [f32; 2], d2: f32, handle: Handle| { if !(d2 < radius2) || !in_bounds(screen) { return; } let (tier, sub) = (snap_tier(snap_type), snap_priority(snap_type)); let depth2 = (world - eye).length_squared(); let better = match &best { None => true, Some((bt, bd2, bs, bdepth2, bh, _)) => { snap_better(tier, d2, sub, (*bt, *bd2, *bs)) || (tier == *bt && (d2 - *bd2).abs() <= 1e-4 && sub == *bs && (depth2 < *bdepth2 || (depth2 == *bdepth2 && handle.value() < *bh))) } }; if better { best = Some(( tier, d2, sub, depth2, handle.value(), crate::snap::SnapResult { world, screen: iced::Point::new(screen[0], screen[1]), snap_type, tangent_obj: None, extension_base: None, extension_base2: None, extension_origin: None, extension_dir: None, viewport: None, source: Some(crate::command::DimensionAssociationSource::inferred(handle)), secondary_source: None, model_point: None, }, )); } }; for (handle, set) in self.meshes.iter() { let Some(lod) = set.geometry_lods().first() else { continue; }; if lod.indices.len() < 3 { continue; } // Screen-space cull from the mesh bounds before touching triangles. let (mut x0, mut y0) = (f32::INFINITY, f32::INFINITY); let (mut x1, mut y1) = (f32::NEG_INFINITY, f32::NEG_INFINITY); for corner in [ [set.world_aabb[0], set.world_aabb[1], set.z_aabb[0]], [set.world_aabb[2], set.world_aabb[1], set.z_aabb[0]], [set.world_aabb[0], set.world_aabb[3], set.z_aabb[0]], [set.world_aabb[2], set.world_aabb[3], set.z_aabb[0]], [set.world_aabb[0], set.world_aabb[1], set.z_aabb[1]], [set.world_aabb[2], set.world_aabb[1], set.z_aabb[1]], [set.world_aabb[0], set.world_aabb[3], set.z_aabb[1]], [set.world_aabb[2], set.world_aabb[3], set.z_aabb[1]], ] { let s = project(glam::DVec3::new( corner[0] as f64, corner[1] as f64, corner[2] as f64, )); if !finite_screen(s) { continue; } x0 = x0.min(s[0]); y0 = y0.min(s[1]); x1 = x1.max(s[0]); y1 = y1.max(s[1]); } if cursor[0] < x0 - aperture_px || cursor[0] > x1 + aperture_px || cursor[1] < y0 - aperture_px || cursor[1] > y1 + aperture_px { continue; } for tri in lod.indices.chunks_exact(3) { let mut v = [glam::DVec3::ZERO; 3]; let mut ok = true; for (slot, index) in v.iter_mut().zip(tri.iter()) { let (Some(high), low) = ( lod.verts.get(*index as usize), lod.verts_low .get(*index as usize) .copied() .unwrap_or([0.0; 3]), ) else { ok = false; break; }; *slot = glam::DVec3::new( high[0] as f64 + low[0] as f64, high[1] as f64 + low[1] as f64, high[2] as f64 + low[2] as f64, ); if !slot.is_finite() { ok = false; break; } } if !ok { continue; } let [a, b, c] = v; let (sa, sb, sc) = (project(a), project(b), project(c)); if !(finite_screen(sa) && finite_screen(sb) && finite_screen(sc)) { continue; } // Per-triangle screen reject before any 3D work. let (tx0, ty0) = (sa[0].min(sb[0]).min(sc[0]), sa[1].min(sb[1]).min(sc[1])); let (tx1, ty1) = (sa[0].max(sb[0]).max(sc[0]), sa[1].max(sb[1]).max(sc[1])); if cursor[0] < tx0 - aperture_px || cursor[0] > tx1 + aperture_px || cursor[1] < ty0 - aperture_px || cursor[1] > ty1 + aperture_px { continue; } if want_nearest { let (q, d2, w) = closest_point_on_tri_2d(cursor, sa, sb, sc); if d2 < radius2 { let world = a * w[0] as f64 + b * w[1] as f64 + c * w[2] as f64; consider(SnapType::NearestFace, world, q, d2, *handle); } } if let Some(base3d) = perp_base { if let Some(foot) = foot_on_triangle(base3d, a, b, c) { let sf = project(foot); if finite_screen(sf) { let dx = sf[0] - cursor[0]; let dy = sf[1] - cursor[1]; consider( SnapType::FacePerpendicular, foot, sf, dx * dx + dy * dy, *handle, ); } } } } } best.map(|(_, _, _, _, _, hit)| hit) } } /// Result of `Scene::entity_index()`. The wire path queries `tree` for /// view-rect candidates and also always emits `unbounded_handles` /// (entities with no usable bbox — legacy `UNBOUNDED_AABB` sentinel). pub(super) struct EntityIndex { pub tree: pick::quadtree::QuadTree, pub unbounded_handles: Vec, } /// Hash of every baked display input a memoized tessellation depends on; a /// memo entry is reusable only under the guard it was built with. fn tess_memo_guard( view_aabb: Option<[f32; 4]>, anno: f32, annotation_scale_handle: Option, all_visible: bool, bg: [f32; 4], avp: Option, ) -> u64 { let mut g: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |x: u64| g = g.rotate_left(13) ^ x; // wpp removed from guard: GPU analytical rendering handles // circles/arcs/ellipses, Point ignores wpp, and Light (the // only remaining wpp consumer) is rare enough that its stale // glyphs don't justify clearing every memoized entity on zoom. if let Some(v) = view_aabb { for c in v { mix(c.to_bits() as u64); } } mix(anno.to_bits() as u64); mix(annotation_scale_handle .map(|handle| handle.value()) .unwrap_or(0)); mix(all_visible as u64); // Direct entities and inherited/faded block colours still bake // the background before Batches::finalize records its inputs. for channel in bg { mix(channel.to_bits() as u64); } mix(avp.map(|h| h.value()).unwrap_or(0)); // SDF glyph quads bake the atlas UV of each tile, so a growth or // a re-bake (which rescale / rewind every UV) makes memoized text // address the wrong tile — garbage on screen, and a silent miss in // the PDF export's glyph lookup (#385 under #347's conditions). mix(crate::scene::text::sdf_atlas::generation()); g } #[derive(Clone, Default)] struct DependencyTargets { render_handles: HashSet, source_handles: HashSet, /// Render handles reached through a block definition (INSERTs and other /// block users), as opposed to entities that carry the name themselves. block_users: HashSet, touches_block_definition: bool, } #[derive(Clone, Copy)] enum DependencyKind { Layer, TextStyle, DimStyle, } #[derive(Default)] struct SceneDependencyIndex { layers: HashMap, text_styles: HashMap, dim_styles: HashMap, object_styles: HashMap, points: DependencyTargets, text_geometry: DependencyTargets, annotation_geometry: DependencyTargets, signatures: HashMap, /// Block name (normalised) → the layout entities that draw it, nested /// references included. block_roots: HashMap>, } fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64; 4]> { let mut aabb = [ f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY, ]; for [x, y] in hatch.boundary.iter().copied() { if !x.is_finite() || !y.is_finite() { continue; } let x = hatch.world_origin[0] + x as f64; let y = hatch.world_origin[1] + y as f64; aabb[0] = aabb[0].min(x); aabb[1] = aabb[1].min(y); aabb[2] = aabb[2].max(x); aabb[3] = aabb[3].max(y); } aabb.iter().all(|value| value.is_finite()).then_some(aabb) } fn mesh_interaction_aabb(set: &model::mesh_model::MeshLodSet) -> Option<[f64; 6]> { if let Some(aabb) = set.instance_aabb { return Some(aabb); } let mut aabb = [ f64::INFINITY, f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY, ]; let mut include = |high: [f32; 3], low: [f32; 3]| { let point = [ high[0] as f64 + low[0] as f64, high[1] as f64 + low[1] as f64, high[2] as f64 + low[2] as f64, ]; if point.iter().all(|value| value.is_finite()) { for axis in 0..3 { aabb[axis] = aabb[axis].min(point[axis]); aabb[axis + 3] = aabb[axis + 3].max(point[axis]); } } }; for mesh in &set.lods { for (index, &high) in mesh.verts.iter().enumerate() { include(high, mesh.verts_low.get(index).copied().unwrap_or([0.0; 3])); } } for (index, &high) in set.edge_verts.iter().enumerate() { include( high, set.edge_verts_low.get(index).copied().unwrap_or([0.0; 3]), ); } aabb.iter().all(|value| value.is_finite()).then_some(aabb) } pub use model::hatch_model::HatchModel; pub use model::image_model::ImageModel; pub use model::mesh_model::MeshLodSet; pub use model::object::{GripApply, GripDef}; pub use model::wire_model::WireModel; pub use pick::selection_state::SelectionState; pub use pipeline::uniforms::Uniforms; pub use pipeline::viewcube::{ hit_test, hit_test_cardinal, hover_id, CubeRegion, NudgeDir, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, VIEWCUBE_REGION_PX, VIEWCUBE_RENDER_PX, }; use view::camera::Camera; pub use view::camera::Projection; use crate::command::EntityTransform; use codec::entities::{Block, BlockEnd, Insert as DxfInsert}; use codec::entities::{ BoundaryEdge, BoundaryPath, Hatch as DxfHatch, PolylineEdge, Solid as DxfSolid, }; use codec::objects::ObjectType; use codec::tables::normalize_name; use codec::types::Vector2; use codec::{CadDocument, EntityType, Handle, TableEntry}; use glam; use iced::time::Duration; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::cell::RefCell; use std::hash::{Hash, Hasher}; use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; /// Session-only object visibility used by ISOLATEOBJECTS / HIDEOBJECTS. /// /// This is deliberately separate from `EntityCommon::invisible`: that DXF /// property belongs to the drawing (and dynamic-block visibility states), /// whereas object isolation must never be serialized. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ObjectIsolationState { /// Objects explicitly hidden with HIDEOBJECTS. pub hidden: HashSet, /// Objects retained by ISOLATEOBJECTS. `None` means no isolate filter. pub keep: Option>, } impl ObjectIsolationState { fn hides(&self, handle: Handle) -> bool { self.hidden.contains(&handle) } fn is_active(&self) -> bool { !self.hidden.is_empty() || self.keep.is_some() } } /// Global counter so every Scene and every geometry mutation gets a /// process-wide unique epoch. This prevents two different tabs (Scenes) /// from ever sharing the same epoch value, which would cause the shared /// GPU Pipeline to skip re-uploading geometry when switching tabs. static GEOMETRY_EPOCH: AtomicU64 = AtomicU64::new(1); /// Process-wide monotonic id stamped each time the Model wire set is built. /// The set is held static across camera moves, so the id stays the same every /// frame until the geometry epoch changes — it uniquely identifies a wire /// buffer's *content* across frames. The GPU pipeline gates wire re-upload on /// it: an unchanged id means the world-space wire buffer is not re-sent. /// Monotonic (never reused) → free of the ABA hazard a raw `Arc` pointer would /// carry when an address is freed and reallocated. static WIRE_CONTENT_GEN: AtomicU64 = AtomicU64::new(1); /// How a single entity changed in a geometry bump. `Modified` covers both a /// coordinate edit and a property (colour / layer / linetype) change — anything /// that alters the entity's tessellated output. A property-only recolour that /// could touch just the GPU WireConst slot is folded into `Modified` for now. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ChangeKind { Added, Removed, Modified, } /// While an entity-only undoable command runs, this captures the pre-mutation /// image of every entity the five mutation primitives (add / update / erase / /// transform / copy) touch, so the app can build a cheap **delta**-undo entry /// instead of cloning the whole ~800k-entity document (~46 ms). The small set /// of object-map entries changed by ordinary entity operations (groups and /// raster-image definitions) is captured alongside the entities. `poisoned` /// remains the fallback for broader structure changes such as a brand-new /// layer or a `*D` block record. #[derive(Default)] pub struct UndoRecording { /// First-touch before-image per handle (the first write for a handle wins, /// so repeated touches within one command keep the true pre-command state). /// A value of `None` marks an entity *added* by the command (no prior /// state). `order` preserves first-touch order for a deterministic delta. before: HashMap>>, order: Vec, /// First-touch before-images for the exact document.objects entries changed /// by the command. `None` denotes a newly-created object. object_before: HashMap>, object_order: Vec, /// First-touch before-images of every parametric-constraint scope a command /// changed (e.g. ERASE removing an entity's constraints along with it via /// `refresh_parametric_constraints`'s deletion policy). `parametric_constraints` /// lives on `Scene`, not in `document`/`document.objects`, so neither /// directory above ever sees this change — it needs its own directory. /// Unlike the two above, there is no "didn't exist before" case: a /// scope's `Vec` entry, once created, is never removed. parametric_constraints_before: HashMap< parametric_constraints::ParametricScope, parametric_constraints::ParametricConstraintSet, >, parametric_constraints_order: Vec, named_parameters_before: Option, /// First-touch before-images of reference-only designations (API v9) /// changed by the command — almost always empty. The designation set /// lives on `Scene`, not in the document, so like /// `parametric_constraints_before` it needs its own channel. Erasing a /// designated entity drops its set entry here so undo restores entity /// and designation together. reference_designations_before: Vec<(Handle, bool)>, poisoned: bool, } impl UndoRecording { /// The command touched non-entity state a pure-entity delta can't restore. pub fn is_poisoned(&self) -> bool { self.poisoned } /// No entity, object, constraint, or parameter state was recorded. pub fn is_empty(&self) -> bool { self.order.is_empty() && self.object_order.is_empty() && self.parametric_constraints_order.is_empty() && self.named_parameters_before.is_none() && self.reference_designations_before.is_empty() } /// Consume the recording directories in deterministic first-touch /// order. A `None` image means the entity/object was added by the /// command; every parametric-constraint scope's before-image is a real /// `ParametricConstraintSet` (its `Vec` entry, once created, is never /// removed, so there is no "didn't exist before" case there). pub fn into_recorded_images( mut self, ) -> ( Vec<(Handle, Option>)>, Vec<(Handle, Option)>, Vec<( parametric_constraints::ParametricScope, parametric_constraints::ParametricConstraintSet, )>, Option, Vec<(Handle, bool)>, ) { let entities = self .order .drain(..) .map(|h| (h, self.before.remove(&h).flatten())) .collect(); let objects = self .object_order .drain(..) .map(|h| (h, self.object_before.remove(&h).flatten())) .collect(); let parametric_constraints = self .parametric_constraints_order .drain(..) .filter_map(|scope| { self.parametric_constraints_before .remove(&scope) .map(|before| (scope, before)) }) .collect(); let reference_designations = std::mem::take(&mut self.reference_designations_before); ( entities, objects, parametric_constraints, self.named_parameters_before, reference_designations, ) } /// Entity-only convenience used by the focused Scene delta tests. #[cfg(test)] pub fn into_before_images(self) -> Vec<(Handle, Option>)> { self.into_recorded_images().0 } } /// One geometry mutation's delta record. `changes` names the exact handles that /// changed; `full` marks a mutation that can't enumerate its handles (undo/redo, /// file open, style/display change, any bulk op) and forces every consumer that /// spans it to fall back to a whole-drawing rebuild. Every `geometry_epoch` bump /// pushes exactly one of these so the journal is never missing a step. struct GeometryDelta { epoch: u64, changes: Vec<(Handle, ChangeKind)>, /// Cache-category membership captured immediately before a Removed entity /// leaves the document. Presence with value 0 means "known unrelated"; /// absence means the caller removed it outside the tracked primitives and /// consumers must remain conservative. removed_categories: HashMap, full: bool, } /// The desk a paper layout lays its sheet on — the surface visible around the /// page. Distinct from both the page (`paper_bg_color`) and the model canvas /// (`bg_color`); if the desk took either of those colours the page edge would /// vanish into its own surround. pub const PAPER_DESK_COLOR: [f32; 4] = [138.0 / 255.0, 138.0 / 255.0, 138.0 / 255.0, 1.0]; const CACHE_CATEGORY_ANNOTATIVE: u16 = 1 << 0; const CACHE_CATEGORY_HATCH: u16 = 1 << 1; const CACHE_CATEGORY_WIPEOUT: u16 = 1 << 2; const CACHE_CATEGORY_IMAGE: u16 = 1 << 3; const CACHE_CATEGORY_MESH: u16 = 1 << 4; const CACHE_CATEGORY_INTERACTION: u16 = 1 << 5; const CACHE_CATEGORY_TEXT: u16 = 1 << 6; const CACHE_CATEGORY_INSERT_HATCH: u16 = 1 << 7; /// Mutable assembly metadata for one resident wire set. Keeping entity ranges /// beside the flat render Vec lets a one-entity edit splice that run directly; /// rebuilding this directory by grouping every WireModel was the dominant /// edit-time CPU cost on dense drawings. struct ResidentWireSet { epoch: u64, gen: u64, wires: Arc>, layout: Option, } #[derive(Clone)] pub(crate) struct ResidentWireLayout { /// Entity handles in final submission order. A temporarily hidden entity /// remains here with no range so grip commit can restore it in place. order: Vec, /// Flat wire range `(start, len)` for each currently visible entity. ranges: HashMap, /// Blanked ranges retained in the flat Vec. Undo/Redo and grip hide/show can /// restore a same-shaped entity in place without shifting every later run. vacant: HashMap, /// Number of blank WireModel slots currently retained. Excessive waste /// triggers the normal full-build compaction fallback. tombstoned_wires: usize, /// First synthesized marker wire. Markers have no entity handle and remain /// at the tail while entity runs are inserted/removed before them. marker_start: usize, } /// One memoized entity tessellation. #[derive(Clone)] pub(crate) enum MemoRun { /// The wires themselves: an entity with no run in the backing set /// (hidden, or tessellated after the set was built). Owned(Arc>), /// Still valid, and the same wires the backing resident set holds for the /// entity, so they are kept once instead of twice (#1542). InSet, /// Tessellated into the resident set being built, which becomes the /// backing when it is done; until then the run reads as a miss. Pending, } /// The resident set `MemoRun::InSet` runs live in. Held weakly so the set /// stays uniquely owned and can be patched in place; `keep` holds a set an /// abandoned patch took apart until the following full build has read it. #[derive(Clone, Default)] pub(crate) struct MemoBacking { set: std::sync::Weak>, ranges: HashMap, keep: Option>>, } /// A memoized run resolved to its wires. enum MemoHit { Owned(Arc>), Slice(Arc>, usize, usize), } impl MemoHit { fn wires(&self) -> &[WireModel] { match self { MemoHit::Owned(wires) => wires, MemoHit::Slice(set, start, len) => &set[*start..*start + *len], } } } /// Hands the set an abandoned resident patch was taking apart back to the /// memo, whose `InSet` runs still read from it. struct ReclaimOnDecline<'a> { scene: &'a Scene, wires: Option>, backed: bool, } impl Drop for ReclaimOnDecline<'_> { fn drop(&mut self) { if let Some(wires) = self.wires.take() { if self.backed { self.scene.keep_memo_backing(wires); } } } } /// Kill-switch: `OCS_NO_RESIDENT_MEMO` reverts the resident set to the old /// full re-tessellation on every edit, in case a mutation site is ever found /// that edits geometry without dropping its handle from the memo. fn resident_memo_enabled() -> bool { static EN: std::sync::OnceLock = std::sync::OnceLock::new(); *EN.get_or_init(|| std::env::var("OCS_NO_RESIDENT_MEMO").is_err()) } #[derive(Clone)] pub struct PreparedOpenGeometry { pub wires: Arc>, pub interaction_index: Option>, /// Loader tessellations and their guard; a state mismatch clears the memo. pub(crate) tess_memo: HashMap, pub(crate) tess_memo_backing: MemoBacking, pub tess_guard: u64, /// The loader's submission-order layout for `wires`. /// /// Without it the installed set has `layout: None`, and `try_resident_patch` /// refuses any set that has none — so the first edit after opening a file /// could never patch and rebuilt the whole wire assembly instead. pub(crate) resident_layout: Option, /// Loader block definitions; the receiving scene supplies its own epoch. pub block_defns: Vec<(u64, Arc)>, /// Loader-computed draw-order map, re-stamped by the receiving scene. pub(crate) draw_depths: Option, } impl std::fmt::Debug for PreparedOpenGeometry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PreparedOpenGeometry") .field("wires", &self.wires.len()) .field("interaction_index", &self.interaction_index.is_some()) .field("tess_memo", &self.tess_memo.len()) .field("block_defns", &self.block_defns.len()) .finish() } } #[derive(Debug)] #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) struct WireGpuPatch { pub(crate) changes: Arc>, pub(crate) runs: Arc>>>, pub(crate) index_edits: Arc>, pub(crate) new_handles_are_suffix: bool, /// Whether Face3D / generic fill buffers can differ after this patch. pub(crate) face_pass_changed: bool, } #[derive(Clone, Copy, Debug)] #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) struct WireIndexEdit { pub(crate) handle: Handle, pub(crate) start: usize, pub(crate) old_len: usize, pub(crate) new_len: usize, /// Whether the handle should exist in the selection/text slot index after /// the physical edit. Tombstoning keeps `old_len == new_len` but removes it /// from the index; restoring the vacant range adds it back without a shift. pub(crate) visible: bool, } #[derive(Clone, Copy)] struct DrawDepthEntry { handle: u64, effective: u64, label: i32, half_label: i32, } // Stable order-maintenance labels. Initial builds occupy the middle half of // this space, leaving 200 million units at either end. New CAD handles are // monotonic, so ordinary Add extends with the existing label stride without // changing any sibling's clip-z bias. The large integer domain preserves enough // f32 separation for nested block children even in very large drawings. // Deleted entries remain as tombstones so Undo/Redo restores the same slot. const DRAW_DEPTH_LABEL_MIN: i32 = -1_000_000_000; const DRAW_DEPTH_LABEL_MAX: i32 = 1_000_000_000; const DRAW_DEPTH_INITIAL_MIN: i32 = -800_000_000; const DRAW_DEPTH_INITIAL_MAX: i32 = 800_000_000; const DRAW_DEPTH_LABEL_SCALE: f32 = 1.0 / 1_073_741_824.0; fn draw_depth_value(label: i32, half_label: i32) -> [f32; 2] { [ label as f32 * DRAW_DEPTH_LABEL_SCALE, half_label as f32 * DRAW_DEPTH_LABEL_SCALE, ] } fn seed_draw_depth_entries(order: Vec<(u64, u64)>) -> Vec { let count = order.len(); let middle_capacity = (DRAW_DEPTH_INITIAL_MAX as i64 - DRAW_DEPTH_INITIAL_MIN as i64 - 1) as usize; let (low, high) = if count <= middle_capacity { (DRAW_DEPTH_INITIAL_MIN, DRAW_DEPTH_INITIAL_MAX) } else { (DRAW_DEPTH_LABEL_MIN, DRAW_DEPTH_LABEL_MAX) }; let span = high as i64 - low as i64; let step = (span / (count as i64 + 1)).max(1); order .into_iter() .enumerate() .map(|(index, (handle, effective))| DrawDepthEntry { handle, effective, label: (low as i64 + step * (index as i64 + 1)) as i32, half_label: (step / 2).max(1) as i32, }) .collect() } fn inserted_draw_depth_label(order: &[DrawDepthEntry], position: usize) -> Option<(i32, i32)> { match ( position.checked_sub(1).and_then(|index| order.get(index)), order.get(position), ) { (None, None) => Some((0, 1)), (Some(left), None) => { let stride = order .get(position.saturating_sub(2)) .map(|previous| left.label - previous.label) .unwrap_or(left.half_label.saturating_mul(2)) .max(1); let label = left.label.checked_add(stride)?; (label <= DRAW_DEPTH_LABEL_MAX).then_some((label, (stride / 2).max(1))) } (None, Some(right)) => { let stride = order .get(1) .map(|next| next.label - right.label) .unwrap_or(right.half_label.saturating_mul(2)) .max(1); let label = right.label.checked_sub(stride)?; (label >= DRAW_DEPTH_LABEL_MIN).then_some((label, (stride / 2).max(1))) } (Some(left), Some(right)) if right.label - left.label > 1 => { let label = left.label + (right.label - left.label) / 2; let nearest = (label - left.label).min(right.label - label); Some((label, (nearest / 2).max(1))) } _ => None, } } #[derive(Clone)] pub(crate) struct DrawDepthCache { epoch: u64, depths: Arc>, /// Per-block stable order labels, including deleted tombstones retained for /// symmetric Undo/Redo. blocks: HashMap>, /// Reverse owner lookup retained across removal for tombstone restoration. owners: HashMap, } struct PaperViewportCache { epoch: u64, layout: String, layout_block: Handle, sheet: Handle, content: Arc>, paper_limits: Option<((f64, f64), (f64, f64))>, } struct PaperSheetRenderCache { epoch: u64, layout: String, selected: u64, paper_bg: [f32; 4], hatches: Arc>, wipeouts: Arc>, images: Arc>, } /// Bound on the geometry-delta ring. A consumer that fell more than this many /// mutations behind (or predates the oldest retained delta) can't be replayed /// and does a one-time full rebuild — the safe fallback, not a correctness hole. const GEOMETRY_JOURNAL_CAP: usize = 256; /// Whether the persistent per-entity GPU wire arena (`OCS_WIRE_GPU_PATCH`) is /// enabled — patches one entity's instance slab on an edit instead of rebuilding /// the whole wire buffer. The render layer selects indexed-storage or packed /// arena storage from the active device's wire pipeline. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn wire_gpu_patch_enabled() -> bool { use std::sync::OnceLock; static ON: OnceLock = OnceLock::new(); // On by default; `OCS_WIRE_GPU_PATCH=0` (or off / false / no) reverts to the // batched full-upload path as a kill-switch. *ON.get_or_init(|| { !matches!( std::env::var("OCS_WIRE_GPU_PATCH").ok().as_deref(), Some("0") | Some("off") | Some("false") | Some("no") ) }) } #[cfg(target_arch = "wasm32")] pub(crate) fn wire_gpu_patch_enabled() -> bool { true } /// Resolve a viewport's paper-to-model scale ratio from its two /// DXF-derived sources. /// /// `view_height` is canonical. `custom_scale` is the fallback when it is /// missing or zero. #[inline] pub fn vp_effective_scale(custom_scale: f64, view_height: f64, vp_height: f64) -> f64 { if view_height.abs() > 1e-9 { return vp_height / view_height; } if custom_scale.abs() > 1e-9 { return custom_scale; } 1.0 } /// A block name a user may assign: non-empty, no control characters, none of /// the symbol-table-reserved characters (`*` marks anonymous blocks, `|` /// xref-dependent ones; the rest are the DXF symbol-name exclusions). pub(crate) fn valid_block_name(name: &str) -> bool { !name.is_empty() && !name.chars().any(|c| { c.is_control() || matches!( c, '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | ',' | '=' | '`' ) }) } /// Pre-built entity caches returned by [`build_derived_caches`]. /// Produced in the file-load background task so the UI thread only assigns. #[derive(Debug, Clone)] pub struct DerivedCaches { pub read_stats: Option, pub source_sha256: Option, pub local_extent_max: f32, pub local_center: [f64; 2], pub hatches: HashMap, pub images: HashMap, pub meshes: HashMap, /// Block-definition solid meshes, block-local frame (instanced per INSERT). (#123) pub block_meshes: HashMap, /// Non-graphical DWG object relationships and Drawing-property rows. /// Prepared during file open so entity selection/deselection never scans /// the complete object store on the UI thread. pub object_data: crate::entities::object_data::ObjectDataCache, /// Number of entities removed by the corrupt-entity guard during load. /// Reported back to the UI so the user knows when a file had parser-junk /// entities silently dropped. pub corrupt_dropped: usize, /// Corrupt entities dropped while resolving referenced drawings. pub xref_dropped: usize, /// XREF resolution results produced by the loader worker. Keeping these in /// the open bundle prevents parsing and merging references on the UI thread. pub xrefs: Vec, /// Model wire set and its spatial interaction index, prepared on the loader /// thread. Installing these prevents the first visible frame from paying a /// whole-drawing tessellation/index build while the progress overlay freezes. pub prepared_geometry: Option, /// Background-thread open-phase timings in milliseconds (parse, purge, /// derived-cache build). Filled in by `open_path_with_phase`; surfaced in /// the open-complete breakdown log so open-time regressions are visible. pub timings: OpenTimings, } /// Wall-clock breakdown of the file-open phases, in milliseconds. #[derive(Debug, Clone, Copy, Default)] pub struct OpenTimings { pub parse_ms: u32, pub purge_ms: u32, pub caches_ms: u32, pub xref_ms: u32, /// The FINALIZING phase: `prepare_open_geometry` — first full Model /// tessellation plus interaction index. Previously unmeasured, which made /// it indistinguishable from parse time in an open that appeared to stall. pub finalize_ms: u32, } /// Build hatch / image / mesh caches from a document without needing `&mut Scene`. /// Intended to run on a background thread during file load. #[cfg(target_arch = "wasm32")] pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches { build_derived_caches_impl(doc, None, None) } /// Build open-time caches while reporting monotonic progress in 0..=10000. /// /// The callback is UI-agnostic and may run from Rayon workers. Callers should /// keep it cheap, normally just updating atomics. #[cfg(not(target_arch = "wasm32"))] pub fn build_derived_caches_with_progress( doc: &CadDocument, progress: &(dyn Fn(u16) + Sync), material_base_dir: Option<&std::path::Path>, ) -> DerivedCaches { build_derived_caches_impl(doc, Some(progress), material_base_dir) } fn build_derived_caches_impl( doc: &CadDocument, progress: Option<&(dyn Fn(u16) + Sync)>, material_base_dir: Option<&std::path::Path>, ) -> DerivedCaches { // A new drawing must not inherit the previous one's resolved images — drop // the memoised set so each reference re-reads / re-fetches once here (and // stays cached across this document's later cache rebuilds). crate::scene::model::image_model::clear_image_cache(); let object_data = crate::entities::object_data::build_cache(doc); // model-space block handle (same logic as Scene::model_space_block_handle) let model_block = doc .objects .values() .find_map(|obj| { if let codec::objects::ObjectType::Layout(l) = obj { if l.name == "Model" && !l.block_record.is_null() { Some(l.block_record) } else { None } } else { None } }) .unwrap_or_else(|| { doc.block_records .get("*Model_Space") .map(|br| br.handle) .unwrap_or(Handle::NULL) }); // world_offset selection // // Header `$EXTMIN`/`$EXTMAX` is the fast path, but it's untrustworthy: // the sentinel (1e20 / -1e20) when the writer never computed extents, // stale values when a drawing was edited and extents weren't refreshed, // and top-level extents that span only an Insert's bounding box rather // than the actual MSPACE geometry. Any of those // leave the precision-preserving offset wrong, so direct MSPACE // entities render at huge magnitudes and f32 wires lose precision. // // Cross-check the header against a per-entity AABB scan of MSPACE // (same `bounding_box()` API and same SANE_EXTENT/zero-placeholder // filters that `cache::block_cache::build_defn` already uses for block defns) // and prefer the entity-scan when the header center drifts more than // 10× its own half-span away from the entity centroid. use crate::par::prelude::*; // Single pass over entities does triple duty: classify cache-kind handle // lists (hatch / image / mesh) AND accumulate per-entity centroids for the // world_offset median. Folding the offset scan in here collapses what were // two O(N) `entities()` walks (offset scan + handle collection) into one. // Heavy tessellation runs in parallel below, reading entities via // `doc.get_entity(h)` (O(1) HashMap lookup); no clones in this pass. let prep = offset_prep(doc, model_block); let mut hatch_handles: Vec = Vec::new(); let mut image_handles: Vec = Vec::new(); let mut mesh_handles: Vec = Vec::new(); let mut centers: Vec<[f64; 3]> = Vec::new(); let entity_total = doc.entity_count().max(1); for (index, e) in doc.entities().enumerate() { let h = e.common().handle; match e { EntityType::Hatch(_) | EntityType::Solid(_) => hatch_handles.push(h), EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) => { image_handles.push(h) } EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) | EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) => mesh_handles.push(h), _ => {} } if let Some(c) = offset_centroid(e, model_block, &prep) { centers.push(c); } if index & 0x1fff == 0 { if let Some(progress) = progress { progress(((index as u64 * 4000) / entity_total as u64) as u16); } } } if let Some(progress) = progress { progress(4000); } let (local_center, local_extent_max) = cluster_extent_from_centers(centers, &doc.header); // Default bg adaptation target at load: the model background (paper // bg is only relevant after the user enters a paper layout, and // `synced_hatch_models` re-runs `render_style` per-frame anyway so // the per-layout adaptation kicks in later regardless). const LOAD_BG: [f32; 4] = [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0]; let detail_total = hatch_handles .len() .saturating_add(image_handles.len()) .saturating_add(mesh_handles.len()) .max(1); let detail_done = std::sync::atomic::AtomicUsize::new(0); let report_detail = |done: usize| { if let Some(progress) = progress { let value = 4000u64 + done as u64 * 6000 / detail_total as u64; progress(value.min(10000) as u16); } }; // hatches let hatches: HashMap = hatch_handles .par_iter() .filter_map(|&handle| { let source = doc.get_entity(handle)?; let contextual = annotative::entity_for_annotation_context( doc, source, annotative::scale_handle_by_name(doc, &doc.header.current_annotation_scale), ); let e = contextual.as_ref(); let (raw, ..) = view::render::render_style_for(doc, e); let color = view::render::adapt_to_bg(raw, LOAD_BG); let model = match e { EntityType::Hatch(dxf) => Scene::hatch_model_from_dxf(dxf, color), EntityType::Solid(solid) => Some(Scene::solid_hatch_model(solid, color)), _ => None, }; let result = model.map(|m| (handle, m)); let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1; if done & 0xff == 0 || done == detail_total { report_detail(done); } result }) .collect(); // images let images: HashMap = image_handles .par_iter() .filter_map(|&handle| { let result = match doc.get_entity(handle)? { EntityType::RasterImage(img) => { ImageModel::from_raster_image(img).map(|m| (handle, m)) } EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)), EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) { Some(codec::objects::ObjectType::UnderlayDefinition(def)) => { // Paper-space underlays adjust to the white sheet. let owner = u.common.owner_handle; let background = if owner.is_null() || owner == model_block { LOAD_BG } else { [1.0, 1.0, 1.0, 1.0] }; ImageModel::from_underlay(u, def, background, None).map(|m| (handle, m)) } _ => None, }, _ => None, }; let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1; if done & 0xff == 0 || done == detail_total { report_detail(done); } result }) .collect(); // FACETRES biases one shared chordal tolerance for all solids. // Top-level (layout-owned) solids are offset into the render frame; block // definition solids keep block-local coords for per-INSERT instancing. (#123) let facet_res = doc.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&doc.header, facet_res); let isolines = crate::entities::solid3d::clamp_header_isolines(doc.header.isolines); // Real layout blocks come from the Layout objects' block_record handles — // `BlockRecord::is_layout()` is unreliable here (it flags ordinary blocks). let layout_blocks: std::collections::HashSet = doc .objects .values() .filter_map(|o| match o { codec::objects::ObjectType::Layout(l) if !l.block_record.is_null() => { Some(l.block_record) } _ => None, }) .collect(); let built: Vec<(Handle, MeshLodSet, bool)> = mesh_handles .par_iter() .filter_map(|&handle| { let e = doc.get_entity(handle)?; let (raw, ..) = view::render::render_style_for(doc, e); let color = view::render::adapt_to_bg(raw, LOAD_BG); let material = crate::scene::model::material_model::resolve_material_with_base( doc, e, color, None, material_base_dir, ); let top_level = layout_blocks.contains(&e.common().owner_handle); let result = crate::entities::solid3d::tessellate_volume( e, color, facet_res, chordal_deflection, isolines, ) .map(|mut mesh| { material.apply_to_with_face_overrides(&mut mesh, doc, material_base_dir); crate::scene::model::visual_style_model::apply_mesh_visual_style(&mut mesh, doc, e); let mesh = if top_level { offset_mesh_lod_set(mesh) } else { mesh }; (handle, mesh, top_level) }); let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1; if done & 0xff == 0 || done == detail_total { report_detail(done); } result }) .collect(); let mut meshes: HashMap = HashMap::default(); let mut block_meshes: HashMap = HashMap::default(); for (handle, mut m, top_level) in built { if top_level { meshes.insert(handle, m); } else { m.prepare_instance_source(handle); block_meshes.insert(handle, m); } } if let Some(progress) = progress { progress(10000); } DerivedCaches { read_stats: None, source_sha256: None, local_extent_max, local_center, hatches, images, meshes, block_meshes, object_data, corrupt_dropped: 0, xref_dropped: 0, xrefs: Vec::new(), prepared_geometry: None, timings: OpenTimings::default(), } } /// Prepare the expensive first Model wire set and spatial interaction index on /// the loader thread. The temporary `Scene` never crosses threads (it contains /// `Rc`/`RefCell` state); only its Send-safe document and immutable prepared /// geometry are returned. #[cfg(not(target_arch = "wasm32"))] pub fn prepare_open_geometry( doc: CadDocument, caches: &DerivedCaches, model_bg: [f32; 4], ) -> (CadDocument, PreparedOpenGeometry) { let mut scene = Scene::new(); scene.document = doc; scene.local_extent_max = caches.local_extent_max; scene.local_center = caches.local_center; scene.bg_color = model_bg; scene.sync_annotation_scale_from_header(); scene.current_layout = "Model".to_string(); let camera = scene.camera.borrow().clone(); let perf = crate::perf::enabled(); let t_wires = perf.then(iced::time::Instant::now); let wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1.0); let wires_ms = crate::perf::elapsed_ms(t_wires); let t_index = perf.then(iced::time::Instant::now); let interaction_index = if scene.interaction_index_worthwhile(&wires) { let index = Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(&wires)); let build_ms = crate::perf::elapsed_ms(t_index); let t_screen = perf.then(iced::time::Instant::now); index.prepare_screen(); if perf { crate::perf_record!( "[perf] open-index build={:.1}ms prepare_screen={:.1}ms", build_ms, crate::perf::elapsed_ms(t_screen), ); } Some(index) } else { None }; if perf { crate::perf_record!( "[perf] open-finalize wires={:.1}ms index={:.1}ms wire_models={}", wires_ms, crate::perf::elapsed_ms(t_index), wires.len(), ); } // Taken, not cloned: the scene is discarded on the next line. let tess_memo = std::mem::take(&mut *scene.resident_tess_memo.borrow_mut()); let tess_memo_backing = std::mem::take(&mut *scene.resident_memo_backing.borrow_mut()); let tess_guard = scene.resident_tess_guard.get(); let draw_depths = scene.draw_depth_cache.borrow_mut().take(); // The layout the loader already built for exactly these wires. Taken by // identity so it cannot be paired with a different assembly. let resident_layout = scene .resident_wire_sets .borrow_mut() .drain() .find_map(|(_, set)| { Arc::ptr_eq(&set.wires, &wires) .then_some(set.layout) .flatten() }); let block_defns: Vec<(u64, Arc)> = std::mem::take(&mut *scene.block_defn_cache.borrow_mut()) .into_iter() .map(|(key, (_epoch, cache))| (key, cache)) .collect(); let doc = std::mem::replace(&mut scene.document, CadDocument::new()); ( doc, PreparedOpenGeometry { wires, interaction_index, tess_memo, tess_memo_backing, tess_guard, block_defns, draw_depths, resident_layout, }, ) } /// Mirrors `cache::block_cache::SANE_EXTENT` — wire coords past this magnitude /// are treated as corruption rather than precision-relevant geometry. const CLUSTER_SANE_EXTENT: f64 = 1.0e8; /// MSPACE-membership prep shared by the world-offset centroid scan. /// /// The filter here MUST agree with `belongs_to_visible_block` (the /// render-time filter): if rendering treats an entity as MSPACE but we skip /// it here, our offset misses on-screen geometry and direct WCS-coordinate /// wires drag f32 precision to its knees. Conversely, including block-defn /// entities the render path drops would pull the centroid toward block-local /// origins. struct OffsetPrep { /// `Some` when the model BlockRecord enumerates its entities; the offset /// scan uses this set directly. `None` falls back to the legacy /// permissive owner-based interpretation. mspace_set: Option>, any_enumerated: bool, owned_by_other_block: rustc_hash::FxHashSet, } fn offset_prep(doc: &codec::CadDocument, model_block: Handle) -> OffsetPrep { let model_br = doc.block_records.iter().find(|br| br.handle == model_block); let mspace_set: Option> = model_br .filter(|br| !br.entity_handles.is_empty()) .map(|br| br.entity_handles.iter().copied().collect()); let any_enumerated = doc .block_records .iter() .any(|br| !br.entity_handles.is_empty()); let owned_by_other_block: rustc_hash::FxHashSet = if mspace_set.is_none() { doc.block_records .iter() .filter(|br| br.handle != model_block) .flat_map(|br| br.entity_handles.iter().copied()) .collect() } else { rustc_hash::FxHashSet::default() }; OffsetPrep { mspace_set, any_enumerated, owned_by_other_block, } } /// Per-entity centroid for the world-offset scan, or `None` if the entity is /// not MSPACE geometry / has no usable bbox. Single-outlier-robust because /// the caller takes the median of these per-entity centroids rather than a /// global min/max midpoint. fn offset_centroid(e: &EntityType, model_block: Handle, prep: &OffsetPrep) -> Option<[f64; 3]> { let c = e.common(); let h = c.handle; let include = if let Some(ref set) = prep.mspace_set { set.contains(&h) } else if c.owner_handle == model_block { true } else if !c.owner_handle.is_null() { false } else if prep.owned_by_other_block.contains(&h) { false } else { // owner null + h not enumerated by any block: legacy permissive // when no block enumerated at all, strict drop otherwise (same // as belongs_to_visible_block). !prep.any_enumerated }; if !include { return None; } // Skip block-defn sentinels and AttributeDefinition — same as // cache::block_cache::build_defn. Their bboxes don't represent drawable // MSPACE geometry. if matches!( e, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::AttributeDefinition(_) ) { return None; } let (bmin, bmax) = match e { EntityType::Insert(ins) => (ins.insert_point, ins.insert_point), _ => { let bb = e.as_entity().bounding_box(); (bb.min, bb.max) } }; // Empty-entity placeholder (Polyline/Hatch/Spline/Mesh with no // vertices). Including these would pull the centroid toward origin // and destroy precision on UTM-authored content. if bmin.x == 0.0 && bmin.y == 0.0 && bmin.z == 0.0 && bmax.x == 0.0 && bmax.y == 0.0 && bmax.z == 0.0 { return None; } let cx = (bmin.x + bmax.x) * 0.5; let cy = (bmin.y + bmax.y) * 0.5; let cz = (bmin.z + bmax.z) * 0.5; if !cx.is_finite() || !cy.is_finite() || !cz.is_finite() { return None; } if cx.abs() > CLUSTER_SANE_EXTENT || cy.abs() > CLUSTER_SANE_EXTENT { return None; } Some([cx, cy, cz]) } /// Pick the model-space precision-preserving offset and the `fit_all` /// outlier-rejection limit from the collected per-entity `centers`. /// /// Prefers the entity-centroid median; cross-checks against header /// `$EXTMIN/$EXTMAX` only as a fallback when the entity scan found nothing. /// `centers` is gathered by the caller's single entity walk (see /// [`build_derived_caches`]) so no separate AABB pass is needed. /// Returns `(center, half_span)` of the dense entity cluster. The center is the /// median of entity centroids — robust against a second, far cluster (e.g. a /// small-coordinate legend beside a UTM survey), unlike the raw extents centre /// which would land in the empty gap between them. fn cluster_extent_from_centers( centers: Vec<[f64; 3]>, header: &codec::document::HeaderVariables, ) -> ([f64; 2], f32) { const SANE_EXTENT: f64 = CLUSTER_SANE_EXTENT; let entity_ok = !centers.is_empty(); // 95th-percentile distance from the median × 2 gives the half-span of the // dense cluster while leaving room for legitimate outliers (sparse leaders, // dimensions, scattered annotations). let median = |v: &mut Vec| -> f64 { v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); v[v.len() / 2] }; let percentile = |v: &mut Vec, frac: f64| -> f64 { v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let i = ((v.len() as f64 - 1.0) * frac).round() as usize; v[i] }; let (ecenter, espan_max) = if entity_ok { let mut xs: Vec = centers.iter().map(|c| c[0]).collect(); let mut ys: Vec = centers.iter().map(|c| c[1]).collect(); let mx = median(&mut xs); let my = median(&mut ys); let mut dx: Vec = centers.iter().map(|c| (c[0] - mx).abs()).collect(); let mut dy: Vec = centers.iter().map(|c| (c[1] - my).abs()).collect(); let p95 = percentile(&mut dx, 0.95).max(percentile(&mut dy, 0.95)); ([mx, my], (p95 * 2.0).max(1.0) as f32) } else { ([0.0, 0.0], 0.0) }; // ── Header extents (fallback only) ─────────────────────────────────── let hmin = header.model_space_extents_min; let hmax = header.model_space_extents_max; let header_ok = hmin.x < hmax.x && hmin.y < hmax.y && hmin.x.abs() < SANE_EXTENT && hmax.x.abs() < SANE_EXTENT && hmin.y.abs() < SANE_EXTENT && hmax.y.abs() < SANE_EXTENT; // Geometry reaches the GPU as absolute coordinates (the double-single // relative-to-eye path keeps it precise at UTM scale), so only the // cluster span — for camera fit and cull — is derived from the content. if entity_ok { (ecenter, espan_max) } else if header_ok { let hw = ((hmax.x - hmin.x) * 0.5) as f32; let hh = ((hmax.y - hmin.y) * 0.5) as f32; let hz = ((hmax.z - hmin.z) * 0.5).max(1.0) as f32; let hcenter = [(hmin.x + hmax.x) * 0.5, (hmin.y + hmax.y) * 0.5]; (hcenter, hw.max(hh).max(hz) * 10.0) } else { ([0.0, 0.0], 1e9_f32) } } /// One viewport to render this frame — a camera, the screen rectangle it /// occupies, and the render mode it draws with. The unified renderer /// produces a `Vec` for both layouts: a Model layout is /// one full-canvas instance (or several tiled ones), a paper layout is one /// instance per floating content viewport. The pipeline draws each in its /// own scissor pass, so a single shader widget covers every case. #[derive(Clone)] pub struct ViewportInstance { /// Source viewport entity handle, or `Handle::NULL` for the implicit /// full-canvas Model view that has no backing entity yet. pub handle: Handle, /// Source Model-space tile index, or `None` for paper-layout viewports /// (they're identified by `handle` instead). Used as the cache key for /// `Scene::model_tile_wires_arc` so each pane reuses its own entry on /// camera moves instead of accumulating one per camera hash. pub tile_idx: Option, /// Screen rectangle (pixels, canvas-relative) this viewport fills. pub screen_rect: iced::Rectangle, pub camera: Camera, pub render_mode: codec::entities::ViewportRenderMode, /// `true` when this is the viewport receiving cursor input. pub active: bool, /// `true` when this view's grid is switched on — drives `grid_views`, so the /// grid overlay enumerates the exact same sub-views (tile or floating /// viewport) the renderer does, instead of a parallel copy. pub grid_on: bool, /// `true` for the full-canvas paper "sheet" viewport — the layout's own /// view (paper-space entities, top-locked), the paper equivalent of the /// Model view. Floating content viewports overlay it. pub paper_sheet: bool, } /// One pane of the Model-space tiled viewport layout: the normalized screen /// rectangle it fills and the camera it last had. The active tile uses the /// live `Scene::camera` (so orbit/pan/zoom drive it); inactive tiles keep a /// snapshot here, swapped in when they become active. #[derive(Clone)] pub(crate) struct ModelTile { pub(crate) rect: iced::Rectangle, pub(crate) camera: Camera, /// Visual style for this tile alone — each pane carries its own so /// changing one tile's render mode never touches the others. pub(crate) render_mode: codec::entities::ViewportRenderMode, /// Grid display + grid-snap for this viewport alone, round-tripped through /// its VPort entry. The app mirrors the *active* tile's pair into the live /// grid/snap toggles. (#121) pub(crate) grid_on: bool, pub(crate) snap_on: bool, } #[derive(Clone)] enum ViewSnapshot { Main { layout: String, tile: usize, camera: Camera, }, Floating { layout: String, handle: Handle, viewport: Box, }, } /// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible /// divider width. The renderer derives tile rects through this same spacing so /// the drawn viewports line up exactly with the pane_grid layout. pub const TILE_DIVIDER_PX: f32 = 2.0; /// Minimum used until the viewport render-mode bar reports its natural width. pub const MODEL_PANE_MIN_FALLBACK_PX: f32 = 50.0; /// Shift every vertex of a freshly tessellated `MeshLodSet` into the /// scene's local f32 space by subtracting `world_offset`. ACIS / SAT /// tessellation hands us WCS coordinates; the wire / hatch / face3d /// paths run in `(WCS - world_offset)` so meshes at large UTM-scale /// origins would otherwise float far away from the rest of the /// geometry. Also recomputes `world_aabb` so per-frame LOD / cull math /// uses the same space. fn offset_mesh_lod_set(mut set: MeshLodSet) -> MeshLodSet { let mut min_x = f32::INFINITY; let mut min_y = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; let mut max_y = f32::NEG_INFINITY; for lod in &mut set.lods { // Reconstruct the f64 absolute position from the double-single pair, // subtract world_offset in f64, then re-split into (high, low) so the // relative-to-eye shader keeps sub-unit precision at UTM scale. let has_low = lod.verts_low.len() == lod.verts.len(); if !has_low { lod.verts_low = vec![[0.0; 3]; lod.verts.len()]; } for (v, vl) in lod.verts.iter_mut().zip(lod.verts_low.iter_mut()) { let ax = v[0] as f64 + vl[0] as f64; let ay = v[1] as f64 + vl[1] as f64; let az = v[2] as f64 + vl[2] as f64; let hx = ax as f32; let hy = ay as f32; let hz = az as f32; *v = [hx, hy, hz]; *vl = [ (ax - hx as f64) as f32, (ay - hy as f64) as f32, (az - hz as f64) as f32, ]; if hx < min_x { min_x = hx; } if hy < min_y { min_y = hy; } if hx > max_x { max_x = hx; } if hy > max_y { max_y = hy; } } } // Re-split the feature edges the same way so they track the mesh at scale. { let n = set.edge_verts.len(); if set.edge_verts_low.len() != n { set.edge_verts_low = vec![[0.0; 3]; n]; } for (v, vl) in set.edge_verts.iter_mut().zip(set.edge_verts_low.iter_mut()) { let ax = v[0] as f64 + vl[0] as f64; let ay = v[1] as f64 + vl[1] as f64; let az = v[2] as f64 + vl[2] as f64; let (hx, hy, hz) = (ax as f32, ay as f32, az as f32); *v = [hx, hy, hz]; *vl = [ (ax - hx as f64) as f32, (ay - hy as f64) as f32, (az - hz as f64) as f32, ]; } } if min_x.is_finite() { set.world_aabb = [min_x, min_y, max_x, max_y]; } set.recompute_aabb(); set } /// Build a lightweight placement over immutable block-local geometry. fn transform_block_mesh_lod_set(set: &MeshLodSet, xform: &codec::types::Transform) -> MeshLodSet { use codec::types::Vector3; let source = set.instance_source.clone().unwrap_or_else(|| { std::sync::Arc::new(model::mesh_model::MeshInstanceSource { handle: set.entity_handle().unwrap_or(Handle::new(0)), lods: set.lods.clone(), edge_verts: set.edge_verts.clone(), edge_verts_low: set.edge_verts_low.clone(), curved_gens: set.curved_gens.clone(), }) }); let mut bounds = [ f64::INFINITY, f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY, ]; for x in [set.world_aabb[0], set.world_aabb[2]] { for y in [set.world_aabb[1], set.world_aabb[3]] { for z in [set.z_aabb[0], set.z_aabb[1]] { let point = xform.apply(Vector3::new(x as f64, y as f64, z as f64)); for (axis, value) in [point.x, point.y, point.z].into_iter().enumerate() { bounds[axis] = bounds[axis].min(value); bounds[axis + 3] = bounds[axis + 3].max(value); } } } } MeshLodSet { lods: Vec::new(), material: set.material.clone(), face_materials: set.face_materials.clone(), visual_style: set.visual_style.clone(), complete: set.complete, edge_verts: Vec::new(), edge_verts_low: Vec::new(), curved_gens: Vec::new(), metrics: set.metrics, world_aabb: [ bounds[0] as f32, bounds[1] as f32, bounds[3] as f32, bounds[4] as f32, ], z_aabb: [bounds[2] as f32, bounds[5] as f32], instance_source: Some(source), instance_transform: Some(*xform), instance_handle: None, instance_color: set.display_color(), instance_aabb: bounds .iter() .all(|value| value.is_finite()) .then_some(bounds), } } #[derive(Clone, Copy, Debug)] pub(crate) enum NavPerfOp { Pan, Zoom, Rotate, /// A message that changed geometry before the next rendered frame. Edit, } impl NavPerfOp { pub(in crate::scene) fn label(self) -> &'static str { match self { Self::Pan => "pan", Self::Zoom => "zoom", Self::Rotate => "rotate", Self::Edit => "edit", } } } #[derive(Clone, Copy, Debug)] pub(in crate::scene) struct NavPerfSample { pub(in crate::scene) op: NavPerfOp, /// Message label that caused the sample. pub(in crate::scene) cause: &'static str, pub(in crate::scene) space: &'static str, pub(in crate::scene) mode: &'static str, pub(in crate::scene) started: iced::time::Instant, pub(in crate::scene) input_ms: f64, pub(in crate::scene) build_ms: f64, } #[derive(Clone)] struct BlockMeshInherit { insert_color: [f32; 4], layer0_color: [f32; 4], insert_material: crate::scene::model::material_model::MeshMaterial, layer0_material: crate::scene::model::material_model::MeshMaterial, } #[derive(Clone)] struct SceneLight { handle: Handle, /// Present when `color` must follow the current ByLayer color. color_layer: Option, light_type: f32, position: [f64; 3], direction: [f32; 3], color: [f32; 3], intensity: f32, hotspot_cos: f32, falloff_cos: f32, attenuation_type: f32, attenuation_start: f32, attenuation_end: f32, cast_shadows: bool, shadow_softness: f32, shadow_map_size: u32, web_profile: [f32; 8], web_rotation: [f32; 3], web_enabled: bool, } /// One-shot presentation-cache refresh scope for REDRAW/REDRAWALL. Resolved to /// concrete `ViewportInstance::instance_id`s at request time; `All` is the /// superset and dominates. `Active` = current-input viewport (paper sheet in /// paper space with nothing entered, D6). #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ViewportRefreshScope { Active, All, } /// Entity membership in document order, keyed by geometry epoch. type BlockMembers = (u64, HashMap>); /// What count mode colours: see [`Scene::count_display`]. #[derive(Debug, Clone, PartialEq, Default)] pub struct CountDisplay { pub counted: HashSet, pub errors: HashSet, pub color: [f32; 4], pub error_color: [f32; 4], } pub struct Scene { pub camera: Rc>, /// View saved immediately before the latest navigation operation. ZOOM /// Previous swaps with this snapshot, allowing the user to toggle between /// the two most recent views without touching drawing history. previous_view: Option, /// Model-space tiled viewport layout. One full-window tile by default; /// the split buttons / VPORTS subdivide the active tile. pub(crate) model_tiles: RefCell>, /// Index of the active model tile (camera input + overlays target it). pub(crate) active_model_tile: std::cell::Cell, /// Cache of the gathered SDF text vertex list for one viewport, keyed on /// the wire-buffer content id. The glyph quads ride on each entity's wire /// (built by the tessellator); this caches the per-viewport gather so it is /// not re-walked every frame while the wire set is unchanged. Keyed on the /// wire content id so it re-gathers exactly when the wires (geometry or /// selection) change. See [`Scene::gather_text_verts`]. /// Small map (not a single slot): a paper frame renders several wire /// sources (sheet + content viewports), each with its own stable content /// id — one slot would thrash between them every frame. sdf_text_cache: RefCell>>>, /// Extra scale representations for highlighted annotative entities. These /// stay outside the resident wire set so rollover and selection never /// invalidate normal geometry, picking, or snapping caches. annotation_highlight_cache: RefCell>>>, /// Per wire-source `(geometry_epoch, gathered text)` of the most recent /// SDF-text build. When a new content id misses the cache but the journal /// shows no text-bearing entity changed since that epoch, the same glyphs /// are reused instead of re-walking every wire — an edit on plain geometry /// never rebuilds the text. Keyed by the render source (sheet / tile / /// content viewport / implicit view): a paper frame gathers several wire /// sets in one frame, and a single slot handed the sheet's glyphs to the /// content viewports, hiding every model-space text entity (#403). last_sdf_text: RefCell< HashMap< u64, ( u64, std::sync::Arc>, ), >, >, /// pane_grid layout tree for the Model tab — the source of truth for the /// tile split layout, resize and focus. `model_tiles` (the renderer's /// per-pane data: camera / render-mode / grid) is kept in lock-step with /// it, and its rects are derived from the pane regions. Each pane's value /// is the index of its backing `ModelTile`. Paper layout is unaffected. /// Plain field (not a `RefCell`) so the view can borrow it for the /// `PaneGrid` widget's lifetime; mutated through `&mut Scene` in update. pub(crate) model_panes: iced::widget::pane_grid::State, /// Natural width of the Model viewport control bar. `DensitySwap` updates /// it during layout; every pane-grid geometry calculation reads it on the /// next frame so a pane cannot become narrower than its controls. model_pane_min_px: std::sync::Arc, pub selection: std::sync::Arc>, /// The CAD document — single source of truth for all entities. pub document: CadDocument, /// Last chain-compatible dimension created this session. /// Never reconstructed from loaded file handles. pub last_created_dimension: Option, /// File-open-prepared index for non-graphical semantic object lookups. pub(crate) object_data_cache: crate::entities::object_data::ObjectDataCache, /// Native AcDbLight/Sun inputs, separated by rendered block and viewport /// frozen-layer signature. Invalidated only when a light entity changes; /// ordinary geometry edits never rescan the drawing's lights. lighting_cache: RefCell>>, /// The visibility state each dynamic block shows in the Block Editor, /// by block record. pub visibility_states: HashMap, /// Currently selected entity handles. pub selected: HashSet, /// Polylines only part of which is selected (Ctrl+click): the picked /// segment indices. pub sub_segments: HashMap>, selected_order: Vec, /// The parametric-constraint glyph currently selected in the viewport. /// Entity selection changes clear it so the two selections stay exclusive. pub selected_constraint: Option, /// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF. pub object_isolation: ObjectIsolationState, /// Scene-scoped reference-only designations (API v9): plugin-owned /// display geometry the built-in edit paths refuse. In-memory only, /// keyed by handle, lives and dies with this scene — never written to /// DWG/DXF (see `scene::reference_only`). pub(crate) reference_only: reference_only::ReferenceOnlyDesignation, /// Entity handles temporarily removed from the base render while an /// interactive grip preview draws their live replacement. /// Separate from object isolation so a grip can never activate the /// isolation status or be captured in its undo state. pub preview_hidden: HashSet, /// Source handles replaced by an active command preview. Kept separate /// from grip state so either interaction can clean up only its own hides. command_preview_hidden: HashSet, /// During in-place block edit (REFEDIT), the handles of the entities being /// edited. Everything else is rendered faded toward the background so the /// edited geometry stands out while the surrounding drawing stays visible /// for context. `None` = not editing. (#136) pub refedit_keep: Option>, /// REFEDIT locks the objects outside the working set (`refedit_keep`). pub refedit_lock: bool, /// COUNT mode colours: counted references in COUNTCOLOR, overlapping /// duplicates in COUNTERRORCOLOR, everything else faded. `None` outside /// count mode. pub count_display: Option, /// Entity drawn with the selection-highlight colour without being part /// of the real selection — used to preview a row in the cycling list box. pub hover_highlight: Option, /// All entities related to the constraint indicator currently under the cursor. constraint_hover_highlights: HashSet, /// Sub-entity references related to that indicator. Polyline segments are /// drawn separately so hovering one constraint does not light the whole chain. constraint_hover_refs: Vec, constraint_hover_wires: Vec, /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). pub selection_color: [f32; 4], /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). pub selection_effect: bool, /// Whether entity transparency is honoured on screen. When false the /// wire shader forces every line opaque (a uniform toggle, no retessellate). pub transparency_display: bool, /// User-adjustable model-space lineweight preview multiplier. pub model_lineweight_scale: f32, /// Selection filter: entity-type names excluded from interactive picking. /// Empty = every type is selectable. pub selection_filter: HashSet, /// In-progress preview wires while a command is active (rubber-band + object ghosts). pub preview_wires: Vec, /// Candidate wireframes produced by editable solid-history grips. The /// resident body remains untouched until the grip is committed. solid_history_preview_wires: HashMap>, /// Live hatch fill used by interactive edits such as dragging the pattern /// origin grip. Kept separate from the resident hatch set so moving one /// pattern never re-uploads every hatch in a large drawing. pub preview_hatches: Arc>, /// In-progress preview SDF glyph quads (grip drag / command preview). Rides /// a per-frame GPU buffer separate from the epoch-cached base text so the /// dragged text stays visible while it's hidden from the base set (#316). pub preview_text: Vec, /// Committed-segment wire drawn during multi-point commands (normal colour). pub interim_wire: Option, /// Set when an entity creation or update automatically registers a novel /// layer into the document layer table, signalling the UI to refresh. pub layer_table_dirty: bool, pub camera_generation: u64, /// Incremented whenever geometry-affecting state changes (entities, selection, /// preview wires, layer visibility, layout). The GPU pipeline uses this to /// skip re-uploading unchanged geometry buffers every frame. pub geometry_epoch: u64, /// Geometry epoch represented by the model bounds cached on the live /// camera. Projection and orientation changes refresh those bounds through /// the same filtered fit path when this falls behind `geometry_epoch`. projection_bounds_epoch: std::cell::Cell, /// Separate epoch for the (expensive) block-definition tessellation cache. /// Bumped together with `geometry_epoch` by `bump_geometry`, but NOT by /// `bump_geometry_no_blocks` — so edits that provably can't change any /// block definition (drawing a top-level entity, grip-moving an /// entity/insert) re-tessellate only the visible wires (~baseline cost) /// instead of rebuilding every block defn (the edit-time spike). pub block_epoch: u64, /// Incremented when the selection / hover-highlight set changes WITHOUT a /// geometry change. The wire tessellation is selection-independent, so a /// pick only refreshes the GPU xray overlay (cheap) instead of bumping /// `geometry_epoch` and re-tessellating the whole model. pub selection_generation: u64, /// Cached fingerprint of `selected`, recomputed lazily after mutations. #[cfg(any(test, not(target_arch = "wasm32")))] selection_fingerprint_cache: u64, selection_fingerprint_dirty: bool, /// Cached tessellation of all visible entity wires for the current layout. /// Keyed by `(geometry_epoch, camera_generation)` so a camera change /// invalidates the cull-dependent wire list as well as a geometry change. /// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation. /// The middle `u64` is the set's [`WIRE_CONTENT_GEN`] content id. wire_cache: RefCell>)>>, /// Shared wire + segment broad phase for snapping, hover, click/cycling and /// area selection. The source pointer is part of the key because a camera /// cull can replace the wire Arc without changing `geometry_epoch`. interaction_index_cache: RefCell< Vec<( u64, usize, std::sync::Weak>, Arc, )>, >, /// Latest large source requested for off-thread preparation. A matching /// cache miss returns no candidates temporarily instead of scanning the /// entire scene on the UI thread. interaction_index_pending_key: std::cell::Cell>, /// Large resident interaction index kept as an immutable base across small /// entity edits. Geometry-journal handles form a tombstone/delta overlay; /// the exact index is rebuilt only after the overlay grows too large. interaction_base_index_cache: RefCell< Option<( u64, u64, Arc, )>, >, /// Index of only the live handles changed since the immutable interaction /// base. Reused across pointer events so a large unchanged block is never /// re-indexed merely because one top-level entity was added or removed. interaction_overlay_index_cache: RefCell< Option<( u64, u64, u64, Arc>, Arc, )>, >, /// Auxiliary broad phase for hatch-only objects. interaction_handle_index_cache: RefCell< Option<( u64, u64, Arc, )>, >, /// Index built from every SortEntitiesTable in the document. /// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup. sort_cache: RefCell>)>>, /// Per-entity stable draw-order depth keyed by entity handle. Initial order /// is assigned sparse integer labels; Add/Delete never renormalizes existing /// siblings, so a structural edit updates only the changed entity's GPU /// constants. `[depth, half]` retains a fixed child sub-range for block /// composition. Full sort/layout/block changes rebuild the labels. draw_depth_cache: RefCell>, /// Bumped when a full draw-order rebuild can move existing depths. /// Cached uploads compare it before reusing baked depth values. draw_depth_generation: std::cell::Cell, /// Shared empty map for 3-D wireframe, where true depth wins instead of /// the entity submission order used by the optimized 2-D style. no_draw_depths: Arc>, /// Cached hatch fill models, keyed by target block and geometry_epoch. View culling /// is handled at draw time via `hatch_skip_flags` in the pipeline, /// not at build time — that lets the GPU buffer stay stable across /// pan/zoom while still skipping out-of-view hatches. /// Keyed by `(geometry_epoch, selection_generation)` — selected hatches /// are tinted, so a select/deselect must rebuild even when the geometry /// is unchanged (issue #71). hatch_cache: RefCell>)>>, /// Cached wipeout fill models, keyed by target block and geometry_epoch. Same /// reasoning as `hatch_cache`. wipeout_cache: RefCell>)>>, /// Cached image models, keyed by target block and geometry_epoch. No camera /// key needed here. image_cache: RefCell>)>>, /// Cached mesh models, keyed by target block and geometry_epoch. mesh_cache: RefCell>)>>, /// Picking mesh source for a non-model active space, keyed by geometry epoch /// and interaction block. Model/MSPACE reuse `mesh_cache` directly. interaction_mesh_cache: RefCell>)>>, /// Direct handle → expanded mesh-set indices for the current renderer mesh /// source. Block instances may contribute several sets under one Insert /// handle. The weak source guard prevents stale pointer reuse. #[allow(clippy::type_complexity)] mesh_pick_lookup_cache: RefCell< Option<( usize, std::sync::Weak>, Arc>>, )>, >, /// Per-viewport (VP-frozen-layer) filtered fill / image / mesh sets, keyed by /// the order-independent signature of the viewport's frozen-layer set. A /// content viewport that freezes layers must hide their hatches, 2-D solids, /// images/OLE and 3-D solids too — not just wires — so these hold the /// filtered variants. Viewports sharing a frozen set share one entry (like /// the resident wire set). Empty for a viewport with no frozen layers (it /// reuses the unfiltered `*_arc` sets directly). frozen_hatch_cache: RefCell>)>>, frozen_wipeout_cache: RefCell>)>>, frozen_image_cache: RefCell>)>>, /// Point clouds shown by the model content, keyed by target block and /// frozen-layer signature, per geometry epoch. point_cloud_cache: RefCell< HashMap<(Handle, u64), (u64, Arc)>, >, frozen_mesh_cache: RefCell>)>>, /// Viewports that carry layer color/alpha/linetype/lineweight overrides. /// Cached per geometry epoch so ordinary viewports can share render data. viewport_style_override_cache: RefCell)>>, /// Cached block-instance hatches for hit-testing, keyed by geometry_epoch. /// The set is geometry-derived, so a camera move or hover never invalidates /// the shared graph result. insert_hatch_cache: RefCell>>)>>, /// Sheet "dressing" cache over the unified resident set: the paper sheet /// drops its own border wire and appends the printable-area guide. /// Per-layout `(geometry_epoch, content gen, wires)` — the base is /// camera-independent, so paper pan/zoom and Model↔Paper tab switches keep /// every already-visited sheet warm. paper_sheet_cache: RefCell>)>>, /// Layout viewport handles, collected from the owning block record once per /// geometry epoch. Avoids walking every document entity on every Paper /// pan/zoom frame just to rediscover the same handful of viewports. paper_viewport_cache: RefCell>, /// Stable Paper sheet fill/image sources. Without this cache every camera /// movement scanned the whole document for wipeouts and recreated the same /// Arcs, making Paper frame construction CPU-bound on large drawings. paper_sheet_render_cache: RefCell>, display_plot_style_cache: RefCell>>>, styled_wire_cache: RefCell>)>>, styled_hatch_cache: RefCell>>>, styled_wire_fill_cache: RefCell>>>, /// Per-viewport projected wire cache for paper-space content viewports. /// Stores projected + clipped wires in paper-space coordinates. /// Maps vp_handle → (geometry_epoch, Vec). paper_projected_cache: RefCell)>>, /// Active layout name — "Model" or a paper space layout name. pub current_layout: String, /// When `Some`, the active space is a BEDIT block editor (issue #261): /// rendering, picking and new-entity ownership are scoped to this block /// record's handle instead of the layout's, so only the block's own /// (block-local) entities are drawn and edited. `current_layout` stays /// "Model" so the rest of the Model-vs-paper code keeps model behaviour. pub block_edit_block: Option, /// UCS→world rotation for the ViewCube, kept in sync with the tab's active /// UCS by `DocumentTab::sync_ucs_to_scene`. Identity = WCS. Applied only in /// model space so the cube's faces follow the user's coordinate system. pub viewcube_ucs: glam::Mat4, /// GPU render data for hatch fills, keyed by the DXF entity Handle. pub hatches: HashMap, /// GPU render data for solid meshes (the kernel Shell/Solid tessellation). /// Top-level (layout-owned) solids only, stored in the offset-relative /// render frame and drawn flat. pub meshes: HashMap, /// Directory of the opened drawing, used to resolve relative AcDbMaterial /// bitmap maps. `None` for unsaved drawings and browser uploads. pub material_base_dir: Option, /// Meshes of block-definition solids, kept in *block-local* coordinates /// (no world_offset). They are not drawn directly; each INSERT of the /// owning block emits a transformed instance so a block placed at an /// INSERT scale renders at the right size. (#123) pub block_meshes: HashMap, /// Kernel B-reps used by solid operations and exact-geometry saves. pub solid_models: HashMap, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, /// The viewport that is currently "entered" (MSPACE mode). /// `None` = paper space editing (PSPACE). Only meaningful when /// `current_layout != "Model"`. pub active_viewport: Option, /// Custom model-space background fill color for Wipeout entities. /// Set from the active tab's `bg_color`; defaults to dark grey. pub bg_color: [f32; 4], /// Custom paper-space background fill color for Wipeout entities. pub paper_bg_color: [f32; 4], /// Dense model-space cluster half-span used for viewport recovery. pub local_extent_max: f32, /// Dense model-space cluster median used for viewport recovery. pub local_center: [f64; 2], /// Current annotation scale (CANNOSCALE equivalent). /// Multiplier applied to Text/MText/Dimension sizes during tessellation. /// 1.0 = no scaling. 50.0 = "1:50" drawing scale. pub annotation_scale: f32, /// Cached per-epoch: does annotation scale actually change wire geometry? /// PSLTSCALE is handled by a per-viewport GPU uniform, so only real /// annotative entities require a scale-specific resident set. annotation_affects_wires: std::cell::Cell>, /// Cached model-space bounding box, keyed by geometry_epoch. /// Avoids re-tessellating all entities on every ZOOM E / auto-fit call. model_extents_cache: RefCell)>>, /// Reverse map: entity_handle → block_record_handle, built from entity_handles lists. /// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block. entity_block_map_cache: RefCell)>>, /// Candidate handles per block, in document order and keyed by geometry epoch. block_members_cache: RefCell>, leaders_by_annotation_cache: RefCell>)>>, /// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index. unindexable_cache: RefCell)>>, /// `(epoch, layout block, present type names, list exposed to the UI)`. /// The set lets pure additions update without rebuilding the list. layout_type_names_cache: RefCell< Option<( u64, Handle, std::collections::BTreeSet, std::sync::Arc>, )>, >, /// Bump on any Layout object insert/remove/rename/reorder. layout_epoch: u64, /// Layouts created in this session and not opened yet: the first time /// one opens it gets the LAYOUTCREATEVIEWPORT viewport. pub(crate) unopened_layouts: std::collections::HashSet, /// The plot style table a layout's page setup takes when it is /// initialized on first opening. pub(crate) new_layout_plot_style: String, layout_names_cache: RefCell>)>>, /// Bump on any annotation-scale list mutation. scale_epoch: u64, scale_picker_cache: RefCell< Option<( u64, f64, Option, String, std::sync::Arc>, )>, >, /// Bump on any named-UCS table mutation (UCS SAVE / DELETE / RENAME, /// record-API patch, file-open, undo Full-swap). ucs_epoch: u64, ucs_names_cache: RefCell)>>, /// Reverse dependencies from layer/style/block definitions to the top-level /// entities whose resident wire runs actually change. Kept independent from /// `geometry_epoch`: a layer colour toggle can reuse the index, invalidate /// only its dependants, and avoid a whole-document scan on every toggle. dependency_index_cache: RefCell>, /// Boundary source → associative hatch handles. Source edits reuse this /// index instead of scanning the document during every drag step. associative_hatch_source_cache: RefCell>>>, /// Conservative association hint: unknown until scanned, then updated from /// changed entities. Retaining `true` after deletion only costs an extra scan. has_associative_centers: std::cell::Cell>, /// Conservative hint for whether any Underlay entities exist in the document. pub(crate) has_underlays: std::cell::Cell>, /// Conservative hint for whether any Face3D entities exist in the document. pub(crate) has_face3d: std::cell::Cell>, /// Cached resolved document render environment (fog and background image), keyed by (geometry_epoch, document.objects.len()). document_render_env_cache: RefCell< Option<( u64, usize, crate::scene::view::render::CachedDocumentRenderEnvironment, )>, >, /// Memoized background image resolution to avoid repeated filesystem probes on each render frame. background_image_cache: RefCell>>, /// Runtime parametric constraint sets decoded from standard graph scopes. pub(crate) parametric_constraints: Vec, /// CONSTRAINTNAMEFORMAT: what a dynamic dimension's text shows — /// 0 the parameter name, 1 the value, 2 `name=value`. pub constraint_name_format: u8, /// DYNCONSTRAINTDISPLAY: whether dynamic dimensions draw at all. pub dynamic_constraint_display: bool, /// Dynamic dimensions DCHIDE or DYNCONSTRAINTDISPLAY 0 keep off screen. hidden_dynamic_dimensions: HashSet, /// The camera generation the dynamic dimensions were last scaled for. dynamic_dimension_camera_gen: Option, /// The view scale (world units per pixel, in quarter-octave steps) the /// underlay rasters are made for, and a new step waiting for the view /// to settle. underlay_world_per_pixel: Option, underlay_scale_pending: Option<(i32, iced::time::Instant)>, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, parametric_constraints::ConstraintId, )>, /// Constraint glyphs explicitly shown through the constraint-bar commands. shown_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, parametric_constraints::ConstraintId, )>, /// Bumped on every parametric-constraint-set mutation (add / remove / /// metadata edit / visibility override / whole-set restore). The /// constraint-glyph memoization keys on this so a stale cache can never /// outlive the edit that invalidated it — a missed bump is a /// correctness bug (stale glyphs), not just a perf miss. constraints_epoch: u64, /// Memoised constraint-glyph placements for the latest [`GlyphKey`](parametric_constraints::GlyphKey) /// (Task 3; overlay/hit consumers adopt it in Task 4). Single-slot memo: /// within one frame view + hit-test + dwell + click share the key (hits), /// camera motion misses once per frame by design, and edits just miss on /// the stale entry — no leak possible. `RefCell` + `Arc` shape mirrors /// `layout_names_cache`; a missed constraint bump serves stale glyphs, so /// every set mutation must bump `constraints_epoch`. glyph_cache: RefCell< Option<( parametric_constraints::GlyphKey, Arc<[parametric_constraints::GlyphEntry]>, )>, >, /// Document-wide named-parameter and expression table decoded from /// standard associative variables. pub(crate) named_parameters: named_parameters::ParameterTable, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours /// differently; retaining both variants prevents a full block rebuild on /// every layout-tab switch. block_defn_cache: RefCell)>>, /// Spatial index + always-emit list for top-level entities /// (Phase 2.1). Lazily rebuilt by `entity_index()` on /// `geometry_epoch` change. See `EntityIndex` for what each side /// holds and why both are needed. entity_index_cache: RefCell>, /// Last viewport aspect ratio captured by the render pipeline. Used by /// `view_world_aabb` to compute the world-space view rect on demand. last_render_aspect: std::cell::Cell, /// World units that map to one screen pixel at the current camera + /// viewport size, captured each render. Drives the LOD pixel-size cull /// in expand_insert / tessellate_entity. 0 means "not yet set" — culling /// falls back to None. last_world_per_pixel: std::cell::Cell, /// ViewCube hover region (0..25, face/edge/corner index), driven by the /// `CursorMoved` message that the cube hit-area overlay publishes. Lives /// here so the unified render path can read it for the active viewport /// without depending on the shader widget's internal `Program::State` /// (which can miss events under overlapping overlays). pub viewcube_hover: std::cell::Cell>, /// Pending REDRAW force set, keyed by viewport `instance_id`. Populated by /// `request_refresh` and drained per-id by `refresh_consume` from whichever /// builder owns that instance. Never touches geometry_epoch/block_epoch. pending_refresh: std::cell::RefCell>, /// Wall time (ms) of the most recent wire re-tessellation /// on a wire-cache miss in `model_tile_wires_arc` / `paper_sheet_wires_arc`. /// Stays at the last value while the cache is hit (idle pan/zoom on a warm /// cache reads ~0). Surfaced by the frame-budget HUD (Phase 5.3). pub(crate) last_tess_ms: std::cell::Cell, /// Wire count produced by that most recent re-tessellation. pub(crate) last_tess_wires: std::cell::Cell, /// Content id ([`WIRE_CONTENT_GEN`]) of the Model wire set returned by the /// most recent `model_tile_wires_arc` call — stamped when the static set is /// (re)built, otherwise the held value. `build_primitive` reads it right /// after the call to gate GPU wire re-upload. 0 = none yet. pub(crate) last_model_wire_gen: std::cell::Cell, /// SDF glyph-atlas generation this scene's caches were built against. /// When the atlas grows or re-bakes, every cached glyph quad (resident /// sets, block caches, projected viewport copies) addresses the wrong /// tile; `update` compares against the live generation and rebuilds. pub(crate) last_atlas_generation: std::cell::Cell, /// Interaction-LOD state: `camera_generation` seen on the previous frame and /// the wall time it last changed. Used to detect "the view is actively being /// panned / zoomed / orbited" so the expensive per-pixel hatch pass can be /// suppressed while moving and rendered once on settle (held by the /// scene-render cache). See [`Scene::navigating_lod`]. nav_last_gen: std::cell::Cell, nav_changed_at: std::cell::Cell>, /// Latest pan / zoom / rotate event waiting for the renderer. Present only /// while PERF tracing is enabled; consumed by the active shader primitive. nav_perf_pending: std::cell::Cell>, /// Unified static-hold wire cache — ONE infrastructure for EVERY space /// (Model tiles, BEDIT block editor, the paper sheet block, and paper /// content viewports): the FULL, un-culled, LOD-free tessellation per /// `(block, ambient bg, anno scale, vp-frozen set)` key, held resident and /// reused for every camera. Each entry carries a stable /// [`WIRE_CONTENT_GEN`] id so the GPU upload gate and `render_signature` /// can tell "same content" from "changed" — no per-frame re-upload and no /// camera-keyed re-tessellation anywhere. Rebuilt per key when /// `geometry_epoch` changes; stale entries evicted on insert. #[allow(clippy::type_complexity)] resident_wire_sets: RefCell>, /// Memoized `(face3d, other)` split of a resident wire set, keyed by its /// [`WIRE_CONTENT_GEN`] id. `split_face3d_wires` is an O(N) per-wire /// handle lookup + clone that otherwise re-runs every frame. A map, not a /// slot: a paper frame walks several sources (sheet + viewports). /// /// `None` for the second element means "no Face3D wire in this set — use /// the base set itself". The base `Arc` is deliberately NOT stored here: /// `try_resident_patch` can only move a resident set out for an /// incremental patch while its `Arc` is uniquely held, so pinning it in /// this cache would silently force a full rebuild on every edit (#358). #[allow(clippy::type_complexity)] split_cache: RefCell>, Option>>)>>, /// Cached `selected ∪ hover` handle set for the GPU xray overlay, keyed by /// `selection_generation`. Rebuilt only when the selection changes so /// `build_primitive` doesn't clone the set every frame. /// Per-entity tessellation memo for the culled Model render path (Phase /// 2.2). Maps a top-level handle to its already-tessellated wires so a /// single-entity edit re-tessellates only the changed entity and reuses the /// rest, instead of re-running the whole model. Keyed implicitly by /// `tess_memo_guard` (tol / view / anno / offset / bg); a guard mismatch /// (zoom, layout, …) clears it. `bump_geometry` clears it (structural /// change); `mark_entity_dirty` drops one handle (incremental edit). tess_memo: RefCell>, /// Hash of the tessellation parameters `tess_memo` was built under. When /// the current call's parameters differ, the memo is stale and cleared. tess_memo_guard: std::cell::Cell, /// Camera-independent model tessellation, separate from the culled memo. resident_tess_memo: RefCell>, /// The resident set `resident_tess_memo`'s `InSet` runs read from. resident_memo_backing: RefCell, /// Set while `resident_wires_for` assembles a set that will become the /// memo's backing; new runs then go straight into it (`Pending`). building_resident_set: std::cell::Cell, /// Annotation, background, viewport and glyph-atlas signature. resident_tess_guard: std::cell::Cell, /// Per-mutation delta journal: which handles changed on each `geometry_epoch` /// bump. Bounded ring ([`GEOMETRY_JOURNAL_CAP`]); a derived cache replays the /// entries past its last-synced epoch and patches per-handle instead of /// re-walking the whole document. See [`Scene::replay_since`]. geometry_deltas: RefCell>, /// Before-category hints staged by erase/history immediately before the /// entity disappears, then consumed by the matching geometry delta. pending_removed_categories: RefCell>, /// Highest epoch whose delta has been evicted from the ring. A consumer /// synced older than this can't be caught up incrementally → full rebuild. geometry_journal_floor: std::cell::Cell, /// Count of resident-set rebuilds served by the incremental patch rather than /// a full re-tessellation. Diagnostics + a hook for the oracle test to prove /// the fast path is actually exercised (not silently always falling back). resident_patch_hits: std::cell::Cell, /// Handoff for the GPU wire arena (`OCS_WIRE_GPU_PATCH`): when the MODEL /// resident set was brought up to date by an incremental patch, this records /// `(prev_gen, new_gen, changed handles)` so the render layer can patch just /// those entities' instance slabs instead of re-uploading every wire. The /// render layer matches `new_gen` against the viewport's content id and /// `prev_gen` against what the GPU currently holds; a mismatch just rebuilds. model_wire_gpu_patch: RefCell)>>, /// Active delta-undo recording, or `None` when no entity-only command is /// capturing. Populated by the five mutation primitives via /// [`Scene::record_undo_before`]; consumed by the app (`take_undo_recording`) /// to build a cheap history delta instead of a full document clone. See /// [`UndoRecording`]. undo_recording: Option, } fn section_frame( data: &codec::entities::SectionObjectData, ) -> Option<(glam::DVec3, glam::DVec3, glam::DVec3, glam::DVec3)> { let first = data.vertices.first()?; let last = data.vertices.last()?; let origin = glam::DVec3::new(first.x, first.y, first.z); let tangent = (glam::DVec3::new(last.x, last.y, last.z) - origin).try_normalize()?; let raw_vertical = glam::DVec3::new( data.vertical_direction.x, data.vertical_direction.y, data.vertical_direction.z, ); let vertical = (raw_vertical - tangent * raw_vertical.dot(tangent)).try_normalize()?; let base_view = vertical.cross(tangent).try_normalize()?; let viewing = if data.flags & 4 != 0 { base_view } else { -base_view }; Some((origin, tangent, vertical, viewing)) } #[derive(Clone)] struct LiveSection { data: codec::entities::SectionObjectData, slice_depth: Option, } fn section_plane( origin: glam::DVec3, tangent: glam::DVec3, normal: glam::DVec3, body_to_world: Option<&codec::types::Transform>, ) -> Option { let Some(transform) = body_to_world else { return kernel::space::Plane::orthonormal( origin.to_array(), tangent.to_array(), normal.to_array(), ); }; let matrix = transform.matrix.m; let linear = codec::types::Matrix3::from_rows( [matrix[0][0], matrix[0][1], matrix[0][2]], [matrix[1][0], matrix[1][1], matrix[1][2]], [matrix[2][0], matrix[2][1], matrix[2][2]], ); let inverse = linear.inverse()?; let translation = codec::types::Vector3::new(matrix[0][3], matrix[1][3], matrix[2][3]); let origin = inverse .transform_point(codec::types::Vector3::new(origin.x, origin.y, origin.z) - translation); let tangent = inverse.transform_point(codec::types::Vector3::new(tangent.x, tangent.y, tangent.z)); let normal = linear .transpose() .transform_point(codec::types::Vector3::new(normal.x, normal.y, normal.z)); kernel::space::Plane::orthonormal( [origin.x, origin.y, origin.z], [tangent.x, tangent.y, tangent.z], [normal.x, normal.y, normal.z], ) } fn keep_section_positive( body: &kernel::brep::Body, plane: kernel::space::Plane, ) -> Result, ()> { match kernel::brep::slice_by_plane(body, plane) { Ok(Some(result)) => Ok(Some(result.positive)), Ok(None) => { let bounds = kernel::brep::body_bounds(body).ok_or(())?; let centre = [ (bounds.min[0] + bounds.max[0]) * 0.5, (bounds.min[1] + bounds.max[1]) * 0.5, (bounds.min[2] + bounds.max[2]) * 0.5, ]; Ok((plane.distance_to(centre).ok_or(())? >= 0.0).then(|| body.clone())) } Err(_) => Err(()), } } impl Scene { pub fn new() -> Self { Self { camera: Rc::new(RefCell::new(Camera::default())), previous_view: None, model_tiles: RefCell::new(vec![ModelTile { rect: iced::Rectangle { x: 0.0, y: 0.0, width: 1.0, height: 1.0, }, camera: Camera::default(), render_mode: codec::entities::ViewportRenderMode::Wireframe2D, grid_on: false, snap_on: false, }]), active_model_tile: std::cell::Cell::new(0), sdf_text_cache: RefCell::new(HashMap::default()), annotation_highlight_cache: RefCell::new(HashMap::default()), last_sdf_text: RefCell::new(HashMap::default()), // One pane mapped to tile 0 — matches the single default tile above. model_panes: iced::widget::pane_grid::State::new(0).0, model_pane_min_px: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), selection: std::sync::Arc::new(std::cell::RefCell::new(SelectionState::default())), document: CadDocument::new(), last_created_dimension: None, object_data_cache: crate::entities::object_data::ObjectDataCache::default(), lighting_cache: RefCell::new(HashMap::default()), visibility_states: HashMap::default(), selected: HashSet::default(), sub_segments: HashMap::default(), selected_order: Vec::new(), selected_constraint: None, object_isolation: ObjectIsolationState::default(), reference_only: Default::default(), preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(), refedit_keep: None, refedit_lock: false, count_display: None, hover_highlight: None, constraint_hover_highlights: HashSet::default(), constraint_hover_refs: Vec::new(), constraint_hover_wires: Vec::new(), selection_color: crate::scene::model::wire_model::WireModel::SELECTED, selection_effect: true, transparency_display: true, model_lineweight_scale: 1.0, selection_filter: HashSet::default(), preview_wires: vec![], solid_history_preview_wires: HashMap::default(), preview_hatches: Arc::new(Vec::new()), preview_text: vec![], interim_wire: None, layer_table_dirty: false, camera_generation: 0, geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), projection_bounds_epoch: std::cell::Cell::new(0), block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), selection_generation: 0, #[cfg(any(test, not(target_arch = "wasm32")))] selection_fingerprint_cache: 0, selection_fingerprint_dirty: false, wire_cache: RefCell::new(None), interaction_index_cache: RefCell::new(Vec::new()), interaction_index_pending_key: std::cell::Cell::new(None), interaction_base_index_cache: RefCell::new(None), interaction_overlay_index_cache: RefCell::new(None), interaction_handle_index_cache: RefCell::new(None), sort_cache: RefCell::new(None), draw_depth_cache: RefCell::new(None), draw_depth_generation: std::cell::Cell::new(0), no_draw_depths: Arc::new(HashMap::default()), hatch_cache: RefCell::new(HashMap::default()), wipeout_cache: RefCell::new(HashMap::default()), image_cache: RefCell::new(HashMap::default()), mesh_cache: RefCell::new(HashMap::default()), interaction_mesh_cache: RefCell::new(None), mesh_pick_lookup_cache: RefCell::new(None), frozen_hatch_cache: RefCell::new(HashMap::default()), frozen_wipeout_cache: RefCell::new(HashMap::default()), frozen_image_cache: RefCell::new(HashMap::default()), point_cloud_cache: RefCell::new(HashMap::default()), frozen_mesh_cache: RefCell::new(HashMap::default()), viewport_style_override_cache: RefCell::new(None), insert_hatch_cache: RefCell::new(None), paper_sheet_cache: RefCell::new(HashMap::default()), paper_viewport_cache: RefCell::new(HashMap::default()), paper_sheet_render_cache: RefCell::new(HashMap::default()), display_plot_style_cache: RefCell::new(HashMap::default()), styled_wire_cache: RefCell::new(HashMap::default()), styled_hatch_cache: RefCell::new(HashMap::default()), styled_wire_fill_cache: RefCell::new(HashMap::default()), paper_projected_cache: RefCell::new(HashMap::default()), current_layout: "Model".to_string(), block_edit_block: None, viewcube_ucs: glam::Mat4::IDENTITY, hatches: HashMap::default(), meshes: HashMap::default(), material_base_dir: None, block_meshes: HashMap::default(), solid_models: HashMap::default(), images: HashMap::default(), active_viewport: None, bg_color: [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0], paper_bg_color: [1.0, 1.0, 1.0, 1.0], local_extent_max: 1e9, local_center: [0.0, 0.0], annotation_scale: 1.0, annotation_affects_wires: std::cell::Cell::new(None), model_extents_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None), block_members_cache: RefCell::new(None), leaders_by_annotation_cache: RefCell::new(None), unindexable_cache: RefCell::new(None), layout_type_names_cache: RefCell::new(None), layout_epoch: 0, unopened_layouts: Default::default(), new_layout_plot_style: String::new(), layout_names_cache: RefCell::new(None), scale_epoch: 0, scale_picker_cache: RefCell::new(None), ucs_epoch: 0, ucs_names_cache: RefCell::new(None), dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), parametric_constraints: Vec::new(), constraint_name_format: 2, dynamic_constraint_display: true, hidden_dynamic_dimensions: HashSet::default(), dynamic_dimension_camera_gen: None, underlay_world_per_pixel: None, underlay_scale_pending: None, hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), constraints_epoch: 0, glyph_cache: RefCell::new(None), named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), has_underlays: std::cell::Cell::new(None), has_face3d: std::cell::Cell::new(None), document_render_env_cache: RefCell::new(None), background_image_cache: RefCell::new(HashMap::default()), block_defn_cache: RefCell::new(HashMap::default()), entity_index_cache: RefCell::new(None), last_render_aspect: std::cell::Cell::new(16.0 / 9.0), last_world_per_pixel: std::cell::Cell::new(0.0), viewcube_hover: std::cell::Cell::new(None), pending_refresh: std::cell::RefCell::new(rustc_hash::FxHashSet::default()), last_tess_ms: std::cell::Cell::new(0.0), last_tess_wires: std::cell::Cell::new(0), last_model_wire_gen: std::cell::Cell::new(0), last_atlas_generation: std::cell::Cell::new(crate::scene::text::sdf_atlas::generation()), nav_last_gen: std::cell::Cell::new(0), nav_changed_at: std::cell::Cell::new(None), nav_perf_pending: std::cell::Cell::new(None), resident_wire_sets: RefCell::new(HashMap::default()), split_cache: RefCell::new(HashMap::default()), tess_memo: RefCell::new(HashMap::default()), tess_memo_guard: std::cell::Cell::new(0), resident_tess_memo: RefCell::new(HashMap::default()), resident_memo_backing: RefCell::new(MemoBacking::default()), building_resident_set: std::cell::Cell::new(false), resident_tess_guard: std::cell::Cell::new(0), geometry_deltas: RefCell::new(std::collections::VecDeque::new()), pending_removed_categories: RefCell::new(HashMap::default()), geometry_journal_floor: std::cell::Cell::new(0), resident_patch_hits: std::cell::Cell::new(0), model_wire_gpu_patch: RefCell::new(None), undo_recording: None, } } /// Called by the render pipeline once per frame so camera fit operations /// know the active widget's aspect ratio. pub fn set_render_aspect(&self, aspect: f32) { if aspect.is_finite() && aspect > 0.0 { self.last_render_aspect.set(aspect); } } /// World units per screen pixel at the current viewport size. Returns /// `None` until the first render captures real bounds. /// /// Also returns `None` in paper space: `last_world_per_pixel` tracks the /// model camera, so a cached value applied to mm-sheet entity AABBs would /// be a stale model-world wpp and cull every paper-space annotation. /// Paper-space callers use their own scale instead. /// Rasterise the underlays again when the view's scale has moved to /// another quarter-octave step and stayed there briefly (not on every /// frame of a zoom). pub fn refresh_underlay_resolution(&mut self, now: iced::time::Instant) { let Some(wpp) = self.world_per_pixel() else { return; }; let step = (f64::from(wpp).log2() * 4.0).ceil() as i32; let current = self .underlay_world_per_pixel .map(|w| (w.log2() * 4.0).round() as i32); if current == Some(step) { self.underlay_scale_pending = None; return; } match self.underlay_scale_pending { Some((pending, since)) if pending == step => { if now.duration_since(since) < std::time::Duration::from_millis(150) { return; } } _ => { self.underlay_scale_pending = Some((step, now)); return; } } self.underlay_scale_pending = None; self.underlay_world_per_pixel = Some(2f64.powf(step as f64 / 4.0)); if self .document .entities() .any(|e| matches!(e, EntityType::Underlay(_))) { self.reseed_underlays(); } } /// The view has moved to another scale step than the underlay rasters /// were made for (frames are requested until they are made again). pub fn underlay_resolution_stale(&self) -> bool { if !self.has_underlays() { return false; } let Some(wpp) = self.world_per_pixel() else { return false; }; let step = (f64::from(wpp).log2() * 4.0).ceil() as i32; self.underlay_world_per_pixel .map(|w| (w.log2() * 4.0).round() as i32) != Some(step) } pub(super) fn world_per_pixel(&self) -> Option { if self.current_layout != "Model" { return None; } let v = self.last_world_per_pixel.get(); if v > 0.0 && v.is_finite() { Some(v) } else { None } } /// Called from the render path with the current widget bounds so the /// LOD pixel-size culler knows how big one world unit projects to. pub fn set_render_pixel_scale(&self, width_px: f32, height_px: f32) { if !width_px.is_finite() || !height_px.is_finite() || height_px <= 0.0 { return; } let cam = self.camera.borrow(); // Orthographic only. (Perspective varies with depth — we'd want a // depth-aware scale per entity. Skipped for now.) let h = cam.ortho_size(); let world_per_px = (2.0 * h) / height_px; if world_per_px.is_finite() && world_per_px > 0.0 { self.last_world_per_pixel.set(world_per_px); } } /// Get (or build on miss) the block-definition cache for the current epoch. /// Built single-threaded — recursive nested expansion makes parallelization /// fiddly and the cache only rebuilds when geometry actually changes. pub(super) fn block_cache_arc_for( &self, annotation_scale_handle: Option, all_visible: bool, viewport: Option, ) -> Arc { let bg = if self.current_layout == "Model" { self.bg_color } else { self.paper_bg_color }; let mut key = annotation_scale_handle .map(|handle| handle.value()) .unwrap_or(0); for component in bg { key = key.rotate_left(13) ^ component.to_bits() as u64; } key = key.rotate_left(13) ^ all_visible as u64; key = key.rotate_left(13) ^ viewport.map(|handle| handle.value()).unwrap_or(0); { let cache = self.block_defn_cache.borrow(); if let Some((epoch, arc)) = cache.get(&key) { if *epoch == self.block_epoch { return Arc::clone(arc); } } } // Block definitions are cached at block-local size (annotation scale // 1.0). An annotative block scales as ONE unit at the INSERT level, so // its internal geometry / text / attributes must NOT be scaled // individually because that would apply the same scale twice. let built = cache::block_cache::BlockCache::build( &self.document, 1.0, annotation_scale_handle, all_visible, bg, viewport, &self.draw_depth_map(), ); let arc = Arc::new(built); let mut cache = self.block_defn_cache.borrow_mut(); cache.retain(|_, (epoch, _)| *epoch == self.block_epoch); cache.insert(key, (self.block_epoch, Arc::clone(&arc))); arc } /// Install loader-thread geometry into this scene's Model resident cache. /// /// The target scene has a different epoch from the temporary loader scene, /// so only immutable geometry is transferred and re-stamped here. The /// optional interaction index is cached against the exact same `Arc`. pub fn install_prepared_open_geometry(&self, prepared: PreparedOpenGeometry) { // The loader tessellated before the constraints were read, so its // wires still hold the dynamic dimensions that must stay hidden. if !self.hidden_dynamic_dimensions.is_empty() { return; } let block = self.model_space_block_handle(); let scale = crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, ); let key = self.resident_wire_key( block, self.bg_color, None, scale, self.annotation_all_visible(), None, None, ); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); self.resident_wire_sets.borrow_mut().insert( key, ResidentWireSet { epoch: self.geometry_epoch, gen, wires: Arc::clone(&prepared.wires), layout: prepared.resident_layout, }, ); // Adopt the loader thread's tessellation memo so the first edit patches // the entities it touched instead of rebuilding every one of them. if !prepared.tess_memo.is_empty() { *self.resident_tess_memo.borrow_mut() = prepared.tess_memo; *self.resident_memo_backing.borrow_mut() = prepared.tess_memo_backing; self.resident_tess_guard.set(prepared.tess_guard); } // Reuse the loader's draw-order map with this scene's epoch. if let Some(mut depths) = prepared.draw_depths { depths.epoch = self.geometry_epoch; *self.draw_depth_cache.borrow_mut() = Some(depths); } // Same for the block definitions, stamped with this scene's block // epoch: the document has not been touched between the loader // finishing and this call, so they describe it exactly. if !prepared.block_defns.is_empty() { let epoch = self.block_epoch; let mut cache = self.block_defn_cache.borrow_mut(); for (key, defns) in prepared.block_defns { cache.insert(key, (epoch, defns)); } } if let Some(index) = prepared.interaction_index { self.cache_interaction_index(self.geometry_epoch, prepared.wires, index); } } /// Push one delta onto the journal, evicting the oldest past the cap and /// raising the floor so a consumer that fell behind falls back to a full /// rebuild. Every `geometry_epoch` bump must call this exactly once so the /// journal never has a gap (a gap would let a consumer serve stale geometry). fn push_geometry_delta(&self, epoch: u64, changes: Vec<(Handle, ChangeKind)>, full: bool) { let pending_categories = std::mem::take(&mut *self.pending_removed_categories.borrow_mut()); let removed_categories = if full { HashMap::default() } else { let mut removed = HashMap::default(); for &(handle, kind) in &changes { if let Some(&bits) = pending_categories.get(&handle) { if matches!(kind, ChangeKind::Removed) { removed.insert(handle, bits); } } } removed }; let mut ring = self.geometry_deltas.borrow_mut(); ring.push_back(GeometryDelta { epoch, changes, removed_categories, full, }); while ring.len() > GEOMETRY_JOURNAL_CAP { if let Some(ev) = ring.pop_front() { self.geometry_journal_floor.set(ev.epoch); } } } /// Coalesce the exact set of handles that changed between a consumer's /// last-synced epoch and now, or `None` if the range can't be replayed /// (a spanned `full` delta, or the consumer fell off the end of the ring) — /// in which case the consumer must rebuild from scratch. Added+Removed of the /// same handle cancel; otherwise the last change wins. pub(crate) fn replay_since(&self, last_epoch: u64) -> Option> { // Anything at or below the floor was evicted — can't catch up. if last_epoch < self.geometry_journal_floor.get() { return None; } let ring = self.geometry_deltas.borrow(); let mut state: HashMap = HashMap::default(); for d in ring.iter() { if d.epoch <= last_epoch { continue; } if d.full { return None; } for &(h, k) in &d.changes { use ChangeKind::*; match (state.get(&h).copied(), k) { (None, k) => { state.insert(h, k); } // Added then removed within the window ⇒ never existed. (Some(Added), Removed) => { state.remove(&h); } // Added stays Added even after later coordinate edits. (Some(Added), _) => {} // Removed then re-added ⇒ treat as a modify (exists, re-tess). (Some(Removed), Added) => { state.insert(h, Modified); } // Removal dominates a prior modify. (Some(Modified), Removed) => { state.insert(h, Removed); } (Some(Removed), _) => {} (Some(Modified), _) => {} } } } Some(state.into_iter().collect()) } /// Whether this entity contributes glyphs to the SDF text buffer, so a /// change to it must invalidate the cached text vertices. Every entity whose /// `to_render` can emit a `GlyphRun` belongs here — miss one and its glyphs /// linger after an erase and fail to appear after an add. fn entity_affects_text_cache(&self, entity: &EntityType) -> bool { if matches!( entity, EntityType::Text(_) | EntityType::MText(_) | EntityType::Dimension(_) | EntityType::MultiLeader(_) | EntityType::Leader(_) | EntityType::Table(_) | EntityType::Tolerance(_) | EntityType::AttributeEntity(_) | EntityType::AttributeDefinition(_) | EntityType::Insert(_) ) { return true; } // A raster reference it cannot resolve draws the saved path as a glyph // run in place of the picture (`RasterImage::to_render`), so its // broken-reference placeholder lives in the text buffer; a resolvable // one contributes only its frame. Same memoised probe the converter // uses, so the two cannot disagree — and keeping it a probe rather than // a blanket type match keeps a working image's grip-drag from // rebuilding every glyph in the drawing on each mouse move. if let EntityType::RasterImage(image) = entity { let path = image.file_path.trim(); if !path.is_empty() && crate::scene::model::image_model::resolve_image(path).is_none() { return true; } } // Complex-linetype glyphs ride the host entity's wire. let lt = crate::scene::view::render::linetype_name_for(&self.document, entity); crate::io::linetypes::resolve_complex_lt(&self.document, <).is_some() } /// Stage the cache categories an entity belongs to before erase removes the /// only cheap way to classify it. The next Removed geometry delta consumes /// this entry; even a zero mask is meaningful ("known plain geometry"). fn remember_removed_cache_categories(&self, handle: Handle) { let Some(entity) = self.document.get_entity(handle) else { return; }; let mut bits = 0u16; if crate::scene::annotative::is_annotative(&self.document, entity) { bits |= CACHE_CATEGORY_ANNOTATIVE; } if self.hatches.contains_key(&handle) { bits |= CACHE_CATEGORY_HATCH | CACHE_CATEGORY_INTERACTION; } if matches!(entity, EntityType::Wipeout(_)) { bits |= CACHE_CATEGORY_WIPEOUT; } if self.images.contains_key(&handle) { bits |= CACHE_CATEGORY_IMAGE; } let is_insert = matches!(entity, EntityType::Insert(_)); let is_section = matches!( entity, EntityType::Extended(extended) if matches!( extended.data, codec::entities::ExtendedEntityData::SectionObject(_) ) ); if self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) || is_insert || is_section { bits |= CACHE_CATEGORY_MESH | CACHE_CATEGORY_INTERACTION; } if is_insert { bits |= CACHE_CATEGORY_INSERT_HATCH; } if self.entity_affects_text_cache(entity) { bits |= CACHE_CATEGORY_TEXT; } self.pending_removed_categories .borrow_mut() .insert(handle, bits); } /// True when a per-category derived cache synced at `cached_epoch` remains /// valid. Adds/edits are classified from live state; removals use the tiny /// pre-erase category mask stored in the journal. Thus deleting a LINE no /// longer rebuilds every hatch/image/mesh/text cache merely because its type /// became unknowable after removal. fn category_cache_valid( &self, cached_epoch: u64, category: u16, in_category: impl Fn(Handle) -> bool, ) -> bool { if cached_epoch == self.geometry_epoch { return true; } if cached_epoch < self.geometry_journal_floor.get() { return false; } let ring = self.geometry_deltas.borrow(); for delta in ring.iter().filter(|delta| delta.epoch > cached_epoch) { if delta.full { return false; } for &(handle, kind) in &delta.changes { match kind { ChangeKind::Removed => { let Some(bits) = delta.removed_categories.get(&handle) else { return false; }; if bits & category != 0 { return false; } } ChangeKind::Added | ChangeKind::Modified if in_category(handle) => { return false; } ChangeKind::Added | ChangeKind::Modified => {} } } } true } /// Whether the per-entity draw-order labels can be replayed since /// `last_epoch`. Add/Remove keep every existing label stable; only a full /// structural delta (DRAWORDER, file/layout/block rebuild, journal overflow) /// requires consumers to regenerate their depth-bearing data. pub(super) fn draw_ranks_stable(&self, last_epoch: u64) -> bool { self.replay_since(last_epoch).is_some() } /// True when no text-bearing entity changed since `last_epoch`, so cached SDF /// glyphs stay valid. Text comes from Text / MText / Dimension / MultiLeader / /// Leader / Table / Tolerance / attributes (incl. ATTDEF), from block /// references (their baked text moves with the instance), and from a raster /// image whose reference cannot be resolved (it draws the saved path in place /// of the picture) — an edit or removal of any of those invalidates it; a /// plain line / arc / polyline edit does not. See /// [`Scene::entity_affects_text_cache`], which classifies both directions: /// live entities for an add/edit, the pre-erase category mask for a removal. fn text_unchanged(&self, last_epoch: u64) -> bool { self.category_cache_valid(last_epoch, CACHE_CATEGORY_TEXT, |handle| { self.document .get_entity(handle) .is_some_and(|entity| self.entity_affects_text_cache(entity)) }) } /// Report the exact handles a mutation changed. Folds the memo drop and the /// epoch bump so a call site can't bump geometry without recording what /// changed — the delta lets every derived cache patch per-handle instead of /// re-walking the whole drawing. Blocks are untouched (`block_epoch` stays), /// so this is for top-level entity add / edit / erase, not block-defn edits. /// Begin capturing before-images for a delta-undo entry. Pair with /// [`Scene::take_undo_recording`]. Only entity-only commands — whose /// mutations flow through the five primitives (add / update / erase / /// transform / copy) and that touch no layers / objects / block records — /// may use this; anything else must fall back to a full history snapshot. pub fn begin_undo_recording(&mut self) { self.undo_recording = Some(UndoRecording::default()); } /// Whether a delta-undo recording is currently open. pub fn is_recording_undo(&self) -> bool { self.undo_recording.is_some() } /// Finish and return the open recording (if any) for the app to build a /// history delta from. pub fn take_undo_recording(&mut self) -> Option { self.undo_recording.take() } /// Record the pre-mutation image of `handle` (first touch wins). `before` /// is `None` for a freshly added entity. No-op when not recording — the /// callers guard with [`Scene::is_recording_undo`] so the clone is skipped /// entirely on the common (no-recording) path. #[doc(hidden)] pub fn record_undo_before(&mut self, handle: Handle, before: Option>) { if let Some(rec) = self.undo_recording.as_mut() { if !rec.before.contains_key(&handle) { rec.order.push(handle); rec.before.insert(handle, before); } } } /// Record one document.objects entry before its first mutation. This keeps /// Group erase and RasterImage add on targeted history rather than forcing /// a clone of all document structure. pub(crate) fn record_undo_object_before(&mut self, handle: Handle, before: Option) { if let Some(rec) = self.undo_recording.as_mut() { if !rec.object_before.contains_key(&handle) { rec.object_order.push(handle); rec.object_before.insert(handle, before); } } } /// Record one parametric-constraint scope's whole-set image before its first /// mutation within the open recording (first touch wins) — e.g. ERASE /// about to remove some of a scope's constraints via /// `refresh_parametric_constraints`'s deletion policy. `parametric_constraints` /// lives on `Scene`, outside `document`/`document.objects`, so neither /// `record_undo_before` nor `record_undo_object_before` ever sees this /// change on their own. pub(crate) fn record_undo_parametric_constraints_before( &mut self, scope: parametric_constraints::ParametricScope, before: parametric_constraints::ParametricConstraintSet, ) { if let Some(rec) = self.undo_recording.as_mut() { if !rec.parametric_constraints_before.contains_key(&scope) { rec.parametric_constraints_order.push(scope); rec.parametric_constraints_before.insert(scope, before); } } } /// Records the drawing-wide parameter table before its first mutation in /// the current undo transaction. pub(crate) fn record_undo_named_parameters_before(&mut self) { let before = self.named_parameters.clone(); if let Some(recording) = self.undo_recording.as_mut() { recording.named_parameters_before.get_or_insert(before); } } /// Record a reference-only designation's value before its first change /// within the open delta recording (API v9). The designation set lives on /// `Scene`, outside the document, so it needs its own channel — like /// the parametric-constraint directory above — for undo to restore the /// designation together with the entity it belongs to. First touch wins. pub(crate) fn record_undo_reference_designation_before(&mut self, handle: Handle) { if let Some(recording) = self.undo_recording.as_mut() { if !recording .reference_designations_before .iter() .any(|(recorded, _)| *recorded == handle) { let before = self.reference_only.is_marked(handle); recording .reference_designations_before .push((handle, before)); } } } /// Flag the open recording as touching non-entity state (a new layer, a /// group edit, a `*D` block record) that a pure-entity delta cannot /// restore. The app's per-command predicate keeps this from firing. pub(crate) fn poison_undo_recording(&mut self) { if let Some(rec) = self.undo_recording.as_mut() { rec.poisoned = true; } } /// Re-apply one side of an entity delta to the document. For each /// `(handle, before, after)`, install the chosen `target` — `before` when /// `undo`, `after` on redo: overwrite the entity in place, re-insert it with /// its original handle, or remove it. Returns the exact per-handle change /// list; derived-cache reseeding and the geometry bump are deferred so a /// multi-step undo/redo can process each final handle only once. pub(crate) fn apply_entity_delta( &mut self, entities: &[(Handle, Option>, Option>)], undo: bool, ) -> Vec<(Handle, ChangeKind)> { if entities.iter().any(|(_, before, after)| { before .as_deref() .is_some_and(|entity| matches!(entity, EntityType::Light(_))) || after .as_deref() .is_some_and(|entity| matches!(entity, EntityType::Light(_))) }) { self.lighting_cache.borrow_mut().clear(); } let mut changes: Vec<(Handle, ChangeKind)> = Vec::with_capacity(entities.len()); for (h, before, after) in entities { let target = if undo { before } else { after }; let existed = self.document.get_entity(*h).is_some(); match target { Some(ent) => { if existed { let _ = self.document.replace_entity_arc(*h, Arc::clone(ent)); changes.push((*h, ChangeKind::Modified)); } else { // Removal keeps the original block membership in place; // restoring only the flat storage avoids an O(all block // members) scan and cannot duplicate the owner link. let _ = self.document.restore_entity_arc(Arc::clone(ent)); changes.push((*h, ChangeKind::Added)); } } None => { if existed { self.remember_removed_cache_categories(*h); self.document.remove_entity_arc(*h); changes.push((*h, ChangeKind::Removed)); } } } } // History replay can apply an entity removal the designation channels // predate (an "added" side rolled back). Sweep so the set never keeps // an entry for an absent entity; exact designations come right after // this from the delta's own channel. if !self.reference_only.is_empty() { self.reference_only.retain_present(&self.document); } if !changes.is_empty() { self.invalidate_dependency_index(); } changes } /// Check changed entities and scan the document only when the hint is unknown. fn associative_centers_possible(&self, changes: &[(Handle, ChangeKind)]) -> bool { if changes.iter().any(|(handle, _)| { self.document.get_entity(*handle).is_some_and(|entity| { crate::scene::centerline::entity_has_center_association(entity) || crate::scene::centermark::entity_has_center_association(entity) }) }) { self.has_associative_centers.set(Some(true)); return true; } if let Some(known) = self.has_associative_centers.get() { return known; } // Undetermined: settle it with one pass over the drawing, then never // again for this document. let any = self.document.entities().any(|entity| { crate::scene::centerline::entity_has_center_association(entity) || crate::scene::centermark::entity_has_center_association(entity) }); self.has_associative_centers.set(Some(any)); any } /// Fast check for whether any Underlay entities exist in the document. pub(crate) fn has_underlays(&self) -> bool { if let Some(known) = self.has_underlays.get() { return known; } let any = self .document .entities() .any(|e| matches!(e, EntityType::Underlay(_))); self.has_underlays.set(Some(any)); any } /// Fast check for whether any Face3D entities exist in the document. pub(crate) fn has_face3d(&self) -> bool { if let Some(known) = self.has_face3d.get() { return known; } let any = self .document .entities() .any(|e| matches!(e, EntityType::Face3D(_))); self.has_face3d.set(Some(any)); any } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { self.bump_entities_with_parametric_policy(changes, &[], false); } /// Update the fields an evaluation event re-evaluates (see /// [`crate::entities::field::update_fields`]) and redraw the hosts whose /// text changed. Returns the number of hosts whose text changed and the /// number of fields the hosts hold. pub fn update_fields(&mut self, event: i32, hosts: Option<&[Handle]>) -> (usize, usize) { let (changed, found) = crate::entities::field::update_fields(&mut self.document, event, hosts); if !changed.is_empty() { self.bump_text_hosts(&changed); } (changed.len(), found) } /// Redraw text hosts whose stored text changed (re-evaluated fields). An /// attribute is drawn by its INSERT, and a host inside a block definition /// by every reference to that block, so those redraw instead of the host; /// nothing else re-tessellates. pub fn bump_text_hosts(&mut self, hosts: &[Handle]) { if hosts.is_empty() { return; } if self.dependency_index_cache.borrow().is_none() { *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); } // One pass over the drawing for all hosts: which block definition // holds an entity, which INSERT draws an attribute, which tables // draw from a block. let layout_blocks: HashSet = self .document .objects .values() .filter_map(|object| match object { ObjectType::Layout(layout) if !layout.block_record.is_null() => { Some(layout.block_record) } _ => None, }) .collect(); let mut definitions: HashMap = HashMap::default(); let mut members: HashMap = HashMap::default(); for record in self.document.block_records.iter() { let name = record.name.to_ascii_uppercase(); if layout_blocks.contains(&record.handle) || name.starts_with("*MODEL_SPACE") || name.starts_with("*PAPER_SPACE") { continue; } definitions.insert(record.handle, (record.handle, normalize_name(&record.name))); for member in &record.entity_handles { members.insert(*member, record.handle); } } let mut attribute_owner: HashMap = HashMap::default(); let mut tables_of: HashMap> = HashMap::default(); for entity in self.document.entities() { match entity { EntityType::Insert(insert) => { for attribute in &insert.attributes { attribute_owner.insert(attribute.common.handle, insert.common.handle); } } EntityType::Table(table) => { if let Some(record) = table.block_record_handle { tables_of .entry(record) .or_default() .push(table.common.handle); } } _ => {} } } let mut targets = DependencyTargets::default(); for &host in hosts { // An attribute is drawn by its INSERT. let handle = match self.document.get_entity(host) { Some(_) => host, None => match attribute_owner.get(&host) { Some(insert) => *insert, None => continue, }, }; let owner = self .document .get_entity(handle) .map(|e| e.common().owner_handle); let record = owner.and_then(|owner| definitions.get(&owner)).or_else(|| { members .get(&handle) .and_then(|record| definitions.get(record)) }); let Some((record_handle, name)) = record else { targets.render_handles.insert(handle); continue; }; // A host inside a block definition is drawn by every reference // to that block, and a table by its own block of cell texts. if let Some(index) = self.dependency_index_cache.borrow().as_ref() { if let Some(users) = index.block_roots.get(name) { targets.render_handles.extend(users.iter().copied()); } } targets .render_handles .extend(tables_of.get(record_handle).into_iter().flatten().copied()); targets.touches_block_definition = true; } self.invalidate_dependency_targets(targets); } pub fn bump_entities_with_parametric_driven( &mut self, changes: &[(Handle, ChangeKind)], driven_refs: &[parametric_constraints::ParametricRef], ) { self.bump_entities_with_parametric_policy(changes, driven_refs, false); } pub fn bump_entities_with_parametric_policy( &mut self, changes: &[(Handle, ChangeKind)], driven_refs: &[parametric_constraints::ParametricRef], retain_size: bool, ) { self.bump_entities_with_parametric_originals(changes, driven_refs, retain_size, &[]); } pub fn bump_entities_with_parametric_originals( &mut self, changes: &[(Handle, ChangeKind)], driven_refs: &[parametric_constraints::ParametricRef], retain_size: bool, retained_originals: &[(Handle, EntityType)], ) { self.bump_entities_with_solve_policy( changes, driven_refs, retain_size, retained_originals, &[], true, false, ); } /// Refresh display and associations after a solved grip or exact history restore. pub(crate) fn bump_entities_after_parametric_solve( &mut self, changes: &[(Handle, ChangeKind)], ) { self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false, false); } /// Refresh display after a restyle that moved no geometry: nothing /// associated to these entities is re-resolved or re-solved. pub(crate) fn bump_entities_restyled(&mut self, changes: &[(Handle, ChangeKind)]) { self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false, true); } /// Apply a newly-created ordered relation while temporarily anchoring /// its reference side. The anchors guide only this first solve and are /// never added to the persistent constraint graph. pub fn bump_entities_with_initial_parametric_policy( &mut self, changes: &[(Handle, ChangeKind)], fixed_refs: &[parametric_constraints::ParametricRef], retain_size: bool, ) { self.bump_entities_with_solve_policy( changes, &[], retain_size, &[], fixed_refs, true, false, ); } pub fn bump_entities_with_parametric_transform_policy( &mut self, changes: &[(Handle, ChangeKind)], driven_refs: &[parametric_constraints::ParametricRef], transformed_refs: &[parametric_constraints::ParametricRef], retained_originals: &[(Handle, EntityType)], ) { self.bump_entities_with_solve_policy( changes, driven_refs, true, retained_originals, transformed_refs, true, false, ); } fn bump_entities_with_solve_policy( &mut self, changes: &[(Handle, ChangeKind)], driven_refs: &[parametric_constraints::ParametricRef], retain_size: bool, retained_originals: &[(Handle, EntityType)], fixed_refs: &[parametric_constraints::ParametricRef], solve_parametric: bool, appearance_only: bool, ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) || self .document .get_entity(*handle) .is_some_and(|entity| matches!(entity, EntityType::Hatch(_))) }) { self.associative_hatch_source_cache.borrow_mut().take(); } let mut changes = changes.to_vec(); // A table whose content this app changed (its picture reference // dropped by the edit, or a new table without one) has its drawing // (the *T block) redrawn from the cells. Tables keeping their picture // (moved, restyled, opened from a file) are never redrawn: the block // may hold authored content the redraw does not reproduce. // Its cell formulas are worked out again into that block. if !appearance_only { let mut rebuilt = false; for (handle, kind) in &changes { if !matches!(kind, ChangeKind::Removed) && matches!( self.document.get_entity(*handle), Some(EntityType::Table(t)) if t.block_record_handle.is_none() ) { rebuilt |= self.document.refresh_table_block(*handle); rebuilt |= !crate::entities::field::refresh_table_formulas( &mut self.document, *handle, ) .is_empty(); } } // The block's cached drawing is stale once its texts change. if rebuilt { self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); } } // A restyle (layer on/off, colour, text or dimension style) moves no // geometry, so nothing measured from it is re-resolved: an // association that disagrees with its dimension would otherwise // re-place the dimension on a layer toggle. if !appearance_only { if self.associative_centers_possible(&changes) { for change in self.refresh_associative_centerlines(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } for change in self.refresh_associative_center_marks(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } } for change in self.refresh_associative_dimensions(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } for change in self.refresh_associative_hatches(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } for change in self.refresh_associative_sweeps(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } for change in self.refresh_associative_surfaces(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } if solve_parametric && !self.parametric_constraints.is_empty() { for change in self.refresh_parametric_constraints_with_initial_policy( &changes, driven_refs, retain_size, retained_originals, fixed_refs, ) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } } } if !self.parametric_constraints.is_empty() || !self.named_parameters.is_empty() { self.refresh_dynamic_dimension_texts(); self.sync_native_parametric_graph(); } // A modified image-bearing entity redraws its raster: an underlay's // display adjustments and clip live on the entity, not the image. for (handle, kind) in &changes { if !matches!(kind, ChangeKind::Modified) { continue; } let seed = match self.document.get_entity(*handle) { Some( entity @ (EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_)), ) => Some(self.image_seed_for(entity)), _ => None, }; if let Some(seed) = seed { self.images.remove(handle); if let Some(model) = seed { self.images.insert(*handle, model); } } } if !changes.is_empty() { self.refresh_dependency_index_for_changes(&changes); } let cached_light_changed = self.lighting_cache.borrow().values().any(|lights| { changes .iter() .any(|(handle, _)| lights.iter().any(|light| light.handle == *handle)) }); let live_light_changed = changes.iter().any(|(handle, _)| { self.document .get_entity(*handle) .is_some_and(|entity| matches!(entity, EntityType::Light(_))) }); if cached_light_changed || live_light_changed { for &(handle, _) in &changes { let exists = self .document .get_entity(handle) .is_some_and(|entity| matches!(entity, EntityType::Light(_))); crate::entities::object_data::update_light_entity( &mut self.object_data_cache, handle, exists, ); } self.lighting_cache.borrow_mut().clear(); } if let Some(true) = self.has_face3d.get() { if changes .iter() .any(|(_, k)| matches!(k, ChangeKind::Removed)) { self.has_face3d.set(None); } } else if changes.iter().any(|(h, _)| { self.document .get_entity(*h) .is_some_and(|e| matches!(e, EntityType::Face3D(_))) }) { self.has_face3d.set(Some(true)); } let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; self.invalidate_projection_bounds(); { let mut tm = self.tess_memo.borrow_mut(); let mut rm = self.resident_tess_memo.borrow_mut(); for &(h, k) in &changes { // A changed or removed entity must re-tessellate; a fresh Add has // no memo entry yet, so there is nothing to drop. if matches!(k, ChangeKind::Modified | ChangeKind::Removed) { tm.remove(&h); rm.remove(&h); } } } self.push_geometry_delta(epoch, changes, false); } /// True when any changed handle belongs to an ordinary block definition /// rather than model/paper space. History replay needs this distinction: /// restoring a block-local entity must invalidate the assembled definition /// and every INSERT that consumes it, not only that entity's direct wire. /// /// Removed entities may no longer have a live slot, so fall back to the /// BlockRecord membership list, which deliberately retains their handles /// for symmetric undo/redo restoration. pub(crate) fn changes_touch_block_definition(&self, changes: &[(Handle, ChangeKind)]) -> bool { let layout_blocks: HashSet = self .document .objects .values() .filter_map(|object| match object { ObjectType::Layout(layout) if !layout.block_record.is_null() => { Some(layout.block_record) } _ => None, }) .collect(); let is_definition = |record: &codec::tables::BlockRecord| { let name = record.name.to_ascii_uppercase(); !layout_blocks.contains(&record.handle) && !name.starts_with("*MODEL_SPACE") && !name.starts_with("*PAPER_SPACE") }; changes.iter().any(|(handle, _)| { let owner = self .document .get_entity(*handle) .map(|entity| entity.common().owner_handle); self.document.block_records.iter().any(|record| { is_definition(record) && (owner == Some(record.handle) || record.block_entity_handle == *handle || record.block_end_handle == *handle || record.entity_handles.contains(handle)) }) }) } pub fn bump_geometry(&mut self) { let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; self.invalidate_projection_bounds(); // A full structural change may move lights between blocks or alter // layer visibility without naming the affected handles. self.lighting_cache.borrow_mut().clear(); *self.document_render_env_cache.borrow_mut() = None; self.background_image_cache.borrow_mut().clear(); self.has_underlays.set(None); self.has_face3d.set(None); // Default: also invalidate block definitions. Safe for every caller; // operations that know blocks are untouched use `bump_geometry_no_blocks`. self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); // A change this coarse can carry a new document; re-determine whether // it holds associative centerlines or center marks. self.has_associative_centers.set(None); // Structural change — drop both per-entity tessellation memos. self.tess_memo.borrow_mut().clear(); self.resident_tess_memo.borrow_mut().clear(); *self.resident_memo_backing.borrow_mut() = MemoBacking::default(); self.invalidate_dependency_index(); // Can't name the changed handles → force every journal consumer to rebuild. self.push_geometry_delta(epoch, Vec::new(), true); } /// Stable per-viewport identity (mirrors the tag used by the renderer so /// request resolution and builder consumption can never disagree — see /// render.rs `instance_id` encoding). pub fn instance_id_for(&self, inst: &ViewportInstance) -> u64 { if let Some(t) = inst.tile_idx { 0x1000_0000_0000_0000 | (t as u64) } else if inst.paper_sheet { 0x2000_0000_0000_0000 } else { 0x3000_0000_0000_0000 | inst.handle.value() } } /// REDRAW core: resolve a scope to the concrete `instance_id`s it targets /// and add them to the pending force set. `All` unions over `Active` so the /// superset always wins (never shrinks a prior `All`). No DB / undo touch. pub fn request_refresh(&self, scope: ViewportRefreshScope) { let mut set = self.pending_refresh.borrow_mut(); if let ViewportRefreshScope::All = scope { set.clear(); // All: every current tile in Model, else sheet + every paper content vp. if self.current_layout == "Model" { let tiles = self.model_tiles.borrow(); for (t, _) in tiles.iter().enumerate() { set.insert(0x1000_0000_0000_0000 | (t as u64)); } } else { set.insert(0x2000_0000_0000_0000); // sheet let (_sheet, _sheet_h, content) = self.paper_viewport_handles(); for h in content.iter() { set.insert(0x3000_0000_0000_0000 | h.value()); } } } else if self.current_layout != "Model" { // Active in paper: entered content vp, else the sheet (D6(a)). match self.active_viewport { Some(h) => { set.insert(0x3000_0000_0000_0000 | h.value()); } None => { set.insert(0x2000_0000_0000_0000); } } } else { // Active in Model: the active tile (always resolvable). let active = self.active_model_tile.get(); set.insert(0x1000_0000_0000_0000 | (active as u64)); } } /// True while any force is still pending (never cleared falsely). #[cfg(test)] pub fn refresh_pending_any(&self) -> bool { !self.pending_refresh.borrow().is_empty() } /// Builder hook: if `instance_id` has a pending force, mark it forced and /// remove it (one-shot per id). Returns whether to set force_rasterize. pub(crate) fn refresh_consume(&self, instance_id: u64) -> bool { self.pending_refresh.borrow_mut().remove(&instance_id) } /// Drop a single entity from the tessellation memo so the next render /// re-tessellates just that entity while reusing every other. Pair with /// [`bump_geometry_no_blocks`] for an incremental single-entity edit. pub fn mark_entity_dirty(&mut self, handle: Handle) { self.tess_memo.borrow_mut().remove(&handle); self.resident_tess_memo.borrow_mut().remove(&handle); } /// Invalidate the visible-wire tessellation but KEEP the cached block /// definitions. Use only when the edit provably can't change any block /// defn (top-level entity create/edit, grip-moving an entity or insert) — /// it skips the all-blocks re-tessellation that otherwise spikes edit time. /// /// Prefer [`bump_entities`] when the changed handles are known: this variant /// can't name them, so it pushes a `full` journal delta and every derived /// cache falls back to a whole-drawing rebuild. Kept for migration and for /// callers whose changed set is genuinely unknowable. pub fn bump_geometry_no_blocks(&mut self) { let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; self.invalidate_projection_bounds(); self.lighting_cache.borrow_mut().clear(); *self.document_render_env_cache.borrow_mut() = None; self.background_image_cache.borrow_mut().clear(); self.has_face3d.set(None); self.invalidate_dependency_index(); self.push_geometry_delta(epoch, Vec::new(), true); } /// Invalidate the cached document render environment (fog parameters and image). pub fn invalidate_render_environment_cache(&self) { *self.document_render_env_cache.borrow_mut() = None; self.background_image_cache.borrow_mut().clear(); } /// Mark the selection / hover-highlight set dirty without invalidating the /// (selection-independent) wire tessellation. Only the GPU xray overlay is /// rebuilt — no re-tessellation. Use this for pure select / deselect / /// hover changes; use [`bump_geometry`] when the geometry itself changed. pub fn bump_selection(&mut self) { self.selection_generation = self.selection_generation.wrapping_add(1); } pub(crate) fn bump_selection_set(&mut self) { self.selection_fingerprint_dirty = true; self.bump_selection(); } /// Milliseconds after the last camera change during which the view counts as /// "actively navigating" for interaction-LOD purposes. const NAV_SETTLE_MS: u128 = 130; /// Interaction LOD: true while the view is actively being panned / zoomed / /// orbited. Detected purely from `camera_generation` (bumped by every camera /// move) plus a short settle timer, so no navigation call site needs to opt /// in. Called on the render path; it stamps the change time as a side effect. /// /// While this is true the per-pixel hatch pass is skipped (it dominates the /// GPU frame — a full-screen procedural pattern/boundary test). When it flips /// back to false on settle, the frame renders hatches once and the /// scene-render cache holds that image, so a still view is full quality. pub fn navigating_lod(&self) -> bool { let gen = self.camera_generation; if gen != self.nav_last_gen.get() { self.nav_last_gen.set(gen); self.nav_changed_at.set(Some(iced::time::Instant::now())); } self.nav_changed_at .get() .map_or(false, |t| t.elapsed().as_millis() < Self::NAV_SETTLE_MS) } pub(crate) fn record_nav_perf(&self, op: NavPerfOp, started: iced::time::Instant) { self.record_nav_perf_caused(op, "-", started); } pub(crate) fn record_nav_perf_caused( &self, op: NavPerfOp, cause: &'static str, started: iced::time::Instant, ) { if !crate::perf::enabled() { return; } let (space, mode) = if self.current_layout == "Model" { ("Model", "MODEL") } else if self.active_viewport.is_some() { ("Paper", "MSPACE") } else { ("Paper", "PSPACE") }; self.nav_perf_pending.set(Some(NavPerfSample { op, cause, space, mode, started, input_ms: started.elapsed().as_secs_f64() * 1000.0, build_ms: 0.0, })); } pub(in crate::scene) fn take_nav_perf(&self) -> Option { self.nav_perf_pending.take() } /// Whether the interaction-LOD hatch suppression is enabled (env /// `OCS_HATCH_LOD`), read once. Default OFF: the tessellated hatch pass is /// cheap enough that suppression — and its zoom flicker (#258) — is not /// needed. Kept behind the flag as a safety net for pathological drawings. pub fn hatch_lod_enabled(&self) -> bool { use std::sync::OnceLock; static ON: OnceLock = OnceLock::new(); *ON.get_or_init(|| std::env::var_os("OCS_HATCH_LOD").is_some()) } /// True for a short window around navigation — used by the subscription to /// keep requesting frames just past the settle point so the one full-quality /// (hatched) frame actually renders after the cursor stops, even when no /// input event would otherwise trigger a redraw. Read-only (no side effect). pub fn is_settling(&self) -> bool { self.nav_changed_at.get().map_or(false, |t| { t.elapsed().as_millis() < Self::NAV_SETTLE_MS + 130 }) } /// Prepare display geometry without changing the entity or resident caches. pub(crate) fn prepare_solid_model_display( &self, handle: Handle, solid: &kernel::brep::Body, ) -> Option<(MeshLodSet, Vec, [f64; 3])> { let color = self .document .get_entity(handle) .map(|e| self.render_style(e).0) .unwrap_or([0.8, 0.8, 0.85, 1.0]); let facet_resolution = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_resolution); let (isolines, planar_isolines) = match self.document.get_entity(handle) { Some(EntityType::Surface(surface)) => ( crate::entities::solid3d::surface_isoline_counts(surface), crate::entities::solid3d::surface_property_state(surface).isolines, ), _ => ( [crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); 2], false, ), }; let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( solid, color, facet_resolution, chordal_deflection, isolines, planar_isolines, )?; let name = handle.value().to_string(); for mesh in &mut set.lods { mesh.name = name.clone(); } if let Some(entity) = self.document.get_entity(handle) { crate::scene::model::material_model::resolve_material_with_base( &self.document, entity, color, None, self.material_base_dir.as_deref(), ) .apply_to_with_face_overrides( &mut set, &self.document, self.material_base_dir.as_deref(), ); crate::scene::model::visual_style_model::apply_mesh_visual_style( &mut set, &self.document, entity, ); } Some((set, wires, center)) } /// Commit a prepared display without tessellating the body a second time. pub(crate) fn register_prepared_solid_model( &mut self, handle: Handle, solid: kernel::brep::Body, display: (MeshLodSet, Vec, [f64; 3]), ) -> bool { let (mut set, wires, center) = display; // New command results can be prepared before their handle exists. // Apply the committed entity's actual style without retessellating. if let Some(entity) = self.document.get_entity(handle) { let color = self.render_style(entity).0; for mesh in &mut set.lods { mesh.name = handle.value().to_string(); mesh.color = color; } crate::scene::model::material_model::resolve_material_with_base( &self.document, entity, color, None, self.material_base_dir.as_deref(), ) .apply_to_with_face_overrides( &mut set, &self.document, self.material_base_dir.as_deref(), ); crate::scene::model::visual_style_model::apply_mesh_visual_style( &mut set, &self.document, entity, ); } if let Some(entity) = self.document.get_entity_mut(handle) { let reference = codec::types::Vector3::new(center[0], center[1], center[2]); match entity { EntityType::Solid3D(entity) => { entity.point_of_reference = reference; entity.wires = wires; entity.silhouettes.clear(); } EntityType::Surface(entity) => { entity.point_of_reference = reference; entity.wires = wires; entity.silhouettes.clear(); } _ => {} } } self.meshes.insert(handle, set); self.solid_models.insert(handle, solid); self.sync_solid_reference_point(handle); self.bump_entities(&[(handle, ChangeKind::Modified)]); true } /// Cache and display a Model-tab kernel solid. A failed preparation leaves /// the previous entity, body and mesh untouched. pub fn register_solid_model(&mut self, handle: Handle, solid: kernel::brep::Body) -> bool { let Some(display) = self.prepare_solid_model_display(handle, &solid) else { return false; }; if !display.0.complete && matches!( self.document.solid_history_operation(handle), Some(codec::objects::SolidHistoryOperation::Loft(_)) ) { return false; } self.register_prepared_solid_model(handle, solid, display) } /// `BACKGROUND` change picks up the new `adapt_to_bg` result without /// re-tessellating ACIS geometry. Caller must bump `geometry_epoch` /// afterwards so the GPU re-uploads the now-updated colour data. pub fn recolor_meshes(&mut self) { // Cache colour lookups by handle to avoid borrowing the document // re-entrantly through `render_style` inside a `&mut self` loop. // Covers both top-level solid meshes and block-definition meshes // (instanced per INSERT), so a solid recolours wherever it lives. // During REFEDIT, solids outside the edited set render faded. let bg = self.bg_color; let materials: HashMap = self .meshes .keys() .chain(self.block_meshes.keys()) .filter_map(|&h| { self.document.get_entity(h).map(|e| { let color = self.render_style(e).0; let mut material = crate::scene::model::material_model::resolve_material_with_base( &self.document, e, color, None, self.material_base_dir.as_deref(), ); if let Some(keep) = &self.refedit_keep { if !keep.contains(&h) { material.diffuse = crate::scene::cache::block_cache::refedit_fade( material.diffuse, bg, ); } } if self.meshes.contains_key(&h) { material.diffuse = self.count_color(h, material.diffuse, bg); } (h, material) }) }) .collect(); for (h, set) in self.meshes.iter_mut().chain(self.block_meshes.iter_mut()) { if let Some(material) = materials.get(h) { material.apply_to_with_face_overrides( set, &self.document, self.material_base_dir.as_deref(), ); } if let Some(entity) = self.document.get_entity(*h) { crate::scene::model::visual_style_model::apply_mesh_visual_style( set, &self.document, entity, ); } } } /// Recolour only the named cached solids after a property edit. pub fn recolor_meshes_for_handles(&mut self, handles: &[Handle]) { let bg = self.bg_color; let materials: HashMap = handles .iter() .filter(|handle| { self.meshes.contains_key(handle) || self.block_meshes.contains_key(handle) }) .filter_map(|&handle| { self.document.get_entity(handle).map(|entity| { let color = self.render_style(entity).0; let mut material = crate::scene::model::material_model::resolve_material_with_base( &self.document, entity, color, None, self.material_base_dir.as_deref(), ); if self .refedit_keep .as_ref() .is_some_and(|keep| !keep.contains(&handle)) { material.diffuse = crate::scene::cache::block_cache::refedit_fade(material.diffuse, bg); } if self.meshes.contains_key(&handle) { material.diffuse = self.count_color(handle, material.diffuse, bg); } (handle, material) }) }) .collect(); for handle in handles { let Some(material) = materials.get(handle) else { continue; }; if let Some(set) = self.meshes.get_mut(handle) { material.apply_to_with_face_overrides( set, &self.document, self.material_base_dir.as_deref(), ); if let Some(entity) = self.document.get_entity(*handle) { crate::scene::model::visual_style_model::apply_mesh_visual_style( set, &self.document, entity, ); } } else if let Some(set) = self.block_meshes.get_mut(handle) { material.apply_to_with_face_overrides( set, &self.document, self.material_base_dir.as_deref(), ); if let Some(entity) = self.document.get_entity(*handle) { crate::scene::model::visual_style_model::apply_mesh_visual_style( set, &self.document, entity, ); } } } } /// Enter / leave the REFEDIT fade. `keep` holds the edited entities (left /// bright); everything else renders faded. Re-tessellates wires and /// recolours solids so the change shows immediately. (#136) pub fn set_refedit_keep(&mut self, keep: Option>) { self.refedit_keep = keep; self.recolor_meshes(); // Fading is applied after the raw per-entity tessellation memo is read; // keep those expensive wires and only rebuild the resident assembly. self.bump_geometry_no_blocks(); } /// Enter / leave the COUNT colouring; a no-op when nothing changed. /// Entering or leaving fades or restores the whole drawing; a change /// inside count mode recolours only the objects whose colour changed. pub fn set_count_display(&mut self, display: Option) { if self.count_display == display { return; } let old = std::mem::replace(&mut self.count_display, display); let (Some(old), Some(new)) = (old, self.count_display.as_ref()) else { self.hatch_cache.borrow_mut().clear(); self.recolor_meshes(); self.bump_geometry_no_blocks(); return; }; let recoloured = (old.color, old.error_color) != (new.color, new.error_color); let class = |d: &CountDisplay, h: &Handle| (d.errors.contains(h), d.counted.contains(h)); let changed: Vec = old .counted .iter() .chain(&old.errors) .chain(&new.counted) .chain(&new.errors) .copied() .collect::>() .into_iter() .filter(|h| recoloured || class(&old, h) != class(new, h)) .collect(); self.recolor_meshes_for_handles(&changed); let changes: Vec<(Handle, ChangeKind)> = changed .into_iter() .map(|h| (h, ChangeKind::Modified)) .collect(); self.bump_entities_restyled(&changes); } /// The colour count mode gives an object drawn as part of `handle` (a /// top-level entity): the count colours, else faded; unchanged outside /// count mode. pub(crate) fn count_color(&self, handle: Handle, color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { let Some(count) = &self.count_display else { return color; }; let [r, g, b, _] = if count.errors.contains(&handle) { count.error_color } else if count.counted.contains(&handle) { count.color } else { return crate::scene::cache::block_cache::fade_toward_bg(color, bg); }; [r, g, b, color[3]] } /// Fade the colours of wires that belong to entities outside the REFEDIT /// keep set (no-op when not editing). The geometry is untouched, so /// hit-testing still works on faded entities. In count mode the counted /// references and the duplicates take the count colours instead. fn apply_refedit_fade(&self, wires: &mut Vec, bg: [f32; 4]) { // Block copies kept as stubs draw in their prototype's colour; while // copies are recoloured one by one they are expanded instead. if (self.count_display.is_some() || self.refedit_keep.is_some()) && wires.iter().any(|wire| wire.stub.is_some()) { *wires = crate::scene::model::instance_model::expand_stubs(wires).into_owned(); } if let Some(count) = &self.count_display { for w in wires.iter_mut() { let (color, class) = match Self::handle_from_wire_name(&w.name) { Some(h) if count.errors.contains(&h) => (count.error_color, 2u64), Some(h) if count.counted.contains(&h) => (count.color, 1), _ => ( crate::scene::cache::block_cache::fade_toward_bg(w.color, bg), 0, ), }; w.color = color; // Instances of one definition draw in one colour: counted and // duplicate references get instance groups of their own. // ponytail: tags the two top bits of the source id, which the // id counter never reaches. if let Some(instance) = w.render_instance.as_mut() { instance.source_id |= class << 62; } } } let Some(keep) = &self.refedit_keep else { return; }; for w in wires.iter_mut() { let handle = Self::handle_from_wire_name(&w.name); // A DWF underlay's page stays as it is outside the working set, // like an image or a PDF page; only its frame fades. A DGN // underlay's content fades with everything else. let dwf_page = w.is_display_only() && handle.is_some_and(|h| { matches!( self.document.get_entity(h), Some(EntityType::Underlay(u)) if u.underlay_type == codec::entities::UnderlayType::Dwf ) }); let keep_bright = dwf_page || handle.is_some_and(|h| keep.contains(&h)); if !keep_bright { w.color = crate::scene::cache::block_cache::refedit_fade(w.color, bg); } } } /// Switch the active layout without pretending the document geometry /// changed. Resident wire sets already key on block/background, so keeping /// `geometry_epoch` stable lets Model and Paper retain their CPU/GPU data /// across tab switches. Only caches whose contents genuinely depend on the /// active layout or its background are dropped. pub fn set_current_layout(&mut self, name: String) { if self.current_layout != name { self.persist_current_layout_state(); self.current_layout = name; self.load_current_layout_state(); self.sync_active_space_to_document(); self.recolor_meshes(); *self.wire_cache.borrow_mut() = None; *self.interaction_mesh_cache.borrow_mut() = None; *self.mesh_pick_lookup_cache.borrow_mut() = None; *self.insert_hatch_cache.borrow_mut() = None; } } pub fn load_current_layout_state(&mut self) { self.display_plot_style_cache .borrow_mut() .retain(|(layout, _), _| layout != &self.current_layout); self.styled_wire_cache.borrow_mut().clear(); self.styled_hatch_cache.borrow_mut().clear(); self.styled_wire_fill_cache.borrow_mut().clear(); if self.current_layout == "Model" { return; } if let Some((flags, insertion_base, min_extents, max_extents, min_limits, max_limits)) = self.document.objects.values().find_map(|object| { let ObjectType::Layout(layout) = object else { return None; }; (layout.name == self.current_layout).then_some(( layout.flags, layout.insertion_base, layout.min_extents, layout.max_extents, layout.min_limits, layout.max_limits, )) }) { self.document.header.paper_space_linetype_scaling = flags & 1 != 0; self.document.header.paper_space_limit_check = flags & 2 != 0; self.document.header.paper_space_insertion_base = codec::types::Vector3::new(insertion_base.0, insertion_base.1, insertion_base.2); self.document.header.paper_space_extents_min = codec::types::Vector3::new(min_extents.0, min_extents.1, min_extents.2); self.document.header.paper_space_extents_max = codec::types::Vector3::new(max_extents.0, max_extents.1, max_extents.2); self.document.header.paper_space_limits_min = codec::types::Vector2::new(min_limits.0, min_limits.1); self.document.header.paper_space_limits_max = codec::types::Vector2::new(max_limits.0, max_limits.1); } } pub fn persist_current_layout_state(&mut self) { if self.current_layout == "Model" { return; } let header = &self.document.header; let psltscale = header.paper_space_linetype_scaling; let plimcheck = header.paper_space_limit_check; let insertion_base = header.paper_space_insertion_base; let min_extents = header.paper_space_extents_min; let max_extents = header.paper_space_extents_max; let min_limits = header.paper_space_limits_min; let max_limits = header.paper_space_limits_max; for object in self.document.objects.values_mut() { let ObjectType::Layout(layout) = object else { continue; }; if layout.name == self.current_layout { layout.flags = if psltscale { layout.flags | 1 } else { layout.flags & !1 }; layout.flags = if plimcheck { layout.flags | 2 } else { layout.flags & !2 }; layout.insertion_base = (insertion_base.x, insertion_base.y, insertion_base.z); layout.min_extents = (min_extents.x, min_extents.y, min_extents.z); layout.max_extents = (max_extents.x, max_extents.y, max_extents.z); layout.min_limits = (min_limits.x, min_limits.y); layout.max_limits = (max_limits.x, max_limits.y); break; } } } /// Mirror the active space (Model tile-mode vs a paper layout) into the /// document's persisted settings so it round-trips on save: the `$TILEMODE` /// header (`show_model_space`) and the `CTAB` current-tab variable. The /// reader restores it via [`current_layout`] on open. Called whenever the /// active layout changes; the file otherwise always reopened in Model. pub fn sync_active_space_to_document(&mut self) { self.document.header.show_model_space = self.current_layout == "Model"; crate::io::set_saved_active_layout(&mut self.document, &self.current_layout); } /// Record the entered viewport (or the sheet, in paper space) as the /// current layout's last active viewport, which reopens it (#1670). pub fn sync_active_viewport_to_document(&mut self) { let Some(handle) = self.current_layout_object_handle() else { return; }; let sheet = match self.document.objects.get(&handle) { Some(ObjectType::Layout(layout)) => { Self::layout_sheet_viewport_handle(&self.document, layout) } _ => return, }; let active = self.active_viewport.unwrap_or(sheet); if let Some(ObjectType::Layout(layout)) = self.document.objects.get_mut(&handle) { layout.viewport = active; } } /// The model viewport the current layout was saved with entered: its /// last active viewport when that is an on viewport other than the sheet. pub fn saved_entered_viewport(&self) -> Option { let handle = self.current_layout_object_handle()?; let Some(ObjectType::Layout(layout)) = self.document.objects.get(&handle) else { return None; }; let viewport = layout.viewport; let sheet = Self::layout_sheet_viewport_handle(&self.document, layout); if viewport.is_null() || viewport == sheet { return None; } match self.document.get_entity(viewport) { Some(EntityType::Viewport(vp)) if vp.common.owner_handle == layout.block_record && !vp.status.is_off => { Some(viewport) } _ => None, } } pub(crate) fn layout_sheet_viewport_handle( document: &codec::CadDocument, layout: &codec::objects::Layout, ) -> Handle { let owned_viewport = |handle| match document.get_entity(handle) { Some(EntityType::Viewport(vp)) if vp.common.owner_handle == layout.block_record => { Some(vp) } _ => None, }; let listed = layout .viewports .iter() .copied() .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); if let Some((handle, _)) = listed.clone().find(|(_, vp)| vp.id == 1) { return handle; } if let Some((handle, _)) = listed.into_iter().next() { return handle; } let block_handles = document .block_records .iter() .find(|block| block.handle == layout.block_record) .map(|block| block.entity_handles.as_slice()) .unwrap_or_default(); let block_viewports = block_handles .iter() .copied() .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); if let Some((handle, _)) = block_viewports.clone().find(|(_, vp)| vp.id == 1) { return handle; } if let Some((handle, _)) = block_viewports.into_iter().next() { return handle; } document .entities() .filter_map(|entity| match entity { EntityType::Viewport(vp) if vp.common.owner_handle == layout.block_record => { Some(( vp.common.handle, vp.id == 1, vp.width.abs() * vp.height.abs(), )) } _ => None, }) .max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.2.total_cmp(&b.2))) .map(|(handle, _, _)| handle) .unwrap_or(Handle::NULL) } pub(crate) fn is_sheet_viewport( document: &codec::CadDocument, vp: &codec::entities::Viewport, ) -> bool { if vp.id == 1 { return true; } if vp.id > 1 { return false; } document.objects.values().any(|object| { matches!( object, ObjectType::Layout(layout) if layout.block_record == vp.common.owner_handle && Self::layout_sheet_viewport_handle(document, layout) == vp.common.handle ) }) } pub(crate) fn sheet_viewport_handles(&self) -> std::collections::HashSet { self.document .objects .values() .filter_map(|object| match object { ObjectType::Layout(layout) => { let handle = Self::layout_sheet_viewport_handle(&self.document, layout); handle.is_valid().then_some(handle) } _ => None, }) .collect() } fn current_layout_sheet_viewport_handle(&self) -> Handle { self.document .objects .values() .find_map(|obj| { let ObjectType::Layout(layout) = obj else { return None; }; if layout.name == self.current_layout { Some(Self::layout_sheet_viewport_handle(&self.document, layout)) } else { None } }) .unwrap_or(Handle::NULL) } /// Ensure a paper layout has its overall (`id == 1`) viewport; true when /// it had none (the layout opens for the first time). pub fn ensure_sheet_viewport(&mut self, layout_name: &str) -> bool { if layout_name == "Model" { return false; } // Locate the layout and its paper limits. let info = self.document.objects.iter().find_map(|(h, obj)| { if let ObjectType::Layout(l) = obj { if l.name == layout_name { return Some(( *h, l.block_record, Self::layout_sheet_viewport_handle(&self.document, l), l.min_limits, l.max_limits, )); } } None }); let Some((layout_handle, block_record, sheet, min_lim, max_lim)) = info else { return false; }; if block_record.is_null() { return false; } if sheet.is_valid() && matches!( self.document.get_entity(sheet), Some(EntityType::Viewport(vp)) if vp.common.owner_handle == block_record ) { return false; } // Create the full-screen overall viewport covering the paper limits. let mut vp = codec::entities::Viewport::new(); vp.id = 1; vp.status = codec::entities::ViewportStatusFlags::default_on(); fit_sheet_viewport(&mut vp, min_lim, max_lim); if let Ok(handle) = self .document .add_entity_to_layout(EntityType::Viewport(vp), layout_name) { if let Some(ObjectType::Layout(l)) = self.document.objects.get_mut(&layout_handle) { l.viewports.retain(|candidate| *candidate != handle); l.viewports.insert(0, handle); } } true } /// Fit a layout's sheet viewport to its page. (Taking the sheet out /// instead would leave a viewport onto the model looking like the sheet.) pub fn fit_layout_sheet(&mut self, layout_name: &str) { let layout = self .document .objects .values() .find_map(|object| match object { ObjectType::Layout(layout) if layout.name == layout_name => Some(layout.clone()), _ => None, }); let Some(layout) = layout else { return; }; let sheet = Self::layout_sheet_viewport_handle(&self.document, &layout); if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(sheet) { fit_sheet_viewport(vp, layout.min_limits, layout.max_limits); } } /// Gives a layout whose page setup was never initialized (no paper size /// and no media name, as the reference leaves a layout until it is first /// opened) the default page setup; true when it did. pub fn initialize_layout_page_setup(&mut self, layout_name: &str) -> bool { let plot_style = self.new_layout_plot_style.clone(); for object in self.document.objects.values_mut() { if let ObjectType::Layout(layout) = object { if layout.name == layout_name { if !page_setup_uninitialized(layout) { return false; } crate::scene::apply_default_page_setup(layout, &plot_style); // A sheet written for a layout never set up is not the // page's; it is fitted to the page. self.fit_layout_sheet(layout_name); self.bump_layout_epoch(); return true; } } } false } /// The viewport a layout gets when it first opens (LAYOUTCREATEVIEWPORT), /// as the reference makes it: centred on the printable area, 0.8 of its /// width and height, showing the model's extents. pub fn add_layout_default_viewport(&mut self, layout_name: &str) { if !layout_create_viewport() || !self.layout_content_viewports().is_empty() { return; } let Some(((x0, y0), (x1, y1))) = self.printable_area_limits() else { return; }; let mut viewport = codec::entities::Viewport::new(); viewport.center = codec::types::Vector3::new((x0 + x1) / 2.0, (y0 + y1) / 2.0, 0.0); viewport.width = (x1 - x0).abs() * 0.8; viewport.height = (y1 - y0).abs() * 0.8; viewport.id = 2; viewport.status = codec::entities::ViewportStatusFlags::default_on(); if let Ok(handle) = self .document .add_entity_to_layout(EntityType::Viewport(viewport), layout_name) { // The reference zooms the new viewport to the model's extents // with a margin of under one percent. self.fit_viewport_to_model(handle, 1.0075); self.notify_viewport_changed(handle); self.bump_entities(&[(handle, ChangeKind::Added)]); } } fn is_content_viewport_in_layout( &self, vp: &codec::entities::Viewport, layout_block: Handle, ) -> bool { if vp.common.owner_handle != layout_block { return false; } let sheet_handle = self.current_layout_sheet_viewport_handle(); if sheet_handle.is_valid() { vp.common.handle != sheet_handle } else { !Self::is_sheet_viewport(&self.document, vp) } } /// Public accessor for the block-record handle of the current layout. /// Used by external callers (e.g. `commit_entity`) that need the handle /// without going through private API. pub fn current_layout_block_handle_pub(&self) -> Handle { self.current_layout_block_handle() } /// A unique id for a new viewport on the current layout: one past the /// largest id there, at least 2 (1 is the layout's own paper viewport). pub fn next_viewport_id(&self) -> i16 { let layout_block = self.current_layout_block_handle(); let max_id = self .document .entities() .filter_map(|e| match e { codec::EntityType::Viewport(v) if v.common.owner_handle == layout_block => { Some(v.id) } _ => None, }) .max() .unwrap_or(1); (max_id + 1).max(2) } /// True when an entity belongs to the active layout, including imported /// DXF entities whose owner handle is NULL but whose BlockRecord still /// lists the entity. Keep command validation aligned with the same /// ownership fallback used by rendering and hit-testing. pub(crate) fn entity_belongs_to_current_layout(&self, handle: Handle) -> bool { let Some(entity) = self.document.get_entity(handle) else { return false; }; self.belongs_to_visible_block( handle, entity.common().owner_handle, self.current_layout_block_handle(), ) } /// True when an entity belongs to the space receiving newly drawn /// entities: BEDIT's block, paper space, or model space (including MSPACE). pub(crate) fn entity_belongs_to_active_space(&self, handle: Handle) -> bool { let Some(entity) = self.document.get_entity(handle) else { return false; }; let block = if let Some(block) = self.block_edit_block { block } else if self.current_layout != "Model" && self.active_viewport.is_none() { self.current_layout_block_handle() } else { self.model_space_block_handle() }; self.belongs_to_visible_block(handle, entity.common().owner_handle, block) } /// Returns the block-record handle for `current_layout`. /// /// Primary path: the Layout object's `block_record` field (set correctly /// by the DWG reader). /// /// Fallback for DXF files: the DXF reader never reads group code 340 /// (block_record handle), so `block_record` is NULL after loading DXF. /// In that case we derive the block-record name from the DXF convention: /// Model → "*Model_Space" /// first paper tab → "*Paper_Space" /// second paper tab → "*Paper_Space0" /// Nth paper tab → "*Paper_Space{N-2}" fn current_layout_block_handle(&self) -> Handle { // BEDIT block editor: the active space IS a block record — draw / pick / // own only its own (block-local) entities (issue #261). if let Some(h) = self.block_edit_block { if !h.is_null() { return h; } } // Locate the Layout object for the active layout name. let layout = self.document.objects.values().find_map(|obj| { if let ObjectType::Layout(l) = obj { if l.name == self.current_layout { Some(l) } else { None } } else { None } }); if let Some(l) = layout { // Fast path: block_record already set (DWG reader). if !l.block_record.is_null() { return l.block_record; } // Fallback: resolve via conventional DXF block-record name. let br_name: String = if self.current_layout == "Model" { "*Model_Space".into() } else { // tab_order 1 → "*Paper_Space", 2 → "*Paper_Space0", etc. let tab = l.tab_order; if tab <= 1 { "*Paper_Space".into() } else { format!("*Paper_Space{}", tab - 2) } }; if let Some(br) = self.document.block_records.get(&br_name) { return br.handle; } // Last resort: match by position among paper layouts when tab_order // is unreliable (some exporters set it to 0 for all layouts). if self.current_layout != "Model" { let mut ps_brs: Vec<_> = self .document .block_records .iter() .filter(|br| br.is_paper_space()) .collect(); ps_brs.sort_by(|a, b| a.name.cmp(&b.name)); let mut paper_layouts: Vec<(i16, &str)> = self .document .objects .values() .filter_map(|obj| { if let ObjectType::Layout(l) = obj { if l.name != "Model" { Some((l.tab_order, l.name.as_str())) } else { None } } else { None } }) .collect(); paper_layouts.sort_by_key(|(o, n)| (*o, *n)); if let Some(pos) = paper_layouts .iter() .position(|(_, n)| *n == self.current_layout) { if let Some(br) = ps_brs.get(pos) { return br.handle; } } } else if let Some(br) = self.document.block_records.get("*Model_Space") { return br.handle; } } Handle::NULL } /// Returns `(min, max)` paper-space limits for the current layout, or `None` /// when in Model space. Falls back to A4 landscape if nothing reliable is found. /// A solid white fill covering the paper sheet's printable area, rendered /// by the GPU hatch pipeline behind the paper entities. Replaces the 2-D /// white-rectangle the old PaperCanvas drew. `None` in model space or when /// the layout has no limits. pub(super) fn paper_sheet_fill(&self) -> Option { let ((x0, y0), (x1, y1)) = self.paper_limits()?; let (x0, y0, x1, y1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); Some(HatchModel { pattern_origin: None, render_instance: None, world_origin: [0.0, 0.0], boundary: Arc::new(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]), boundary_wcs: None, fill_plane: None, fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, boundary_paths: None, style: codec::entities::HatchStyleType::Normal, pattern: crate::scene::model::hatch_model::HatchPattern::Solid, name: "SOLID".to_string(), color: self.paper_bg_color, aci: 0, line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, // Draw-order bias is signed: entity fills/wires land in (-1, 1) // (0 = neutral). A value below -1 forces the sheet strictly behind // EVERY object, in every case, with a tiny z offset (BIAS = 0.001, // so no far-plane clipping). The sheet is the canvas, never on top. draw_depth: -2.0, }) } /// Dashed rectangle marking the printable area — the paper inset by the /// layout's plot margins. `None` in model space, when /// the layout has no margins, or when the inset would be degenerate. pub(super) fn printable_area_wire(&self) -> Option { if self.current_layout == "Model" { return None; } let ((x0, y0), (x1, y1)) = self.paper_limits()?; let (left, bottom, right, top, rot) = self.document.objects.values().find_map(|obj| { if let ObjectType::Layout(l) = obj { if l.name == self.current_layout { return Some(( l.plot_margin_left, l.plot_margin_bottom, l.plot_margin_right, l.plot_margin_top, l.plot_rotation, )); } } None })?; // `paper_limits()` already swaps the sheet for a 90°/270° rotation, so the // margins must rotate to the same edges. let (ml, mb, mr, mt) = page_setup::rotated_margins((left, bottom, right, top), rot); // Plot margins are millimetres like the paper size; scale them into the // layout's paper-space units so the inset matches the (already scaled) // sheet rect. Without this an inch paper space insets an ~8-inch sheet // by ~6 "inches" of margin and the printable rect collapses. let f = self.paper_space_unit_factor(); let (ml, mb, mr, mt) = (ml * f, mb * f, mr * f, mt * f); // Nothing to show when there are no margins (printable area == sheet). if ml <= 0.0 && mb <= 0.0 && mr <= 0.0 && mt <= 0.0 { return None; } let (px0, py0, px1, py1) = (x0 + ml, y0 + mb, x1 - mr, y1 - mt); if px1 - px0 < 1e-3 || py1 - py0 < 1e-3 { return None; } let (px0, py0, px1, py1) = (px0 as f32, py0 as f32, px1 as f32, py1 as f32); let mut wire = WireModel::solid( "paper_printable_area".to_string(), vec![ [px0, py0, 0.0], [px1, py0, 0.0], [px1, py1, 0.0], [px0, py1, 0.0], [px0, py0, 0.0], ], [0.5, 0.5, 0.5, 1.0], false, ); // Dashed: 4 mm dash, 3 mm gap. The dash lengths are millimetres, so // scale them into the layout's paper-space units too (`f`) — otherwise // an inch paper space draws 4-"inch" dashes (25.4× too long). let ff = f as f32; wire.pattern_length = 7.0 * ff; wire.pattern = [4.0 * ff, -3.0 * ff, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; Some(wire) } /// The effective plot settings embedded in the current layout. pub fn effective_plot_settings(&self) -> Option { self.plot_settings_for(&self.current_layout) } /// Plot settings embedded in a specific layout. pub fn plot_settings_for(&self, name: &str) -> Option { use codec::objects::{ ObjectType, PaperMargin, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, PlotWindow, ScaledType, ShadePlotMode, ShadePlotResolutionLevel, }; self.document.objects.values().find_map(|o| { let ObjectType::Layout(l) = o else { return None; }; if l.name.as_str() != name { return None; } let mut ps = PlotSettings::new(l.plot_page_name.clone()); ps.printer_name = l.plot_printer_name.clone(); ps.paper_width = l.paper_width; ps.paper_height = l.paper_height; ps.paper_size = l.paper_size.clone(); ps.plot_view_name = l.plot_view_name.clone(); ps.current_style_sheet = l.plot_style_sheet.clone(); ps.margins = PaperMargin::new( l.plot_margin_left, l.plot_margin_bottom, l.plot_margin_right, l.plot_margin_top, ); ps.origin_x = l.plot_origin_x; ps.origin_y = l.plot_origin_y; ps.plot_window = PlotWindow::new( l.plot_window_min_x, l.plot_window_min_y, l.plot_window_max_x, l.plot_window_max_y, ); ps.paper_units = PlotPaperUnits::from_code(l.plot_paper_units); ps.rotation = PlotRotation::from_code(l.plot_rotation); ps.plot_type = PlotType::from_code(l.plot_type); ps.scale_type = ScaledType::from_code(l.plot_scale_type); ps.scale_numerator = l.plot_scale_numerator; ps.scale_denominator = l.plot_scale_denominator; ps.flags = l.plot_flags; ps.standard_scale_factor = l.plot_scale_factor; ps.paper_image_origin_x = l.paper_image_origin_x; ps.paper_image_origin_y = l.paper_image_origin_y; ps.shade_plot_mode = ShadePlotMode::from_code(l.shade_plot_mode); ps.shade_plot_resolution = ShadePlotResolutionLevel::from_code(l.shade_plot_resolution); ps.shade_plot_dpi = l.shade_plot_dpi; ps.plot_view_handle = l.plot_view_handle; ps.visual_style_handle = l.visual_style_handle; Some(ps) }) } /// Replace the plot-settings portion embedded in one layout. pub fn set_layout_plot_settings( &mut self, name: &str, ps: &codec::objects::PlotSettings, ) -> bool { let Some(layout) = self.document.objects.values_mut().find_map(|object| { let ObjectType::Layout(layout) = object else { return None; }; (layout.name == name).then_some(layout) }) else { return false; }; layout.plot_page_name = ps.page_name.clone(); layout.plot_printer_name = ps.printer_name.clone(); layout.paper_size = ps.paper_size.clone(); layout.plot_view_name = ps.plot_view_name.clone(); layout.plot_style_sheet = ps.current_style_sheet.clone(); layout.plot_margin_left = ps.margins.left; layout.plot_margin_bottom = ps.margins.bottom; layout.plot_margin_right = ps.margins.right; layout.plot_margin_top = ps.margins.top; layout.paper_width = ps.paper_width; layout.paper_height = ps.paper_height; layout.plot_origin_x = ps.origin_x; layout.plot_origin_y = ps.origin_y; layout.plot_window_min_x = ps.plot_window.lower_left_x; layout.plot_window_min_y = ps.plot_window.lower_left_y; layout.plot_window_max_x = ps.plot_window.upper_right_x; layout.plot_window_max_y = ps.plot_window.upper_right_y; layout.plot_scale_numerator = ps.scale_numerator; layout.plot_scale_denominator = ps.scale_denominator; layout.plot_paper_units = ps.paper_units.to_code(); layout.plot_rotation = ps.rotation.to_code(); layout.plot_type = ps.plot_type.to_code(); layout.plot_scale_type = ps.scale_type.to_code(); layout.shade_plot_mode = ps.shade_plot_mode.to_code(); layout.shade_plot_resolution = ps.shade_plot_resolution.to_code(); layout.shade_plot_dpi = ps.shade_plot_dpi; layout.plot_flags = ps.flags; layout.plot_scale_factor = ps.standard_scale_factor; layout.paper_image_origin_x = ps.paper_image_origin_x; layout.paper_image_origin_y = ps.paper_image_origin_y; layout.plot_view_handle = ps.plot_view_handle; layout.visual_style_handle = ps.visual_style_handle; layout.raw_plot_settings_codes = None; self.display_plot_style_cache .borrow_mut() .retain(|(layout_name, _), _| layout_name != name); self.styled_wire_cache.borrow_mut().clear(); self.styled_hatch_cache.borrow_mut().clear(); self.styled_wire_fill_cache.borrow_mut().clear(); true } pub fn invalidate_display_plot_style(&self) { self.display_plot_style_cache.borrow_mut().clear(); self.styled_wire_cache.borrow_mut().clear(); self.styled_hatch_cache.borrow_mut().clear(); self.styled_wire_fill_cache.borrow_mut().clear(); } /// PlotSettings store the paper size and plot margins in millimetres, but a /// layout's paper space is laid out in its own units — its viewports, /// drawing limits and the sheet the user sees can be in inches, millimetres, /// or an arbitrary scaled unit. Return the factor that converts those /// millimetre plot-settings values into the active layout's paper-space /// units (`1.0` for a millimetre paper space, `1/25.4` for an inch one, and /// anything else for a scaled layout). /// /// The unit is derived from the plot scale, which maps paper-space units to /// the physical page: `mm_per_unit = (scale_num / scale_den) × page_unit_mm` /// where `page_unit_mm` is 25.4 for an inch page (code 72 == 0) or 1 for a /// millimetre page. The scale and the page unit are a matched pair, so this /// is correct even when the page unit alone looks wrong (an inch page at /// scale 1:25.4 is a millimetre paper space). Falls back to `1.0` when the /// scale is missing or degenerate. pub fn paper_space_unit_factor(&self) -> f64 { if self.current_layout == "Model" { return 1.0; } self.document .objects .values() .find_map(|obj| match obj { ObjectType::Layout(l) if l.name == self.current_layout => { Some(Self::layout_unit_factor(l)) } _ => None, }) .unwrap_or(1.0) } /// Millimetre → paper-space-unit factor for a specific layout (see /// [`Scene::paper_space_unit_factor`]). fn layout_unit_factor(l: &codec::objects::Layout) -> f64 { // Millimetres represented by one plotted page unit: an inch page // (plot_paper_units == 0) is 25.4 mm, a millimetre page is 1 mm. let page_unit_mm = if l.plot_paper_units == 0 { 25.4 } else { 1.0 }; let (num, den) = (l.plot_scale_numerator, l.plot_scale_denominator); if num > 1e-12 && den > 1e-12 { // One paper-space unit spans this many millimetres on the page. let mm_per_unit = (num / den) * page_unit_mm; if mm_per_unit > 1e-9 { return 1.0 / mm_per_unit; } } // No usable plot scale — assume the paper space is already millimetres. 1.0 } pub fn paper_limits(&self) -> Option<((f64, f64), (f64, f64))> { if self.current_layout == "Model" { return None; } if let Some(cache) = self.paper_viewport_cache.borrow().get(&self.current_layout) { if cache.epoch == self.geometry_epoch && cache.layout == self.current_layout { return cache.paper_limits; } } self.document .objects .values() .find_map(|obj| { if let ObjectType::Layout(l) = obj { if l.name != self.current_layout { return None; } // Use the physical paper dimensions from PlotSettings if available // (populated from DWG embedded plot settings or DXF codes 44/45/73). // Rotation 1=90° or 3=270° → swap width and height. if l.paper_width > 1e-6 && l.paper_height > 1e-6 { let (pw, ph) = if l.plot_rotation == 1 || l.plot_rotation == 3 { (l.paper_height, l.paper_width) } else { (l.paper_width, l.paper_height) }; // paper_width/height are millimetres; scale them into // the layout's paper-space units so the sheet lands in // the same coordinate system as the viewports drawn on // it. An inch-based paper space would otherwise get a // 25.4× oversized sheet, shrinking the content into the // corner. let f = Self::layout_unit_factor(l); let (pw, ph) = (pw * f, ph * f); let (wx0, wy0, wx1, wy1) = ( l.plot_window_min_x, l.plot_window_min_y, l.plot_window_max_x, l.plot_window_max_y, ); // A window plot maps that window onto the sheet, so the // sheet sits around it rather than at the origin. let (ox, oy) = if l.plot_type == 4 && wx1 > wx0 && wy1 > wy0 { if l.plot_flags.plot_centered { ((wx0 + wx1 - pw) / 2.0, (wy0 + wy1 - ph) / 2.0) } else { (wx0 - l.plot_origin_x * f, wy0 - l.plot_origin_y * f) } } else { (l.min_limits.0.min(0.0), l.min_limits.1.min(0.0)) }; return Some(((ox, oy), (ox + pw, oy + ph))); } // Fall back to the Layout's drawing limits. let (min, max) = (l.min_limits, l.max_limits); let w = (max.0 - min.0).abs(); let h = (max.1 - min.1).abs(); if w < 1e-6 || h < 1e-6 { return Some(((0.0, 0.0), (297.0, 210.0))); } Some((min, max)) } else { None } }) .or(Some(((0.0, 0.0), (297.0, 210.0)))) } /// Printable rectangle of the current paper layout, in paper-space units. /// Falls back to the whole sheet when the layout has no usable margins. pub fn printable_area_limits(&self) -> Option<((f64, f64), (f64, f64))> { let ((x0, y0), (x1, y1)) = self.paper_limits()?; let margins = self.document.objects.values().find_map(|object| { if let ObjectType::Layout(layout) = object { if layout.name == self.current_layout { return Some(( layout.plot_margin_left, layout.plot_margin_bottom, layout.plot_margin_right, layout.plot_margin_top, layout.plot_rotation, )); } } None }); let Some((left, bottom, right, top, rotation)) = margins else { return Some(((x0, y0), (x1, y1))); }; let (left, bottom, right, top) = match rotation { 1 | 3 => (bottom, left, top, right), 2 => (right, top, left, bottom), _ => (left, bottom, right, top), }; let factor = self.paper_space_unit_factor(); let printable = ( (x0 + left * factor, y0 + bottom * factor), (x1 - right * factor, y1 - top * factor), ); if printable.1 .0 - printable.0 .0 < 1e-6 || printable.1 .1 - printable.0 .1 < 1e-6 { Some(((x0, y0), (x1, y1))) } else { Some(printable) } } /// Scale of the active, selected, or first content viewport in the current /// paper layout. Returns `None` in Model space or when no viewport exists. pub fn first_viewport_scale(&self) -> Option { let handle = self.target_viewport_handle()?; let EntityType::Viewport(vp) = self.document.get_entity(handle)? else { return None; }; Some(vp_effective_scale( vp.custom_scale, vp.view_height, vp.height, )) } /// Annotation/viewport scales defined in the drawing's scale list /// (the `ACAD_SCALELIST` dictionary), as `(label, annotation_multiplier, /// viewport_factor)`. The annotation multiplier sizes model-space /// text/dims (50.0 for "1:50"); the viewport factor is the paper/drawing /// ratio (0.02 for "1:50"). Sorted smallest ratio first (1:100 … 1:1 … /// 10:1). Falls back to a standard ratio set when the drawing carries no /// scale list of its own, so the scale picker is always usable. (#154) /// Standard fallback sets as (label, paper/drawing factor). The drawing's /// INSUNITS selects metric ratios or imperial architectural labels. Used /// when no file scales exist and materialised on demand by /// [`Scene::ensure_real_scale_list`]. const DEFAULT_METRIC_SCALES: &'static [(&'static str, f64)] = &[ ("1:1000", 0.001), ("1:500", 0.002), ("1:200", 0.005), ("1:100", 0.01), ("1:50", 0.02), ("1:25", 0.04), ("1:20", 0.05), ("1:10", 0.1), ("1:5", 0.2), ("1:2", 0.5), ("1:1", 1.0), ("2:1", 2.0), ("5:1", 5.0), ("10:1", 10.0), ]; const DEFAULT_IMPERIAL_SCALES: &'static [(&'static str, f64)] = &[ ("1/128\" = 1'-0\"", 1.0 / 1536.0), ("1/64\" = 1'-0\"", 1.0 / 768.0), ("1/32\" = 1'-0\"", 1.0 / 384.0), ("1/16\" = 1'-0\"", 1.0 / 192.0), ("3/32\" = 1'-0\"", 1.0 / 128.0), ("1/8\" = 1'-0\"", 1.0 / 96.0), ("3/16\" = 1'-0\"", 1.0 / 64.0), ("1/4\" = 1'-0\"", 1.0 / 48.0), ("3/8\" = 1'-0\"", 1.0 / 32.0), ("1/2\" = 1'-0\"", 1.0 / 24.0), ("3/4\" = 1'-0\"", 1.0 / 16.0), ("1\" = 1'-0\"", 1.0 / 12.0), ("1-1/2\" = 1'-0\"", 1.0 / 8.0), ("3\" = 1'-0\"", 1.0 / 4.0), ("6\" = 1'-0\"", 1.0 / 2.0), ("1'-0\" = 1'-0\"", 1.0), ]; fn prefers_imperial_scales(&self) -> Option { match self.document.header.insertion_units { 1..=3 | 8..=10 | 21..=24 => Some(true), 4..=7 | 11..=20 => Some(false), 0 if self.document.header.measurement == 1 => Some(false), _ => None, } } fn default_scales(&self) -> &'static [(&'static str, f64)] { if self.prefers_imperial_scales() == Some(true) { Self::DEFAULT_IMPERIAL_SCALES } else { Self::DEFAULT_METRIC_SCALES } } /// Model-space annotation multiplier from the header's CANNOSCALEVALUE /// (paper/drawing factor), converted from paper units into the drawing's /// INSUNITS, so a metre drawing uses 0.001 model units per paper /// millimetre at 1:1. Every path that starts or loads a drawing, or /// changes its units, goes through here so it agrees with CANNOSCALE. pub(crate) fn sync_annotation_scale_from_header(&mut self) { let cannoscale_value = self.document.header.annotation_scale_value; let unit_factor = self.annotation_scale_unit_factor(); self.annotation_scale = if cannoscale_value > 1e-9 { ((1.0 / cannoscale_value) / unit_factor) as f32 } else { (1.0 / unit_factor) as f32 }; } /// Conversion from one drawing unit to the paper unit used by the /// standard annotation-scale family: millimetres for metric drawings /// and inches for imperial drawings. /// /// The built-in scale factors are defined assuming model and paper use /// the same base unit. A drawing in metres therefore needs an extra /// factor of 1000 when its paper side is measured in millimetres. pub(crate) fn annotation_scale_unit_factor(&self) -> f64 { let paper_unit = if self.prefers_imperial_scales() == Some(true) { 1 // Inches } else { 4 // Millimeters }; let factor = crate::modules::draw::units::conversion_factor( self.document.header.insertion_units, paper_unit, ) .unwrap_or(1.0); // Some templates store their ratio scales already converted to the // drawing unit (a metre drawing's "1:50" as 1 paper = 0.05 drawing). // Those must not be converted a second time. let converted = self.document.objects.values().find_map(|object| { let ObjectType::Scale(scale) = object else { return None; }; let (paper, drawing) = scale.name.split_once(':')?; let paper = paper.trim().parse::().ok()?; let nominal = drawing.trim().parse::().ok()? / paper; let stored = scale.drawing_units / scale.paper_units; (nominal.is_finite() && nominal > 0.0) .then(|| (stored * factor - nominal).abs() <= 1.0e-6 * nominal) }); if factor != 1.0 && converted == Some(true) { 1.0 } else { factor } } fn is_architectural_scale_name(name: &str) -> bool { name.contains('=') && name.contains('"') && name.contains('\'') } fn is_ratio_scale_name(name: &str) -> bool { name.split_once(':') .and_then(|(paper, drawing)| { Some(( paper.trim().parse::().ok()?, drawing.trim().parse::().ok()?, )) }) .is_some_and(|(paper, drawing)| paper > 0.0 && drawing > 0.0) } /// True when the drawing owns at least one annotation scale of its own /// (i.e. `scale_list` returns real objects rather than the fallback set). fn has_own_scales(&self) -> bool { self.document.objects.values().any(|o| { matches!(o, ObjectType::Scale(s) if !s.is_temporary && !s.name.contains('|') && !s.name.to_ascii_uppercase().ends_with("_XREF")) }) } /// If the drawing has no annotation scales of its own, populate it with the /// standard fallback set as real `Scale` objects (and the `ACAD_SCALELIST` /// dictionary) so they can be selected, edited and renamed uniformly with a /// drawing that ships its own list. Returns `true` if any were created. /// /// The scale manager calls this on open and stages the result, so the set /// is discarded again unless the user applies an edit. /// /// `scale_handle_ensuring` calls it for real, before it writes the first /// scale a drawing has ever owned: one scale on its own would end the /// substitution and leave the drawing holding a list of one. pub fn ensure_real_scale_list(&mut self) -> bool { if self.has_own_scales() { return false; } for &(label, factor) in self.default_scales() { // Fallback labels are "paper:drawing"; parse them for tidy whole // units, else derive drawing units from the factor (paper = 1). let (paper, drawing) = label .split_once(':') .and_then(|(p, d)| { Some((p.trim().parse::().ok()?, d.trim().parse::().ok()?)) }) .filter(|&(p, d)| p > 0.0 && d > 0.0) .unwrap_or((1.0, 1.0 / factor)); self.add_scale(label, paper, drawing); } self.bump_scale_epoch(); true } pub fn scale_list(&self) -> Vec<(String, f32, f64)> { let unit_factor = self.annotation_scale_unit_factor(); let mut list: Vec<(String, f32, f64)> = self .document .objects .values() .filter_map(|o| match o { // Skip xref-derived scales. Scales pulled in from an external // reference get an "_XREF" suffix ("1:50_XREF"); unbound // dependent symbols carry a "xref|name" prefix. Neither // belongs to this drawing's own scale list. ObjectType::Scale(s) if !s.is_temporary && !s.name.contains('|') && !s.name.to_ascii_uppercase().ends_with("_XREF") => { Some(( s.name.clone(), (s.inverse_factor() / unit_factor) as f32, s.factor() * unit_factor, )) } _ => None, }) .collect(); list.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); if list.is_empty() { // Many drawings (and minimal DXF/DWG exports) carry no // ACAD_SCALELIST Scale objects at all. Without a fallback the // annotation / viewport scale picker would be empty and so appear // broken. Substitute the standard ratio set — file scales still // win whenever the drawing actually defines any. (#154) list = self .default_scales() .iter() .map(|&(label, base_vp)| { let vp = base_vp * unit_factor; (label.to_string(), (1.0 / vp) as f32, vp) }) .collect(); } list } /// Scale list for the status-bar picker. Some DWGs carry both the standard /// metric and architectural sets in `ACAD_SCALELIST`; show only the family /// matching INSUNITS while retaining custom names. The complete file list /// remains available to the scale manager and is written back unchanged. pub fn scale_picker_list(&self) -> Vec<(String, f32, f64)> { let all = self.scale_list(); let Some(imperial) = self.prefers_imperial_scales() else { return all; }; let mut visible: Vec<_> = all .iter() .filter(|(name, _, _)| { let architectural = Self::is_architectural_scale_name(name); let ratio = Self::is_ratio_scale_name(name); if imperial { architectural || (!architectural && !ratio) } else { ratio || (!architectural && !ratio) } }) .cloned() .collect(); let has_matching_family = visible.iter().any(|(name, _, _)| { if imperial { Self::is_architectural_scale_name(name) } else { Self::is_ratio_scale_name(name) } }); if !has_matching_family { let unit_factor = self.annotation_scale_unit_factor(); visible.extend(self.default_scales().iter().map(|&(label, base_vp)| { let vp = base_vp * unit_factor; (label.to_string(), (1.0 / vp) as f32, vp) })); } // If the current scale belongs to the opposite family and has no // equivalent factor in the visible family, keep that one active entry // visible instead of silently hiding the drawing's current state. if let Some(active) = all.iter().find(|(name, _, _)| { name.eq_ignore_ascii_case(&self.document.header.current_annotation_scale) }) { let has_equivalent = visible .iter() .any(|(_, _, vp)| (vp - active.2).abs() < 0.001 * active.2.max(0.001)); if !has_equivalent { visible.push(active.clone()); } } visible.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); visible } /// Handle of the drawing's `ACAD_SCALELIST` dictionary, located robustly. /// /// The canonical path is the root named-objects dict → `"ACAD_SCALELIST"`, /// but DWGs written by other programs don't always expose a resolvable root /// dict (the header handle points at no loaded object). In that case fall /// back to the owner dictionary shared by the drawing's `Scale` objects. /// Returns `None` only when the drawing genuinely has no scale list. pub(crate) fn scalelist_dict_handle(&self) -> Option { use codec::objects::ObjectType; let root_h = self.document.header.named_objects_dict_handle; if let Some(h) = crate::scene::annotative::as_dict(&self.document, root_h) .and_then(|d| d.get("ACAD_SCALELIST")) { return Some(h); } let owner = self.document.objects.values().find_map(|o| match o { ObjectType::Scale(s) if !s.is_temporary => Some(s.owner_handle), _ => None, })?; matches!( self.document.objects.get(&owner), Some(ObjectType::Dictionary(_)) ) .then_some(owner) } /// Millimetres in one unit of the paper side of this drawing's scale /// family: 25.4 for an imperial drawing (whose `1/8" = 1'-0"` style scales /// measure paper in inches), 1 for a metric one. Plot-scale factors built /// from [`Scene::scale_list`] are expressed per this unit; page setups /// store theirs per the layout's own paper unit, so the two are converted /// through this value. pub(crate) fn scale_family_unit_mm(&self) -> f64 { if self.prefers_imperial_scales() == Some(true) { 25.4 } else { 1.0 } } /// The `paper : drawing` ratio behind a scale-list name, from the /// drawing's own `Scale` object when it has one, otherwise from the /// built-in family. A ratio keeps the plot scale readable in a page setup /// (`1 : 250` rather than `0.004 : 1`). pub(crate) fn scale_ratio(&self, name: &str) -> Option<(f64, f64)> { use codec::objects::ObjectType; let stored = self.document.objects.values().find_map(|o| match o { ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => { Some((s.paper_units, s.drawing_units)) } _ => None, }); stored .or_else(|| Self::parse_scale_name_ratio(name)) .filter(|(paper, drawing)| *paper > 0.0 && *drawing > 0.0) } /// The `paper : drawing` ratio spelled by a scale name: `1:250` gives /// `(1, 250)`, an architectural `1/8" = 1'-0"` gives `(0.125, 12)` in /// inches. Anything else is not a scale name. pub(crate) fn parse_scale_name_ratio(name: &str) -> Option<(f64, f64)> { if let Some((paper, drawing)) = name.split_once(':') { let paper: f64 = paper.trim().parse().ok()?; let drawing: f64 = drawing.trim().parse().ok()?; return (paper > 0.0 && drawing > 0.0).then_some((paper, drawing)); } let (paper, drawing) = name.split_once('=')?; let paper = Self::parse_feet_inches(paper)?; let drawing = Self::parse_feet_inches(drawing)?; (paper > 0.0 && drawing > 0.0).then_some((paper, drawing)) } /// Inches in a `F'-I"` / `I"` / `F'` length, where the inch part may be a /// fraction (`3/32"`). fn parse_feet_inches(text: &str) -> Option { let text = text.trim(); let (feet, inches) = match text.split_once('\'') { Some((feet, rest)) => ( feet.trim().parse::().ok()?, rest.trim_start_matches('-').trim(), ), None => (0.0, text), }; let inches = inches.trim_end_matches('"').trim(); let inches = if inches.is_empty() { 0.0 } else if let Some((num, den)) = inches.split_once('/') { let den: f64 = den.trim().parse().ok()?; if den <= 0.0 { return None; } num.trim().parse::().ok()? / den } else { inches.parse::().ok()? }; Some(feet * 12.0 + inches) } /// Handle of the real (non-temporary) `Scale` object with this display name. /// Scales are matched by their `name` field, not the dictionary key — some /// files key the entries by an internal identifier (`*A1`, `A0`, …) that /// bears no relation to the scale name. pub(crate) fn scale_object_handle(&self, name: &str) -> Option { use codec::objects::ObjectType; self.document.objects.iter().find_map(|(h, o)| match o { ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => { Some(*h) } _ => None, }) } /// Resolve the current annotation scale (CANNOSCALE) to a real `Scale` /// object handle, materializing the object from the scale list when the /// drawing names the scale but has no `Scale` object for it. Returns `None` /// when there is no current annotation scale. Used when giving an object a /// per-object annotation context (the context's `340` must point at a real /// `AcDbScale`). pub(crate) fn current_annotation_scale_handle(&mut self) -> Option { let name = self.document.header.current_annotation_scale.clone(); if name.trim().is_empty() { return None; } self.scale_handle_ensuring(&name) } pub(crate) fn paper_annotation_scale_handle(&self) -> Option { self.scale_object_handle("1:1").or_else(|| { self.document .objects .iter() .find_map(|(handle, object)| match object { ObjectType::Scale(scale) if !scale.is_temporary && (scale.factor() - 1.0).abs() <= 1.0e-9 => { Some(*handle) } _ => None, }) }) } pub(crate) fn creation_annotation_scale_handle(&mut self) -> Option { if self.current_layout == "Model" { return self.current_annotation_scale_handle(); } if let Some(viewport) = self.active_viewport { if let Some(scale) = self.viewport_scale_handle(viewport) { return Some(scale); } } if let Some(scale) = self.paper_annotation_scale_handle() { return Some(scale); } self.scale_handle_ensuring("1:1") } pub(crate) fn creation_annotation_multiplier(&self) -> f64 { if self.current_layout == "Model" { return self.annotation_scale.max(1.0e-9) as f64; } let Some(handle) = self.active_viewport else { return 1.0; }; let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) else { return 1.0; }; let paper_to_model = vp_effective_scale(viewport.custom_scale, viewport.view_height, viewport.height); if paper_to_model.is_finite() && paper_to_model > 1.0e-9 { 1.0 / paper_to_model } else { 1.0 } } pub fn set_annotation_scale_named(&mut self, name: &str) -> Option { let handle = self.scale_handle_ensuring(name)?; let unit_factor = self.annotation_scale_unit_factor(); let (scale_name, scale_factor, multiplier) = { let ObjectType::Scale(scale) = self.document.objects.get(&handle)? else { return None; }; ( scale.name.clone(), scale.factor(), scale.inverse_factor() / unit_factor, ) }; self.annotation_scale = multiplier as f32; self.document.header.current_annotation_scale = scale_name; self.document.header.annotation_scale_value = scale_factor; self.invalidate_annotation_dependencies(); Some(handle) } fn current_layout_object_handle(&self) -> Option { self.document .objects .iter() .find_map(|(handle, object)| match object { ObjectType::Layout(layout) if layout.name.eq_ignore_ascii_case(&self.current_layout) => { Some(*handle) } _ => None, }) } pub fn annotation_all_visible(&self) -> bool { use codec::objects::XRecordValue; let Some(layout) = self.current_layout_object_handle() else { return true; }; self.document .xrecord(layout, "OPEN_CAD_ANNOTATION_STATE") .and_then(|record| { record.entries.iter().find_map(|entry| match entry.value { XRecordValue::Bool(value) if entry.code == 290 => Some(value), _ => None, }) }) .unwrap_or(true) } pub fn set_annotation_all_visible(&mut self, value: bool) { use codec::objects::{XRecordEntry, XRecordValue}; let Some(layout) = self.current_layout_object_handle() else { return; }; self.document .ensure_xrecord(layout, "OPEN_CAD_ANNOTATION_STATE"); if let Some(record) = self .document .xrecord_mut(layout, "OPEN_CAD_ANNOTATION_STATE") { if let Some(entry) = record.entries.iter_mut().find(|entry| entry.code == 290) { entry.value = XRecordValue::Bool(value); } else { record.entries.push(XRecordEntry::bool(290, value)); } } self.invalidate_annotation_dependencies(); } pub fn add_annotation_scale_to_objects( &mut self, scale: Handle, previous_scale: Option, mode: u8, ) -> usize { if previous_scale == Some(scale) || !(1..=4).contains(&mode) { return 0; } let viewport_frozen: HashSet = self .target_viewport_handle() .and_then(|handle| match self.document.get_entity(handle) { Some(EntityType::Viewport(viewport)) => { Some(viewport.frozen_layers.iter().copied().collect()) } _ => None, }) .unwrap_or_default(); let handles: Vec<_> = self .document .entities() .filter(|entity| { if !crate::scene::annotative::is_annotative(&self.document, entity) { return false; } let memberships = crate::scene::annotative::object_scale_memberships( &self.document, entity.common().handle, ); if !previous_scale .is_some_and(|current| memberships.iter().any(|(_, member)| *member == current)) { return false; } let layer = self.document.layers.get(&entity.common().layer); let (off, frozen, locked, viewport_frozen) = layer .map(|layer| { ( layer.flags.off, layer.flags.frozen, layer.flags.locked, viewport_frozen.contains(&layer.handle), ) }) .unwrap_or((false, false, false, false)); match mode { 1 => !(off || frozen || locked || viewport_frozen), 2 => !(off || frozen || viewport_frozen), 3 => !locked, 4 => true, _ => false, } }) .map(|entity| entity.common().handle) .collect(); let mut added = 0; for handle in handles { let existed = crate::scene::annotative::object_scale_memberships(&self.document, handle) .iter() .any(|(_, member)| *member == scale); if !existed && crate::scene::annotative::create_annotation_context( &mut self.document, handle, scale, ) { added += 1; } } if added > 0 { self.bump_geometry(); } added } /// For an annotative TEXT/MTEXT, its stored height per unit of paper text /// height at the displayed scale (the stored height is the model height /// of its default representation). `None` for anything else. pub(crate) fn annotative_text_height_factor(&self, handle: Handle) -> Option { let entity = self.document.get_entity(handle)?; if !matches!(entity, EntityType::Text(_) | EntityType::MText(_)) || !crate::scene::annotative::is_annotative(&self.document, entity) { return None; } let scale = self.displayed_annotation_scale_handle(); let model_per_paper = scale .and_then(|scale| match self.document.objects.get(&scale) { Some(ObjectType::Scale(scale)) => { Some(scale.inverse_factor() / self.annotation_scale_unit_factor()) } _ => None, }) .unwrap_or(self.annotation_scale as f64); let ratio = crate::scene::annotative::text_context_ratio(&self.document, handle, scale) .unwrap_or(1.0); Some(model_per_paper / ratio) } pub(crate) fn displayed_annotation_scale_handle(&self) -> Option { if self.current_layout == "Model" { return self.scale_object_handle(&self.document.header.current_annotation_scale); } self.explicit_viewport_handle() .and_then(|viewport| self.viewport_scale_handle(viewport)) .or_else(|| self.paper_annotation_scale_handle()) } /// Resolve a named annotation scale to a real `Scale` object handle, /// materializing the object from the scale list when the drawing names the /// scale (e.g. a virtual fallback scale) but has no `Scale` object for it. pub(crate) fn scale_handle_ensuring(&mut self, name: &str) -> Option { if let Some(h) = self.scale_object_handle(name) { return Some(h); } // A drawing that owns no scales is being shown the standard set that // `scale_list` substitutes for an empty one. Writing only the scale // asked for would give it a list of its own — of exactly one entry — // and the substitution would stop, so every other scale would vanish // from the picker the moment anything needed a real one. Bring the // whole set in instead, which is what the drawing was already being // shown. (#666, #683) if self.ensure_real_scale_list() { if let Some(h) = self.scale_object_handle(name) { return Some(h); } } let fallback = self .default_scales() .iter() .find(|(label, _)| label.eq_ignore_ascii_case(name)) .map(|(_, factor)| (1.0, 1.0 / factor)) .or_else(|| { let (paper, drawing) = name.split_once(':')?; let paper = paper.trim().parse::().ok()?; let drawing = drawing.trim().parse::().ok()?; (paper > 0.0 && drawing > 0.0).then_some((paper, drawing)) }) .or_else(|| { let drawing = name.trim().parse::().ok()?; (drawing > 0.0).then_some((1.0, drawing)) })?; let (paper, drawing) = self.scale_paper_drawing(name).unwrap_or(fallback); // `add_scale` already bumps `scale_epoch`; no extra bump here. self.add_scale(name, paper, drawing); self.scale_object_handle(name) } /// Add a named annotation scale to the drawing's `ACAD_SCALELIST`. Returns /// `false` if a scale with that name already exists. Creates the SCALELIST /// dictionary (and registers it in the root dictionary) when the drawing /// has none of its own — many minimal files carry no scale list at all. pub fn add_scale(&mut self, name: &str, paper: f64, drawing: f64) -> bool { use codec::objects::{Dictionary, ObjectType, Scale}; // Reject a duplicate by the scale's display name (the dictionary key may // be an unrelated internal identifier, so check the objects directly). if self.scale_object_handle(name).is_some() { return false; } let scalelist_h = match self.scalelist_dict_handle() { Some(h) => h, None => { // Resolve (or synthesise) the root robustly — a drawing whose // header root pointer is unresolvable would otherwise register // the new dictionary against a missing root and silently orphan // it. See `annotative::root_named_dict_handle`. let root_h = crate::scene::annotative::root_named_dict_handle(&mut self.document); let h = self.document.allocate_handle(); let mut d = Dictionary::new(); d.handle = h; d.owner = root_h; self.document.objects.insert(h, ObjectType::Dictionary(d)); if let Some(ObjectType::Dictionary(root)) = self.document.objects.get_mut(&root_h) { root.add_entry("ACAD_SCALELIST", h); } h } }; let sh = self.document.allocate_handle(); let mut s = Scale::new(name, paper, drawing); s.handle = sh; s.owner_handle = scalelist_h; self.document.objects.insert(sh, ObjectType::Scale(s)); if let Some(ObjectType::Dictionary(sl)) = self.document.objects.get_mut(&scalelist_h) { sl.add_entry(name, sh); } self.bump_scale_epoch(); true } /// Remove a named annotation scale from the drawing's `ACAD_SCALELIST` /// (both the `Scale` object and its dictionary entry). Returns `false` if /// no such scale exists. The current annotation scale should not be removed /// — the caller guards that. pub fn remove_scale(&mut self, name: &str) -> bool { use codec::objects::ObjectType; let Some(sh) = self.scale_object_handle(name) else { return false; }; if self .document .header .current_annotation_scale .eq_ignore_ascii_case(name) { return false; } let entity_handles: Vec<_> = self .document .entities() .map(|entity| entity.common().handle) .collect(); for entity in entity_handles { crate::scene::annotative::remove_annotation_context_for_scale( &mut self.document, entity, sh, ); } let replacement = self .document .objects .iter() .find_map(|(handle, object)| match object { ObjectType::Scale(scale) if *handle != sh && !scale.is_temporary => Some(*handle), _ => None, }); let viewports: Vec<_> = self .document .entities() .filter_map(|entity| match entity { EntityType::Viewport(viewport) if self .document .viewport_annotation_scale(viewport.common.handle) == Some(sh) => { Some(viewport.common.handle) } _ => None, }) .collect(); for viewport in viewports { if let Some(replacement) = replacement { self.document .set_viewport_annotation_scale(viewport, replacement); } else if let Some(record) = self .document .xrecord_mut(viewport, "ASDK_XREC_ANNOTATION_SCALE_INFO") { record.entries.retain(|entry| entry.code != 340); } } // Resolve the dictionary before removing the object (the fallback lookup // reads a surviving scale's owner). let dict_h = self.scalelist_dict_handle(); self.document.objects.remove(&sh); if let Some(dict_h) = dict_h { if let Some(ObjectType::Dictionary(sl)) = self.document.objects.get_mut(&dict_h) { // Match by handle — the entry key is not necessarily the name. sl.entries.retain(|(_, h)| *h != sh); } } self.invalidate_annotation_dependencies(); self.bump_scale_epoch(); true } /// Rename `old_name` and/or change its paper:drawing ratio. Returns `false` /// if the scale is missing or the new name collides with another scale. pub fn edit_scale(&mut self, old_name: &str, new_name: &str, paper: f64, drawing: f64) -> bool { use codec::objects::ObjectType; let Some(sh) = self.scale_object_handle(old_name) else { return false; }; // A new name that collides with a *different* scale object is rejected. if !new_name.eq_ignore_ascii_case(old_name) { if let Some(other) = self.scale_object_handle(new_name) { if other != sh { return false; } } } if let Some(ObjectType::Scale(s)) = self.document.objects.get_mut(&sh) { s.name = new_name.to_string(); s.paper_units = paper; s.drawing_units = drawing; s.is_unit_scale = (paper - drawing).abs() < 1e-10; } // Keep the owning dictionary's entry key in sync, matched by handle. if let Some(dict_h) = self.scalelist_dict_handle() { if let Some(ObjectType::Dictionary(sl)) = self.document.objects.get_mut(&dict_h) { if let Some(e) = sl.entries.iter_mut().find(|(_, h)| *h == sh) { e.0 = new_name.to_string(); } } } self.bump_scale_epoch(); true } /// The (paper_units, drawing_units) of a named scale, for the editor. pub fn scale_paper_drawing(&self, name: &str) -> Option<(f64, f64)> { self.document.objects.values().find_map(|o| match o { ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => { Some((s.paper_units, s.drawing_units)) } _ => None, }) } /// List of user viewports in the current layout: (handle, label, frozen_layer_handles). pub fn viewport_list(&self) -> Vec<(codec::Handle, String, Vec)> { if self.current_layout == "Model" { return vec![]; } let (layout_block, _, content) = self.paper_viewport_handles(); if layout_block.is_null() { return vec![]; } let mut result: Vec<(codec::Handle, String, Vec)> = content .iter() .filter_map(|handle| { let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else { return None; }; Some((vp.common.handle, vp.id, vp.frozen_layers.clone())) }) .collect::>() .into_iter() .enumerate() .map(|(i, (h, id, frozen))| { let label = if id > 1 { format!("VP {}", id - 1) } else { format!("VP {}", i + 1) }; (h, label, frozen) }) .collect(); result.sort_by_key(|(_, label, _)| label.clone()); result } /// Count of user viewports (id > 1) in the current layout. pub fn viewport_count(&self) -> usize { if self.current_layout == "Model" { return 0; } let (layout_block, _, content) = self.paper_viewport_handles(); if layout_block.is_null() { return 0; } content.len() } /// True when ISOLATEOBJECTS or HIDEOBJECTS has an active session filter. pub fn is_isolation_active(&self) -> bool { self.object_isolation.is_active() } /// True when a top-level entity must be omitted for a session-only object /// visibility command or an interactive replacement preview. fn entity_temporarily_hidden(&self, handle: Handle) -> bool { self.object_isolation.hides(handle) || self.preview_hidden.contains(&handle) || self.command_preview_hidden.contains(&handle) || self.hidden_dynamic_dimensions.contains(&handle) } /// Replace the command-owned source hide set and refresh only handles whose /// visibility changed. An empty slice restores every source on completion. pub fn set_command_preview_hidden(&mut self, handles: &[Handle]) { let desired: HashSet<_> = handles.iter().copied().collect(); if desired == self.command_preview_hidden { return; } let changes: Vec<_> = self .command_preview_hidden .symmetric_difference(&desired) .copied() .map(|handle| (handle, ChangeKind::Modified)) .collect(); self.command_preview_hidden = desired; if !changes.is_empty() { self.bump_entities(&changes); } } /// Set (or clear) the previewed entity that renders with the selection /// highlight without joining the real selection. Only refreshes the GPU /// xray overlay (no re-tessellation). pub fn set_hover_highlight(&mut self, handle: Option) { if self.hover_highlight == handle { return; } // Selectable groups roll over as one unit, matching click selection. // Exclude already-selected members because they already contribute to // the overlay and remain in the selected colour. let contribution = |scene: &Scene, hovered: Option| { hovered .map(|handle| scene.handles_expanded_for_selectable_groups(&[handle])) .unwrap_or_default() .into_iter() .filter(|handle| !scene.selected.contains(handle)) .collect::>() }; let changed = contribution(self, self.hover_highlight) != contribution(self, handle); self.hover_highlight = handle; if changed { self.bump_selection(); } } /// Handles that currently contribute the rollover colour. The stored hover /// remains the picked member for hit-test/UI bookkeeping; rendering expands /// it to the same selectable group that a click would select. pub fn hover_highlight_handles(&self) -> HashSet { let mut handles = self .hover_highlight .map(|handle| self.handles_expanded_for_selectable_groups(&[handle])) .unwrap_or_default(); handles.extend(self.constraint_hover_highlights.iter().copied()); handles } pub fn set_constraint_hover_highlights( &mut self, references: &[crate::scene::parametric_constraints::ParametricRef], ) { let mut references = references.to_vec(); references.dedup(); if references == self.constraint_hover_refs { return; } let mut handles = HashSet::default(); let mut wires = Vec::new(); for reference in &references { let Some(segment) = reference.segment_index() else { handles.insert(reference.entity); continue; }; let Some(entity) = self.document.get_entity(reference.entity) else { continue; }; let Some(mut curve) = crate::entities::curve::entity_curve(entity) else { handles.insert(reference.entity); continue; }; let Some(segment_curve) = curve.curve.segments().into_iter().nth(segment) else { continue; }; curve.curve = segment_curve; let mut wire = WireModel::solid_f64( format!("constraint-segment:{}:{segment}", reference.entity.value()), curve.tessellate(8.0), WireModel::HOVER, false, ); wire.set_fixed_screen_width(2.0); wires.push(wire); } self.constraint_hover_refs = references; self.constraint_hover_highlights = handles; self.constraint_hover_wires = wires; self.bump_selection(); } /// Keep the current selection visible and temporarily filter every other /// top-level object. Block-definition children are intentionally not added /// here: an isolated INSERT remains a complete visible block instance. pub fn isolate_selected(&mut self) { if self.selected.is_empty() { return; } let keep = self.selected.clone(); let active_block = self.interaction_block_handle(); let hide: Vec = self .document .entities() .filter(|entity| { let common = entity.common(); !common.handle.is_null() && !keep.contains(&common.handle) && self.belongs_to_visible_block( common.handle, common.owner_handle, active_block, ) }) .map(|entity| entity.common().handle) .collect(); let changes: Vec<_> = hide .iter() .copied() .map(|handle| (handle, ChangeKind::Modified)) .collect(); self.object_isolation.hidden.extend(hide); self.object_isolation.keep = Some(keep); // Isolation hides entities the memoised glyph placements read // (`entity_temporarily_hidden`), so invalidate them too. self.bump_constraints_epoch(); if !changes.is_empty() { self.bump_entities(&changes); } } /// Temporarily hide the current selection without changing any entity /// property in the document. pub fn hide_selected(&mut self) { if self.selected.is_empty() { return; } let changes: Vec<_> = self .selected .iter() .copied() .map(|handle| (handle, ChangeKind::Modified)) .collect(); self.object_isolation .hidden .extend(self.selected.iter().copied()); self.selected.clear(); self.selected_order.clear(); self.bump_selection_set(); // Newly hidden entities change `entity_temporarily_hidden`, which the // memoised glyph placements read — invalidate them too. self.bump_constraints_epoch(); self.bump_entities(&changes); } /// Clear every session-only object visibility filter. pub fn end_isolation(&mut self) { if !self.object_isolation.is_active() { return; } let changes: Vec<_> = self .object_isolation .hidden .iter() .copied() .map(|handle| (handle, ChangeKind::Modified)) .collect(); self.object_isolation = ObjectIsolationState::default(); // Restoring isolated entities changes `entity_temporarily_hidden`, // which the memoised glyph placements read — invalidate them too. // (`reset_transient_visibility` needs no bump: it only runs on // doc-replace, where `load_parametric_constraints_from_document` // already bumps the epoch for the whole-set replace.) self.bump_constraints_epoch(); if !changes.is_empty() { self.bump_entities(&changes); } } /// A newly opened/replaced document starts with no session visibility. /// Persisted `EntityCommon::invisible` values stay untouched and continue /// to serve file/dynamic-block visibility. pub fn reset_transient_visibility(&mut self) { self.object_isolation = ObjectIsolationState::default(); self.preview_hidden.clear(); self.command_preview_hidden.clear(); } /// True if any currently selected entity is a Viewport. /// Used to enable the scale picker when a viewport is selected in paper space. pub fn has_selected_viewport(&self) -> bool { self.selected .iter() .any(|&h| matches!(self.document.get_entity(h), Some(EntityType::Viewport(_)))) } /// First content viewport handle in the current layout, used as fallback target /// when no viewport is active or explicitly selected. fn first_viewport_handle(&self) -> Option { if self.current_layout == "Model" { return None; } let layout_block = self.current_layout_block_handle(); if layout_block.is_null() { return None; } self.document.entities().find_map(|e| { if let EntityType::Viewport(vp) = e { if self.is_content_viewport_in_layout(vp, layout_block) { return Some(vp.common.handle); } } None }) } fn target_viewport_handle(&self) -> Option { if self.current_layout == "Model" { return None; } self.active_viewport .filter(|handle| { matches!( self.document.get_entity(*handle), Some(EntityType::Viewport(_)) ) }) .or_else(|| { self.selected.iter().copied().find(|handle| { matches!( self.document.get_entity(*handle), Some(EntityType::Viewport(_)) ) }) }) .or_else(|| self.first_viewport_handle()) } fn explicit_viewport_handle(&self) -> Option { if self.current_layout == "Model" { return None; } self.active_viewport .filter(|handle| { matches!( self.document.get_entity(*handle), Some(EntityType::Viewport(_)) ) }) .or_else(|| { self.selected.iter().copied().find(|handle| { matches!( self.document.get_entity(*handle), Some(EntityType::Viewport(_)) ) }) }) } fn viewport_scale_handle(&self, viewport: Handle) -> Option { if let Some(handle) = self.document.viewport_annotation_scale(viewport) { if matches!( self.document.objects.get(&handle), Some(ObjectType::Scale(_)) ) { return Some(handle); } } let EntityType::Viewport(vp) = self.document.get_entity(viewport)? else { return None; }; let factor = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); self.document .objects .iter() .filter_map(|(handle, object)| match object { ObjectType::Scale(scale) if !scale.is_temporary => { Some((*handle, (scale.factor() - factor).abs())) } _ => None, }) .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) .map(|(handle, _)| handle) } fn viewport_annotation_multiplier(&self, viewport: Handle) -> f32 { let unit_factor = self.annotation_scale_unit_factor(); self.viewport_scale_handle(viewport) .and_then(|handle| match self.document.objects.get(&handle) { Some(ObjectType::Scale(scale)) => { Some((scale.inverse_factor() / unit_factor) as f32) } _ => None, }) .unwrap_or(self.annotation_scale) } pub fn displayed_annotation_scale_name(&self) -> String { if self.current_layout == "Model" { return self.document.header.current_annotation_scale.clone(); } // Same viewport the pill number comes from (active → selected → // first-content fallback), so label, number and badge always describe // one viewport (#48). Degenerate case: with zero Scale objects the // name falls back to paper/"1:1" while the number stays factual. // The render paths use displayed_annotation_scale_HANDLE, which stays // explicit-only — untouched by this fix. self.target_viewport_handle() .and_then(|viewport| self.viewport_scale_handle(viewport)) .and_then(|handle| match self.document.objects.get(&handle) { Some(ObjectType::Scale(scale)) => Some(scale.name.clone()), _ => None, }) .or_else(|| { self.paper_annotation_scale_handle().and_then(|handle| { match self.document.objects.get(&handle) { Some(ObjectType::Scale(scale)) => Some(scale.name.clone()), _ => None, } }) }) .unwrap_or_else(|| "1:1".to_string()) } pub fn set_viewport_scale_named(&mut self, name: &str) -> Option { let viewport = self.explicit_viewport_handle()?; self.set_viewport_scale_named_for(viewport, name) } pub fn set_viewport_scale_named_for(&mut self, viewport: Handle, name: &str) -> Option { let scale_handle = self.scale_handle_ensuring(name)?; let unit_factor = self.annotation_scale_unit_factor(); let factor = match self.document.objects.get(&scale_handle) { Some(ObjectType::Scale(scale)) => scale.factor() * unit_factor, _ => return None, }; let locked = matches!( self.document.get_entity(viewport), Some(EntityType::Viewport(vp)) if vp.status.locked ); if locked || factor <= 1.0e-9 { return None; } if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(viewport) { vp.custom_scale = factor; vp.view_height = vp.height / factor; } else { return None; } self.document .set_viewport_annotation_scale(viewport, scale_handle); // Viewport scale changed: the dimensions drawn on the sheet // through it measure the same model geometry at a new paper // size and have to be re-placed. self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); Some(scale_handle) } /// Scale the active viewport to `factor` paper units per model unit /// (ZOOM nXP). A named scale of that factor is linked as its annotation /// scale; otherwise the viewport keeps a custom scale. pub fn set_viewport_scale_factor(&mut self, factor: f64) -> bool { let Some(viewport) = self.explicit_viewport_handle() else { return false; }; self.set_viewport_scale_factor_for(viewport, factor) } /// Scale `viewport` to `factor` paper units per model unit, as /// [`Self::set_viewport_scale_factor`] does for the active one. pub fn set_viewport_scale_factor_for(&mut self, viewport: Handle, factor: f64) -> bool { let unit_factor = self.annotation_scale_unit_factor(); let named = self .document .objects .values() .find_map(|object| match object { ObjectType::Scale(scale) if !scale.is_temporary && (scale.factor() * unit_factor - factor).abs() <= 1.0e-9 * factor.max(1.0) => { Some(scale.name.clone()) } _ => None, }); if let Some(name) = named { return self.set_viewport_scale_named_for(viewport, &name).is_some(); } match self.document.get_entity_mut(viewport) { Some(EntityType::Viewport(vp)) if !vp.status.locked && factor > 1.0e-9 => { vp.custom_scale = factor; vp.view_height = vp.height / factor; } _ => return false, } self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); true } pub fn viewport_annotation_scale_synced(&self) -> Option { // Same viewport as the pill number and label (#48). let viewport = self.target_viewport_handle()?; let EntityType::Viewport(vp) = self.document.get_entity(viewport)? else { return None; }; let scale = self.viewport_scale_handle(viewport)?; let ObjectType::Scale(scale) = self.document.objects.get(&scale)? else { return None; }; let effective = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); let expected = scale.factor() * self.annotation_scale_unit_factor(); Some((effective - expected).abs() <= 1.0e-6 * expected.max(1.0)) } pub fn sync_viewport_annotation_scale(&mut self) -> bool { let Some(viewport) = self.explicit_viewport_handle() else { return false; }; let Some(scale_handle) = self.viewport_scale_handle(viewport) else { return false; }; let unit_factor = self.annotation_scale_unit_factor(); let factor = match self.document.objects.get(&scale_handle) { Some(ObjectType::Scale(scale)) => scale.factor() * unit_factor, _ => return false, }; let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(viewport) else { return false; }; if vp.status.locked || factor <= 1.0e-9 { return false; } vp.custom_scale = factor; vp.view_height = vp.height / factor; self.document .set_viewport_annotation_scale(viewport, scale_handle); // Viewport scale changed: the dimensions drawn on the sheet // through it measure the same model geometry at a new paper // size and have to be re-placed. self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); true } /// Sorted list of layout names: "Model" first, then paper layouts by tab order. pub fn layout_names(&self) -> Vec { let mut names = vec!["Model".to_string()]; // Deduplicate by name: prefer the entry with a non-null block_record (the // real layout from the file) over the default placeholder created by // CadDocument::new(). let mut by_name: rustc_hash::FxHashMap = Default::default(); for obj in self.document.objects.values() { if let ObjectType::Layout(l) = obj { if l.name == "Model" || l.name.is_empty() { continue; } let entry = by_name .entry(l.name.clone()) .or_insert((l.tab_order, l.block_record)); if entry.1.is_null() && !l.block_record.is_null() { *entry = (l.tab_order, l.block_record); } } } let mut paper: Vec<(i16, String)> = by_name .into_iter() .map(|(name, (order, _))| (order, name)) .collect(); paper.sort_by_key(|(order, _)| *order); names.extend(paper.into_iter().map(|(_, n)| n)); names } pub fn bump_layout_epoch(&mut self) { self.layout_epoch += 1; } pub fn cached_layout_names(&self) -> std::sync::Arc> { if let Some((epoch, names)) = self.layout_names_cache.borrow().as_ref() { if *epoch == self.layout_epoch { return std::sync::Arc::clone(names); } } let names = std::sync::Arc::new(self.layout_names()); *self.layout_names_cache.borrow_mut() = Some((self.layout_epoch, std::sync::Arc::clone(&names))); names } pub fn bump_scale_epoch(&mut self) { self.scale_epoch += 1; } /// Current parametric-constraint-set epoch (cache key input for the /// memoised `cached_glyph_placements` accessor). #[allow(dead_code)] pub(crate) fn constraints_epoch(&self) -> u64 { self.constraints_epoch } /// Invalidate constraint-glyph consumers. Call on EVERY mutation of the /// constraint set or its visibility overrides — a missed bump serves /// stale glyphs. pub(crate) fn bump_constraints_epoch(&mut self) { self.constraints_epoch += 1; } pub fn cached_scale_picker_list(&self) -> std::sync::Arc> { let factor = self.annotation_scale_unit_factor(); let imperial = self.prefers_imperial_scales(); let current = self.document.header.current_annotation_scale.clone(); if let Some((epoch, cached_factor, cached_imperial, cached_current, list)) = self.scale_picker_cache.borrow().as_ref() { if *epoch == self.scale_epoch && *cached_factor == factor && *cached_imperial == imperial && *cached_current == current { return std::sync::Arc::clone(list); } } let list = std::sync::Arc::new(self.scale_picker_list()); *self.scale_picker_cache.borrow_mut() = Some(( self.scale_epoch, factor, imperial, current, std::sync::Arc::clone(&list), )); list } pub fn bump_ucs_epoch(&mut self) { self.ucs_epoch += 1; } /// Named-UCS names for the ViewCube picker, memoised behind `ucs_epoch` /// (#34, mirrors `cached_layout_names`). Same empty-name filter the view /// blocks used when building the list inline every frame. pub fn cached_ucs_names(&self) -> std::sync::Arc<[String]> { if let Some((epoch, names)) = self.ucs_names_cache.borrow().as_ref() { if *epoch == self.ucs_epoch { return std::sync::Arc::clone(names); } } let names: std::sync::Arc<[String]> = self .document .ucss .iter() .map(|u| u.name.clone()) .filter(|n| !n.is_empty()) .collect::>() .into(); *self.ucs_names_cache.borrow_mut() = Some((self.ucs_epoch, std::sync::Arc::clone(&names))); names } /// Wire set for the Model layout, shared by every tile. /// /// The model wire geometry is **camera-independent**, so it is tessellated /// in full (un-culled, fixed detail) once per geometry epoch, held resident /// (`model_static_wires`), and returned for any camera/tile. A pan/zoom only /// changes the view uniform — the GPU re-draws the same buffer, with no /// frustum cull, no zoom LOD, and no re-tessellation/re-upload on camera /// moves. The per-tile camera args are unused (kept for call-site symmetry /// with the paper-space wire sources). #[doc(hidden)] pub fn model_tile_wires_arc( &self, _tile_idx: usize, _cam: &Camera, _cam_aspect: f32, _tile_pixel_height: f32, ) -> Arc> { // In a BEDIT block editor the resident set is the edited block's own // (block-local) entities; otherwise model space. (#261) let block = self .block_edit_block .unwrap_or_else(|| self.model_space_block_handle()); let scale = crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, ); self.resident_wires_for(block, None, scale, None, None) } /// Unified static-hold wire builder — the ONE tessellation path every /// space renders through (Model tiles, BEDIT, the paper sheet block, and /// paper content viewports): the FULL, un-culled, LOD-free set of `block`, /// held resident per `(block, ambient bg, anno, frozen)` key and rebuilt /// only when `geometry_epoch` changes. Mints a stable /// [`WIRE_CONTENT_GEN`] id per build (relayed via `last_model_wire_gen`) /// so the GPU upload gate and `render_signature` skip unchanged content. /// Whether the per-viewport annotation scale changes wire output at all /// Cached per geometry epoch; the scan short-circuits on the first /// annotative entity. PSLTSCALE no longer participates because dash scaling /// is applied in the wire shader from the viewport uniform. fn annotation_affects_wires(&self) -> bool { if let Some((epoch, v)) = self.annotation_affects_wires.get() { if epoch == self.geometry_epoch { return v; } // Skip the whole-document annotative scan when nothing annotative // changed since (PSLTSCALE toggles route through a full delta, which // fails this check and forces the recompute below). if self.category_cache_valid(epoch, CACHE_CATEGORY_ANNOTATIVE, |h| { self.document .get_entity(h) .map(|e| crate::scene::annotative::is_annotative(&self.document, e)) .unwrap_or(false) }) { self.annotation_affects_wires .set(Some((self.geometry_epoch, v))); return v; } } let v = self .document .entities() .any(|e| crate::scene::annotative::is_annotative(&self.document, e)); self.annotation_affects_wires .set(Some((self.geometry_epoch, v))); v } fn resident_wires_for( &self, block: Handle, anno_scale_override: Option, annotation_scale_handle: Option, frozen_layers: Option<&HashSet>, style_viewport: Option, ) -> Arc> { // Normalize an inert anno override away so distinct viewport scales // share one resident set when annotation can't change the wires. let anno_scale_override = if self.annotation_affects_wires() { anno_scale_override } else { None }; // Ambient bg is part of the key: the same block tessellated against // the model vs paper background adapts wire colors differently. let bg = if self.current_layout == "Model" { self.bg_color } else { self.paper_bg_color }; let all_visible = self.annotation_all_visible(); let key = self.resident_wire_key( block, bg, anno_scale_override, annotation_scale_handle, all_visible, frozen_layers, style_viewport, ); { let sets = self.resident_wire_sets.borrow(); if let Some(set) = sets.get(&key) { if set.epoch == self.geometry_epoch { self.last_model_wire_gen.set(set.gen); return Arc::clone(&set.wires); } } } // Incremental: bring the cached set up to date by replaying just the // changed entities into it, instead of re-cloning + re-sorting the whole // set. Falls back to the full build below when it can't (no cache entry, // journal un-replayable, or a structural assumption violated). // A live section changes the displayed wires of every intersected // solid, even when the delta journal contains only the section entity. // Rebuild the resident set in that case so an incremental patch cannot // retain the source solid's uncut edge cache. if self.active_live_section(block).is_none() { if let Some(arc) = self.try_resident_patch( key, block, bg, anno_scale_override, annotation_scale_handle, all_visible, frozen_layers, style_viewport, ) { return arc; } } // Build once: full tessellation, no cull, no zoom LOD — the resident // set is zoom-independent (GPU analytical circles/arcs/ellipses). let t_tess = iced::time::Instant::now(); let mut atlas_gen = crate::scene::text::sdf_atlas::generation(); self.building_resident_set.set(true); let mut wires = self.wires_for_block_culled( block, None, None, frozen_layers, anno_scale_override, annotation_scale_handle, all_visible, style_viewport, ); // Baking a drawing's glyphs for the first time can grow the SDF atlas // part-way through this pass (a CJK sheet set bakes thousands of // tiles). Text tessellated before the growth carries UVs that now // address the wrong tiles — visible as scrambled glyphs — and this // set is cached by geometry epoch, so nothing would ever re-lay it // out. The memo guard already folds the generation in, so rebuilding // once the atlas has settled re-tessellates only the stale text. for _ in 0..3 { let now = crate::scene::text::sdf_atlas::generation(); if now == atlas_gen { break; } atlas_gen = now; // Block definitions tessellated earlier in this pass hold the // stale quads too, and their cache is keyed by block epoch (which // has not moved), so drop them or the rebuild would reuse them. self.block_defn_cache.borrow_mut().clear(); wires = self.wires_for_block_culled( block, None, None, frozen_layers, anno_scale_override, annotation_scale_handle, all_visible, style_viewport, ); } self.building_resident_set.set(false); let perf = crate::perf::enabled(); let t_post = perf.then(iced::time::Instant::now); // Synthesized nonprint markers (geo-location daisy) live in model space // only and are derived from document objects, not entities — append them // to the freshly built resident set (incremental patches preserve them). if block == self.model_space_block_handle() { self.append_scene_markers(&mut wires, bg); } let markers_ms = crate::perf::elapsed_ms(t_post); let t_fade = perf.then(iced::time::Instant::now); self.apply_refedit_fade(&mut wires, bg); let fade_ms = crate::perf::elapsed_ms(t_fade); let t_layout = perf.then(iced::time::Instant::now); let layout = Self::resident_wire_layout(&wires); if perf { crate::perf_record!( "[perf] resident-post markers={:.1}ms fade={:.1}ms layout={:.1}ms wires={}", markers_ms, fade_ms, crate::perf::elapsed_ms(t_layout), wires.len(), ); } self.last_tess_ms .set(t_tess.elapsed().as_secs_f32() * 1000.0); self.last_tess_wires.set(wires.len()); let arc = Arc::new(wires); // The memo keeps one copy of what this set holds; read before the // previous set is evicted below, which may hold runs it still needs. if self.uses_resident_memo(block, frozen_layers) { match &layout { Some(layout) if self.resident_runs_match_memo(block) => { self.bind_memo_to_set(&arc, &layout.ranges); } _ => self.detach_memo_backing(), } } let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); // A full rebuild of the Model set — the GPU arena must rebuild too, not // apply a stale patch, so clear any pending handoff. if block == self.model_space_block_handle() { *self.model_wire_gpu_patch.borrow_mut() = None; } // Every content viewport of the shown layout holds a set of its own; // a cap below their count cleared them all each frame and rebuilt // every viewport's wires on every redraw (#1769). let shown = if self.current_layout == "Model" { 0 } else { self.paper_viewport_handles().2.len() }; let mut sets = self.resident_wire_sets.borrow_mut(); // Evict stale entries (older epochs / abandoned keys) so switching // spaces or re-scaling a viewport can't accumulate dead full sets. let cur_epoch = self.geometry_epoch; sets.retain(|_, set| set.epoch == cur_epoch); if sets.len() > 16.max(shown + 2) { sets.clear(); } sets.insert( key, ResidentWireSet { epoch: cur_epoch, gen, wires: Arc::clone(&arc), layout, }, ); arc } fn resident_wire_key( &self, block: Handle, bg: [f32; 4], anno_scale_override: Option, annotation_scale_handle: Option, all_visible: bool, frozen_layers: Option<&HashSet>, style_viewport: Option, ) -> u64 { let mut key: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |value: u64| key = key.rotate_left(17) ^ value.wrapping_mul(0x9E37_79B9_7F4A_7C15); mix(block.value()); for component in bg { mix(component.to_bits() as u64); } mix(anno_scale_override .map(|scale| scale.to_bits() as u64) .unwrap_or(u64::MAX)); mix(annotation_scale_handle .map(|handle| handle.value()) .unwrap_or(0)); mix(all_visible as u64); mix(self.viewport_style_key(style_viewport)); // quantized_wpp removed: GPU analytical rendering for circles/arcs/ // ellipses makes tessellation zoom-independent, so the resident set is // shared across all zoom levels. match frozen_layers { Some(frozen) => { let mut signature = 0u64; for handle in frozen { signature ^= handle.value().wrapping_mul(0x9E37_79B9_7F4A_7C15); } mix(signature); mix(frozen.len() as u64); } None => mix(u64::MAX - 1), } key } /// Quantize world units per pixel (`wpp`) into half-octave bands (~1.414x zoom ratio). /// /// This provides discrete, stable zoom bands so that panning (where wpp is constant) /// and small subpixel viewport drifts or mouse-wheel motions within a band do not /// cause re-tessellation or GPU buffer re-uploads, while zooming past a band threshold /// smoothly scales circle tessellation detail. pub fn quantize_wpp(wpp: Option) -> Option { let w = wpp.filter(|&w| w.is_finite() && w > 0.0)?; let step = (w.log2() * 2.0).floor() * 0.5; Some(2.0f32.powf(step)) } /// The GPU wire-arena handoff for a viewport whose content id is `gen`: /// `(prev_gen, changed handles)` when the Model set reached `gen` via an /// incremental resident patch, else `None`. Read (not consumed) so every /// Model tile applies the same patch; gen-matching keeps it from applying to /// a viewport that rebuilt independently. pub(crate) fn model_wire_patch_for(&self, gen: u64) -> Option<(u64, Arc)> { match &*self.model_wire_gpu_patch.borrow() { Some((prev, new, patch)) if *new == gen => Some((*prev, Arc::clone(patch))), _ => None, } } /// The wires a memoized run stands for; `None` when an `InSet` run's /// backing set is gone, so the caller tessellates afresh. fn resolve_memo_run(&self, handle: Handle, run: &MemoRun) -> Option { match run { MemoRun::Owned(wires) => Some(MemoHit::Owned(Arc::clone(wires))), MemoRun::InSet => { let backing = self.resident_memo_backing.borrow(); let set = backing.set.upgrade()?; let &(start, len) = backing.ranges.get(&handle)?; (start + len <= set.len()).then_some(MemoHit::Slice(set, start, len)) } MemoRun::Pending => None, } } /// Whether building `block` reads and fills the resident memo. fn uses_resident_memo(&self, block: Handle, frozen_layers: Option<&HashSet>) -> bool { block == self.model_space_block_handle() && frozen_layers.is_none_or(|layers| layers.is_empty()) && resident_memo_enabled() } /// Whether a resident set's entity runs are exactly the memo's: nothing /// recolours or replaces them after tessellation. fn resident_runs_match_memo(&self, block: Handle) -> bool { self.count_display.is_none() && self.refedit_keep.is_none() && self.active_live_section(block).is_none() } /// Point the memo at `set`: an entity with a run there keeps no copy of /// its own; a run only the previous backing held is copied out first. fn bind_memo_to_set( &self, set: &Arc>, ranges: &HashMap, ) { let old = std::mem::take(&mut *self.resident_memo_backing.borrow_mut()); let old_set = old.set.upgrade(); self.resident_tess_memo.borrow_mut().retain(|handle, run| { if ranges.contains_key(handle) { *run = MemoRun::InSet; } else if let MemoRun::Pending = run { return false; } else if let MemoRun::InSet = run { match old_set.as_ref().zip(old.ranges.get(handle)) { Some((wires, &(start, len))) if start + len <= wires.len() => { *run = MemoRun::Owned(Arc::new(wires[start..start + len].to_vec())); } _ => return false, } } true }); *self.resident_memo_backing.borrow_mut() = MemoBacking { set: Arc::downgrade(set), ranges: ranges.clone(), keep: None, }; } /// Give every `InSet` run a copy of its own and drop the backing, before /// a build whose set will not match the memo. fn detach_memo_backing(&self) { let old = std::mem::take(&mut *self.resident_memo_backing.borrow_mut()); let set = old.set.upgrade(); self.resident_tess_memo.borrow_mut().retain(|handle, run| { if let MemoRun::Pending = run { return false; } if let MemoRun::InSet = run { match set.as_ref().zip(old.ranges.get(handle)) { Some((wires, &(start, len))) if start + len <= wires.len() => { *run = MemoRun::Owned(Arc::new(wires[start..start + len].to_vec())); } _ => return false, } } true }); } /// An abandoned patch took the backing set apart: keep it alive for the /// full build that follows. Runs the patch left untouched are intact, and /// the runs it blanked were copied out or dropped from the memo first. fn keep_memo_backing(&self, wires: Vec) { let keep = Arc::new(wires); let mut backing = self.resident_memo_backing.borrow_mut(); backing.set = Arc::downgrade(&keep); backing.keep = Some(keep); } fn resident_wire_layout(wires: &[WireModel]) -> Option { let mut order = Vec::new(); let mut ranges = HashMap::default(); let mut current: Option<(Handle, usize)> = None; let mut marker_start = wires.len(); for (index, wire) in wires.iter().enumerate() { let Some(handle) = Self::handle_from_wire_name(&wire.name) else { if let Some((handle, start)) = current.take() { if ranges.insert(handle, (start, index - start)).is_some() { return None; } } marker_start = index; if wires[index..] .iter() .any(|tail| Self::handle_from_wire_name(&tail.name).is_some()) { return None; } break; }; if current.map(|(h, _)| h) != Some(handle) { if let Some((previous, start)) = current.take() { if ranges.insert(previous, (start, index - start)).is_some() { return None; } } if ranges.contains_key(&handle) { return None; } order.push(handle); current = Some((handle, index)); } } if let Some((handle, start)) = current { if ranges .insert(handle, (start, marker_start - start)) .is_some() { return None; } } Some(ResidentWireLayout { order, ranges, vacant: HashMap::default(), tombstoned_wires: 0, marker_start, }) } /// Bring the resident set for `key` up to the current epoch by replaying only /// the changed entities into the cached assembly. Returns the patched `Arc`, /// or `None` to fall back to a full rebuild — the safe default whenever the /// fast path can't apply: no cache entry, the journal can't be replayed /// (a `full` delta or ring overflow), the cached `Arc` is still shared (so /// its wires can't be moved out), or a structural assumption is violated /// (a wire not named with its entity handle, or an entity's wires not /// contiguous). Because every failure falls back to the identical full /// build, correctness never depends on the fast path being taken. fn try_resident_patch( &self, key: u64, block: Handle, bg: [f32; 4], anno_scale_override: Option, annotation_scale_handle: Option, all_visible: bool, frozen_layers: Option<&HashSet>, style_viewport: Option, ) -> Option>> { let perf = crate::perf::enabled(); // Each early exit here costs a full wire assembly, so say which one. let declined = |reason: &str| -> Option>> { if perf { crate::perf_record!("[perf] resident-patch-skip reason={reason}"); } None }; if self.viewport_style_key(style_viewport) != 0 { return declined("viewport-style"); } let t_patch = iced::time::Instant::now(); // A patch whose set will not equal the memo's runs leaves the memo // holding its own copies, taken while the set is still whole. let shareable = self.uses_resident_memo(block, frozen_layers) && self.resident_runs_match_memo(block); if self.uses_resident_memo(block, frozen_layers) && !shareable { self.detach_memo_backing(); } // The entry must exist, be stale, and be uniquely held so we can move // its wires out rather than deep-clone them. let (cached_epoch, backed_here) = { let sets = self.resident_wire_sets.borrow(); let Some(entry) = sets.get(&key) else { drop(sets); return declined("no-entry"); }; if entry.epoch == self.geometry_epoch { return None; } if entry.layout.is_none() { drop(sets); return declined("no-layout"); } let strong = Arc::strong_count(&entry.wires); if strong != 1 { if perf { crate::perf_record!("[perf] resident-shared strong={strong}"); } return None; } let backed_here = shareable && self .resident_memo_backing .borrow() .set .ptr_eq(&Arc::downgrade(&entry.wires)); (entry.epoch, backed_here) }; let Some(deltas) = self.replay_since(cached_epoch) else { return declined("journal-too-old"); }; // Take ownership of the cached assembly (guaranteed unique above). // `prev_gen` is the content id the GPU currently holds for this set — the // base the wire-arena patch replays from. let (prev_gen, owned, mut layout) = { let removed = self.resident_wire_sets.borrow_mut().remove(&key)?; ( removed.gen, Arc::try_unwrap(removed.wires).ok()?, removed.layout?, ) }; // Every early return below abandons the patch; the memo's `InSet` // runs then still need this set, so the guard hands it back. let mut reclaim = ReclaimOnDecline { scene: self, wires: Some(owned), backed: backed_here, }; let owned = reclaim.wires.as_mut()?; if layout.marker_start > owned.len() { return None; } // Re-tessellate the changed entities, replicating the full build's exact // context (anno derivation, empty selection, no cull / no LOD). Faded // copy goes into the assembly; the raw copy refreshes the memo, matching // wires_for_block_culled (memo stores pre-fade wires). let anno = if let Some(a) = anno_scale_override { a } else if self.current_layout == "Model" { self.annotation_scale } else { 1.0 }; let blk = self.block_cache_arc_for(annotation_scale_handle, all_visible, style_viewport); let empty_sel: HashSet = HashSet::default(); let mut new_runs: HashMap> = HashMap::default(); let mut memo_updates: Vec<(Handle, Arc>)> = Vec::new(); let mut visible_changed: HashSet = HashSet::default(); // Every geometry edit drops its handle's memo entry, so an entry that // survived its delta marks a visibility-only change: its wires come // back from the memo, provided it was built under this display context. let avp = style_viewport.or(self.active_viewport); let memo_ok = self.resident_tess_guard.get() == tess_memo_guard(None, anno, annotation_scale_handle, all_visible, bg, avp); for (h, kind) in &deltas { if matches!(kind, ChangeKind::Removed) { continue; } let Some(e) = self.document.get_entity(*h) else { continue; }; if !self.resident_entity_visible( e, block, frozen_layers, annotation_scale_handle, all_visible, ) { continue; } visible_changed.insert(*h); let memoized = memo_ok .then(|| { let memo = self.resident_tess_memo.borrow(); match memo.get(h)? { MemoRun::Owned(wires) => Some(wires.as_ref().clone()), MemoRun::InSet if backed_here => { let &(start, len) = layout.ranges.get(h)?; owned.get(start..start + len).map(<[WireModel]>::to_vec) } run => self .resolve_memo_run(*h, run) .map(|hit| hit.wires().to_vec()), } }) .flatten(); if let Some(mut faded) = memoized { self.apply_refedit_fade(&mut faded, bg); new_runs.insert(*h, faded); continue; } let mut raw = tessellate_entity( &self.document, &empty_sel, avp, bg, anno, annotation_scale_handle, e, Some(&blk), None, None, anno_scale_override.is_some(), self.reference_only.handles(), ); // Solid B-rep corners snap via the mesh edge list (their wire // path is an empty shell). Enrich before the memo/assembly split // so both share the same snap-bearing wires. self.attach_solid_snaps(*h, &mut raw); memo_updates.push((*h, Arc::new(raw.clone()))); let mut faded = raw; self.apply_refedit_fade(&mut faded, bg); new_runs.insert(*h, faded); } { let mut memo = self.resident_tess_memo.borrow_mut(); for (h, a) in memo_updates { memo.insert(h, MemoRun::Owned(a)); } } // Capture whether any replaced/removed OLD run fed the Face3D/fill // pass before blanking its resident slots. Treating every removal as a // face change made deleting an ordinary line rescan/re-upload the whole // fill pass on large drawings. let old_face_pass_changed = deltas.iter().any(|(handle, _)| { layout.ranges.get(handle).is_some_and(|&(start, len)| { start + len <= layout.marker_start && owned[start..start + len] .iter() .any(|wire| !wire.fill_tris.is_empty()) }) }); // Keep the submission-order directory current. Adds are inserted by // the same effective SortEntitiesTable key used by the full builder; // hidden Modified entities retain a zero-length placeholder so grip // commit restores them at their original position. { let cache = self.sort_cache.borrow(); let sort_map = cache.as_ref().and_then(|(_, index)| index.get(&block)); let effective = |handle: Handle| { sort_map .and_then(|map| map.get(&handle.value())) .copied() .unwrap_or(handle.value()) }; // Hashed once: a linear `order.contains` per re-shown entity made // turning a big layer back on quadratic. let ordered: HashSet = if visible_changed.is_empty() { HashSet::default() } else { layout.order.iter().copied().collect() }; for &(handle, kind) in &deltas { if matches!(kind, ChangeKind::Removed) { // Keep the order entry as a tombstone. Undo/Redo can then // restore the exact physical slot and submission position. continue; } if visible_changed.contains(&handle) && !ordered.contains(&handle) { let key = effective(handle); let position = layout .order .partition_point(|&existing| effective(existing) <= key); layout.order.insert(position, handle); } } } // Patch changed runs without shifting the resident Vec. Delete/hide // blanks the old slots; same-shaped Undo/Redo restores them in place. // Only a true tail add/resize touches Vec length (and shifts the handful // of synthesized marker wires, never the drawing's entity runs). let mut gpu_runs: HashMap>> = HashMap::default(); let mut index_edits = Vec::new(); for &(handle, kind) in &deltas { let new_run = if matches!(kind, ChangeKind::Removed) { Vec::new() } else { new_runs.remove(&handle).unwrap_or_default() }; gpu_runs.insert(handle, Arc::new(new_run.clone())); let new_len = new_run.len(); let old_range = layout.ranges.remove(&handle); if new_len == 0 { if let Some((start, old_len)) = old_range { if start + old_len > layout.marker_start { return None; } // A hidden entity's memo run lives in these slots: copy it // out before blanking them, for when it is shown again. if backed_here && !matches!(kind, ChangeKind::Removed) { let mut memo = self.resident_tess_memo.borrow_mut(); if let Some(run) = memo.get_mut(&handle) { if matches!(run, MemoRun::InSet) { *run = MemoRun::Owned(Arc::new( owned[start..start + old_len].to_vec(), )); } } } for slot in &mut owned[start..start + old_len] { *slot = WireModel::solid(String::new(), Vec::new(), [0.0; 4], false); slot.aabb = [ f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY, ]; } layout.vacant.insert(handle, (start, old_len)); layout.tombstoned_wires = layout.tombstoned_wires.saturating_add(old_len); index_edits.push(WireIndexEdit { handle, start, old_len, new_len: old_len, visible: false, }); } continue; } if let Some((start, vacant_len)) = layout.vacant.remove(&handle) { if vacant_len != new_len || start + vacant_len > layout.marker_start { return None; } for (slot, wire) in owned[start..start + vacant_len].iter_mut().zip(new_run) { *slot = wire; } layout.ranges.insert(handle, (start, new_len)); layout.tombstoned_wires = layout.tombstoned_wires.saturating_sub(vacant_len); index_edits.push(WireIndexEdit { handle, start, old_len: vacant_len, new_len, visible: true, }); continue; } if let Some((start, old_len)) = old_range { if start + old_len > layout.marker_start || layout.marker_start > owned.len() { return None; } if old_len == new_len { for (slot, wire) in owned[start..start + old_len].iter_mut().zip(new_run) { *slot = wire; } layout.ranges.insert(handle, (start, new_len)); continue; } // Shape-changing edits are relocation-free only for the // terminal entity (the live-polyline case). A non-terminal // resize takes the clean full-build fallback instead of // shifting every following range. if start + old_len != layout.marker_start { return None; } index_edits.push(WireIndexEdit { handle, start, old_len, new_len, visible: true, }); owned.splice(start..start + old_len, new_run); let delta = new_len as isize - old_len as isize; layout.marker_start = (layout.marker_start as isize + delta) as usize; layout.ranges.insert(handle, (start, new_len)); continue; } // A genuinely new handle must append after all live entity runs. // Monotonic document handles make this the ordinary Add path. A // middle insertion (custom sort key) falls back to a full rebuild. let order_index = layout.order.iter().position(|&h| h == handle)?; let insertion = layout.order[order_index + 1..] .iter() .find_map(|next| layout.ranges.get(next).map(|range| range.0)) .unwrap_or(layout.marker_start); if insertion != layout.marker_start { return None; } index_edits.push(WireIndexEdit { handle, start: insertion, old_len: 0, new_len, visible: true, }); owned.splice(insertion..insertion, new_run); layout.marker_start += new_len; layout.ranges.insert(handle, (insertion, new_len)); } if !new_runs.is_empty() { return None; } if layout.tombstoned_wires > layout.marker_start / 2 { return None; } let added: HashSet = deltas .iter() .filter_map(|&(handle, kind)| matches!(kind, ChangeKind::Added).then_some(handle)) .collect(); let mut saw_added = false; let mut new_handles_are_suffix = true; for handle in &layout.order { if added.contains(handle) { saw_added = true; } else if saw_added && layout.ranges.contains_key(handle) { new_handles_are_suffix = false; break; } } let face_pass_changed = old_face_pass_changed || deltas.iter().any(|&(handle, _)| { matches!( self.document.get_entity(handle), Some(EntityType::Face3D(_)) ) || gpu_runs .get(&handle) .is_some_and(|run| run.iter().any(|wire| !wire.fill_tris.is_empty())) }); let arc = Arc::new(reclaim.wires.take().unwrap_or_default()); self.last_tess_wires.set(arc.len()); self.resident_patch_hits .set(self.resident_patch_hits.get() + 1); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); // Hand the exact changed handles to the GPU wire arena so it patches just // those entities' slabs. Only for the Model set (the arena is model-only); // the render layer verifies prev_gen against what the GPU actually holds. if wire_gpu_patch_enabled() && block == self.model_space_block_handle() { *self.model_wire_gpu_patch.borrow_mut() = Some(( prev_gen, gen, Arc::new(WireGpuPatch { changes: Arc::new(deltas.clone()), runs: Arc::new(gpu_runs), index_edits: Arc::new(index_edits), new_handles_are_suffix, face_pass_changed, }), )); } if shareable { self.bind_memo_to_set(&arc, &layout.ranges); } let cur_epoch = self.geometry_epoch; let mut sets = self.resident_wire_sets.borrow_mut(); sets.retain(|_, set| set.epoch == cur_epoch); sets.insert( key, ResidentWireSet { epoch: cur_epoch, gen, wires: Arc::clone(&arc), layout: Some(layout), }, ); if perf { crate::perf_record!( "[perf] resident-patch {:>7.1}ms wires={} changes={}", t_patch.elapsed().as_secs_f64() * 1000.0, arc.len(), deltas.len(), ); } Some(arc) } /// Cached tessellation of the current layout block's paper-space entities, /// shared by `entity_wires_arc()` and the GPU sheet viewport. A thin /// "dressing" layer over the unified resident set (border drop + /// printable-area guide) — camera-independent, epoch-keyed, so paper /// pan/zoom never re-tessellates the sheet. fn paper_sheet_wires_arc(&self) -> Arc> { { let cache = self.paper_sheet_cache.borrow(); if let Some((epoch, gen, arc)) = cache.get(&self.current_layout) { if *epoch == self.geometry_epoch { self.last_model_wire_gen.set(*gen); return Arc::clone(arc); } } } let layout_block = self.current_layout_block_handle(); let scale = self.paper_annotation_scale_handle(); let base = self.resident_wires_for(layout_block, None, scale, None, None); let mut wires = (*base).clone(); // The overall "sheet" viewport now IS the paper view itself, so its own // border rectangle must not be drawn as an entity on the sheet. let sheet = self.current_layout_sheet_viewport_handle(); if sheet.is_valid() { let sheet_name = sheet.value().to_string(); wires.retain(|w| w.name != sheet_name); } // Printable-area guide (paper inset by plot margins), paper space only. if let Some(pa) = self.printable_area_wire() { wires.push(pa); } let arc = Arc::new(wires); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); let mut cache = self.paper_sheet_cache.borrow_mut(); cache.retain(|_, (epoch, _, _)| *epoch == self.geometry_epoch); cache.insert( self.current_layout.clone(), (self.geometry_epoch, gen, Arc::clone(&arc)), ); arc } /// Build WireModels from all document entities for the current layout. /// Returns a shared `Arc` so `build_primitive()` can skip the clone during /// navigation frames where no preview wires are active. pub(super) fn entity_wires_arc(&self) -> Arc> { let key = (self.geometry_epoch, self.camera_generation); { let cache = self.wire_cache.borrow(); if let Some((cached_key, gen, ref arc)) = *cache { if cached_key == key { self.last_model_wire_gen.set(gen); return Arc::clone(arc); } } } let layout_block = self.current_layout_block_handle(); // Model space: reuse the resident, camera-independent static wire set the // render already holds (keyed on geometry_epoch only). This is the FULL // entity set — pick / snap want every entity, not a view-culled subset — // and it does NOT re-tessellate on a camera move. The old path went // through the camera_generation-keyed, view-culled paper_sheet set, so // every pan/rotate cold-missed it and paid a full O(visible) re-tess // (~300 ms on large drawings) the first time hit-testing ran after the // move — the "jump" at the start of each gesture. The tile args are // unused by `model_tile_wires_arc`. if self.current_layout == "Model" { let cam = self.camera.borrow().clone(); return self.model_tile_wires_arc(0, &cam, 1.0, 1.0); } // Paper space: extend sheet wires with projected viewport content. // This composite is the CPU pick/snap set; the projection follows the // paper camera, so the key keeps `camera_generation`. let mut wires = (*self.paper_sheet_wires_arc()).clone(); wires.extend(self.viewport_content_wires(layout_block, None, None)); let arc = Arc::new(wires); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); *self.wire_cache.borrow_mut() = Some((key, gen, Arc::clone(&arc))); arc } /// Build WireModels from all document entities + optional preview wire. /// /// Returns the memoized set by `Arc` — callers only ever iterate it, and /// the previous per-call deep clone was a full copy of every wire buffer /// (hundreds of MB on large mesh imports, #358). pub fn entity_wires(&self) -> Arc> { let wires = self.entity_wires_arc(); match crate::scene::model::instance_model::expand_stubs(&wires) { std::borrow::Cow::Borrowed(_) => wires, std::borrow::Cow::Owned(expanded) => Arc::new(expanded), } } /// Return paper entities and projected model-viewport entities separately. /// A plot-only render override is applied to cloned wires; viewport entities /// and their saved display modes remain unchanged. pub fn plot_wire_groups( &self, render_mode_override: Option, ) -> (Vec, Vec) { let apply_mode = |wires: &mut Vec, mode| { let flags = view::render::render_mode_flags(mode); for wire in wires.iter_mut().filter(|wire| wire.fill_is_3d) { if !flags.face3d_fill && !flags.mesh_fill { wire.fill_tris.clear(); wire.fill_tris_low.clear(); } if !flags.show_3d_edges { wire.points.clear(); wire.points_low.clear(); } } }; if self.current_layout == "Model" { let mut wires = crate::scene::model::instance_model::expand_stubs(&self.entity_wires_arc()) .into_owned(); apply_mode( &mut wires, render_mode_override.unwrap_or_else(|| self.active_model_tile_render_mode()), ); return (wires, Vec::new()); } let paper_block = self.current_layout_block_handle(); let (_, _, viewport_handles) = self.paper_viewport_handles(); let mut model_wires = Vec::new(); for handle in viewport_handles.iter().copied() { let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) else { continue; }; if viewport.common.owner_handle != paper_block || !viewport.is_on() { continue; } let mode = render_mode_override.unwrap_or(viewport.render_mode); let mut wires = self.viewport_content_wires(paper_block, Some(handle), None); apply_mode(&mut wires, mode); model_wires.extend(wires); } let paper = crate::scene::model::instance_model::expand_stubs(&self.paper_sheet_wires_arc()) .into_owned(); (paper, model_wires) } pub(crate) fn plot_wire_depths(&self, wires: &[WireModel]) -> Vec { let depths = self.draw_depth_map(); wires .iter() .map(|wire| pipeline::wire_gpu::wire_draw_depth(wire, depths.as_ref())) .collect() } /// Invalidate only the draw-order depth cache without dropping geometry tessellation. pub fn invalidate_draw_depth(&self) { *self.draw_depth_cache.borrow_mut() = None; } /// Per-entity stable draw-order depth, keyed by entity handle value. /// A full build assigns sparse labels in effective draw order. Incremental /// Add/Remove then changes only the named handle: existing siblings retain /// their depth, avoiding an O(all entities) map rewrite and GPU const upload. /// See [`Self::draw_depth_generation`]. pub(in crate::scene) fn draw_depth_generation(&self) -> u64 { self.draw_depth_generation.get() } #[doc(hidden)] pub fn draw_depth_map(&self) -> Arc> { { let cache = self.draw_depth_cache.borrow(); if let Some(cache) = cache.as_ref() { if cache.epoch == self.geometry_epoch { return Arc::clone(&cache.depths); } } } let perf = crate::perf::enabled(); let t_depth = iced::time::Instant::now(); // Replay Add/Remove into the retained sparse order directory. Removed // entries stay in `blocks`/`owners` as tombstones, so Undo/Redo restores // their exact prior label without shifting a sibling. let stale_cache = self.draw_depth_cache.borrow_mut().take(); if let Some(mut cache) = stale_cache { if let Some(deltas) = self.replay_since(cache.epoch) { if deltas .iter() .all(|(_, kind)| matches!(kind, ChangeKind::Modified)) { cache.epoch = self.geometry_epoch; let arc = Arc::clone(&cache.depths); *self.draw_depth_cache.borrow_mut() = Some(cache); return arc; } let depths = Arc::make_mut(&mut cache.depths); let ms = self.model_space_block_handle(); let mut incremental_ok = true; for &(handle, kind) in &deltas { match kind { ChangeKind::Modified => {} ChangeKind::Removed => { depths.remove(&handle.value()); } ChangeKind::Added => { let Some(entity) = self.document.get_entity(handle) else { continue; }; if matches!( entity, EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) | EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) ) { continue; } let common = entity.common(); let block = if common.owner_handle.is_null() { ms } else { common.owner_handle }; let value = handle.value(); if let Some(&known_block) = cache.owners.get(&value) { if known_block != block { incremental_ok = false; break; } let restored = cache.blocks.get(&block).and_then(|order| { order.iter().find(|entry| entry.handle == value) }); let Some(entry) = restored else { incremental_ok = false; break; }; depths .insert(value, draw_depth_value(entry.label, entry.half_label)); continue; } // A newly allocated handle cannot already have a // SortEntitiesTable override; its effective key is // therefore its own handle. let order = cache.blocks.entry(block).or_default(); let position = order.partition_point(|entry| { (entry.effective, entry.handle) <= (value, value) }); let Some((label, half_label)) = inserted_draw_depth_label(order, position) else { // Pathological middle insertion exhausted the // reserved label gap. A full rebuild is the safe // rare fallback (ordinary monotonic adds use the // two-million-slot tail reserve). incremental_ok = false; break; }; order.insert( position, DrawDepthEntry { handle: value, effective: value, label, half_label, }, ); cache.owners.insert(value, block); depths.insert(value, draw_depth_value(label, half_label)); } } } if incremental_ok { cache.epoch = self.geometry_epoch; let arc = Arc::clone(&cache.depths); *self.draw_depth_cache.borrow_mut() = Some(cache); if perf { crate::perf_record!( "[perf] draw-depth-patch {:>7.1}ms entries={} changes={}", t_depth.elapsed().as_secs_f64() * 1000.0, arc.len(), deltas.len(), ); } return arc; } } } // From here the labels are assigned afresh, so an existing entity's // depth can move. Anything holding a baked depth has to rebuild. self.draw_depth_generation .set(self.draw_depth_generation.get().wrapping_add(1)); use codec::objects::ObjectType; // Per-block SortEntitiesTable overrides: block -> (entity_val -> sort_val). let mut overrides: HashMap> = HashMap::default(); for obj in self.document.objects.values() { if let ObjectType::SortEntitiesTable(t) = obj { if !t.is_empty() { overrides.insert( t.block_owner_handle, t.entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(), ); } } } let ms = self.model_space_block_handle(); // Group entities by owning block, carrying each entity's effective key. let mut by_block: HashMap> = HashMap::default(); for e in self.document.entities() { let c = e.common(); // 3D meshes keep real geometric depth — exclude them from // draw-order biasing so 3D occlusion is never flattened. if matches!( e, EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) | EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) ) { continue; } let block = if c.owner_handle.is_null() { ms } else { c.owner_handle }; let hv = c.handle.value(); let eff = overrides .get(&block) .and_then(|m| m.get(&hv)) .copied() .unwrap_or(hv); by_block.entry(block).or_default().push((hv, eff)); } let mut depth_map: HashMap = HashMap::default(); let mut owners: HashMap = HashMap::default(); let mut blocks: HashMap> = HashMap::default(); for (block, mut order) in by_block { order.sort_by_key(|(handle, effective)| (*effective, *handle)); let entries = seed_draw_depth_entries(order); for entry in &entries { depth_map.insert( entry.handle, draw_depth_value(entry.label, entry.half_label), ); owners.insert(entry.handle, block); } blocks.insert(block, entries); } let arc = Arc::new(depth_map); *self.draw_depth_cache.borrow_mut() = Some(DrawDepthCache { epoch: self.geometry_epoch, depths: Arc::clone(&arc), blocks, owners, }); if perf { crate::perf_record!( "[perf] draw-depth {:>7.1}ms entries={}", t_depth.elapsed().as_secs_f64() * 1000.0, arc.len(), ); } arc } #[cfg(test)] pub(super) fn hatch_models_arc(&self) -> Arc> { self.hatch_models_arc_for_view(true) } fn hatch_models_arc_for_view(&self, tint_selected: bool) -> Arc> { // Hatch models bake the selection tint (issue #71), so they depend on // the *selected set* — but NOT on hover. Keying on `selection_generation` // (which also bumps on every hover) made each hover-over a new entity // rebuild every hatch model: an O(N-hatch) stutter on hatch-heavy // drawings. Key on a signature of `selected` instead, so hover (which // never changes `selected`) keeps the cache warm. let sel_sig = if tint_selected { self.selected_hatch_sig() } else { 0 }; let target_block = self.content_render_block_handle(); let key = (target_block, self.current_layout.clone()); { let reuse = { let cache = self.hatch_cache.borrow(); match cache.get(&key) { // Selection tint is baked in, so the selected set must also // match; category = a changed direct fill or an INSERT // whose expanded block tree can contain hatch fills. Some((cached_epoch, cached_sel, arc)) if *cached_sel == sel_sig && self.category_cache_valid( *cached_epoch, CACHE_CATEGORY_HATCH | CACHE_CATEGORY_INSERT_HATCH, |h| { self.hatches.contains_key(&h) || matches!( self.document.get_entity(h), Some(EntityType::Insert(_)) ) }, ) => { Some(Arc::clone(arc)) } _ => None, } }; if let Some(arc) = reuse { if let Some((e, _, _)) = self.hatch_cache.borrow_mut().get_mut(&key) { *e = self.geometry_epoch; } return arc; } } let scale = if self.current_layout == "Model" { self.displayed_annotation_scale_handle() } else { self.paper_annotation_scale_handle() }; let arc = Arc::new(self.synced_hatch_models( target_block, None, scale, self.annotation_all_visible(), None, tint_selected, )); self.hatch_cache .borrow_mut() .insert(key, (self.geometry_epoch, sel_sig, Arc::clone(&arc))); arc } /// Order-independent signature of the selected set. Cheap (the set is /// normally a handful of entities) and unchanged by hover, so caches that /// only depend on what's *selected* don't thrash on rollover. fn selected_set_sig(&self) -> u64 { let mut sig: u64 = self.selected.len() as u64; for h in self.selected.iter() { sig ^= h.value().wrapping_mul(0x9E37_79B9_7F4A_7C15); } sig } fn selected_hatch_sig(&self) -> u64 { let mut sig = 0u64; let mut count = 0u64; for &h in &self.selected { if !self.hatches.contains_key(&h) && !matches!(self.document.get_entity(h), Some(EntityType::Insert(_))) { continue; } count += 1; sig ^= h.value().wrapping_mul(0x9E37_79B9_7F4A_7C15); } sig ^ count } pub(super) fn wipeout_models_arc(&self) -> Arc> { let target_block = self.content_render_block_handle(); let key = (target_block, self.current_layout.clone()); { let reuse = { let cache = self.wipeout_cache.borrow(); match cache.get(&key) { Some((cached_epoch, arc)) // The displayed wipeout set also contains masks // expanded from INSERTs. COPY and grip edits modify or // add INSERT handles without changing the child WIPEOUT // entity, so the old predicate reused stale positions // until REGEN. Track both categories, including // removed INSERTs via the recorded removal bits. if self.category_cache_valid( *cached_epoch, CACHE_CATEGORY_WIPEOUT | CACHE_CATEGORY_INSERT_HATCH, |h| { matches!( self.document.get_entity(h), Some(EntityType::Wipeout(_) | EntityType::Insert(_)) ) }, ) => { Some(Arc::clone(arc)) } _ => None, } }; if let Some(arc) = reuse { if let Some((e, _)) = self.wipeout_cache.borrow_mut().get_mut(&key) { *e = self.geometry_epoch; } return arc; } } let scale = if self.current_layout == "Model" { self.displayed_annotation_scale_handle() } else { self.paper_annotation_scale_handle() }; let arc = Arc::new(self.wipeout_models(target_block, None, scale, self.annotation_all_visible())); self.wipeout_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); arc } pub(super) fn images_arc(&self) -> Arc> { let target_block = self.content_render_block_handle(); { let reuse = { let cache = self.image_cache.borrow(); match cache.get(&target_block) { Some((cached_epoch, arc)) if self.category_cache_valid( *cached_epoch, CACHE_CATEGORY_IMAGE, |h| self.images.contains_key(&h), ) => { Some(Arc::clone(arc)) } _ => None, } }; if let Some(arc) = reuse { // No image changed since — keep it warm, just advance the sync // epoch so the next replay window stays short. if let Some((e, _)) = self.image_cache.borrow_mut().get_mut(&target_block) { *e = self.geometry_epoch; } return arc; } } let scale = if self.current_layout == "Model" { self.displayed_annotation_scale_handle() } else { self.paper_annotation_scale_handle() }; let arc = Arc::new(self.image_models( target_block, None, scale, self.annotation_all_visible(), None, )); self.image_cache .borrow_mut() .insert(target_block, (self.geometry_epoch, Arc::clone(&arc))); arc } /// Image / OLE-frame models owned by `target_block`, optionally dropping /// those whose layer is frozen in a content viewport (`frozen`). fn image_models( &self, target_block: Handle, frozen: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, ) -> Vec { self.collect_images( target_block, frozen, annotation_scale_handle, all_visible, viewport, false, |image, _| image, ) } pub fn paper_plot_images(&self) -> Vec { let scale = if self.current_layout == "Model" { self.displayed_annotation_scale_handle() } else { self.paper_annotation_scale_handle() }; self.placed_images( self.current_layout_block_handle(), None, scale, self.annotation_all_visible(), None, true, ) } fn placed_images( &self, target_block: Handle, frozen: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, plotting: bool, ) -> Vec { self.collect_images( target_block, frozen, annotation_scale_handle, all_visible, viewport, plotting, |image, context| crate::io::pdf_export::PlotImage { image, clips: context.clips.clone(), }, ) } fn collect_images( &self, target_block: Handle, frozen: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, plotting: bool, mut collect: impl FnMut(ImageModel, &render_graph::InstanceContext) -> T, ) -> Vec { if self.images.is_empty() { return Vec::new(); } let depth_map = self.draw_depth_map(); let graph = render_graph::RenderSceneGraph::new( &self.document, frozen, annotation_scale_handle, all_visible, depth_map.as_ref(), ) .with_annotation_scale(self.annotation_scale) .with_viewport(viewport); let mut models = Vec::new(); let mut image_sources = rustc_hash::FxHashMap::default(); let root = if self.block_edit_block.is_none() { viewport.map(|viewport| render_graph::SceneRoot::Viewport { paper_block: self.current_layout_block_handle(), viewport, model_block: target_block, }) } else { None } .unwrap_or_else(|| self.render_scene_root(target_block)); graph.walk_root( root, |entity, context| { (!plotting || self .document .layers .get(&entity.common().layer) .is_none_or(|layer| layer.is_plottable)) && (context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle)) }, |entity, context| { let handle = entity.common().handle; let Some(model) = self.images.get(&handle) else { return; }; let mut placed = model.clone(); if context.is_instanced() { for (corner, low) in placed.corners.iter_mut().zip(&mut placed.corners_low) { let point = codec::types::Vector3::new( corner[0] as f64 + low[0] as f64, corner[1] as f64 + low[1] as f64, corner[2] as f64 + low[2] as f64, ); let point = context.transform.apply(point); *corner = [point.x as f32, point.y as f32, point.z as f32]; *low = [ (point.x - corner[0] as f64) as f32, (point.y - corner[1] as f64) as f32, (point.z - corner[2] as f64) as f32, ]; } for vertex in &mut placed.verts { let point = codec::types::Vector3::new( vertex.pos[0] as f64 + vertex.pos_low[0] as f64, vertex.pos[1] as f64 + vertex.pos_low[1] as f64, vertex.pos[2] as f64 + vertex.pos_low[2] as f64, ); let point = context.transform.apply(point); vertex.pos = [point.x as f32, point.y as f32, point.z as f32]; vertex.pos_low = [ (point.x - vertex.pos[0] as f64) as f32, (point.y - vertex.pos[1] as f64) as f32, (point.z - vertex.pos[2] as f64) as f32, ]; } let matrix = &context.transform.matrix.m; let linear = [ matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), ]; let source_id = *image_sources .entry((handle.value(), linear)) .or_insert_with(crate::scene::model::instance_model::next_source_id); placed.render_instance = Some(crate::scene::model::instance_model::RenderInstance { source_id, translation: [matrix[0][3], matrix[1][3], matrix[2][3]], }); } placed.draw_depth = context.draw_depth(handle, depth_map.as_ref()); models.push(collect(placed, context)); }, ); models } /// Images owned by the active paper layout block only. The full-canvas /// sheet viewport uses this so model-block images don't bleed onto the /// paper sheet (mirrors `paper_canvas_hatches`). pub(super) fn paper_sheet_images(&self) -> Arc> { let layout_block = self.current_layout_block_handle(); Arc::new(self.image_models( layout_block, None, self.paper_annotation_scale_handle(), self.annotation_all_visible(), None, )) } pub(super) fn meshes_arc(&self) -> Arc> { let target_block = self.content_render_block_handle(); let key = (target_block, self.current_layout.clone()); { let reuse = { let cache = self.mesh_cache.borrow(); match cache.get(&key) { // Top-level solids seed self.meshes; the instanced_block part // is driven by INSERTs, so an INSERT edit (e.g. a move) must // also invalidate. Block-definition edits route through // bump_geometry (a full delta) and invalidate regardless. Some((cached_epoch, arc)) if self.category_cache_valid(*cached_epoch, CACHE_CATEGORY_MESH, |h| { self.meshes.contains_key(&h) || match self.document.get_entity(h) { Some(EntityType::Insert(_)) => true, Some(EntityType::Extended(extended)) => matches!( extended.data, codec::entities::ExtendedEntityData::SectionObject(_) ), _ => false, } }) => { Some(Arc::clone(arc)) } _ => None, } }; if let Some(arc) = reuse { if let Some((e, _)) = self.mesh_cache.borrow_mut().get_mut(&key) { *e = self.geometry_epoch; } return arc; } } let scale = if self.current_layout == "Model" { self.displayed_annotation_scale_handle() } else { self.paper_annotation_scale_handle() }; let arc = Arc::new(self.mesh_models( target_block, None, scale, self.annotation_all_visible(), None, )); self.mesh_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); arc } /// Block rendered by model-content viewports. Paper layout entities belong /// only to the sheet viewport; floating paper viewports always show model /// space. BEDIT is the sole exception and renders the edited block itself. fn content_render_block_handle(&self) -> Handle { self.block_edit_block .filter(|block| !block.is_null()) .unwrap_or_else(|| self.model_space_block_handle()) } fn render_scene_root(&self, block: Handle) -> render_graph::SceneRoot { let role = if self.block_edit_block == Some(block) { render_graph::BlockRootRole::DefinitionEdit } else if block == self.model_space_block_handle() { render_graph::BlockRootRole::ModelSpace } else { render_graph::BlockRootRole::PaperSpace }; render_graph::SceneRoot::Block(render_graph::BlockRoot { record: block, role, }) } fn interaction_block_handle(&self) -> Handle { if self.block_edit_block.is_none() && self.active_viewport.is_some() { self.model_space_block_handle() } else { self.current_layout_block_handle() } } fn interaction_viewport_frozen_layers(&self) -> Option<&[Handle]> { if self.block_edit_block.is_some() { return None; } let handle = self.active_viewport?; match self.document.get_entity(handle) { Some(EntityType::Viewport(viewport)) => Some(&viewport.frozen_layers), _ => None, } } fn interaction_space_key(&self) -> u64 { let block = self.interaction_block_handle().value(); let viewport = self .active_viewport .filter(|_| self.block_edit_block.is_none()) .map_or(0, |handle| handle.value()); let frozen = self .interaction_viewport_frozen_layers() .map_or(0, |layers| { layers .iter() .fold(layers.len() as u64, |signature, handle| { signature ^ handle.value().wrapping_mul(0x9E37_79B9_7F4A_7C15) }) }); block ^ viewport.rotate_left(29) ^ frozen.rotate_left(47) } fn interaction_layer_frozen(&self, layer: &str) -> bool { let Some(frozen) = self.interaction_viewport_frozen_layers() else { return false; }; self.document .layers .get(layer) .is_some_and(|record| frozen.contains(&record.handle)) } fn interaction_meshes_arc(&self) -> Arc> { let block = self.interaction_block_handle(); if self.block_edit_block.is_none() && block == self.model_space_block_handle() { let Some(frozen) = self.interaction_viewport_frozen_layers() else { return self.meshes_arc(); }; if frozen.is_empty() { return self.meshes_arc(); } let key = self.interaction_space_key(); if let Some((epoch, cached_key, meshes)) = self.interaction_mesh_cache.borrow().as_ref() { if *epoch == self.geometry_epoch && *cached_key == key { return Arc::clone(meshes); } } let frozen: HashSet = frozen.iter().copied().collect(); let viewport = self.active_viewport.unwrap_or(Handle::NULL); let meshes = self.meshes_for_viewport(viewport, &frozen); *self.interaction_mesh_cache.borrow_mut() = Some((self.geometry_epoch, key, Arc::clone(&meshes))); return meshes; } let key = self.interaction_space_key(); if let Some((epoch, cached_key, meshes)) = self.interaction_mesh_cache.borrow().as_ref() { if *epoch == self.geometry_epoch && *cached_key == key { return Arc::clone(meshes); } } let meshes = Arc::new(self.mesh_models( block, None, self.displayed_annotation_scale_handle(), self.annotation_all_visible(), self.active_viewport, )); *self.interaction_mesh_cache.borrow_mut() = Some((self.geometry_epoch, key, Arc::clone(&meshes))); meshes } fn mesh_pick_lookup(&self, meshes: &Arc>) -> Arc>> { let source = Arc::as_ptr(meshes) as usize; { let cache = self.mesh_pick_lookup_cache.borrow(); if let Some((ptr, weak, lookup)) = cache.as_ref() { if *ptr == source && weak .upgrade() .is_some_and(|cached| Arc::ptr_eq(&cached, meshes)) { return Arc::clone(lookup); } } } let mut lookup: HashMap> = HashMap::default(); for (index, set) in meshes.iter().enumerate() { let Some(handle) = set.entity_handle() else { continue; }; lookup.entry(handle).or_default().push(index as u32); } let lookup = Arc::new(lookup); *self.mesh_pick_lookup_cache.borrow_mut() = Some((source, Arc::downgrade(meshes), Arc::clone(&lookup))); lookup } /// Solid-mesh set (top-level + block-instanced), optionally dropping those /// whose layer is frozen in a content viewport (`frozen`). `None` reproduces /// the full set. fn mesh_models( &self, target_block: Handle, frozen: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, ) -> Vec { let live_section = self.active_live_section(target_block); // Top-level solids: drop those whose layer is off/frozen or that are // flagged invisible / isolated-hidden, mirroring the 2D wire path, plus // any whose layer is frozen in the requesting viewport. let mut all: Vec = self .meshes .iter() .filter(|(&h, _)| { self.mesh_entity_visible(h) && self .document .get_entity(h) .map(|e| { !self.layer_frozen_in(&e.common().layer, frozen) && self.belongs_to_visible_block( h, e.common().owner_handle, target_block, ) }) .unwrap_or(false) }) .filter_map(|(&handle, set)| { let mut set = if let Some(section) = live_section.as_ref() { match self.sectioned_body(handle, section) { Ok(Some(body)) => self .prepare_solid_model_display(handle, &body) .map(|display| display.0) .unwrap_or_else(|| set.clone()), Ok(None) => return None, Err(()) => set.clone(), } } else { set.clone() }; if let Some(entity) = self.document.get_entity(handle) { let style = crate::scene::view::render::render_style_for_viewport( &self.document, entity, viewport, ); let attached_material = set .material .as_ref() .is_some_and(|material| material.handle.is_some()); for mesh in &mut set.lods { if !attached_material { mesh.color[0..3].copy_from_slice(&style.0[0..3]); } mesh.color[3] = style.0[3]; } if let Some(material) = set.material.as_mut() { if !attached_material { material.diffuse[0..3].copy_from_slice(&style.0[0..3]); } material.diffuse[3] = style.0[3]; } } Some(set) }) .collect(); // Block-definition solids are instanced per INSERT of the ACTIVE space's // block so a block placed at an INSERT scale renders at the right size // (#123) — model space normally, the edited block in a BEDIT editor so // model-space solids don't leak into it (#261). all.extend(self.instanced_block_meshes( target_block, frozen, annotation_scale_handle, all_visible, viewport, live_section.as_ref(), )); all } fn active_live_section(&self, target_block: Handle) -> Option { self.document .entities() .filter_map(|entity| { let EntityType::Extended(extended) = entity else { return None; }; let codec::entities::ExtendedEntityData::SectionObject(data) = &extended.data else { return None; }; let owner = extended.common.owner_handle; (data.flags & 1 != 0 && !extended.common.invisible && (owner.is_null() || owner == target_block)) .then(|| LiveSection { data: data.clone(), slice_depth: crate::entities::extended::section_is_slice(extended).then( || { crate::entities::extended::section_slice_depth(extended) .unwrap_or(0.0) }, ), }) }) .last() } fn sectioned_body( &self, handle: Handle, section: &LiveSection, ) -> Result, ()> { let body = self.section_source_body(handle).ok_or(())?; Self::section_body(&body, §ion.data, section.slice_depth, None) } /// Build a temporary entity whose wire data matches the live-section body. /// The document entity stays unchanged so disabling the section restores /// the complete source geometry and saving never persists the clipped body. fn sectioned_wire_entity( &self, handle: Handle, section: &LiveSection, ) -> Result, ()> { let Some(body) = self.sectioned_body(handle, section)? else { return Ok(None); }; let (_, wires, center) = self.prepare_solid_model_display(handle, &body).ok_or(())?; let mut entity = self.document.get_entity(handle).cloned().ok_or(())?; let reference = codec::types::Vector3::new(center[0], center[1], center[2]); match &mut entity { EntityType::Solid3D(solid) => { solid.point_of_reference = reference; solid.wires = wires; solid.silhouettes.clear(); } EntityType::Surface(surface) => { surface.point_of_reference = reference; surface.wires = wires; surface.silhouettes.clear(); } EntityType::Region(region) => { region.point_of_reference = reference; region.wires = wires; region.silhouettes.clear(); } EntityType::Body(body) => { body.point_of_reference = reference; body.wires = wires; body.silhouettes.clear(); } // The cut mesh is drawn as the solid it bounds. EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) => { let mut solid = codec::entities::Solid3D::new(); solid.common = entity.common().clone(); solid.point_of_reference = reference; solid.wires = wires; entity = EntityType::Solid3D(solid); } _ => return Err(()), } Ok(Some(entity)) } fn section_source_body( &self, handle: Handle, ) -> Option> { if let Some(body) = self.solid_models.get(&handle) { return Some(std::borrow::Cow::Borrowed(body)); } let body = match self.document.get_entity(handle) { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) } Some(EntityType::Region(region)) => { crate::scene::convert::solid3d_tess::kernel_region_body(region) } Some(EntityType::Body(body)) => { crate::scene::convert::solid3d_tess::kernel_acis_body(&body.acis_data) } Some(EntityType::Surface(surface)) => { crate::scene::convert::solid3d_tess::kernel_surface_body(surface) } // A mesh that closes a volume is cut as the solid it bounds. Some( mesh @ (EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_)), ) => crate::entities::mesh::closed_mesh_body(mesh), _ => None, }?; Some(std::borrow::Cow::Owned(body)) } fn section_body( body: &kernel::brep::Body, data: &codec::entities::SectionObjectData, slice_depth: Option, body_to_world: Option<&codec::types::Transform>, ) -> Result, ()> { let planes = Self::section_planes(data, slice_depth, body_to_world).ok_or(())?; let mut current = body.clone(); for plane in planes { match keep_section_positive(¤t, plane)? { Some(next) => current = next, None => return Ok(None), } } Ok(Some(current)) } /// The planes bounding what section `data` keeps, each facing inward: /// the section plane, then the back of a slice, the ends of a boundary /// and the top and bottom of a volume. fn section_planes( data: &codec::entities::SectionObjectData, slice_depth: Option, body_to_world: Option<&codec::types::Transform>, ) -> Option> { let (origin, tangent, vertical, viewing) = section_frame(data)?; let mut planes = vec![section_plane(origin, tangent, viewing, body_to_world)?]; if data.state == 1 && slice_depth.is_none() { return Some(planes); } let depth = slice_depth.unwrap_or_else(|| { data.vertices .first() .zip(data.back_line_vertices.first()) .map_or(0.0, |(front, back)| { (glam::DVec3::new(back.x, back.y, back.z) - glam::DVec3::new(front.x, front.y, front.z)) .dot(viewing) .abs() }) }); if depth <= 1e-12 { return Some(planes); } planes.push(section_plane( origin + viewing * depth, tangent, -viewing, body_to_world, )?); if data.state == 1 { return Some(planes); } let first = data.vertices.first()?; let last = data.vertices.last()?; let first = glam::DVec3::new(first.x, first.y, first.z); let last = glam::DVec3::new(last.x, last.y, last.z); if (last - first).length() <= 1e-12 { return Some(planes); } planes.push(section_plane(first, vertical, tangent, body_to_world)?); planes.push(section_plane(last, vertical, -tangent, body_to_world)?); if data.state != 4 { return Some(planes); } planes.push(section_plane( origin - vertical * data.bottom_height, tangent, vertical, body_to_world, )?); planes.push(section_plane( origin + vertical * data.top_height, tangent, -vertical, body_to_world, )?); Some(planes) } /// True when `layer` is turned off or frozen — entities on it never render. fn layer_hidden(&self, layer: &str) -> bool { self.document .layers .get(layer) .map(|l| l.flags.off || l.flags.frozen) .unwrap_or(false) } /// Handles hidden inside content viewport `vp`: its VP-frozen layers plus /// model-space references bound to another viewport (Inventor drawing /// views overlap in model space; each is drawn only in its own viewport). /// Entity and layer handles never collide, so both ride the same set and /// its cache signature. pub(super) fn viewport_hidden_handles(&self, vp: Handle) -> HashSet { let mut hidden: HashSet = match self.document.get_entity(vp) { Some(EntityType::Viewport(v)) => v.frozen_layers.iter().copied().collect(), _ => HashSet::default(), }; // ponytail: full entity scan per call, gated to drawings declaring the // class; memoize per geometry epoch if large Inventor sheets show up. if self .document .classes .get_by_name("ACIDBLOCKREFERENCE") .is_some() { let model = self.model_space_block_handle(); hidden.extend(self.document.entities().filter_map(|e| match e { EntityType::Insert(i) if i.common.owner_handle == model && i.view_rep_handle.is_some_and(|bound| bound != vp) => { Some(i.common.handle) } _ => None, })); } hidden } /// True when `layer`'s handle is in a content viewport's per-viewport /// frozen-layer set (VP freeze). Mirrors the wire path's test in /// [`Scene::resident_entity_visible`] so fills / images / meshes hide on the /// same layers wires do. `None` / empty set → never frozen. fn layer_frozen_in(&self, layer: &str, frozen: Option<&HashSet>) -> bool { match frozen { Some(fz) if !fz.is_empty() => self .document .layers .get(layer) .map(|l| fz.contains(&l.handle)) .unwrap_or(false), _ => false, } } /// Order-independent signature of a per-viewport frozen-layer set, so /// viewports that freeze the same layers share one cached filtered fill / /// image / mesh set. Matches the fold the resident wire cache uses. fn frozen_layers_sig(frozen: &HashSet) -> u64 { let mut sig: u64 = frozen.len() as u64; for h in frozen.iter() { sig ^= h.value().wrapping_mul(0x9E37_79B9_7F4A_7C15); } sig } fn viewport_style_key(&self, viewport: Option) -> u64 { let Some(viewport) = viewport.filter(|handle| handle.is_valid()) else { return 0; }; let stale = self .viewport_style_override_cache .borrow() .as_ref() .map(|(epoch, _)| *epoch != self.geometry_epoch) .unwrap_or(true); if stale { use codec::objects::KnownXRecordKind; let kinds = [ KnownXRecordKind::LayerViewportAlphaOverride, KnownXRecordKind::LayerViewportColorOverride, KnownXRecordKind::LayerViewportLinetypeOverride, KnownXRecordKind::LayerViewportLineweightOverride, ]; let mut overridden = HashSet::default(); for layer in self.document.layers.iter() { for kind in kinds { overridden.extend( self.document .layer_viewport_overrides(layer.handle, kind) .into_iter() .map(|(handle, _)| handle), ); } } *self.viewport_style_override_cache.borrow_mut() = Some((self.geometry_epoch, overridden)); } self.viewport_style_override_cache .borrow() .as_ref() .is_some_and(|(_, overridden)| overridden.contains(&viewport)) .then_some(viewport.value()) .unwrap_or(0) } /// Hatch / 2-D-solid fills for a content viewport, with its frozen layers /// removed. Cached per frozen-set signature (viewports sharing a frozen set /// share the build). No frozen layers → the shared unfiltered set. pub(super) fn hatch_models_for_viewport( &self, viewport: Handle, frozen: &HashSet, tint_selected: bool, ) -> Arc> { if viewport.is_null() && frozen.is_empty() { return self.hatch_models_arc_for_view(tint_selected); } let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); let all_visible = self.annotation_all_visible(); let context_sig = scale.map_or(0, |handle| handle.value()) ^ u64::from(all_visible).rotate_left(61) ^ self.viewport_style_key(Some(viewport)).rotate_left(23); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); let sel = if tint_selected { self.selected_hatch_sig() } else { 0 }; if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&key) { if *e == self.geometry_epoch && *s == sel { return Arc::clone(arc); } } let arc = Arc::new(self.synced_hatch_models( target_block, Some(frozen), scale, all_visible, Some(viewport), tint_selected, )); self.frozen_hatch_cache .borrow_mut() .insert(key, (self.geometry_epoch, sel, Arc::clone(&arc))); arc } /// Wipeout fills for a content viewport, with its frozen layers removed. pub(super) fn wipeout_models_for_viewport( &self, viewport: Handle, frozen: &HashSet, ) -> Arc> { if viewport.is_null() && frozen.is_empty() { return self.wipeout_models_arc(); } let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); let all_visible = self.annotation_all_visible(); let context_sig = scale.map_or(0, |handle| handle.value()) ^ u64::from(all_visible).rotate_left(61); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); if let Some((e, arc)) = self.frozen_wipeout_cache.borrow().get(&key) { if *e == self.geometry_epoch { return Arc::clone(arc); } } let arc = Arc::new(self.wipeout_models(target_block, Some(frozen), scale, all_visible)); self.frozen_wipeout_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); arc } /// Point clouds of the model content, with a content viewport's frozen /// layers removed. The paper sheet shows none. The set is rebuilt per /// geometry epoch; unchanged clouds keep their placed points (and GPU /// buffers) by key. pub(super) fn point_clouds_for_viewport( &self, frozen: &HashSet, paper_sheet: bool, ) -> Arc { use crate::scene::model::point_cloud; static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); let empty = || Arc::clone(EMPTY.get_or_init(Default::default)); if paper_sheet { return empty(); } let target_block = self.content_render_block_handle(); let key = (target_block, Self::frozen_layers_sig(frozen)); if let Some((epoch, arc)) = self.point_cloud_cache.borrow().get(&key) { if *epoch == self.geometry_epoch { return Arc::clone(arc); } } let arc = if point_cloud::any_definition(&self.document) { let depths = rustc_hash::FxHashMap::default(); let graph = render_graph::RenderSceneGraph::new( &self.document, (!frozen.is_empty()).then_some(frozen), None, true, &depths, ); let mut clouds = Vec::new(); graph.walk_root( self.render_scene_root(target_block), |entity, context| { context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle) }, |entity, context| { let EntityType::Extended(extended) = entity else { return; }; let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else { return; }; let transform = context.is_instanced().then_some(&context.transform); // Object Color stylization draws the cloud in its own colour. let common = entity.common(); let color = if matches!(common.color, codec::types::Color::ByLayer) { self.document .layers .get(&common.layer) .map(|layer| layer.color.clone()) .unwrap_or(common.color.clone()) } else { common.color.clone() }; let object_color = color.rgb().map_or([255; 3], |(r, g, b)| [r, g, b]); clouds.extend(point_cloud::placed( &self.document, data, object_color, transform, &point_cloud::hidden(data), )); }, ); if clouds.is_empty() { empty() } else { Arc::new(point_cloud::PointCloudSet { point_size: point_cloud::point_size(&self.document), clouds, }) } } else { empty() }; self.point_cloud_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); arc } /// Wireframe 2D draws no cloud points: each shown cloud whose file is /// found draws its extents box and a message instead, unless /// POINTCLOUD2DVSDISPLAY is 1. // ponytail: model-space clouds only, rebuilt per frame; cache by epoch if // drawings with many clouds make it show. pub(super) fn point_cloud_2d_wires(&self) -> Vec { use crate::scene::model::point_cloud::{resolve_source, setting, setting_value}; if !crate::scene::model::point_cloud::any_definition(&self.document) || setting("POINTCLOUD2DVSDISPLAY") .is_some_and(|s| setting_value(&self.document, s) == 1) { return Vec::new(); } let mut wires = Vec::new(); for entity in self.document.entities() { let EntityType::Extended(extended) = entity else { continue; }; let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else { continue; }; let common = entity.common(); if common.invisible || self.entity_temporarily_hidden(common.handle) || self .document .layers .get(&common.layer) .is_some_and(|layer| layer.is_off() || layer.is_frozen()) || resolve_source(&self.document, data).is_none() { continue; } let color = if matches!(common.color, codec::types::Color::ByLayer) { self.document .layers .get(&common.layer) .map(|layer| layer.color.clone()) .unwrap_or(common.color.clone()) } else { common.color.clone() }; let (r, g, b) = color.rgb().unwrap_or((255, 255, 255)); wires.push(WireModel::solid_f64( common.handle.value().to_string(), crate::entities::extended::point_cloud_2d_style_lines(data), [ f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0, ], false, )); } wires } /// Image / OLE models for a content viewport, with its frozen layers removed. pub(super) fn images_for_viewport( &self, viewport: Handle, frozen: &HashSet, ) -> Arc> { if viewport.is_null() && frozen.is_empty() { return self.images_arc(); } let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); let all_visible = self.annotation_all_visible(); let context_sig = scale.map_or(0, |handle| handle.value()) ^ u64::from(all_visible).rotate_left(61) ^ self.viewport_style_key(Some(viewport)).rotate_left(23); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, sig); if let Some((e, arc)) = self.frozen_image_cache.borrow().get(&key) { if *e == self.geometry_epoch { return Arc::clone(arc); } } let arc = Arc::new(self.image_models( target_block, Some(frozen), scale, all_visible, Some(viewport), )); self.frozen_image_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); arc } /// Solid meshes for a content viewport, with its frozen layers removed. pub(super) fn meshes_for_viewport( &self, viewport: Handle, frozen: &HashSet, ) -> Arc> { if viewport.is_null() && frozen.is_empty() { return self.meshes_arc(); } let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); let all_visible = self.annotation_all_visible(); let context_sig = scale.map_or(0, |handle| handle.value()) ^ u64::from(all_visible).rotate_left(61) ^ self.viewport_style_key(Some(viewport)).rotate_left(23); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); if let Some((e, arc)) = self.frozen_mesh_cache.borrow().get(&key) { if *e == self.geometry_epoch { return Arc::clone(arc); } } let arc = Arc::new(self.mesh_models( target_block, Some(frozen), scale, all_visible, Some(viewport), )); self.frozen_mesh_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); arc } /// True when `handle`'s entity sits on a locked layer, or is a point cloud /// locked on its own. Locked objects stay visible, snappable and /// selectable, but mutation paths must skip them. pub fn is_layer_locked(&self, handle: Handle) -> bool { let Some(entity) = self.document.get_entity(handle) else { return false; }; is_locked_point_cloud(entity) || self .document .layers .get(&entity.common().layer) .is_some_and(|l| l.is_locked()) // REFEDIT with "Lock objects not in working set": only the // working set can be edited. || (self.refedit_lock && self .refedit_keep .as_ref() .is_some_and(|keep| !keep.contains(&handle))) } /// True when `handle` addresses a reference-only entity (API v9): /// plugin-owned display geometry the built-in edit paths treat like a /// locked-layer entity while its owning plugin keeps write access through /// the plugin host API. Absent entities are never reference-only. pub fn is_reference_only(&self, handle: Handle) -> bool { self.reference_only.is_marked(handle) && self.document.get_entity(handle).is_some() } /// Insert or remove `handle` in the scene-scoped designation set, /// returning whether membership changed. Pure storage: no epoch bump, no /// undo bookkeeping — those belong to the callers (the plugin host API /// mutation and the history channels). pub(crate) fn set_reference_only_membership(&mut self, handle: Handle, on: bool) -> bool { self.reference_only.set(handle, on) } /// The name of the locked layer `handle` sits on, if any (for messages). pub fn locked_layer_name(&self, handle: Handle) -> Option { let name = self .document .get_entity(handle) .map(|e| e.common().layer.clone())?; let locked = self .document .layers .get(&name) .map(|l| l.is_locked()) .unwrap_or(false); locked.then_some(name) } /// File-backed visibility shared by top-level and block-definition meshes. /// Object isolation is intentionally absent: a retained INSERT must retain /// every visible child of its block definition. fn mesh_definition_entity_visible(&self, handle: Handle) -> bool { let Some(c) = self.document.get_entity(handle).map(|e| e.common()) else { return false; }; if c.invisible { return false; } !self.layer_hidden(&c.layer) } /// Visibility test for a top-level solid mesh entity, mirroring the direct /// 2D wire path. fn mesh_entity_visible(&self, handle: Handle) -> bool { if !self.mesh_definition_entity_visible(handle) { return false; } if self.entity_temporarily_hidden(handle) { return false; } true } /// One transformed mesh per block-definition solid instance reached from an /// INSERT owned by `layout_block`. Nested INSERTs accumulate their /// transform. Empty when no block solids exist. (#123) fn instanced_block_meshes( &self, layout_block: Handle, frozen: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, live_section: Option<&LiveSection>, ) -> Vec { if self.block_meshes.is_empty() { return Vec::new(); } let depth_map = self.draw_depth_map(); let graph = render_graph::RenderSceneGraph::new( &self.document, frozen, annotation_scale_handle, all_visible, depth_map.as_ref(), ) .with_annotation_scale(self.annotation_scale) .with_viewport(viewport); let mut out = Vec::new(); graph.walk_root( self.render_scene_root(layout_block), |entity, context| { context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle) }, |entity, context| { if !context.is_instanced() { return; } let handle = entity.common().handle; let Some(original) = self.block_meshes.get(&handle) else { return; }; let sectioned; let set = if let Some((section, body)) = live_section.and_then(|section| { self.section_source_body(handle).map(|body| (section, body)) }) { match Self::section_body( &body, §ion.data, section.slice_depth, Some(&context.transform), ) { Ok(Some(body)) => { if let Some((mut mesh, _, _)) = self.prepare_solid_model_display(handle, &body) { mesh.prepare_instance_source(handle); sectioned = mesh; §ioned } else { original } } Ok(None) => return, Err(()) => original, } } else { original }; let inherit = self.mesh_inherit_for_path(&context.insert_path); let own_alpha = set.display_color().map_or(1.0, |color| color[3]); let mut transformed = transform_block_mesh_lod_set(set, &context.transform); if let Some(color) = self.block_mesh_override_color(entity, handle, inherit.as_ref(), own_alpha) { transformed.instance_color = Some(color); if let Some(material) = transformed.material.as_mut() { if material.handle.is_none() { material.diffuse = color; } } } if let Some(material) = self.block_mesh_override_material(entity, inherit.as_ref()) { material.apply_to_with_face_overrides( &mut transformed, &self.document, self.material_base_dir.as_deref(), ); } if self.count_display.is_some() { let own = transformed .instance_color .or(set.display_color()) .unwrap_or([1.0; 4]); let color = self.count_color(context.root_handle, own, self.current_bg()); transformed.instance_color = Some(color); if let Some(material) = transformed.material.as_mut() { material.diffuse = color; } } transformed.instance_handle = Some(context.root_handle); out.push(transformed); }, ); out } fn mesh_inherit_for_path(&self, path: &[DxfInsert]) -> Option { let first = path.first()?; let entity = EntityType::Insert(first.clone()); let bg = self.current_bg(); let insert_color = crate::scene::view::render::adapt_to_bg( crate::scene::view::render::render_style_for(&self.document, &entity).0, bg, ); let layer0_color = crate::scene::view::render::adapt_to_bg( crate::scene::view::render::layer_render_style(&self.document, &first.common.layer) .color, bg, ); let mut inherit = BlockMeshInherit { insert_color, layer0_color, insert_material: crate::scene::model::material_model::resolve_material_with_base( &self.document, &entity, insert_color, None, self.material_base_dir.as_deref(), ), layer0_material: crate::scene::model::material_model::resolve_layer_material_with_base( &self.document, &first.common.layer, layer0_color, self.material_base_dir.as_deref(), ), }; for insert in &path[1..] { inherit = self.chain_mesh_inherit(insert, &inherit); } Some(inherit) } /// Background colour for the current layout (model vs paper). pub(crate) fn current_bg(&self) -> [f32; 4] { if self.current_layout != "Model" { self.paper_bg_color } else { self.bg_color } } /// Chain block-child colour inheritance into a nested INSERT, mirroring /// `expand_insert`: ByBlock keeps the parent source; a nested insert on /// layer "0" with ByLayer colour adopts the parent layer-0 target; else it /// uses its own resolved colour / layer. Returned colours are bg-adapted. fn chain_mesh_inherit( &self, ins: &codec::entities::Insert, parent: &BlockMeshInherit, ) -> BlockMeshInherit { use codec::types::Color; let bg = self.current_bg(); let on_l0 = crate::scene::view::render::is_effective_layer_zero(&ins.common.layer); let insert_entity = EntityType::Insert(ins.clone()); let has_book_color = crate::scene::view::render::has_resolved_book_color(&self.document, &insert_entity); let child_ins_color = if !has_book_color && ins.common.color == Color::ByBlock { parent.insert_color } else if !has_book_color && on_l0 && ins.common.color == Color::ByLayer { parent.layer0_color } else { crate::scene::view::render::adapt_to_bg( crate::scene::view::render::render_style_for( &self.document, &EntityType::Insert(ins.clone()), ) .0, bg, ) }; let child_l0 = if on_l0 { parent.layer0_color } else { crate::scene::view::render::adapt_to_bg( crate::scene::view::render::layer_render_style(&self.document, &ins.common.layer) .color, bg, ) }; let child_insert_material = if ins.common.material_flags == 1 { parent.insert_material.clone() } else if on_l0 && ins.common.material_flags == 0 { parent.layer0_material.clone() } else { crate::scene::model::material_model::resolve_material_with_base( &self.document, &insert_entity, child_ins_color, Some(&parent.insert_material), self.material_base_dir.as_deref(), ) }; let child_l0_material = if on_l0 { parent.layer0_material.clone() } else { crate::scene::model::material_model::resolve_layer_material_with_base( &self.document, &ins.common.layer, child_l0, self.material_base_dir.as_deref(), ) }; BlockMeshInherit { insert_color: child_ins_color, layer0_color: child_l0, insert_material: child_insert_material, layer0_material: child_l0_material, } } /// Per-instance colour override for a block-internal solid mesh: ByBlock → /// insert colour, layer-0 + ByLayer → insert-layer colour, else `None` /// (keep the cached own-layer colour). Applies the same REFEDIT fade as /// `recolor_meshes`, keyed on the inner solid handle. (#221) fn block_mesh_override_color( &self, e: &EntityType, h: Handle, inherit: Option<&BlockMeshInherit>, own_alpha: f32, ) -> Option<[f32; 4]> { let inherit = inherit?; use codec::types::Color; let common = e.common(); let on_l0 = crate::scene::view::render::is_effective_layer_zero(&common.layer); let has_book_color = crate::scene::view::render::has_resolved_book_color(&self.document, e); let mut c = if !has_book_color && common.color == Color::ByBlock { inherit.insert_color } else if !has_book_color && on_l0 && common.color == Color::ByLayer { // Inherit the insert layer's RGB but keep the solid's own alpha, // matching the wire/hatch path (render_style_for_block_sub). [ inherit.layer0_color[0], inherit.layer0_color[1], inherit.layer0_color[2], own_alpha, ] } else { return None; }; if let Some(keep) = &self.refedit_keep { if !keep.contains(&h) { c = crate::scene::cache::block_cache::refedit_fade(c, self.current_bg()); } } Some(c) } fn block_mesh_override_material( &self, entity: &EntityType, inherit: Option<&BlockMeshInherit>, ) -> Option { let inherit = inherit?; let common = entity.common(); if common.material_flags == 1 { Some(inherit.insert_material.clone()) } else if common.material_flags == 0 && crate::scene::view::render::is_effective_layer_zero(&common.layer) { Some(inherit.layer0_material.clone()) } else { None } } /// Hatches eligible for click / box / lasso hit-testing in the current /// layout. Filters out block-internal source hatches (stored in /// `self.hatches` at block-local coords for the block-defn position, /// which doesn't project correctly through the offset-rel view_proj /// and was causing the wrong hatch to be selected on click). pub fn visible_hatches_for_click( &self, candidate_handles: Option<&HashSet>, ) -> HashMap { let visible = |h: Handle, m: &HatchModel| { self.hatch_visible_for_interaction(h) .then(|| (h, m.clone())) }; if let Some(handles) = candidate_handles { handles .iter() .filter_map(|&handle| { self.hatches .get(&handle) .and_then(|hatch| visible(handle, hatch)) }) .collect() } else { self.hatches .iter() .filter_map(|(&handle, hatch)| visible(handle, hatch)) .collect() } } fn hatch_visible_for_interaction(&self, handle: Handle) -> bool { let Some(entity) = self.document.get_entity(handle) else { return false; }; // Use the SOLID's WCS-aware wire fill for interaction as well as // drawing. Its cached HatchModel is flattened to XY for plotting and // must not leave an invisible selection target at z=0 (#617). if matches!(entity, EntityType::Solid(_)) { return false; } let common = entity.common(); if common.invisible || self.entity_temporarily_hidden(handle) || self.layer_hidden(&common.layer) || self.interaction_layer_frozen(&common.layer) || crate::scene::annotative::annotative_offscale_for( &self.document, common, self.displayed_annotation_scale_handle(), self.annotation_all_visible(), ) { return false; } self.belongs_to_visible_block(handle, common.owner_handle, self.interaction_block_handle()) } /// Instanced hatch models keyed by their block-backed host handle. pub fn insert_hatches_for_click(&self) -> Arc>> { let interaction_block = self.interaction_block_handle(); let space_key = self.interaction_space_key(); { let c = self.insert_hatch_cache.borrow(); if let Some((epoch, cached_space, ref arc)) = *c { if cached_space == space_key && self.category_cache_valid(epoch, CACHE_CATEGORY_INSERT_HATCH, |handle| { self.document.get_entity(handle).is_some_and(|entity| { render_graph::entity_render_block_uses( &self.document, entity, self.annotation_scale, ) .into_iter() .any(|block_use| block_use.active) }) }) { let arc = Arc::clone(arc); drop(c); if let Some((cached_epoch, _, _)) = self.insert_hatch_cache.borrow_mut().as_mut() { *cached_epoch = self.geometry_epoch; } return arc; } } } let layer_hidden = |layer: &str| self.layer_hidden(layer) || self.interaction_layer_frozen(layer); let mut out: HashMap> = HashMap::default(); // Skip block subtrees that contain no hatch at all. The presence test // is memoised across instances. let mut hatch_memo: std::collections::HashMap = std::collections::HashMap::new(); let annotation_scale_handle = self.displayed_annotation_scale_handle(); let all_visible = self.annotation_all_visible(); let depth_map = self.draw_depth_map(); let frozen: HashSet = self .interaction_viewport_frozen_layers() .into_iter() .flatten() .copied() .collect(); let graph = render_graph::RenderSceneGraph::new( &self.document, (!frozen.is_empty()).then_some(&frozen), annotation_scale_handle, all_visible, depth_map.as_ref(), ) .with_annotation_scale(self.annotation_scale); for entity in self.document.entities() { let contextual = crate::scene::annotative::entity_for_annotation_context( &self.document, entity, annotation_scale_handle, ); let host = contextual.as_ref(); let common = host.common(); if common.invisible || self.entity_temporarily_hidden(common.handle) || layer_hidden(&common.layer) || crate::scene::annotative::annotative_offscale_for( &self.document, common, annotation_scale_handle, all_visible, ) { continue; } if !self.belongs_to_visible_block(common.handle, common.owner_handle, interaction_block) { continue; } for block_use in render_graph::entity_render_block_uses(&self.document, host, self.annotation_scale) .into_iter() .filter(|block_use| block_use.active) { if !render_graph::block_contains_hatch( &self.document, &block_use.insert.block_name, &mut hatch_memo, ) { continue; } graph.walk_block_use( &block_use, common.handle, |_, _| true, |entity, context| { let EntityType::Hatch(source) = entity else { return; }; let mut placed = EntityType::Hatch(source.clone()); placed.apply_transform(&context.transform); let EntityType::Hatch(hatch) = placed else { return; }; let color = crate::scene::view::render::adapt_to_bg( context.style_for(&self.document, entity).0, self.current_bg(), ); let Some(mut model) = Self::hatch_model_from_dxf(&hatch, color) else { return; }; for clip in &context.clips { let clip: Vec<[f32; 2]> = clip .iter() .map(|point| [point[0] as f32, point[1] as f32]) .collect(); let clipped = pick::xclip::clip_hatch_boundary( &model.boundary, model.world_origin, &clip, ); if clipped.is_empty() { return; } model.boundary = Arc::new(clipped); } out.entry(common.handle).or_default().push(model); }, ); } } let arc = Arc::new(out); *self.insert_hatch_cache.borrow_mut() = Some((self.geometry_epoch, space_key, Arc::clone(&arc))); arc } /// Wires that should participate in hit-testing, snapping, and selection. /// /// - Model layout: all entity wires (same as entity_wires). /// - PSPACE (paper layout, no active viewport): paper-space entities only — /// viewport content is NOT interactive. /// - MSPACE (active viewport set): model-space content of the active viewport /// only — paper-space entities are NOT interactive. pub fn hit_test_wires(&self) -> Arc> { if self.current_layout == "Model" { // Model uses the resident, camera-independent full wire set, so the // interaction index survives pan/zoom and reaches every entity. return self.entity_wires_arc(); } match self.active_viewport { None => self.paper_sheet_wires_arc(), // MSPACE editing already uses the viewport's model camera, so its // interaction source must stay in model coordinates too. The old // projected-Paper copy rebuilt a large Vec/Arc and interaction // index on entry even though the resident model set already exists. Some(vp_handle) => self.model_wires_for_viewport_arc(vp_handle, 0.0), } } /// Small simple sets are cheaper to scan. Either many wires or substantial /// sub-geometry (one giant polyline/text batch/mesh) enables the index. const INTERACTION_INDEX_MIN_WIRES: usize = 4_000; const INTERACTION_INDEX_MIN_WORK: usize = 20_000; fn interaction_index_worthwhile(&self, wires: &[WireModel]) -> bool { wires.len() >= Self::INTERACTION_INDEX_MIN_WIRES || crate::scene::pick::interaction_index::InteractionIndex::estimated_work(wires) >= Self::INTERACTION_INDEX_MIN_WORK || self.hatches.len() >= Self::INTERACTION_INDEX_MIN_WIRES || self.meshes.len().saturating_add(self.block_meshes.len()) >= Self::INTERACTION_INDEX_MIN_WIRES || self .hatches .values() .any(|hatch| hatch.boundary.len() >= Self::INTERACTION_INDEX_MIN_WORK) || self .meshes .values() .chain(self.block_meshes.values()) .any(|set| { set.lods .first() .is_some_and(|mesh| mesh.verts.len() >= Self::INTERACTION_INDEX_MIN_WORK) }) } fn cached_interaction_index( &self, wires: &Arc>, ) -> Option> { let source = Arc::as_ptr(wires) as usize; let mut cache = self.interaction_index_cache.borrow_mut(); cache .retain(|(epoch, _, weak, _)| *epoch == self.geometry_epoch && weak.strong_count() > 0); if let Some(position) = cache.iter().position(|(_, ptr, weak, _)| { *ptr == source && weak .upgrade() .is_some_and(|cached| Arc::ptr_eq(&cached, wires)) }) { let entry = cache.remove(position); let index = Arc::clone(&entry.3); cache.push(entry); if self.current_layout == "Model" { *self.interaction_base_index_cache.borrow_mut() = Some(( self.geometry_epoch, self.interaction_space_key(), Arc::clone(&index), )); } return Some(index); } None } fn cache_interaction_index( &self, epoch: u64, wires: Arc>, index: Arc, ) { const MAX_CACHED_SOURCES: usize = 4; let source = Arc::as_ptr(&wires) as usize; let mut cache = self.interaction_index_cache.borrow_mut(); cache.retain(|(cached_epoch, ptr, weak, _)| { *cached_epoch == self.geometry_epoch && weak.strong_count() > 0 && !(*cached_epoch == epoch && *ptr == source) }); cache.push((epoch, source, Arc::downgrade(&wires), Arc::clone(&index))); if cache.len() > MAX_CACHED_SOURCES { cache.remove(0); } if epoch == self.geometry_epoch && self.current_layout == "Model" { *self.interaction_base_index_cache.borrow_mut() = Some((epoch, self.interaction_space_key(), index)); } } /// Return a stable build key when this source needs its large /// interaction index. The app uses it to deduplicate background builds. pub fn interaction_index_build_key( &self, wires: &Arc>, screen_height_px: f32, ) -> Option<(u64, usize)> { let key = (self.geometry_epoch, Arc::as_ptr(wires) as usize); if !self.interaction_source_is_resident(wires, screen_height_px.max(1.0)) || !self.interaction_index_worthwhile(wires) || self.interaction_overlay_base().is_some() { return None; } if self.cached_interaction_index(wires).is_some() { if self.interaction_index_pending_key.get() == Some(key) { self.interaction_index_pending_key.set(None); } return None; } Some(key) } pub fn mark_interaction_index_pending(&self, epoch: u64, source: usize) { self.interaction_index_pending_key .set(Some((epoch, source))); } /// Install a fully prepared background index only if its geometry/source /// still belongs to this scene. pub fn install_prepared_interaction_index( &self, epoch: u64, source: usize, wires: std::sync::Weak>, index: Arc, ) -> bool { if self.interaction_index_pending_key.get() == Some((epoch, source)) { self.interaction_index_pending_key.set(None); } let Some(wires) = wires.upgrade() else { return false; }; if epoch != self.geometry_epoch || Arc::as_ptr(&wires) as usize != source { return false; } self.cache_interaction_index(epoch, wires, index); true } const INTERACTION_OVERLAY_MAX_HANDLES: usize = 2_048; fn interaction_overlay_base( &self, ) -> Option<( u64, Arc, Vec<(Handle, ChangeKind)>, )> { let space_key = self.interaction_space_key(); let (epoch, index) = { let cache = self.interaction_base_index_cache.borrow(); let (epoch, cached_space, index) = cache.as_ref()?; if *epoch == self.geometry_epoch || *cached_space != space_key { return None; } (*epoch, Arc::clone(index)) }; let changes = self.replay_since(epoch)?; (changes.len() <= Self::INTERACTION_OVERLAY_MAX_HANDLES).then_some((epoch, index, changes)) } fn interaction_overlay_changed_index( &self, base_epoch: u64, changes: &[(Handle, ChangeKind)], ) -> ( Arc>, Arc, ) { let space_key = self.interaction_space_key(); { let cache = self.interaction_overlay_index_cache.borrow(); if let Some((epoch, cached_base, cached_space, wires, index)) = cache.as_ref() { if *epoch == self.geometry_epoch && *cached_base == base_epoch && *cached_space == space_key { return (Arc::clone(wires), Arc::clone(index)); } } } let block = self.interaction_block_handle(); let frozen: Option> = self .interaction_viewport_frozen_layers() .map(|layers| layers.iter().copied().collect()); let annotation_scale_handle = self.displayed_annotation_scale_handle(); let all_visible = self.annotation_all_visible(); let changed_live: Vec = changes .iter() .filter_map(|(handle, kind)| { if matches!(kind, ChangeKind::Removed) { return None; } let entity = self.document.get_entity(*handle)?; self.resident_entity_visible( entity, block, frozen.as_ref(), annotation_scale_handle, all_visible, ) .then_some(*handle) }) .collect(); let wires = Arc::new(self.wire_models_for(&changed_live)); let index = Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(&wires)); *self.interaction_overlay_index_cache.borrow_mut() = Some(( self.geometry_epoch, base_epoch, space_key, Arc::clone(&wires), Arc::clone(&index), )); (wires, index) } fn interaction_overlay_wires( &self, base_keys: impl IntoIterator, changes: &[(Handle, ChangeKind)], changed_wires: &[WireModel], ) -> ( Arc>, HashMap<(u64, u32), u32>, HashMap<(u64, u32), u32>, ) { let changed: HashSet = changes.iter().map(|(handle, _)| *handle).collect(); let memo = self.resident_tess_memo.borrow(); let mut wires = Vec::new(); let mut base_slots = HashMap::default(); let mut misses: HashMap> = HashMap::default(); for (handle_value, ordinal) in base_keys { let handle = Handle::new(handle_value); if changed.contains(&handle) || self.document.get_entity(handle).is_none() { continue; } let hit = memo .get(&handle) .and_then(|run| self.resolve_memo_run(handle, run)); if let Some(wire) = hit .as_ref() .and_then(|hit| hit.wires().get(ordinal as usize)) { base_slots.insert((handle_value, ordinal), wires.len() as u32); wires.push(wire.clone()); } else { misses.entry(handle).or_default().push(ordinal); } } drop(memo); // A memo miss is uncommon (guard change / legacy source). Preserve // correctness without expanding every nearby entity: regenerate that // handle, then retain only the exact wire ordinals the base index // reported. for (handle, ordinals) in misses { let entity_wires = self.wire_models_for(&[handle]); for ordinal in ordinals { if let Some(wire) = entity_wires.get(ordinal as usize) { base_slots.insert((handle.value(), ordinal), wires.len() as u32); wires.push(wire.clone()); } } } let mut changed_slots = HashMap::default(); let mut next_ordinal: HashMap = HashMap::default(); for wire in changed_wires { if let Some(handle) = pipeline::wire_gpu::fast_parse_u64(&wire.name) { let ordinal = next_ordinal.entry(handle).or_default(); changed_slots.insert((handle, *ordinal), wires.len() as u32); *ordinal += 1; } wires.push(wire.clone()); } (Arc::new(wires), base_slots, changed_slots) } fn indexed_interaction_candidates_xy( &self, wires: Arc>, aabb: [f64; 4], allow_pending_empty: bool, area_only: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { self.indexed_interaction_candidates_xy_stubbed(wires, aabb, allow_pending_empty, area_only) .materialize_stubs() } fn indexed_interaction_candidates_xy_stubbed( &self, wires: Arc>, aabb: [f64; 4], allow_pending_empty: bool, area_only: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { let perf = crate::perf::enabled(); let t0 = iced::time::Instant::now(); let keys = base.query_wire_keys_xy(aabb); let key_ms = t0.elapsed().as_secs_f64() * 1000.0; let t_changed = iced::time::Instant::now(); let (changed_wires, changed_index) = self.interaction_overlay_changed_index(base_epoch, &changes); let changed_ms = t_changed.elapsed().as_secs_f64() * 1000.0; let t_local = iced::time::Instant::now(); let (local, base_slots, changed_slots) = self.interaction_overlay_wires(keys.iter().copied(), &changes, &changed_wires); let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; let t_remap = iced::time::Instant::now(); let mut result = if area_only { base.query_remapped_xy_area(Arc::clone(&local), &base_slots, aabb) } else { base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb) }; if area_only { result.extend_indexed_area(changed_index.query_remapped_xy_area( local, &changed_slots, aabb, )); } else { result.extend_indexed(changed_index.query_remapped_xy(local, &changed_slots, aabb)); } let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0; let total_ms = t0.elapsed().as_secs_f64() * 1000.0; if perf && total_ms >= 50.0 { crate::perf_record!( "[perf] interaction-overlay {:>7.1}ms keys={} wires={} query={:.1} changed={:.1} gather={:.1} remap={:.1}", total_ms, keys.len(), result.len(), key_ms, changed_ms, local_ms, remap_ms, ); } return result; } if let Some(index) = self.cached_interaction_index(&wires) { if area_only { index.query_xy_area(wires, aabb) } else { index.query_xy(wires, aabb) } } else if allow_pending_empty && self.interaction_index_pending_key.get() == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) { crate::scene::pick::interaction_index::InteractionCandidates::pending(wires) } else { crate::scene::pick::interaction_index::InteractionCandidates::all(wires) } } fn indexed_interaction_candidates_screen( &self, wires: Arc>, screen_rect: [f32; 4], view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, allow_pending_empty: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { self.indexed_interaction_candidates_screen_stubbed( wires, screen_rect, view_rot, eye, bounds, allow_pending_empty, ) .materialize_stubs() } fn indexed_interaction_candidates_screen_stubbed( &self, wires: Arc>, screen_rect: [f32; 4], view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, allow_pending_empty: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { let perf = crate::perf::enabled(); let t0 = iced::time::Instant::now(); let keys = base.query_wire_keys_screen(screen_rect, view_rot, eye, bounds); let key_ms = t0.elapsed().as_secs_f64() * 1000.0; let t_changed = iced::time::Instant::now(); let (changed_wires, changed_index) = self.interaction_overlay_changed_index(base_epoch, &changes); let changed_ms = t_changed.elapsed().as_secs_f64() * 1000.0; let t_local = iced::time::Instant::now(); let (local, base_slots, changed_slots) = self.interaction_overlay_wires(keys.iter().copied(), &changes, &changed_wires); let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; let t_remap = iced::time::Instant::now(); let mut result = base.query_remapped_screen( Arc::clone(&local), &base_slots, screen_rect, view_rot, eye, bounds, ); result.extend_indexed(changed_index.query_remapped_screen( local, &changed_slots, screen_rect, view_rot, eye, bounds, )); let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0; let total_ms = t0.elapsed().as_secs_f64() * 1000.0; if perf && total_ms >= 50.0 { crate::perf_record!( "[perf] interaction-overlay {:>7.1}ms keys={} wires={} query={:.1} changed={:.1} gather={:.1} remap={:.1}", total_ms, keys.len(), result.len(), key_ms, changed_ms, local_ms, remap_ms, ); } return result; } if let Some(index) = self.cached_interaction_index(&wires) { index.query_screen(wires, screen_rect, view_rot, eye, bounds) } else if allow_pending_empty && self.interaction_index_pending_key.get() == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) { crate::scene::pick::interaction_index::InteractionCandidates::pending(wires) } else { crate::scene::pick::interaction_index::InteractionCandidates::all(wires) } } fn indexed_interaction_pick_radius( &self, wires: &Arc>, base_radius_px: f32, viewport_height_px: f32, include_line_weight: bool, ) -> f32 { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { let (_, changed) = self.interaction_overlay_changed_index(base_epoch, &changes); base.pick_radius_px( changed.pick_radius_px(base_radius_px, viewport_height_px, include_line_weight), viewport_height_px, include_line_weight, ) } else { self.cached_interaction_index(wires) .map_or(base_radius_px, |index| { index.pick_radius_px(base_radius_px, viewport_height_px, include_line_weight) }) } } fn interaction_source_is_resident( &self, wires: &Arc>, screen_height_px: f32, ) -> bool { if self.current_layout == "Model" { return Arc::ptr_eq(&self.entity_wires_arc(), wires); } match self.active_viewport { Some(viewport) => { let resident = self.model_wires_for_viewport_arc(viewport, screen_height_px); Arc::ptr_eq(&resident, wires) } None => Arc::ptr_eq(&self.paper_sheet_wires_arc(), wires), } } /// Prepare hover indexes after derived geometry is installed during open. pub(crate) fn warm_interaction_caches(&self) { let perf = crate::perf::enabled(); let t_start = perf.then(iced::time::Instant::now); let t_ei = perf.then(iced::time::Instant::now); let _ = self.entity_index(); let ei_ms = crate::perf::elapsed_ms(t_ei); let t_ihi = perf.then(iced::time::Instant::now); let _ = self.interaction_handle_index(); let ihi_ms = crate::perf::elapsed_ms(t_ihi); let total_ms = crate::perf::elapsed_ms(t_start); if perf { crate::perf_record!( "[perf] warm_interaction_caches total={:.1}ms entity_index={:.1}ms interaction_handle_index={:.1}ms", total_ms, ei_ms, ihi_ms, ); } } fn interaction_handle_index( &self, ) -> Arc { let space_key = self.interaction_space_key(); { let reuse = { let cache = self.interaction_handle_index_cache.borrow(); match cache.as_ref() { Some((epoch, cached_space, index)) if *cached_space == space_key && self.category_cache_valid( *epoch, CACHE_CATEGORY_INTERACTION, |handle| { self.hatches.contains_key(&handle) || self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) || matches!( self.document.get_entity(handle), Some(EntityType::Insert(_)) ) }, ) => { Some(Arc::clone(index)) } _ => None, } }; if let Some(index) = reuse { if let Some((epoch, _, _)) = self.interaction_handle_index_cache.borrow_mut().as_mut() { *epoch = self.geometry_epoch; } return index; } } let mut entries: Vec<(u64, [f64; 6])> = Vec::new(); for (&handle, hatch) in &self.hatches { if !self.hatch_visible_for_interaction(handle) { continue; } if let Some([min_x, min_y, max_x, max_y]) = hatch_interaction_aabb(hatch) { entries.push((handle.value(), [min_x, min_y, 0.0, max_x, max_y, 0.0])); } } for (&handle, hatches) in self.insert_hatches_for_click().iter() { for hatch in hatches { if let Some([min_x, min_y, max_x, max_y]) = hatch_interaction_aabb(hatch) { entries.push((handle.value(), [min_x, min_y, 0.0, max_x, max_y, 0.0])); } } } let index = Arc::new(crate::scene::pick::interaction_index::InteractionHandleIndex::build(entries)); *self.interaction_handle_index_cache.borrow_mut() = Some((self.geometry_epoch, space_key, Arc::clone(&index))); index } /// Cursor-local interaction candidates. `radius_px` is supplied by the /// consumer (OSNAP aperture or click tolerance), avoiding the old fixed /// 64-pixel neighbourhood. Returned wires stay borrowed from `wires`; /// no heavyweight `WireModel` clone occurs. pub fn interaction_candidates_near( &self, wires: Arc>, cursor: glam::DVec3, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, radius_px: f32, ) -> crate::scene::pick::interaction_index::InteractionCandidates { self.interaction_candidates_near_impl( wires, cursor, view_rot, eye, bounds, radius_px, false, false, ) } pub fn interaction_pick_candidates_near( &self, wires: Arc>, cursor: glam::DVec3, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, radius_px: f32, ) -> crate::scene::pick::interaction_index::InteractionCandidates { self.interaction_candidates_near_impl( wires, cursor, view_rot, eye, bounds, radius_px, self.document.header.lineweight_display, false, ) } pub fn interaction_hover_candidates_near( &self, wires: Arc>, cursor: glam::DVec3, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, radius_px: f32, ) -> crate::scene::pick::interaction_index::InteractionCandidates { self.interaction_candidates_near_impl( wires, cursor, view_rot, eye, bounds, radius_px, self.document.header.lineweight_display, true, ) } fn interaction_candidates_near_impl( &self, wires: Arc>, cursor: glam::DVec3, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, radius_px: f32, include_line_weight: bool, allow_pending_empty: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if !self.interaction_source_is_resident(&wires, bounds.height) || !self.interaction_index_worthwhile(&wires) { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires) .materialize_stubs(); } let radius_px = self.indexed_interaction_pick_radius( &wires, radius_px, bounds.height, include_line_weight, ); let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9 && (view_rot.w_axis.w - 1.0).abs() < 1e-6; if !flat_ortho { let clip = view_rot * (cursor - eye).as_vec3().extend(1.0); if !clip.is_finite() || clip.w <= 1e-6 { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires) .materialize_stubs(); } let ndc = clip.truncate() / clip.w; let screen = iced::Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, ); let radius = radius_px.max(0.0); return self.indexed_interaction_candidates_screen( wires, [ screen.x - radius, screen.y - radius, screen.x + radius, screen.y + radius, ], view_rot, eye, bounds, allow_pending_empty, ); } let world_x_px = ((view_rot.x_axis.x * bounds.width * 0.5).powi(2) + (view_rot.x_axis.y * bounds.height * 0.5).powi(2)) .sqrt(); let world_y_px = ((view_rot.y_axis.x * bounds.width * 0.5).powi(2) + (view_rot.y_axis.y * bounds.height * 0.5).powi(2)) .sqrt(); let s = world_x_px.min(world_y_px); if s <= 1e-6 { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires) .materialize_stubs(); } let radius = radius_px.max(0.0) as f64 / s as f64; let query = [ cursor.x - radius, cursor.y - radius, cursor.x + radius, cursor.y + radius, ]; self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty, false) } /// Shared rectangular broad phase for box/lasso/fence and command windows. /// Flat orthographic views query world XY; tilted/perspective views query /// projected 3D bounds using the supplied screen rectangle. /// Candidates for an area selection: box, crossing, fence and lasso. /// /// Every caller feeds the result to the box/fence hit tests and to /// `interaction_candidate_handles`, none of which snap, so the flat-ortho /// path asks for the reduced category set. A caller that needs snapping /// belongs on `interaction_candidates_near` instead. pub fn interaction_candidates_in_aabb( &self, wires: Arc>, aabb: [f64; 4], screen_rect: [f32; 4], view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, ) -> crate::scene::pick::interaction_index::InteractionCandidates { let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9 && (view_rot.w_axis.w - 1.0).abs() < 1e-6; if !self.interaction_source_is_resident(&wires, bounds.height) || !self.interaction_index_worthwhile(&wires) { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires) .materialize_stubs(); } let pad_px = self.indexed_interaction_pick_radius(&wires, 0.0, bounds.height, true); if flat_ortho { let world_x_px = ((view_rot.x_axis.x * bounds.width * 0.5).powi(2) + (view_rot.x_axis.y * bounds.height * 0.5).powi(2)) .sqrt(); let world_y_px = ((view_rot.y_axis.x * bounds.width * 0.5).powi(2) + (view_rot.y_axis.y * bounds.height * 0.5).powi(2)) .sqrt(); let scale = world_x_px.min(world_y_px); let pad = if scale > 1e-6 { pad_px as f64 / scale as f64 } else { 0.0 }; self.indexed_interaction_candidates_xy( wires, [aabb[0] - pad, aabb[1] - pad, aabb[2] + pad, aabb[3] + pad], false, true, ) } else { self.indexed_interaction_candidates_screen( wires, [ screen_rect[0] - pad_px, screen_rect[1] - pad_px, screen_rect[2] + pad_px, screen_rect[3] + pad_px, ], view_rot, eye, bounds, false, ) } } pub fn interaction_candidate_handles( &self, candidates: &crate::scene::pick::interaction_index::InteractionCandidates, ) -> Option> { let handles = self.interaction_candidate_handles_all(candidates); // Reference-only entities (API v9) are plugin-owned display geometry: // their fills, solids and instanced meshes are not pickable, so the // click/hover fallback paths that consume this set skip them and a // click passes through to the editable entity underneath. handles.map(|mut handles| { handles.retain(|h| !self.is_reference_only(*h)); handles }) } fn interaction_candidate_handles_all( &self, candidates: &crate::scene::pick::interaction_index::InteractionCandidates, ) -> Option> { let mut handles: HashSet = candidates .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) .collect(); if candidates.is_indexed() && candidates.query_aabb().is_none() && candidates.screen_query().is_none() { return Some(handles); } if let Some(aabb) = candidates.query_aabb() { let index = self.entity_index(); handles.extend(index.tree.query_rect(aabb)); handles.extend( self.interaction_handle_index() .query_xy(aabb) .into_iter() .map(Handle::new), ); return Some(handles); } let (screen_rect, view_rot, eye, bounds) = candidates.screen_query()?; handles.extend( self.interaction_handle_index() .query_screen(screen_rect, view_rot, eye, bounds) .into_iter() .map(Handle::new), ); Some(handles) } pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet { let wires = self.hit_test_wires(); let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false, false); let mut handles: HashSet = candidates .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) .collect(); let insert_hatches = self.insert_hatches_for_click(); let meshes = self.interaction_meshes_arc(); handles.extend( self.interaction_handle_index() .query_xy(aabb) .into_iter() .filter_map(|value| { let handle = Handle::new(value); (self.hatch_visible_for_interaction(handle) || insert_hatches.contains_key(&handle)) .then_some(handle) }), ); handles.extend(meshes.iter().filter_map(|set| { let handle = set.entity_handle()?; let bounds = mesh_interaction_aabb(set)?; (bounds[3] >= aabb[0] && bounds[0] <= aabb[2] && bounds[4] >= aabb[1] && bounds[1] <= aabb[3]) .then_some(handle) })); handles } /// Top-level solid handles caught by a rectangular selection box. pub fn mesh_box_hit( &self, a: iced::Point, b: iced::Point, crossing: bool, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, _candidate_handles: Option<&HashSet>, ) -> Vec { let meshes = self.interaction_meshes_arc(); let lookup = self.mesh_pick_lookup(&meshes); let handles: Vec = lookup.keys().copied().collect(); let mut out = Vec::new(); for handle in handles { if matches!( self.document.get_entity(handle), Some(EntityType::Insert(_)) ) { continue; } let Some(indices) = lookup.get(&handle) else { continue; }; let test = |set: &MeshLodSet| { set.geometry_lods().first().is_some_and(|mesh| { !pick::hit_test::mesh_box_hit( a, b, crossing, std::iter::once((handle, mesh, set.instance_transform)), view_rot, eye, bounds, ) .is_empty() }) }; let mut sets = indices .iter() .filter_map(|&index| meshes.get(index as usize)); let hit = if crossing { sets.any(test) } else { let mut count = 0; sets.all(|set| { count += 1; test(set) }) && count > 0 }; if hit { out.push(handle); } } out } /// Top-level solid handles caught by a lasso polygon. /// Everything a picked path takes, gathered from every kind of geometry the /// selection can reach: wires, hatches, hatches inside blocks, meshes and /// block meshes, narrowed first to what the path's own bounds can touch. /// /// `open_fence` distinguishes the two gestures that arrive here. A fence is /// a line drawn through the drawing and takes only what it actually cuts; a /// polygon closes back on itself and can also take what it encloses, or /// merely touches when `crossing`. The lasso and the Fence / WPolygon / /// CPolygon keywords both come through this, so they cannot drift apart on /// which geometry they remember to look at. (#596) #[allow(clippy::too_many_arguments)] pub fn path_hit_handles( &self, poly_pts: &[iced::Point], crossing: bool, open_fence: bool, all_wires: std::sync::Arc>, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, to_world: impl Fn(iced::Point) -> glam::DVec3, ) -> Vec { let world_aabb = poly_pts.iter().fold( [ f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY, ], |mut aabb, &point| { let world = to_world(point); aabb[0] = aabb[0].min(world.x); aabb[1] = aabb[1].min(world.y); aabb[2] = aabb[2].max(world.x); aabb[3] = aabb[3].max(world.y); aabb }, ); let screen_rect = poly_pts.iter().fold( [ f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY, ], |mut rect, point| { rect[0] = rect[0].min(point.x); rect[1] = rect[1].min(point.y); rect[2] = rect[2].max(point.x); rect[3] = rect[3].max(point.y); rect }, ); let area_candidates = self.interaction_candidates_in_aabb( all_wires, world_aabb, screen_rect, view_rot, eye, bounds, ); let candidate_handles = self.interaction_candidate_handles(&area_candidates); let wire_hits = if open_fence { crate::scene::pick::hit_test::poly_fence_hit( poly_pts, &area_candidates, view_rot, eye, bounds, ) } else { crate::scene::pick::hit_test::poly_hit( poly_pts, crossing, &area_candidates, view_rot, eye, bounds, ) }; let mut handles: Vec = wire_hits .into_iter() .filter_map(Self::handle_from_wire_name) .collect(); // An open fence has no interior, so the area-based geometry can only be // reached by the polygon gestures. if !open_fence { handles.extend(crate::scene::pick::hit_test::poly_hit_hatch( poly_pts, crossing, &self.visible_hatches_for_click(candidate_handles.as_ref()), view_rot, eye, bounds, candidate_handles.as_ref(), )); handles.extend(crate::scene::pick::hit_test::poly_hit_insert_hatch( poly_pts, crossing, self.insert_hatches_for_click().as_ref(), view_rot, eye, bounds, candidate_handles.as_ref(), )); handles.extend(self.mesh_poly_hit( poly_pts, crossing, view_rot, eye, bounds, candidate_handles.as_ref(), )); handles.extend(self.block_mesh_poly_hit( poly_pts, crossing, view_rot, eye, bounds, candidate_handles.as_ref(), )); } handles.retain(|&h| self.passes_selection_filter(h) && !self.is_reference_only(h)); handles } pub fn mesh_poly_hit( &self, poly: &[iced::Point], crossing: bool, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, _candidate_handles: Option<&HashSet>, ) -> Vec { let meshes = self.interaction_meshes_arc(); let lookup = self.mesh_pick_lookup(&meshes); let handles: Vec = lookup.keys().copied().collect(); let mut out = Vec::new(); for handle in handles { if matches!( self.document.get_entity(handle), Some(EntityType::Insert(_)) ) { continue; } let Some(indices) = lookup.get(&handle) else { continue; }; let test = |set: &MeshLodSet| { set.geometry_lods().first().is_some_and(|mesh| { !pick::hit_test::mesh_poly_hit( poly, crossing, std::iter::once((handle, mesh, set.instance_transform)), view_rot, eye, bounds, ) .is_empty() }) }; let mut sets = indices .iter() .filter_map(|&index| meshes.get(index as usize)); let hit = if crossing { sets.any(test) } else { let mut count = 0; sets.all(|set| { count += 1; test(set) }) && count > 0 }; if hit { out.push(handle); } } out } /// Front-most solid under the cursor across BOTH top-level solid meshes /// (keyed by their own handle) and block-internal solid instances (keyed /// by the parent INSERT). Combining them in one depth-sorted test means a /// block in front of a stray solid wins, instead of the solid always /// taking priority by virtue of being tried first. pub fn solid_click_hit( &self, cursor: iced::Point, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, ) -> Option { self.solid_hit(cursor, view_rot, eye, bounds, candidate_handles, false) } /// Normal object selection for shaded 3D bodies is edge based. This keeps /// the broad face interior available to modelling commands without making /// it part of ordinary object picking. pub fn solid_edge_click_hit( &self, cursor: iced::Point, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, tolerance_px: f32, ) -> Option { let meshes = self.interaction_meshes_arc(); pick::hit_test::mesh_edge_click_hit( cursor, meshes.iter().filter_map(|set| { let handle = set.entity_handle()?; if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { return None; } let (edges, edges_low) = set.geometry_edges(); (!edges.is_empty()).then_some((handle, edges, edges_low, set.instance_transform)) }), view_rot, eye, bounds, tolerance_px, ) } pub fn solid_edge_hover_hit( &self, cursor: iced::Point, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, tolerance_px: f32, ) -> Option { self.solid_edge_click_hit( cursor, view_rot, eye, bounds, candidate_handles, tolerance_px, ) } pub fn solid_click_point_for( &self, cursor: iced::Point, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, target: Handle, ) -> Option { let meshes = self.interaction_meshes_arc(); pick::hit_test::mesh_click_point( cursor, meshes.iter().filter_map(|set| { (set.entity_handle()? == target).then_some(())?; let mesh = set.geometry_lods().first()?; Some(( target, mesh, set.instance_transform, mesh_interaction_aabb(set)?, )) }), view_rot, eye, bounds, ) } /// The solid under the cursor and where the pick ray meets it. pub fn solid_click_hit_point( &self, cursor: iced::Point, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, ) -> Option<(Handle, glam::DVec3)> { let meshes = self.interaction_meshes_arc(); pick::hit_test::mesh_click_handle_point( cursor, meshes.iter().filter_map(|set| { let handle = set.entity_handle()?; let mesh = set.geometry_lods().first()?; Some(( handle, mesh, set.instance_transform, mesh_interaction_aabb(set)?, )) }), view_rot, eye, bounds, ) } pub fn solid_planar_face_normal_at( &mut self, handle: Handle, pick: glam::DVec3, ) -> Option { self.restore_solid_models(&[handle]); let body = self.solid_models.get(&handle)?; let face = model::solid_model::nearest_planar_face(body, pick.to_array())?; model::solid_model::planar_face_normal(body, face).map(glam::DVec3::from_array) } pub fn view_center_surface_pivot(&self, bounds: iced::Rectangle) -> Option { self.surface_pivot_at( bounds, iced::Point::new(bounds.width * 0.5, bounds.height * 0.5), ) } /// The UCS of the planar solid face under `local` (relative to `bounds`), /// Z along the face normal, and the picked point on the face. pub fn planar_face_ucs_at( &mut self, bounds: iced::Rectangle, local: iced::Point, ) -> Option<(codec::tables::Ucs, glam::DVec3)> { let camera = self.camera.borrow(); let view_rot = camera.view_proj_rte(bounds); let eye = camera.eye(); drop(camera); let (handle, pick) = self.solid_click_hit_point(local, view_rot, eye, bounds)?; self.restore_solid_models(&[handle]); let body = self.solid_models.get(&handle)?; let face = crate::scene::model::solid_model::nearest_planar_face(body, pick.to_array())?; crate::scene::model::solid_model::planar_face_ucs(body, face, pick.to_array()) .map(|ucs| (ucs, pick)) } /// The nearest solid or mesh surface under `local` (relative to `bounds`). pub fn surface_pivot_at( &self, bounds: iced::Rectangle, local: iced::Point, ) -> Option { let camera = self.camera.borrow(); let view_rot = camera.view_proj_rte(bounds); let eye = camera.eye(); drop(camera); let meshes = self.interaction_meshes_arc(); pick::hit_test::mesh_click_point( local, meshes.iter().filter_map(|set| { let handle = set.entity_handle()?; let mesh = set.geometry_lods().first()?; Some(( handle, mesh, set.instance_transform, mesh_interaction_aabb(set)?, )) }), view_rot, eye, bounds, ) } /// Hover may use the coarsest cached solid LOD. Click selection keeps the /// full-resolution mesh, while rollover only needs a stable parent handle /// and must stay within an interactive frame budget. pub fn solid_hover_hit( &self, cursor: iced::Point, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, ) -> Option { self.solid_hit(cursor, view_rot, eye, bounds, candidate_handles, true) } fn solid_hit( &self, cursor: iced::Point, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, _candidate_handles: Option<&HashSet>, coarse_lod: bool, ) -> Option { // Reuse the renderer's cached top-level and block-instance mesh set. let meshes = self.interaction_meshes_arc(); let sets: Vec<(Handle, &MeshLodSet)> = meshes .iter() .filter_map(|set| { let handle = set.entity_handle()?; Some((handle, set)) }) .collect(); pick::hit_test::mesh_click_hit( cursor, sets.iter().filter_map(|(handle, set)| { let lods = set.geometry_lods(); let mesh = if coarse_lod { lods.last()? } else { lods.first()? }; Some(( *handle, mesh, set.instance_transform, mesh_interaction_aabb(set)?, )) }), view_rot, eye, bounds, ) } /// Parent INSERT handles whose block-internal solid meshes fall in a /// rectangular selection box. A block whose visible body is a solid has /// no wires to catch, so box/lasso selection must test its instanced /// meshes too. pub fn block_mesh_box_hit( &self, a: iced::Point, b: iced::Point, crossing: bool, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, _candidate_handles: Option<&HashSet>, ) -> Vec { if self.block_meshes.is_empty() { return Vec::new(); } let meshes = self.interaction_meshes_arc(); let lookup = self.mesh_pick_lookup(&meshes); let handles: Vec = lookup.keys().copied().collect(); let mut out = Vec::new(); for handle in handles { if !matches!( self.document.get_entity(handle), Some(EntityType::Insert(_)) ) { continue; } let Some(indices) = lookup.get(&handle) else { continue; }; let test = |set: &MeshLodSet| { set.geometry_lods().first().is_some_and(|mesh| { !pick::hit_test::mesh_box_hit( a, b, crossing, std::iter::once((handle, mesh, set.instance_transform)), view_rot, eye, bounds, ) .is_empty() }) }; let mut sets = indices .iter() .filter_map(|&index| meshes.get(index as usize)); let hit = if crossing { sets.any(test) } else { let mut count = 0; sets.all(|set| { count += 1; test(set) }) && count > 0 }; if hit { out.push(handle); } } out } /// Parent INSERT handles whose block-internal solid meshes fall in a lasso. pub fn block_mesh_poly_hit( &self, poly: &[iced::Point], crossing: bool, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, _candidate_handles: Option<&HashSet>, ) -> Vec { if self.block_meshes.is_empty() { return Vec::new(); } let meshes = self.interaction_meshes_arc(); let lookup = self.mesh_pick_lookup(&meshes); let handles: Vec = lookup.keys().copied().collect(); let mut out = Vec::new(); for handle in handles { if !matches!( self.document.get_entity(handle), Some(EntityType::Insert(_)) ) { continue; } let Some(indices) = lookup.get(&handle) else { continue; }; let test = |set: &MeshLodSet| { set.geometry_lods().first().is_some_and(|mesh| { !pick::hit_test::mesh_poly_hit( poly, crossing, std::iter::once((handle, mesh, set.instance_transform)), view_rot, eye, bounds, ) .is_empty() }) }; let mut sets = indices .iter() .filter_map(|&index| meshes.get(index as usize)); let hit = if crossing { sets.any(test) } else { let mut count = 0; sets.all(|set| { count += 1; test(set) }) && count > 0 }; if hit { out.push(handle); } } out } /// Whether entity `e` renders as direct content of `block_handle` right now: /// visible (not invisible / hidden / on an off-or-frozen layer / frozen /// through the viewport) and owned by the block. Shared by the full resident /// build and the incremental patch so a changed entity is classified /// identically either way. fn resident_entity_visible( &self, e: &EntityType, block_handle: Handle, frozen_layers: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, ) -> bool { let layer = self.document.layers.get(&e.common().layer); self.resident_entity_visible_with_layer( e, block_handle, frozen_layers, annotation_scale_handle, all_visible, layer, ) } /// Visibility predicate with the layer resolved by the caller. fn resident_entity_visible_with_layer( &self, e: &EntityType, block_handle: Handle, frozen_layers: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, layer: Option<&codec::tables::layer::Layer>, ) -> bool { let c = e.common(); if c.invisible { return false; } // Session-only Isolate / Hide and interactive replacement previews. if self.entity_temporarily_hidden(c.handle) { return false; } // Block/BlockEnd are block-defn sentinels, not drawable geometry. if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) { return false; } if layer .map(|l| l.flags.off || l.flags.frozen) .unwrap_or(false) { return false; } if let Some(frozen) = frozen_layers { if !frozen.is_empty() { if frozen.contains(&c.handle) { return false; } if let Some(lh) = layer.map(|l| l.handle) { if frozen.contains(&lh) { return false; } } } } if crate::scene::annotative::annotative_offscale_for( &self.document, c, annotation_scale_handle, all_visible, ) { return false; } self.belongs_to_visible_block(c.handle, c.owner_handle, block_handle) } fn wires_for_block_culled<'doc>( &'doc self, block_handle: Handle, view_aabb: Option<[f32; 4]>, wpp: Option, // Layers frozen specifically through the requesting viewport. // Hidden in addition to the document-level off / frozen flags. // `None` skips the per-viewport check (Model-space callers). frozen_layers: Option<&HashSet>, // Paper-space content viewports compute their own annotation // scale from `vp_effective_scale`; the Model-space and paper- // sheet paths use `self.annotation_scale` / 1.0 respectively. // `None` selects the default branch on `current_layout`. anno_scale_override: Option, annotation_scale_handle: Option, all_visible: bool, style_viewport: Option, ) -> Vec { use codec::objects::ObjectType; // Skip diagnostic clock reads when PERF is disabled. let perf = crate::perf::enabled(); let t_fn = perf.then(iced::time::Instant::now); // ── Ensure sort-order index is current ──────────────────────────── // Replaces the old O(objects) find_map with one rebuild per epoch, // after which every wires_for_block call is an O(1) HashMap lookup. { let needs_rebuild = self .sort_cache .borrow() .as_ref() .map(|(e, _)| *e != self.geometry_epoch) .unwrap_or(true); if needs_rebuild { let mut idx: HashMap> = HashMap::default(); for obj in self.document.objects.values() { if let ObjectType::SortEntitiesTable(t) = obj { if !t.is_empty() { let map = t .entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(); idx.insert(t.block_owner_handle, map); } } } *self.sort_cache.borrow_mut() = Some((self.geometry_epoch, idx)); } } // Visibility test reused by both paths below — and by the resident // incremental patch, so a changed entity is included/excluded exactly as // a from-scratch build would (no divergence). // Resolve each distinct layer name once for this candidate pass. type LayerRef<'a> = Option<&'a codec::tables::layer::Layer>; let layer_of: RefCell>> = RefCell::new(rustc_hash::FxHashMap::default()); let visibility_ok = |e: &'doc EntityType| { let name = e.common().layer.as_str(); let layer = match layer_of.borrow_mut().entry(name) { std::collections::hash_map::Entry::Occupied(hit) => *hit.get(), std::collections::hash_map::Entry::Vacant(slot) => { *slot.insert(self.document.layers.get(name)) } }; self.resident_entity_visible_with_layer( e, block_handle, frozen_layers, annotation_scale_handle, all_visible, layer, ) }; let sort_cache_ms = crate::perf::elapsed_ms(t_fn); let t_visible = perf.then(iced::time::Instant::now); // Which path the selection took, and how many candidates it looked at. // Left uninitialised on purpose: every branch below must set it, and // the compiler enforces that. A default here is what made the first // version of this diagnostic report a constant. let visible_path: &str; let mut visible_candidates: usize; // PERF-only breakdown for index maintenance and handle resolution. let mut visible_index_ms = 0.0f64; let mut visible_probe_ms = 0.0f64; // Phase 2.1 — quadtree-driven candidate selection. When a view // AABB exists (Model layout with a settled camera), only iterate // entities whose stored WCS bbox intersects the view; unindexable // entities (Insert/Viewport) are appended via a small linear scan. // Paper space and the first-frame "settle" path fall back to the // full doc scan — preserving prior behaviour. let visible: Vec<&EntityType> = if let Some(local_view) = view_aabb { visible_path = "quadtree"; let view_wcs: [f64; 4] = [ local_view[0] as f64, local_view[1] as f64, local_view[2] as f64, local_view[3] as f64, ]; let (candidates, unbounded): (Vec, Vec) = { let idx = self.entity_index(); (idx.tree.query_rect(view_wcs), idx.unbounded_handles.clone()) }; let mut out: Vec<&EntityType> = Vec::with_capacity(candidates.len() + unbounded.len() + 16); visible_candidates = candidates.len() + unbounded.len(); for h in candidates { if let Some(e) = self.document.get_entity(h) { if visibility_ok(e) { out.push(e); } } } // Unbounded entities — always emit regardless of view, mirroring // legacy `entity_aabb`'s UNBOUNDED_AABB sentinel. for h in unbounded { if let Some(e) = self.document.get_entity(h) { if visibility_ok(e) { out.push(e); } } } // Inserts/Viewports/Block/BlockEnd — handled by their own paths // (block expansion, viewport rendering); always candidates. Taken // from the per-epoch list rather than by walking the document, // which preserves document order because the list is built that // way. { let unindexable = self.unindexable_handles(); visible_candidates += unindexable.1.len(); for &h in &unindexable.1 { if let Some(e) = self.document.get_entity(h) { if visibility_ok(e) { out.push(e); } } } } out } else if block_handle.is_null() || self.entity_block_map().is_empty() { // Either every entity qualifies, or the drawing declares no block // contents anywhere and `belongs_to_visible_block` turns permissive // — the legacy-DXF case. Both need the full walk, and both must // keep document order, which is draw order for the wire pass. visible_path = "full-scan"; visible_candidates = self.document.entity_count(); self.document .entities() .filter(|e| visibility_ok(e)) .collect() } else { // The per-epoch index preserves document order for each block. visible_path = "block-index"; // Time index maintenance separately from the candidate loop. let t_members = perf.then(iced::time::Instant::now); let members = self.block_members(); visible_index_ms = crate::perf::elapsed_ms(t_members); let (_, by_block) = &*members; let claimed = by_block .get(&block_handle) .map(Vec::as_slice) .unwrap_or(&[]); visible_candidates = claimed.len(); let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len()); for &handle in claimed { if let Some(entity) = self.document.get_entity(handle) { if visibility_ok(entity) { out.push(entity); } } } if perf { // PERF-only lower bound for warm handle resolution. let t_probe = iced::time::Instant::now(); let mut resolved = 0usize; for &handle in claimed { if self.document.get_entity(handle).is_some() { resolved += 1; } } std::hint::black_box(resolved); visible_probe_ms = t_probe.elapsed().as_secs_f64() * 1000.0; } out }; let visible_ms = crate::perf::elapsed_ms(t_visible); let visible_count = visible.len(); // Tessellate in parallel across all available CPU cores. use crate::par::prelude::*; let doc = &self.document; // Selection / hover highlight is NOT baked into tessellation. It is // applied per frame in the GPU xray overlay pass from the live // selection set (`Scene::selected` ∪ hover). Keeping `sel` empty here // makes the wire cache selection-independent, so picking an entity // bumps only `selection_generation` (cheap overlay refresh) instead of // `geometry_epoch` (a full model re-tessellation). let empty_sel: HashSet = HashSet::default(); let sel: &HashSet = &empty_sel; let avp = style_viewport.or(self.active_viewport); // A paper-space content viewport renders MODEL block entities while // the user is sitting in a paper layout — that path expects // `world_offset` subtraction even though `current_layout != "Model"`. // Decide based on the block being tessellated, not the layout. let is_model_block = block_handle == self.model_space_block_handle(); // The reference-only designation set drives the wire stamp; captured // as a shared ref like `doc`/`sel` so the rayon workers can read it. let reference_only_set = self.reference_only.handles(); let bg = if self.current_layout == "Model" { self.bg_color } else { self.paper_bg_color }; let anno = if let Some(a) = anno_scale_override { a } else if self.current_layout == "Model" { self.annotation_scale } else { 1.0 }; // Content shown inside a paper-space viewport carries a scale override; // only there does PSLTSCALE resize linetypes by the viewport scale. let paper = anno_scale_override.is_some(); let t_blk = perf.then(iced::time::Instant::now); let blk_cache = self.block_cache_arc_for(annotation_scale_handle, all_visible, style_viewport); let blk_ms = crate::perf::elapsed_ms(t_blk); let blk_ref: &cache::block_cache::BlockCache = &blk_cache; // Per-entity tessellation memo. Same classify/tessellate logic, two // SEPARATE stores so they can't thrash each other: // * culled path (`view_aabb == Some`) → `tess_memo`, guard keyed on the // per-view cull params (zoom/tol, frustum, entered viewport); // * resident path (`view_aabb == None`, no per-view cull, Model block) → // the camera-INDEPENDENT `resident_tess_memo`, guard keyed only on // anno / bg. This is the set the main GPU render holds // (`model_tile_wires_arc`), so memoizing it makes a single-entity edit // re-tessellate just the changed entity instead of the whole model. // Frozen-layer / anno-override viewport paths bypass (their params would // thrash a shared memo). Hit-test also passes `view_aabb == None` but is // not the Model block, so it never lands on the resident branch. // An empty vp-frozen set and an anno override are memo-compatible: the // guard hash below mixes anno/bg, so a param change invalidates rather // than corrupts. Only a NON-empty frozen set (entity subset changes) // bypasses the memo. let frozen_empty = frozen_layers.map(|f| f.is_empty()).unwrap_or(true); let base_ok = is_model_block && frozen_empty; let resident = base_ok && view_aabb.is_none() && resident_memo_enabled(); let memo_active = base_ok && (view_aabb.is_some() || resident); // Reported in one line below. `tess_ms` / `memo_misses` are assigned by // both branches; the rest exist only on the memo path and stay zero. let mut classify_ms = 0.0f64; let mut hits_ms = 0.0f64; let tess_ms; let mut materialize_ms = 0.0f64; let mut memo_hits = 0usize; let memo_misses; // Two consecutive identical resident builds both missed every entry, // which a stable guard cannot explain — the memo is being cleared // between them. Report the guard and the inputs that can move it. let mut guard_dbg = 0u64; let mut guard_was_stale = false; let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { // Invalidate when any baked display input changes. let guard = tess_memo_guard( view_aabb, anno, annotation_scale_handle, all_visible, bg, avp, ); let (memo_cell, guard_cell) = if resident { (&self.resident_tess_memo, &self.resident_tess_guard) } else { (&self.tess_memo, &self.tess_memo_guard) }; guard_dbg = guard; guard_was_stale = guard_cell.get() != guard; if guard_was_stale { memo_cell.borrow_mut().clear(); guard_cell.set(guard); } // Classify (serial, cheap): reuse memoized Arcs, collect misses. let t_classify = perf.then(iced::time::Instant::now); let mut hit_arcs: Vec = Vec::new(); let mut misses: Vec<&EntityType> = Vec::new(); { let memo = memo_cell.borrow(); for e in &visible { let h = e.common().handle; match memo.get(&h).and_then(|run| self.resolve_memo_run(h, run)) { Some(hit) => hit_arcs.push(hit), None => misses.push(*e), } } } classify_ms = crate::perf::elapsed_ms(t_classify); memo_hits = hit_arcs.len(); memo_misses = misses.len(); // Materialize hits + tessellate misses, both in parallel. let t_hits = perf.then(iced::time::Instant::now); let hit_wires: Vec = hit_arcs .par_iter() .flat_map_iter(|hit| hit.wires().iter().cloned()) .collect(); hits_ms = crate::perf::elapsed_ms(t_hits); let t_tess_miss = perf.then(iced::time::Instant::now); let miss_pairs: Vec<(Handle, Arc>)> = misses .par_iter() .map(|e| { let e: &EntityType = e; let w = tessellate_entity( doc, sel, avp, bg, anno, annotation_scale_handle, e, Some(blk_ref), view_aabb, wpp, paper, reference_only_set, ); (e.common().handle, Arc::new(w)) }) .collect(); tess_ms = crate::perf::elapsed_ms(t_tess_miss); // Materialize new wires while the memo retains their shared source. let t_materialize = perf.then(iced::time::Instant::now); let mut out = hit_wires; { let mut memo = memo_cell.borrow_mut(); for (h, a) in miss_pairs { // Uniquely owned (just built above): enrich solid shells // with B-rep vertex snaps before the memo/assembly split. let mut wires = Arc::try_unwrap(a).unwrap_or_else(|a| a.as_ref().clone()); self.attach_solid_snaps(h, &mut wires); if resident && self.building_resident_set.get() { // The resident set built from `out` becomes the // memo's backing: no second copy (#1542, #1698). out.extend(wires); memo.insert(h, MemoRun::Pending); } else { let a = Arc::new(wires); out.extend(a.iter().cloned()); memo.insert(h, MemoRun::Owned(Arc::clone(&a))); } } } materialize_ms = crate::perf::elapsed_ms(t_materialize); out } else { let t_tess_all = perf.then(iced::time::Instant::now); let out: Vec = visible .into_par_iter() .flat_map(|e| { tessellate_entity( doc, sel, avp, bg, anno, annotation_scale_handle, e, Some(blk_ref), view_aabb, wpp, paper, reference_only_set, ) }) .collect(); tess_ms = crate::perf::elapsed_ms(t_tess_all); memo_misses = visible_count; out }; // Normal Wireframe renders the persisted entity wires, whereas shaded // and 3D Wireframe modes render the temporary sectioned mesh. Replace // only the displayed wire run with one tessellated from that same // temporary body so every visual style shows the identical live cut. if let Some(section) = self.active_live_section(block_handle) { let handles: Vec = self.meshes.keys().copied().collect(); for handle in handles { let Some(source) = self.document.get_entity(handle) else { continue; }; if !visibility_ok(source) { continue; } let replacement = match self.sectioned_wire_entity(handle, §ion) { Ok(Some(entity)) => Some(tessellate_entity( doc, sel, avp, bg, anno, annotation_scale_handle, &entity, Some(blk_ref), view_aabb, wpp, paper, reference_only_set, )), Ok(None) => Some(Vec::new()), Err(()) => None, }; let Some(replacement) = replacement else { continue; }; let name = handle.value().to_string(); wires.retain(|wire| wire.name != name); wires.extend(replacement); } } let build_ms = crate::perf::elapsed_ms(t_build); // Resolve display colours for the live background. Memo hits were // built under whatever background was current then, misses under this // one; the pass recomputes both from their recorded raw colour, so a // mixed set lands in the same place either way. let t_colors = perf.then(iced::time::Instant::now); crate::scene::view::render::resolve_colors_for_bg(&mut wires, bg); let colors_ms = crate::perf::elapsed_ms(t_colors); // Apply draw order via the cached index (O(1) block lookup). let t_sort = perf.then(iced::time::Instant::now); let mut sorted = false; { let cache = self.sort_cache.borrow(); if let Some((_, ref idx)) = *cache { if let Some(sort_map) = idx.get(&block_handle) { sorted = true; // Parse each handle once while preserving stable draw order. wires.sort_by_cached_key(|w| { let key = Self::handle_from_wire_name(&w.name) .map(|h| h.value()) .unwrap_or(u64::MAX); // Entities absent from the table sort by their own // handle — the same key space the table's sort handles // live in — so reordered and untouched entities interleave // correctly instead of all collapsing to one constant. sort_map.get(&key).copied().unwrap_or(key) }); } } } if perf { crate::perf_record!( "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} colors={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={} \ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={} \ vis_index={:.1} visible_probe={:.1}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, blk_ms, colors_ms, build_ms, classify_ms, hits_ms, tess_ms, materialize_ms, crate::perf::elapsed_ms(t_sort), if sorted { "applied" } else { "skipped" }, visible_count, memo_hits, memo_misses, wires.len(), if memo_active { "on" } else { "off" }, visible_path, visible_candidates, guard_dbg, guard_was_stale, avp.map(|h| h.value()).unwrap_or(0), anno, annotation_scale_handle.map(|h| h.value()).unwrap_or(0), all_visible, crate::scene::text::sdf_atlas::generation(), visible_index_ms, visible_probe_ms, ); } wires } /// Unindexable entity handles in document order, cached per geometry epoch. fn unindexable_handles(&self) -> std::cell::Ref<'_, (u64, Vec)> { { let cache = self.unindexable_cache.borrow(); if cache .as_ref() .is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { drop(cache); return std::cell::Ref::map(self.unindexable_cache.borrow(), |c| { c.as_ref().unwrap() }); } } let handles: Vec = self .document .entities() .filter(|entity| is_unindexable_entity(entity)) .map(|entity| entity.common().handle) .collect(); *self.unindexable_cache.borrow_mut() = Some((self.geometry_epoch, handles)); std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap()) } /// Resolve block membership once per geometry epoch. A single addition is /// appended safely; any other delta rebuilds the document-ordered index. fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { let mut cached_epoch = None; { let cache = self.block_members_cache.borrow(); if let Some((epoch, _)) = cache.as_ref() { if *epoch == self.geometry_epoch { drop(cache); return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { c.as_ref().unwrap() }); } cached_epoch = Some(*epoch); } } if let Some(since) = cached_epoch { let lone_add = self .replay_since(since) .filter(|deltas| deltas.len() == 1 && deltas[0].1 == ChangeKind::Added); if let Some(deltas) = lone_add { let handle = deltas[0].0; // Resolve the owner before touching the cache: both lookups // borrow, and `entity_block_map` is its own `RefCell`. let owner = self.document.get_entity(handle).and_then(|entity| { let owner = entity.common().owner_handle; if !owner.is_null() { Some(owner) } else { self.entity_block_map().get(&handle).copied() } }); let mut cache = self.block_members_cache.borrow_mut(); if let Some((epoch, members)) = cache.as_mut() { if let Some(owner) = owner { members.entry(owner).or_default().push(handle); } *epoch = self.geometry_epoch; drop(cache); return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { c.as_ref().unwrap() }); } } } let mut members: HashMap> = HashMap::default(); { let map = self.entity_block_map(); for entity in self.document.entities() { let common = entity.common(); let owner = common.owner_handle; if !owner.is_null() { members.entry(owner).or_default().push(common.handle); } else if let Some(&block) = map.get(&common.handle) { members.entry(block).or_default().push(common.handle); } } } *self.block_members_cache.borrow_mut() = Some((self.geometry_epoch, members)); std::cell::Ref::map(self.block_members_cache.borrow(), |c| c.as_ref().unwrap()) } /// Whether the entity is direct content of this block. fn belongs_to_visible_block( &self, entity_handle: Handle, owner_handle: Handle, block_handle: Handle, ) -> bool { if block_handle.is_null() { return true; } if owner_handle == block_handle { return true; } if !owner_handle.is_null() { return false; } // owner_handle is null (common in DXF files that omit group code 330). // Use the current layout's entity_handles as the authoritative list when // available — this prevents block-definition geometry from leaking into // the viewport even when owner handles are missing. if let Some(br) = self .document .block_records .iter() .find(|br| br.handle == block_handle) { if !br.entity_handles.is_empty() { return br.entity_handles.contains(&entity_handle); } } // P: epoch-cached reverse map replaces O(B) block_records scan. let map = self.entity_block_map(); if let Some(&owner) = map.get(&entity_handle) { return owner == block_handle; } // Map miss. Permissive only when NO BlockRecord enumerated its // entity_handles — that's a legacy DXF that omits 330 group codes // everywhere, where dropping unknown-owner entities would empty // model space. When at least one block did enumerate, the file is // capable of declaring ownership, so an unknown-owner entity is // an orphan (typically a block-defn entity whose owner was lost on // round-trip) and must not leak into the queried block. if map.is_empty() { return true; } false } /// Build (and epoch-cache) a reverse map: entity_handle → block_record_handle, /// covering every entity explicitly listed in a block_record's entity_handles. fn entity_block_map(&self) -> std::cell::Ref<'_, HashMap> { { let cache = self.entity_block_map_cache.borrow(); if let Some((epoch, _)) = *cache { if epoch == self.geometry_epoch { drop(cache); return std::cell::Ref::map(self.entity_block_map_cache.borrow(), |c| { &c.as_ref().unwrap().1 }); } } } let mut map: HashMap = HashMap::default(); for br in self.document.block_records.iter() { for &eh in &br.entity_handles { map.insert(eh, br.handle); } } *self.entity_block_map_cache.borrow_mut() = Some((self.geometry_epoch, map)); std::cell::Ref::map(self.entity_block_map_cache.borrow(), |c| { &c.as_ref().unwrap().1 }) } fn entity_dependency_signature(&self, entity: &EntityType) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); let common = entity.common(); common.handle.hash(&mut hasher); common.owner_handle.hash(&mut hasher); normalize_name(&common.layer).hash(&mut hasher); let category = match entity { EntityType::Point(_) => 1_u8, EntityType::Text(_) | EntityType::MText(_) | EntityType::AttributeDefinition(_) | EntityType::AttributeEntity(_) | EntityType::Dimension(_) | EntityType::Leader(_) | EntityType::Tolerance(_) | EntityType::MultiLeader(_) | EntityType::Table(_) | EntityType::Shape(_) => 2, EntityType::Hatch(_) => 3, EntityType::Insert(insert) if !insert.attributes.is_empty() => 4, _ => 0, }; category.hash(&mut hasher); crate::scene::annotative::is_annotative(&self.document, entity).hash(&mut hasher); for block_use in render_graph::entity_block_uses(&self.document, entity, 1.0) { block_use.block.hash(&mut hasher); normalize_name(&block_use.insert.block_name).hash(&mut hasher); block_use.role.hash(&mut hasher); block_use.scale_policy.hash(&mut hasher); block_use.active.hash(&mut hasher); } match entity { EntityType::Text(text) => normalize_name(&text.style).hash(&mut hasher), EntityType::MText(text) => normalize_name(&text.style).hash(&mut hasher), EntityType::AttributeDefinition(attribute) => { normalize_name(&attribute.text_style).hash(&mut hasher) } EntityType::AttributeEntity(attribute) => { normalize_name(&attribute.text_style).hash(&mut hasher) } EntityType::Insert(insert) => { for attribute in &insert.attributes { normalize_name(&attribute.common.layer).hash(&mut hasher); normalize_name(&attribute.text_style).hash(&mut hasher); } } EntityType::Dimension(dimension) => { normalize_name(&dimension.base().style_name).hash(&mut hasher) } EntityType::Leader(leader) => normalize_name(&leader.dimension_style).hash(&mut hasher), EntityType::Tolerance(tolerance) => { normalize_name(&tolerance.dimension_style_name).hash(&mut hasher) } EntityType::Table(table) => { table.table_style_handle.hash(&mut hasher); for row in &table.rows { row.style .as_ref() .and_then(|style| style.text_style_handle) .hash(&mut hasher); for cell in &row.cells { cell.style .as_ref() .and_then(|style| style.text_style_handle) .hash(&mut hasher); for content in &cell.contents { content.text_style_handle.hash(&mut hasher); } } } } EntityType::MultiLeader(leader) => { leader.style_handle.hash(&mut hasher); leader.text_style_handle.hash(&mut hasher); leader.context.text_style_handle.hash(&mut hasher); } EntityType::MLine(line) => line.style_handle.hash(&mut hasher), _ => {} } hasher.finish() } fn refresh_dependency_index_for_changes(&self, changes: &[(Handle, ChangeKind)]) { let mut cache = self.dependency_index_cache.borrow_mut(); let Some(index) = cache.as_ref() else { return; }; let unchanged = changes.iter().all(|(handle, kind)| { if !matches!(kind, ChangeKind::Modified) { return false; } let Some(entity) = self.document.get_entity(*handle) else { return false; }; index.signatures.get(handle).copied() == Some(self.entity_dependency_signature(entity)) }); if !unchanged { cache.take(); } } fn rebuild_dependency_index(&self) -> SceneDependencyIndex { let mut layout_blocks: HashSet = self .document .objects .values() .filter_map(|object| match object { codec::objects::ObjectType::Layout(layout) if !layout.block_record.is_null() => { Some(layout.block_record) } _ => None, }) .collect(); // DWG imports may have model/paper block records without matching // Layout objects. They still own directly rendered entities, so their // layer dependencies must resolve to those entity handles. layout_blocks.extend(self.document.block_records.iter().filter_map(|record| { let name = normalize_name(&record.name); (name.starts_with("*MODEL_SPACE") || name.starts_with("*PAPER_SPACE")) .then_some(record.handle) })); let block_names: HashMap = self .document .block_records .iter() .map(|record| (record.handle, normalize_name(&record.name))) .collect(); let membership: HashMap = self .document .block_records .iter() .flat_map(|record| { record .entity_handles .iter() .copied() .map(move |handle| (handle, record.handle)) }) .collect(); let text_style_names: HashMap = self .document .text_styles .iter() .map(|style| (style.handle, style.name.clone())) .collect(); let mut roots: HashMap> = HashMap::default(); let mut parents: HashMap> = HashMap::default(); for entity in self.document.entities() { let common = entity.common(); let owner = if common.owner_handle.is_null() { membership .get(&common.handle) .copied() .unwrap_or(Handle::NULL) } else { common.owner_handle }; for block_use in render_graph::entity_block_uses(&self.document, entity, 1.0) { let target = normalize_name(&block_use.insert.block_name); if target.is_empty() { continue; } if owner.is_null() || layout_blocks.contains(&owner) { roots.entry(target).or_default().insert(common.handle); } else if let Some(parent) = block_names.get(&owner) { parents.entry(target).or_default().insert(parent.clone()); } } } // Propagate root users through nested block references. // Fixed-point form is cycle-safe and block graphs are normally shallow. let mut changed = true; while changed { changed = false; for (child, parent_names) in &parents { let inherited: Vec = parent_names .iter() .flat_map(|parent| roots.get(parent).into_iter().flatten().copied()) .collect(); let entry = roots.entry(child.clone()).or_default(); let before = entry.len(); entry.extend(inherited); changed |= entry.len() != before; } } let mut index = SceneDependencyIndex::default(); for entity in self.document.entities() { let common = entity.common(); index .signatures .insert(common.handle, self.entity_dependency_signature(entity)); let owner = if common.owner_handle.is_null() { membership .get(&common.handle) .copied() .unwrap_or(Handle::NULL) } else { common.owner_handle }; let inside_block = !owner.is_null() && !layout_blocks.contains(&owner); let render_handles: HashSet = if inside_block { block_names .get(&owner) .and_then(|name| roots.get(name)) .cloned() .unwrap_or_default() } else { std::iter::once(common.handle).collect() }; let extend_category = |target: &mut DependencyTargets| { target.render_handles.extend(render_handles.iter().copied()); target.source_handles.insert(common.handle); target.touches_block_definition |= inside_block; if inside_block { target.block_users.extend(render_handles.iter().copied()); } }; if matches!(entity, EntityType::Point(_)) { extend_category(&mut index.points); } if matches!( entity, EntityType::Text(_) | EntityType::MText(_) | EntityType::AttributeDefinition(_) | EntityType::AttributeEntity(_) | EntityType::Dimension(_) | EntityType::Leader(_) | EntityType::Tolerance(_) | EntityType::MultiLeader(_) | EntityType::Table(_) | EntityType::Shape(_) ) || matches!(entity, EntityType::Insert(insert) if !insert.attributes.is_empty()) { extend_category(&mut index.text_geometry); } if matches!(entity, EntityType::Hatch(_)) || crate::scene::annotative::is_annotative(&self.document, entity) { extend_category(&mut index.annotation_geometry); } let add = |map: &mut HashMap, name: &str| { let target = map.entry(normalize_name(name)).or_default(); target.render_handles.extend(render_handles.iter().copied()); target.source_handles.insert(common.handle); target.touches_block_definition |= inside_block; if inside_block { target.block_users.extend(render_handles.iter().copied()); } }; let add_handle = |map: &mut HashMap, handle: Option| { let Some(handle) = handle.filter(|handle| !handle.is_null()) else { return; }; let target = map.entry(handle).or_default(); target.render_handles.extend(render_handles.iter().copied()); target.source_handles.insert(common.handle); target.touches_block_definition |= inside_block; if inside_block { target.block_users.extend(render_handles.iter().copied()); } }; add(&mut index.layers, &common.layer); match entity { EntityType::Text(text) => add(&mut index.text_styles, &text.style), EntityType::MText(text) => add(&mut index.text_styles, &text.style), EntityType::AttributeDefinition(attribute) => { add(&mut index.text_styles, &attribute.text_style) } EntityType::AttributeEntity(attribute) => { add(&mut index.text_styles, &attribute.text_style) } EntityType::Insert(insert) => { for attribute in &insert.attributes { add(&mut index.layers, &attribute.common.layer); add(&mut index.text_styles, &attribute.text_style); } } EntityType::Dimension(dimension) => { add(&mut index.dim_styles, &dimension.base().style_name); if let Some(style) = self.document.dim_styles.get(&dimension.base().style_name) { add(&mut index.text_styles, &style.dimtxsty); } } EntityType::Leader(leader) => { add(&mut index.dim_styles, &leader.dimension_style); if let Some(style) = self.document.dim_styles.get(&leader.dimension_style) { add(&mut index.text_styles, &style.dimtxsty); } } EntityType::Tolerance(tolerance) => { add(&mut index.dim_styles, &tolerance.dimension_style_name); if let Some(style) = self .document .dim_styles .get(&tolerance.dimension_style_name) { add(&mut index.text_styles, &style.dimtxsty); } } EntityType::Table(table) => { add_handle(&mut index.object_styles, table.table_style_handle); if let Some(ObjectType::TableStyle(style)) = table .table_style_handle .and_then(|handle| self.document.objects.get(&handle)) { for row in [ &style.data_row_style, &style.header_row_style, &style.title_row_style, ] { add(&mut index.text_styles, &row.text_style_name); } } for row in &table.rows { if let Some(style) = &row.style { if let Some(name) = style .text_style_handle .and_then(|handle| text_style_names.get(&handle)) { add(&mut index.text_styles, name); } } for cell in &row.cells { if let Some(style) = &cell.style { if let Some(name) = style .text_style_handle .and_then(|handle| text_style_names.get(&handle)) { add(&mut index.text_styles, name); } } for content in &cell.contents { if let Some(name) = content .text_style_handle .and_then(|handle| text_style_names.get(&handle)) { add(&mut index.text_styles, name); } } } } } EntityType::MultiLeader(leader) => { add_handle(&mut index.object_styles, leader.style_handle); for handle in [leader.text_style_handle, leader.context.text_style_handle] { if let Some(name) = handle.and_then(|handle| text_style_names.get(&handle)) { add(&mut index.text_styles, name); } } if let Some(ObjectType::MultiLeaderStyle(style)) = leader .style_handle .and_then(|handle| self.document.objects.get(&handle)) { if let Some(name) = style .text_style_handle .and_then(|handle| text_style_names.get(&handle)) { add(&mut index.text_styles, name); } } } EntityType::MLine(line) => add_handle(&mut index.object_styles, line.style_handle), _ => {} } } index.block_roots = roots; index } fn dependency_targets(&self, kind: DependencyKind, names: &[String]) -> DependencyTargets { if self.dependency_index_cache.borrow().is_none() { *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); } let cache = self.dependency_index_cache.borrow(); let index = cache.as_ref().unwrap(); let map = match kind { DependencyKind::Layer => &index.layers, DependencyKind::TextStyle => &index.text_styles, DependencyKind::DimStyle => &index.dim_styles, }; let mut combined = DependencyTargets::default(); for name in names { let Some(target) = map.get(&normalize_name(name)) else { continue; }; combined .render_handles .extend(target.render_handles.iter().copied()); combined .source_handles .extend(target.source_handles.iter().copied()); combined .block_users .extend(target.block_users.iter().copied()); combined.touches_block_definition |= target.touches_block_definition; } combined } fn invalidate_dependency_targets(&mut self, targets: DependencyTargets) { if targets.render_handles.is_empty() { return; } if targets.touches_block_definition { self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); } let sources: Vec = targets.source_handles.iter().copied().collect(); self.recolor_meshes_for_handles(&sources); let changes: Vec<(Handle, ChangeKind)> = targets .render_handles .into_iter() .map(|handle| (handle, ChangeKind::Modified)) .collect(); self.bump_entities_restyled(&changes); } /// Off/freeze toggle on `names`, nothing else changed. Visibility gates an /// entity before it is tessellated, so entities carrying these layers keep /// their tessellation memo and only leave or re-enter the resident set; /// block users re-expand, since their definitions bake the children's /// visibility. Nothing is recoloured. pub fn invalidate_layer_visibility(&mut self, names: &[String]) { // Turning a layer off or freezing it removes its entities from the // interactive scene as well as from rendering. Drop any entities that // were already selected before the visibility change, otherwise a // subsequent MOVE/ERASE/etc. could still modify invisible geometry. let block = self.interaction_block_handle(); let frozen: Option> = self .interaction_viewport_frozen_layers() .map(|layers| layers.iter().copied().collect()); let annotation_scale_handle = self.displayed_annotation_scale_handle(); let all_visible = self.annotation_all_visible(); let visible_selection: Vec = self .selected_handles_in_order() .into_iter() .filter(|handle| { self.document.get_entity(*handle).is_some_and(|entity| { self.resident_entity_visible( entity, block, frozen.as_ref(), annotation_scale_handle, all_visible, ) }) }) .collect(); self.replace_selection_exact(&visible_selection); let targets = self.dependency_targets(DependencyKind::Layer, names); if targets.render_handles.is_empty() { return; } if targets.touches_block_definition { self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); } let kept: Vec<_> = { let mut tm = self.tess_memo.borrow_mut(); let mut rm = self.resident_tess_memo.borrow_mut(); targets .render_handles .iter() .filter(|handle| !targets.block_users.contains(handle)) .map(|&handle| (handle, tm.remove(&handle), rm.remove(&handle))) .collect() }; let changes: Vec<(Handle, ChangeKind)> = targets .render_handles .iter() .map(|&handle| (handle, ChangeKind::Modified)) .collect(); self.bump_entities_restyled(&changes); let mut tm = self.tess_memo.borrow_mut(); let mut rm = self.resident_tess_memo.borrow_mut(); for (handle, culled, resident) in kept { if let Some(wires) = culled { tm.insert(handle, wires); } if let Some(wires) = resident { rm.insert(handle, wires); } } } pub fn invalidate_layer_dependencies(&mut self, names: &[String]) { let targets = self.dependency_targets(DependencyKind::Layer, names); self.invalidate_dependency_targets(targets); } pub fn invalidate_text_style_dependencies(&mut self, name: &str) { self.invalidate_text_style_dependencies_many(&[name.to_string()]); } pub fn invalidate_text_style_dependencies_many(&mut self, names: &[String]) { let targets = self.dependency_targets(DependencyKind::TextStyle, names); self.invalidate_dependency_targets(targets); } pub fn invalidate_dim_style_dependencies(&mut self, name: &str) { self.invalidate_dim_style_dependencies_many(&[name.to_string()]); } pub fn invalidate_dim_style_dependencies_many(&mut self, names: &[String]) { let targets = self.dependency_targets(DependencyKind::DimStyle, names); self.invalidate_dependency_targets(targets); } pub fn invalidate_object_style_dependencies(&mut self, handles: &[Handle]) { if self.dependency_index_cache.borrow().is_none() { *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); } let mut combined = DependencyTargets::default(); if let Some(index) = self.dependency_index_cache.borrow().as_ref() { for handle in handles { let Some(target) = index.object_styles.get(handle) else { continue; }; combined .render_handles .extend(target.render_handles.iter().copied()); combined .source_handles .extend(target.source_handles.iter().copied()); combined.touches_block_definition |= target.touches_block_definition; } } self.invalidate_dependency_targets(combined); } fn invalidate_dependency_category( &mut self, select: impl FnOnce(&SceneDependencyIndex) -> &DependencyTargets, ) { if self.dependency_index_cache.borrow().is_none() { *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); } let targets = self .dependency_index_cache .borrow() .as_ref() .map(select) .cloned() .unwrap_or_default(); self.invalidate_dependency_targets(targets); } pub fn invalidate_point_dependencies(&mut self) { self.invalidate_dependency_category(|index| &index.points); } pub fn invalidate_text_geometry_dependencies(&mut self) { self.invalidate_dependency_category(|index| &index.text_geometry); } pub fn invalidate_annotation_dependencies(&mut self) { self.invalidate_dependency_category(|index| &index.annotation_geometry); } pub(crate) fn invalidate_dependency_index(&self) { self.dependency_index_cache.borrow_mut().take(); } /// Spatial index + always-emit list for top-level entities. Lazily /// rebuilt on `geometry_epoch` change. /// /// `tree` holds entities whose `bounding_box()` is finite and /// non-degenerate. `unbounded_handles` holds entities whose bbox /// is degenerate or non-finite — the legacy `entity_aabb` treated /// those as `UNBOUNDED_AABB` (never culled), so the wire path must /// always emit them regardless of view. Inserts/Viewports/Blocks /// /BlockEnds are filtered out at build time and re-added by the /// wire path via a separate scan (their WCS bbox depends on /// transforms handled elsewhere). pub(super) fn entity_index(&self) -> std::cell::Ref<'_, EntityIndex> { { let cache = self.entity_index_cache.borrow(); if let Some((epoch, _)) = *cache { if epoch == self.geometry_epoch { drop(cache); return std::cell::Ref::map(self.entity_index_cache.borrow(), |c| { &c.as_ref().unwrap().1 }); } } } // Incremental path: if the cache is only a few edits behind, patch just // the changed handles into the existing quadtree (which supports // insert/remove/update, out-of-root items falling into overflow) instead // of re-walking every entity. Resolve each change against the document // first, then apply under one borrow. enum IdxOp { Remove(Handle), SetBounded(Handle, [f64; 4]), SetUnbounded(Handle), } let cached_epoch = self.entity_index_cache.borrow().as_ref().map(|c| c.0); if let Some(ce) = cached_epoch { if let Some(deltas) = self.replay_since(ce) { let mut ops: Vec = Vec::with_capacity(deltas.len()); for (h, kind) in &deltas { match kind { ChangeKind::Removed => ops.push(IdxOp::Remove(*h)), ChangeKind::Added | ChangeKind::Modified => { match self.document.get_entity(*h) { None => ops.push(IdxOp::Remove(*h)), Some(e) if is_unindexable_entity(e) => ops.push(IdxOp::Remove(*h)), Some(e) => match entity_world_aabb_f64(e) { Some(ab) => ops.push(IdxOp::SetBounded(*h, ab)), None => ops.push(IdxOp::SetUnbounded(*h)), }, } } } } let mut cache = self.entity_index_cache.borrow_mut(); if let Some((epoch, idx)) = cache.as_mut() { for op in ops { match op { IdxOp::Remove(h) => { idx.tree.remove(h); idx.unbounded_handles.retain(|x| *x != h); } IdxOp::SetBounded(h, ab) => { idx.tree.update(h, ab); idx.unbounded_handles.retain(|x| *x != h); } IdxOp::SetUnbounded(h) => { idx.tree.remove(h); idx.unbounded_handles.retain(|x| *x != h); idx.unbounded_handles.push(h); } } } *epoch = self.geometry_epoch; drop(cache); return std::cell::Ref::map(self.entity_index_cache.borrow(), |c| { &c.as_ref().unwrap().1 }); } } } let perf = crate::perf::enabled(); let t_aabbs_start = perf.then(iced::time::Instant::now); let mut items: Vec<(Handle, [f64; 4])> = Vec::new(); let mut unbounded: Vec = Vec::new(); let mut union: Option<[f64; 4]> = None; for e in self.document.entities() { if is_unindexable_entity(e) { continue; } match entity_world_aabb_f64(e) { Some(ab) => { union = Some(match union { None => ab, Some(u) => [ u[0].min(ab[0]), u[1].min(ab[1]), u[2].max(ab[2]), u[3].max(ab[3]), ], }); items.push((e.common().handle, ab)); } None => unbounded.push(e.common().handle), } } let aabbs_ms = crate::perf::elapsed_ms(t_aabbs_start); let root = match union { Some(u) => { let w = (u[2] - u[0]).max(1.0); let h = (u[3] - u[1]).max(1.0); let mx = w * 0.01; let my = h * 0.01; [u[0] - mx, u[1] - my, u[2] + mx, u[3] + my] } None => [-1.0, -1.0, 1.0, 1.0], }; let t_tree_start = perf.then(iced::time::Instant::now); let mut tree = pick::quadtree::QuadTree::new(root); for (h, ab) in items { tree.insert(h, ab); } let tree_ms = crate::perf::elapsed_ms(t_tree_start); if perf { crate::perf_record!( "[perf] entity_index-rebuild aabbs={:.1}ms tree={:.1}ms entities={} root={:?}", aabbs_ms, tree_ms, tree.len(), root, ); } *self.entity_index_cache.borrow_mut() = Some(( self.geometry_epoch, EntityIndex { tree, unbounded_handles: unbounded, }, )); std::cell::Ref::map(self.entity_index_cache.borrow(), |c| &c.as_ref().unwrap().1) } /// Resolve the active layout and annotation context for tessellation. fn tessellation_context( &self, ) -> ( [f32; 4], f32, Option, Arc, bool, ) { let is_model = self.current_layout == "Model"; let bg = if is_model { self.bg_color } else { self.paper_bg_color }; let anno = if is_model { self.annotation_scale } else if let Some(viewport) = self.active_viewport { self.viewport_annotation_multiplier(viewport) } else { 1.0 }; let annotation_scale_handle = if is_model { crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, ) } else if let Some(viewport) = self.active_viewport { self.viewport_scale_handle(viewport) } else { self.paper_annotation_scale_handle() }; let blk_cache = self.block_cache_arc_for( annotation_scale_handle, self.annotation_all_visible(), self.active_viewport, ); (bg, anno, annotation_scale_handle, blk_cache, !is_model) } fn tessellate_one(&self, e: &EntityType) -> Vec { let (bg, anno, annotation_scale_handle, blk_cache, paper) = self.tessellation_context(); // tessellate_one is used for one-off lookups (hit test, properties). // Skip culling here so the caller always gets the full geometry. let mut wires = tessellate_entity( &self.document, &self.selected, self.active_viewport, bg, anno, annotation_scale_handle, e, Some(&blk_cache), None, None, paper, self.reference_only.handles(), ); // Same solid-corner snaps as the resident paths, so one-off consumers // (overlay changed-wires, edit previews) agree with hit-test/snap. self.attach_solid_snaps(e.common().handle, &mut wires); wires } fn model_space_block_handle(&self) -> Handle { // Primary: Layout object's block_record (DWG reader sets this). if let Some(h) = self.document.objects.values().find_map(|obj| { if let ObjectType::Layout(l) = obj { if l.name == "Model" && !l.block_record.is_null() { Some(l.block_record) } else { None } } else { None } }) { return h; } // Fallback for DXF files: conventional block-record name. self.document .block_records .get("*Model_Space") .map(|br| br.handle) .unwrap_or(Handle::NULL) } /// Compute the axis-aligned bounding box of all model-space entities. /// Result is epoch-cached so repeated ZOOM E / auto-fit calls are O(1). pub fn model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> { { let cache = self.model_extents_cache.borrow(); if let Some((epoch, ext)) = *cache { if epoch == self.geometry_epoch { return ext; } } } let result = self.compute_model_space_extents(); *self.model_extents_cache.borrow_mut() = Some((self.geometry_epoch, result)); result } /// The AABB centre of the current selection, in absolute world coordinates /// (same space as `Camera::target`) — the point the 3D view orbits around /// when something is selected. `None` keeps the current camera target as /// the orbit centre. (#229) pub fn orbit_pivot(&self) -> Option { self.navigation_selection_bounds() .map(|(min, max)| (min + max) * 0.5) } pub(crate) fn navigation_selection_bounds(&self) -> Option<(glam::DVec3, glam::DVec3)> { if self.selected.is_empty() { return None; } let model_view = self.current_layout == "Model" || self.active_viewport.is_some(); let block = if self.active_viewport.is_some() { self.model_space_block_handle() } else { self.current_layout_block_handle() }; let scale = if model_view { crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, ) } else { self.paper_annotation_scale_handle() }; let wires = self.wires_for_block_culled( block, None, None, None, None, scale, self.annotation_all_visible(), None, ); let mut min = glam::DVec3::splat(f64::INFINITY); let mut max = glam::DVec3::splat(f64::NEG_INFINITY); let mut any = false; let mut include = |point: glam::DVec3| { if point.is_finite() { min = min.min(point); max = max.max(point); any = true; } }; for wire in &wires { let Some(h) = Self::handle_from_wire_name(&wire.name) else { continue; }; if !self.selected.contains(&h) { continue; } for (index, &[x, y, z]) in wire.points.iter().enumerate() { let [lx, ly, lz] = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); include(glam::DVec3::new( x as f64 + lx as f64, y as f64 + ly as f64, z as f64 + lz as f64, )); } } for set in self.interaction_meshes_arc().iter() { let Some(handle) = set.entity_handle() else { continue; }; if !self.selected.contains(&handle) { continue; } if let Some(aabb) = mesh_interaction_aabb(set) { include(glam::DVec3::new(aabb[0], aabb[1], aabb[2])); include(glam::DVec3::new(aabb[3], aabb[4], aabb[5])); } } if any { Some((min, max)) } else { None } } fn compute_model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> { let model_block = self.model_space_block_handle(); // Shared with ZOOM EXTENTS so the two never disagree about what the // drawing covers. The previous walk here read only `key_vertices`, // which is empty for every tessellated curve (see `WireModel`) — so // circles, arcs, ellipses and splines contributed nothing, and // neither did hatches or images. Everything that reads drawing // extents (plot Extents, sheet-set framing, viewport auto-fit, // SpaceMouse model bounds) inherited that blind spot. if let Some((min, max)) = self.visible_block_bounds(model_block) { return Some((min.as_vec3(), max.as_vec3())); } // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; let h = &self.document.header; let hmin = h.model_space_extents_min; let hmax = h.model_space_extents_max; if hmin.x < hmax.x && hmin.y < hmax.y && hmin.x.abs() < SANE_EXTENT && hmax.x.abs() < SANE_EXTENT && hmin.y.abs() < SANE_EXTENT && hmax.y.abs() < SANE_EXTENT { return Some(( glam::Vec3::new(hmin.x as f32, hmin.y as f32, hmin.z as f32), glam::Vec3::new(hmax.x as f32, hmax.y as f32, hmax.z as f32), )); } None } /// Set a newly created viewport's `view_target` and `view_height` so that /// all model-space content is visible at a reasonable scale. pub fn auto_fit_viewport(&mut self, vp_handle: Handle) { self.fit_viewport_to_model(vp_handle, 1.1); } /// Fit a viewport's view to the model-space content, with `margin` (1 is /// tight) around it. pub fn fit_viewport_to_model(&mut self, vp_handle: Handle, margin: f64) { let extents = self.model_space_extents(); let (min, max) = match extents { Some(e) => e, None => return, }; let (min, max) = (min.as_dvec3(), max.as_dvec3()); let center = (min + max) * 0.5; let vp = match self.document.get_entity_mut(vp_handle) { Some(codec::EntityType::Viewport(vp)) => vp, _ => return, }; let direction = glam::DVec3::new( vp.view_direction.x, vp.view_direction.y, vp.view_direction.z, ) .normalize_or(glam::DVec3::Z); // The extents box as seen along the view direction: its span across // the view's right and up axes. Looking down Z that is its X/Y size. let right = glam::DVec3::Z.cross(direction).normalize_or(glam::DVec3::X); let up = direction.cross(right); let (mut span_right, mut span_up) = ((f64::MAX, f64::MIN), (f64::MAX, f64::MIN)); for corner in 0..8 { let point = glam::DVec3::new( if corner & 1 == 0 { min.x } else { max.x }, if corner & 2 == 0 { min.y } else { max.y }, if corner & 4 == 0 { min.z } else { max.z }, ); let (along_right, along_up) = (point.dot(right), point.dot(up)); span_right = (span_right.0.min(along_right), span_right.1.max(along_right)); span_up = (span_up.0.min(along_up), span_up.1.max(along_up)); } let content_w = (span_right.1 - span_right.0).max(1e-3); let content_h = (span_up.1 - span_up.0).max(1e-3); // Aim at the model's centre; a plan view keeps it on the XY plane. vp.view_target.x = center.x; vp.view_target.y = center.y; vp.view_target.z = if direction == glam::DVec3::Z { 0.0 } else { center.z }; // Choose the scale that fits both dimensions with the margin. let scale_w = vp.width / (content_w * margin); let scale_h = vp.height / (content_h * margin); let fit_scale = scale_w.min(scale_h).min(1000.0).max(1e-6); vp.custom_scale = fit_scale; vp.view_height = vp.height / fit_scale; } } /// A page setup never initialized: no paper size and no media name (the /// reference writes a layout so until it is first opened). pub fn page_setup_uninitialized(layout: &codec::objects::Layout) -> bool { layout.paper_size.trim().is_empty() && layout.paper_width <= 0.0 && layout.paper_height <= 0.0 } /// A layout written as never opened: the page setup fields back to the /// uninitialized state and no viewports, as the reference stores a layout /// it has not opened yet. A layout already holding a viewport onto the model /// (one added by automation) is plainly set up and is written as it is. pub(crate) fn write_layout_unopened(document: &mut codec::CadDocument, layout_name: &str) { let block = document.objects.values().find_map(|object| match object { ObjectType::Layout(layout) if layout.name == layout_name => Some(layout.block_record), _ => None, }); let has_model_view = document.entities().any(|entity| { matches!(entity, EntityType::Viewport(viewport) if Some(viewport.common.owner_handle) == block && !Scene::is_sheet_viewport(document, viewport)) }); if has_model_view { return; } let fresh = codec::objects::Layout::new(layout_name); let mut block = Handle::NULL; for object in document.objects.values_mut() { if let ObjectType::Layout(layout) = object { if layout.name == layout_name { block = layout.block_record; layout.paper_size = fresh.paper_size.clone(); layout.paper_width = fresh.paper_width; layout.paper_height = fresh.paper_height; layout.plot_rotation = fresh.plot_rotation; layout.plot_paper_units = fresh.plot_paper_units; layout.min_limits = fresh.min_limits; layout.max_limits = fresh.max_limits; layout.min_extents = fresh.min_extents; layout.max_extents = fresh.max_extents; } } } drop_layout_viewports(document, block); } /// Size a sheet (overall paper) viewport to the paper limits `min`..`max`, /// framing the whole sheet with a small margin. fn fit_sheet_viewport(vp: &mut codec::entities::Viewport, min: (f64, f64), max: (f64, f64)) { let height = (max.1 - min.1).abs().max(1.0); // Paper-space center is an (x, y) point with z = 0. let center = codec::types::Vector3::new((min.0 + max.0) / 2.0, (min.1 + max.1) / 2.0, 0.0); vp.center = center; vp.width = (max.0 - min.0).abs().max(1.0); vp.height = height; vp.view_target = center; vp.view_center = codec::types::Vector3::ZERO; vp.view_height = height * 1.1; } /// Takes a layout's sheet (overall paper) viewport out of its block (entity /// and membership). Viewports onto the model are the user's drawing and stay: /// a file may carry them on a layout whose page setup was never filled in. pub(crate) fn drop_layout_viewports(document: &mut codec::CadDocument, block: Handle) { if block.is_null() { return; } let viewports: Vec = document .entities() .filter_map(|entity| match entity { EntityType::Viewport(viewport) if viewport.common.owner_handle == block && Scene::is_sheet_viewport(document, viewport) => { Some(viewport.common.handle) } _ => None, }) .collect(); for handle in &viewports { document.remove_entity(*handle); } if let Some(record) = document .block_records .iter_mut() .find(|record| record.handle == block) { record .entity_handles .retain(|handle| !viewports.contains(handle)); } for object in document.objects.values_mut() { if let ObjectType::Layout(layout) = object { if layout.block_record == block { layout .viewports .retain(|handle| !viewports.contains(handle)); if viewports.contains(&layout.viewport) { layout.viewport = Handle::NULL; } } } } } /// LAYOUTCREATEVIEWPORT: a layout opened for the first time gets a viewport. static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); pub fn layout_create_viewport() -> bool { LAYOUT_CREATE_VIEWPORT.load(std::sync::atomic::Ordering::Relaxed) } pub fn set_layout_create_viewport(on: bool) { LAYOUT_CREATE_VIEWPORT.store(on, std::sync::atomic::Ordering::Relaxed); } impl Default for Scene { fn default() -> Self { Self::new() } } #[cfg(test)] mod section_tests { use super::*; use codec::entities::{ EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData, Solid3D, }; use codec::objects::ClassObjectData; use codec::types::{Color, Vector3}; fn section(state: i32, depth: f64) -> SectionObjectData { let vertices = vec![Vector3::new(5.0, 2.0, 5.0), Vector3::new(5.0, 8.0, 5.0)]; SectionObjectData { state, flags: 5, name: "Section Plane (1)".to_string(), vertical_direction: Vector3::UNIT_Z, top_height: 1.0, bottom_height: 2.0, indicator_alpha: 70, indicator_color: Color::from_index(9), back_line_vertices: (depth > 0.0) .then(|| { vertices .iter() .map(|point| *point + Vector3::new(-depth, 0.0, 0.0)) .collect() }) .unwrap_or_default(), vertices, settings_handle: Handle::NULL, } } fn bounds(body: Option) -> ([f64; 3], [f64; 3]) { let body = body.expect("section should retain material"); assert!(body.validate().is_empty()); let bounds = kernel::brep::body_bounds(&body).expect("sectioned body should be bounded"); (bounds.min, bounds.max) } fn assert_near(actual: [f64; 3], expected: [f64; 3]) { for (actual, expected) in actual.into_iter().zip(expected) { assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}"); } } #[test] fn kernel_clipping_obeys_plane_slice_boundary_and_volume_limits() { let body = kernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); let (min, max) = bounds(Scene::section_body(&body, §ion(1, 0.0), None, None).unwrap()); assert_near(min, [0.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); let (min, max) = bounds(Scene::section_body(&body, §ion(1, 0.0), Some(2.0), None).unwrap()); assert_near(min, [3.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); let (min, max) = bounds(Scene::section_body(&body, §ion(2, 4.0), None, None).unwrap()); assert_near(min, [1.0, 2.0, 0.0]); assert_near(max, [5.0, 8.0, 10.0]); let (min, max) = bounds(Scene::section_body(&body, §ion(4, 4.0), None, None).unwrap()); assert_near(min, [1.0, 2.0, 3.0]); assert_near(max, [5.0, 8.0, 6.0]); } #[test] fn instance_transform_pulls_the_section_plane_into_body_space() { let body = kernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); let transform = codec::types::Transform::from_scaling(codec::types::Vector3::new( 2.0, 1.0, 1.0, )) .then(&codec::types::Transform::from_translation( codec::types::Vector3::new(10.0, 0.0, 0.0), )); let mut data = section(1, 0.0); for point in &mut data.vertices { point.x = 20.0; } let (min, max) = bounds(Scene::section_body(&body, &data, None, Some(&transform)).unwrap()); assert_near(min, [0.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); } #[test] fn adding_and_erasing_a_section_keeps_its_object_graph_consistent() { let mut scene = Scene::new(); let handle = scene.add_entity(EntityType::Extended(Box::new(ExtendedEntity { common: EntityCommon::new(), data: ExtendedEntityData::SectionObject(section(1, 0.0)), }))); let settings_handle = match scene.document.get_entity(handle).unwrap() { EntityType::Extended(extended) => match &extended.data { ExtendedEntityData::SectionObject(data) => data.settings_handle, _ => unreachable!(), }, _ => unreachable!(), }; assert!(matches!( scene.document.objects.get(&settings_handle), Some(ObjectType::ClassObject(object)) if object.owner == handle && matches!(&object.data, ClassObjectData::SectionSettings(_)) )); let root = scene.document.header.named_objects_dict_handle; let manager_handle = match scene.document.objects.get(&root).unwrap() { ObjectType::Dictionary(dictionary) => dictionary .entries .iter() .find(|(name, _)| name == "ACAD_SECTION_MANAGER") .map(|(_, handle)| *handle) .unwrap(), _ => panic!("root must be a dictionary"), }; assert!(matches!( scene.document.objects.get(&manager_handle), Some(ObjectType::ClassObject(object)) if matches!(&object.data, ClassObjectData::SectionManager(manager) if manager.sections == vec![handle]) )); scene.erase_entities(&[handle]); assert!(!scene.document.objects.contains_key(&settings_handle)); assert!(matches!( scene.document.objects.get(&manager_handle), Some(ObjectType::ClassObject(object)) if matches!(&object.data, ClassObjectData::SectionManager(manager) if manager.sections.is_empty()) )); } #[test] fn live_section_rebuilds_an_uncached_persisted_solid() { let body = kernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); let sat = crate::scene::convert::acis_export::solid_to_sat(&body).unwrap(); let mut solid = Solid3D::new(); solid.set_sat_document(&sat); let mut scene = Scene::new(); let handle = scene.add_entity(EntityType::Solid3D(solid)); assert!(!scene.solid_models.contains_key(&handle)); let clipped = scene .sectioned_body( handle, &LiveSection { data: section(1, 0.0), slice_depth: None, }, ) .unwrap(); let (min, max) = bounds(clipped); assert_near(min, [0.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); } } #[cfg(test)] mod journal_tests { use super::*; fn h(v: u64) -> Handle { Handle::new(v) } // Collect replay output into a handle->kind map for order-independent asserts. fn as_map(v: Option>) -> Option> { v.map(|list| list.into_iter().collect()) } #[test] fn replay_reports_changes_since_a_synced_epoch() { let mut s = Scene::new(); let e0 = s.geometry_epoch; s.bump_entities(&[(h(10), ChangeKind::Added)]); let e1 = s.geometry_epoch; s.bump_entities(&[(h(11), ChangeKind::Modified)]); // From e0 we see both; from e1 only the second. let all = as_map(s.replay_since(e0)).unwrap(); assert_eq!(all.get(&h(10)), Some(&ChangeKind::Added)); assert_eq!(all.get(&h(11)), Some(&ChangeKind::Modified)); let since_e1 = as_map(s.replay_since(e1)).unwrap(); assert_eq!(since_e1.len(), 1); assert_eq!(since_e1.get(&h(11)), Some(&ChangeKind::Modified)); } #[test] fn added_then_removed_cancels() { let mut s = Scene::new(); let e0 = s.geometry_epoch; s.bump_entities(&[(h(20), ChangeKind::Added)]); s.bump_entities(&[(h(20), ChangeKind::Removed)]); let m = as_map(s.replay_since(e0)).unwrap(); assert!( !m.contains_key(&h(20)), "add+remove in one window must cancel" ); } #[test] fn modified_then_removed_is_removed() { let mut s = Scene::new(); let e0 = s.geometry_epoch; s.bump_entities(&[(h(21), ChangeKind::Modified)]); s.bump_entities(&[(h(21), ChangeKind::Removed)]); assert_eq!( as_map(s.replay_since(e0)).unwrap().get(&h(21)), Some(&ChangeKind::Removed) ); } #[test] fn full_bump_forces_rebuild() { let mut s = Scene::new(); let e0 = s.geometry_epoch; s.bump_entities(&[(h(30), ChangeKind::Added)]); s.bump_geometry(); // full delta assert!(s.replay_since(e0).is_none(), "a spanned full delta ⇒ None"); } #[test] fn insert_add_and_update_publish_targeted_deltas() { use codec::entities::Insert; use codec::types::Vector3; let mut s = Scene::new(); let before_add = s.geometry_epoch; let handle = s.add_entity(EntityType::Insert(Insert::new( "EXISTING_BLOCK", Vector3::new(0.0, 0.0, 0.0), ))); assert_eq!( as_map(s.replay_since(before_add)).unwrap().get(&handle), Some(&ChangeKind::Added), "adding an INSERT must not force a whole-drawing rebuild" ); let before_update = s.geometry_epoch; let mut edited = s.document.get_entity(handle).cloned().unwrap(); if let EntityType::Insert(insert) = &mut edited { insert.insert_point.x = 25.0; } assert!(s.update_entity(edited)); assert_eq!( as_map(s.replay_since(before_update)).unwrap().get(&handle), Some(&ChangeKind::Modified), "retargeting or moving an INSERT changes only its render handle" ); } #[test] fn dependency_categories_invalidate_only_affected_render_handles() { use codec::entities::{Line, MText, Point, Text}; use codec::types::Vector3; let mut s = Scene::new(); let line = s.add_entity(EntityType::Line(Line::from_points( Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0), ))); let point = s.add_entity(EntityType::Point(Point::new())); let text = s.add_entity(EntityType::Text(Text::with_value( "plain", Vector3::new(0.0, 2.0, 0.0), ))); let mut annotative = MText::new(); annotative.value = "annotative".to_string(); annotative.is_annotative = true; let annotative = s.add_entity(EntityType::MText(annotative)); let before_points = s.geometry_epoch; s.invalidate_point_dependencies(); let points = as_map(s.replay_since(before_points)).unwrap(); assert_eq!(points.len(), 1); assert_eq!(points.get(&point), Some(&ChangeKind::Modified)); let before_text = s.geometry_epoch; s.invalidate_text_geometry_dependencies(); let texts = as_map(s.replay_since(before_text)).unwrap(); assert_eq!(texts.get(&text), Some(&ChangeKind::Modified)); assert_eq!(texts.get(&annotative), Some(&ChangeKind::Modified)); assert!(!texts.contains_key(&line)); assert!(!texts.contains_key(&point)); let before_annotation = s.geometry_epoch; s.invalidate_annotation_dependencies(); let annotation = as_map(s.replay_since(before_annotation)).unwrap(); assert_eq!(annotation.len(), 1); assert_eq!(annotation.get(&annotative), Some(&ChangeKind::Modified)); } #[test] fn object_visibility_uses_entity_deltas() { use codec::entities::Line; use codec::types::Vector3; let mut s = Scene::new(); let hidden = s.add_entity(EntityType::Line(Line::from_points( Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0), ))); let untouched = s.add_entity(EntityType::Line(Line::from_points( Vector3::new(0.0, 1.0, 0.0), Vector3::new(1.0, 1.0, 0.0), ))); s.selected.insert(hidden); let before_hide = s.geometry_epoch; s.hide_selected(); let hide = as_map(s.replay_since(before_hide)).unwrap(); assert_eq!(hide.len(), 1); assert_eq!(hide.get(&hidden), Some(&ChangeKind::Modified)); assert!(!hide.contains_key(&untouched)); let before_show = s.geometry_epoch; s.end_isolation(); let show = as_map(s.replay_since(before_show)).unwrap(); assert_eq!(show.len(), 1); assert_eq!(show.get(&hidden), Some(&ChangeKind::Modified)); } #[test] fn ring_overflow_forces_rebuild() { let mut s = Scene::new(); let e0 = s.geometry_epoch; for i in 0..(GEOMETRY_JOURNAL_CAP as u64 + 5) { s.bump_entities(&[(h(1000 + i), ChangeKind::Added)]); } assert!( s.replay_since(e0).is_none(), "a consumer older than the ring floor ⇒ None" ); // A recent consumer is still replayable. let recent = s.geometry_epoch; s.bump_entities(&[(h(9999), ChangeKind::Added)]); assert!(s.replay_since(recent).is_some()); } // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. /// A line has tangent geometry but does not feed the analytical uploads. #[test] fn the_partition_membership_test_separates_lines_from_curves() { use crate::scene::pipeline::wire_arena::feeds_analytical_uploads; use codec::entities::{Circle, Line}; use codec::types::Vector3; let mut scene = Scene::new(); let feeds = |scene: &Scene, handle: Handle| { let entity = scene.document.get_entity(handle).unwrap().clone(); scene .tessellate_one(&entity) .iter() .any(feeds_analytical_uploads) }; let line = scene.add_entity(EntityType::Line(Line::from_points( Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 5.0, 0.0), ))); assert!( !feeds(&scene, line), "a drawn line must not force the partition walk", ); let mut circle = Circle::new(); circle.radius = 4.0; let circle = scene.add_entity(EntityType::Circle(circle)); assert!( feeds(&scene, circle), "a drawn circle feeds the analytical uploads and must force the walk", ); } /// Incremental additions preserve existing depths; full rebuilds invalidate them. #[test] fn adding_an_entity_leaves_existing_draw_depths_alone() { use codec::entities::Line; use codec::types::Vector3; fn add(scene: &mut Scene, x: f64) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( Vector3::new(x, 0.0, 0.0), Vector3::new(x + 1.0, 0.0, 0.0), ))) } let mut scene = Scene::new(); let first = add(&mut scene, 0.0); let second = add(&mut scene, 1.0); let before = scene.draw_depth_map(); let generation = scene.draw_depth_generation(); add(&mut scene, 2.0); let after = scene.draw_depth_map(); assert_eq!( scene.draw_depth_generation(), generation, "an incremental add must not invalidate baked depths", ); for handle in [first, second] { assert_eq!( after.get(&handle.value()), before.get(&handle.value()), "an existing entity's depth must not move when another is added", ); } // A rebuild from scratch reassigns labels, so it has to say so. scene.draw_depth_cache.borrow_mut().take(); let _ = scene.draw_depth_map(); assert_ne!( scene.draw_depth_generation(), generation, "a full rebuild must invalidate anything holding a baked depth", ); } /// A folded addition must match the full document-order scan. #[test] fn folding_one_addition_matches_the_full_scan() { use codec::entities::Line; use codec::types::Vector3; let mut scene = Scene::new(); fn add(scene: &mut Scene, x: f64) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( Vector3::new(x, 0.0, 0.0), Vector3::new(x + 1.0, 0.0, 0.0), ))) } add(&mut scene, 0.0); add(&mut scene, 1.0); // Build the index, then add one more so the next call folds. let block = scene.model_space_block_handle(); let before = scene .block_members() .1 .get(&block) .cloned() .unwrap_or_default(); let third = add(&mut scene, 2.0); let folded = scene .block_members() .1 .get(&block) .cloned() .unwrap_or_default(); scene.block_members_cache.borrow_mut().take(); let rebuilt = scene .block_members() .1 .get(&block) .cloned() .unwrap_or_default(); assert_eq!( folded, rebuilt, "the folded index must equal a full rebuild" ); assert_eq!( folded.last(), Some(&third), "an appended entity belongs at the end of its block's list", ); assert_eq!(folded.len(), before.len() + 1); } // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. #[test] fn block_member_index_matches_the_full_scan() { use codec::entities::Line; use codec::types::Vector3; fn line(a: (f64, f64), b: (f64, f64)) -> EntityType { EntityType::Line(Line::from_points( Vector3::new(a.0, a.1, 0.0), Vector3::new(b.0, b.1, 0.0), )) } // What the membership index reports for `block`, in order. fn indexed(s: &Scene, block: Handle) -> Vec { let members = s.block_members(); let (_, by_block) = &*members; by_block.get(&block).cloned().unwrap_or_default() } // What testing every entity the old way reports, in document order. fn scanned(s: &Scene, block: Handle) -> Vec { s.document .entities() .filter(|entity| { let common = entity.common(); s.belongs_to_visible_block(common.handle, common.owner_handle, block) }) .map(|entity| entity.common().handle) .collect() } let mut s = Scene::new(); let block = s.model_space_block_handle(); let h1 = s.add_entity(line((0.0, 0.0), (10.0, 10.0))); let h2 = s.add_entity(line((5.0, 5.0), (20.0, 3.0))); let _ = (h1, h2); // Order matters: it is the wire pass's draw order. assert_eq!(indexed(&s, block), scanned(&s, block), "after adds"); let h3 = s.add_entity(line((100.0, 100.0), (140.0, 90.0))); assert_eq!(indexed(&s, block), scanned(&s, block), "after another add"); // A modify must not reorder the block's contents either. { let mut moved = line((900.0, 900.0), (950.0, 970.0)); moved.common_mut().handle = h3; s.update_entity(moved); } assert_eq!(indexed(&s, block), scanned(&s, block), "after modify"); } /// Raw layer spellings used as memo keys must resolve through the /// case-insensitive table. #[test] fn the_layer_memo_respects_case_insensitive_layer_names() { use codec::entities::Line; use codec::tables::layer::Layer; use codec::types::Vector3; let mut scene = Scene::new(); let mut hidden = Layer::new("Walls"); hidden.flags.off = true; scene.document.layers.add_or_replace(hidden); let mut on_layer = |layer: &str| { let mut line = Line::from_points(Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)); line.common.layer = layer.to_string(); scene.add_entity(EntityType::Line(line)) }; // Spellings of the switched-off layer must all remain hidden. on_layer("Walls"); on_layer("WALLS"); on_layer("walls"); on_layer("Doors"); let block = scene.model_space_block_handle(); let visible: Vec<_> = scene .document .entities() .filter(|e| scene.resident_entity_visible(e, block, None, None, true)) .map(|e| e.common().layer.clone()) .collect(); assert_eq!( visible, vec!["Doors".to_string()], "every spelling of a switched-off layer must be hidden", ); // Each spelling resolves to the same table entry. let resolved: Vec<_> = ["Walls", "WALLS", "walls"] .iter() .map(|name| scene.document.layers.get(name).map(|l| l.name.clone())) .collect(); assert_eq!( resolved, vec![Some("Walls".to_string()); 3], "the table is case-insensitive, which is what lets the memo key on \ the raw name", ); } #[test] fn imported_model_space_hatch_without_layout_object_is_a_layer_target() { use codec::entities::Hatch; use codec::objects::ObjectType; use codec::tables::layer::Layer; let mut scene = Scene::new(); scene.document.layers.add_or_replace(Layer::new("Shadows")); let mut hatch = Hatch::new(); hatch.common.layer = "Shadows".to_string(); let handle = scene.add_entity(EntityType::Hatch(hatch)); let model = scene.model_space_block_handle(); assert_eq!( scene .document .get_entity(handle) .unwrap() .common() .owner_handle, model ); scene .document .objects .retain(|_, object| !matches!(object, ObjectType::Layout(_))); scene.invalidate_dependency_index(); let targets = scene.dependency_targets(DependencyKind::Layer, &["Shadows".to_string()]); assert!(targets.render_handles.contains(&handle)); assert!(targets.source_handles.contains(&handle)); assert!(!targets.touches_block_definition); scene.document.layers.get_mut("Shadows").unwrap().flags.off = true; let epoch = scene.geometry_epoch; scene.invalidate_layer_dependencies(&["Shadows".to_string()]); assert_ne!( scene.geometry_epoch, epoch, "the cached hatch needs an entity delta" ); } /// The loader's handed-over draw depths must equal a local rebuild. #[test] fn the_handed_over_draw_depth_map_matches_a_recompute() { use codec::entities::{Circle, Line}; use codec::types::Vector3; let mut scene = Scene::new(); scene.add_entity(EntityType::Line(Line::from_points( Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0), ))); scene.add_entity(EntityType::Circle(Circle::new())); scene.add_entity(EntityType::Line(Line::from_points( Vector3::new(2.0, 0.0, 0.0), Vector3::new(3.0, 0.0, 0.0), ))); // Capture the map the loader would hand over. let expected = scene.draw_depth_map(); let handed_over = scene .draw_depth_cache .borrow_mut() .take() .expect("the map was just computed, so it is cached"); // Install it under the receiving scene's epoch. scene.bump_geometry(); scene.install_prepared_open_geometry(PreparedOpenGeometry { wires: Arc::new(Vec::new()), interaction_index: None, tess_memo: HashMap::default(), tess_memo_backing: MemoBacking::default(), tess_guard: 0, block_defns: Vec::new(), draw_depths: Some(handed_over), resident_layout: None, }); assert_eq!( *scene.draw_depth_map(), *expected, "the handed-over map must be served as-is", ); // A local rebuild must agree. *scene.draw_depth_cache.borrow_mut() = None; assert_eq!( *scene.draw_depth_map(), *expected, "a recompute must agree with what was handed over", ); } #[test] fn prepared_open_geometry_keeps_layout_for_the_first_edit_patch() { use codec::entities::Line; use codec::types::Vector3; fn line(y: f64) -> EntityType { EntityType::Line(Line::from_points( Vector3::new(0.0, y, 0.0), Vector3::new(10.0, y, 0.0), )) } let mut document = CadDocument::new(); document.add_entity(line(0.0)).unwrap(); let caches = build_derived_caches_impl(&document, None, None); let model_bg = [0.0, 0.0, 0.0, 1.0]; let (document, prepared) = prepare_open_geometry(document, &caches, model_bg); assert!(prepared.resident_layout.is_some()); let mut scene = Scene::new(); scene.document = document; scene.local_extent_max = caches.local_extent_max; scene.local_center = caches.local_center; scene.bg_color = model_bg; scene.current_layout = "Model".to_string(); scene.sync_annotation_scale_from_header(); scene.install_prepared_open_geometry(prepared); let hits = scene.resident_patch_hits.get(); scene.add_entity(line(1.0)); let wires = scene.model_tile_wires_arc(0, &Camera::default(), 1.0, 1.0); drop(wires); assert_eq!(scene.resident_patch_hits.get(), hits + 1); } #[test] fn entity_index_incremental_matches_full_rebuild() { use codec::entities::Line; use codec::types::Vector3; fn line(a: (f64, f64), b: (f64, f64)) -> EntityType { EntityType::Line(Line::from_points( Vector3::new(a.0, a.1, 0.0), Vector3::new(b.0, b.1, 0.0), )) } // Set of handles the index reports over the whole plane (bounded via the // quadtree + always-surfaced unbounded). fn indexed(s: &Scene) -> HashSet { let idx = s.entity_index(); let mut set: HashSet = idx .tree .query_rect([-1e9, -1e9, 1e9, 1e9]) .into_iter() .collect(); set.extend(idx.unbounded_handles.iter().copied()); set } fn full_rebuild(s: &Scene) -> HashSet { *s.entity_index_cache.borrow_mut() = None; indexed(s) } let mut s = Scene::new(); let h1 = s.add_entity(line((0.0, 0.0), (10.0, 10.0))); let h2 = s.add_entity(line((5.0, 5.0), (20.0, 3.0))); // Prime the index (full build), then each edit must keep it == rebuild. let _ = indexed(&s); // Add let h3 = s.add_entity(line((100.0, 100.0), (140.0, 90.0))); assert_eq!(indexed(&s), full_rebuild(&s), "after add"); // Modify (move h1 far away) { let e = line((900.0, 900.0), (950.0, 970.0)); let mut e = e; e.common_mut().handle = h1; s.update_entity(e); } assert_eq!(indexed(&s), full_rebuild(&s), "after modify"); // Erase h2 s.erase_entities(&[h2]); assert_eq!(indexed(&s), full_rebuild(&s), "after erase"); assert!(indexed(&s).contains(&h3) && indexed(&s).contains(&h1)); assert!(!indexed(&s).contains(&h2)); } // Differential oracle for the resident-wire splice: the incrementally // patched resident set must equal a from-scratch rebuild (same wires in the // same draw order) after add / move / erase — and the patch path must run. #[test] fn resident_set_incremental_matches_full_rebuild() { use codec::entities::Line; use codec::types::Vector3; fn line(a: (f64, f64), b: (f64, f64)) -> EntityType { EntityType::Line(Line::from_points( Vector3::new(a.0, a.1, 0.0), Vector3::new(b.0, b.1, 0.0), )) } // Visible wire names, in order. Resident erase deliberately retains // blank tombstone slots, which are not part of a from-scratch build. fn resident_names(s: &Scene) -> Vec { let cam = Camera::default(); let arc = s.model_tile_wires_arc(0, &cam, 1.0, 1.0); let names: Vec = arc .iter() .filter(|wire| Scene::handle_from_wire_name(&wire.name).is_some()) .map(|wire| wire.name.clone()) .collect(); drop(arc); // release so the next patch can move the wires out names } fn from_scratch(s: &Scene) -> Vec { s.resident_wire_sets.borrow_mut().clear(); resident_names(s) } let mut s = Scene::new(); let _h1 = s.add_entity(line((0.0, 0.0), (10.0, 10.0))); let h2 = s.add_entity(line((5.0, 5.0), (20.0, 3.0))); let h2_image = s.document.get_entity_arc(h2).unwrap(); let _ = resident_names(&s); // prime the resident cache let hits0 = s.resident_patch_hits.get(); let h3 = s.add_entity(line((1.0, 1.0), (2.0, 2.0))); assert_eq!(resident_names(&s), from_scratch(&s), "resident after add"); { let mut e = line((100.0, 100.0), (140.0, 90.0)); e.common_mut().handle = h3; s.update_entity(e); } assert_eq!( resident_names(&s), from_scratch(&s), "resident after modify" ); let original_h2_start = s .resident_wire_sets .borrow() .values() .find_map(|set| { set.layout .as_ref() .and_then(|layout| layout.ranges.get(&h2).map(|range| range.0)) }) .unwrap(); let before_erase_names = resident_names(&s); s.erase_entities(&[h2]); let patched = resident_names(&s); let erase_gen = s.last_model_wire_gen.get(); assert!( s.model_wire_patch_for(erase_gen) .is_some_and(|(_, patch)| !patch.face_pass_changed), "removing a plain line must keep the unrelated Face3D/fill pass warm" ); assert!( s.resident_wire_sets.borrow().values().any(|set| { set.layout .as_ref() .is_some_and(|layout| layout.vacant.contains_key(&h2)) }), "erase should retain h2's physical range as a tombstone" ); let expected_erased: Vec<_> = before_erase_names .iter() .filter(|name| *name != &h2.value().to_string()) .cloned() .collect(); assert_eq!(patched, expected_erased, "resident after erase"); // Replay the same Added delta history Undo emits. The entity must return // to its original physical range instead of appending/shifting the set. let restored = s.apply_entity_delta(&[(h2, Some(Arc::clone(&h2_image)), None)], true); for &(handle, _) in &restored { s.reseed_derived_caches(handle); } s.bump_entities(&restored); let restored_names = resident_names(&s); assert_eq!(restored_names, before_erase_names); assert!(s.resident_wire_sets.borrow().values().any(|set| { set.layout.as_ref().is_some_and(|layout| { layout.ranges.get(&h2).map(|range| range.0) == Some(original_h2_start) && !layout.vacant.contains_key(&h2) }) })); assert_eq!( s.resident_patch_hits.get(), hits0 + 4, "every add / modify / erase / restore edit must use the incremental \ patch, not silently fall back to a full rebuild" ); } #[test] fn draw_depth_add_and_erase_keep_existing_labels_stable() { use codec::entities::Line; use codec::types::Vector3; let line = |y: f64| { EntityType::Line(Line::from_points( Vector3::new(0.0, y, 0.0), Vector3::new(10.0, y, 0.0), )) }; let mut s = Scene::new(); let h1 = s.add_entity(line(1.0)); let h2 = s.add_entity(line(2.0)); let initial = s.draw_depth_map(); let d1 = initial[&h1.value()]; let d2 = initial[&h2.value()]; drop(initial); let h3 = s.add_entity(line(3.0)); let after_add = s.draw_depth_map(); assert_eq!(after_add[&h1.value()], d1); assert_eq!(after_add[&h2.value()], d2); let d3 = after_add[&h3.value()]; drop(after_add); s.erase_entities(&[h2]); let after_erase = s.draw_depth_map(); assert_eq!(after_erase[&h1.value()], d1); assert_eq!(after_erase[&h3.value()], d3); assert!(!after_erase.contains_key(&h2.value())); } #[test] fn category_gate_keeps_unrelated_caches_warm() { let mut s = Scene::new(); let e0 = s.geometry_epoch; // Editing handles NOT in the (empty) hatch category leaves it valid. s.bump_entities(&[(h(40), ChangeKind::Modified)]); assert!( s.category_cache_valid(e0, CACHE_CATEGORY_HATCH, |hh| { s.hatches.contains_key(&hh) }), "an edit outside the category must keep it warm" ); // A caller that bypasses the tracked erase primitive supplies no // before-category hint, so removal remains conservatively invalid. s.bump_entities(&[(h(41), ChangeKind::Removed)]); assert!( !s.category_cache_valid(e0, CACHE_CATEGORY_HATCH, |hh| { s.hatches.contains_key(&hh) }), "an unclassified removal must invalidate" ); } #[test] fn plain_geometry_keeps_insert_hatch_cache_warm() { use codec::entities::{Insert, Line}; use codec::types::Vector3; let mut s = Scene::new(); let initial = s.insert_hatches_for_click(); s.add_entity(EntityType::Line(Line::from_points( Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0), ))); let after_line = s.insert_hatches_for_click(); assert!( Arc::ptr_eq(&initial, &after_line), "a line edit must not rescan and explode every block insert" ); let insert = s.add_entity(EntityType::Insert(Insert::new( "EXISTING_BLOCK", Vector3::new(0.0, 0.0, 0.0), ))); let after_insert = s.insert_hatches_for_click(); assert!(!Arc::ptr_eq(&after_line, &after_insert)); s.erase_entities(&[insert]); let after_erase = s.insert_hatches_for_click(); assert!(!Arc::ptr_eq(&after_insert, &after_erase)); } #[test] fn selecting_plain_geometry_keeps_hatch_models_warm() { use codec::entities::Line; use codec::types::Vector3; let mut s = Scene::new(); let line = s.add_entity(EntityType::Line(Line::from_points( Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0), ))); let before = s.hatch_models_arc(); s.select_entity(line, false); let after = s.hatch_models_arc(); assert!( Arc::ptr_eq(&before, &after), "selecting a non-hatch must not rebuild every hatch model" ); } #[test] fn plain_line_erase_keeps_unrelated_category_and_text_caches_warm() { use codec::entities::Line; use codec::types::Vector3; let mut s = Scene::new(); let line = s.add_entity(EntityType::Line(Line::from_points( Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0), ))); let cached_epoch = s.geometry_epoch; s.erase_entities(&[line]); assert!( s.category_cache_valid(cached_epoch, CACHE_CATEGORY_HATCH, |h| { s.hatches.contains_key(&h) }) ); assert!( s.category_cache_valid(cached_epoch, CACHE_CATEGORY_IMAGE, |h| { s.images.contains_key(&h) }) ); assert!( s.category_cache_valid(cached_epoch, CACHE_CATEGORY_MESH, |h| { s.meshes.contains_key(&h) }) ); assert!(s.text_unchanged(cached_epoch)); } /// A raster image whose file cannot be resolved draws the saved path as a /// glyph run instead of the picture, so erasing it MUST invalidate the SDF /// text buffer — otherwise `gather_text_verts` reuses the previous vertex /// list and the path text keeps drawing over the now-empty frame. #[test] fn erasing_broken_raster_reference_invalidates_text_cache() { let mut s = Scene::new(); let image = s.add_entity(EntityType::RasterImage(broken_raster("erase"))); let cached_epoch = s.geometry_epoch; s.erase_entities(&[image]); assert!( !s.text_unchanged(cached_epoch), "erasing a broken raster reference must drop its placeholder glyphs" ); } /// The same classification in the other direction: attaching (and, through /// the identical Added/Modified path, moving or undoing the erase of) a /// broken reference must invalidate the text buffer so its path text appears. #[test] fn attaching_broken_raster_reference_invalidates_text_cache() { let mut s = Scene::new(); let cached_epoch = s.geometry_epoch; s.add_entity(EntityType::RasterImage(broken_raster("attach"))); assert!( !s.text_unchanged(cached_epoch), "attaching a broken raster reference must build its placeholder glyphs" ); } /// The converse perf property: a raster that resolves contributes no glyphs, /// so editing or erasing it must leave the text buffer warm — a grip-drag of /// a working image cannot re-walk every glyph in the drawing per mouse move. /// An empty path counts as resolvable to both the converter and the /// classifier, so this needs no picture on disk. #[test] fn erasing_resolvable_raster_keeps_text_cache_warm() { use codec::entities::RasterImage; use codec::types::Vector3; let mut s = Scene::new(); let image = s.add_entity(EntityType::RasterImage(RasterImage::with_size( "", Vector3::new(0.0, 0.0, 0.0), 16.0, 16.0, 10.0, 10.0, ))); let cached_epoch = s.geometry_epoch; s.erase_entities(&[image]); assert!(s.text_unchanged(cached_epoch)); } /// End-to-end on the buffer the GPU actually draws: the placeholder's path /// glyphs must be gone from the gathered SDF vertices after the erase. This /// is the shape of the reported bug — `gather_text_verts` misses its content- /// id cache on every geometry edit and then reuses the previous vertex list /// whenever `text_unchanged` says the text is untouched. #[test] fn erasing_broken_raster_reference_drops_its_glyphs_from_the_text_buffer() { let depth_map = rustc_hash::FxHashMap::default(); let mut s = Scene::new(); let image = s.add_entity(EntityType::RasterImage(broken_raster("gather"))); let wires = s.entity_wires(); let before = s.gather_text_verts(&wires, 1, 7, &depth_map); assert!( !before.is_empty(), "a broken raster reference must contribute its path glyphs" ); s.erase_entities(&[image]); let wires = s.entity_wires(); let after = s.gather_text_verts(&wires, 2, 7, &depth_map); assert!( after.is_empty(), "the erased placeholder left {} glyph vertices on screen", after.len() ); } /// A raster pointing at a path that cannot exist. `resolve_image` memoises /// per path for the life of the process, so each test uses its own path and /// cannot inherit another's cached answer. fn broken_raster(tag: &str) -> codec::entities::RasterImage { use codec::entities::RasterImage; use codec::types::Vector3; RasterImage::with_size( &format!("/nonexistent-ocs-test/{tag}/missing-reference.png"), Vector3::new(0.0, 0.0, 0.0), 16.0, 16.0, 10.0, 10.0, ) } } /// Delta-undo round-trips: a recorded entity-only edit must be exactly /// invertible (undo restores the pre-edit images) and re-appliable (redo /// restores the post-edit images), including handle preservation and no /// duplication of a re-inserted handle in its owning block record. #[cfg(test)] mod delta_undo_tests { use super::*; use crate::command::EntityTransform; use codec::entities::Line; use codec::types::Vector3; use glam::DVec3; fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> EntityType { EntityType::Line(Line::from_points( Vector3::new(x1, y1, 0.0), Vector3::new(x2, y2, 0.0), )) } /// Build the delta the way `commit_undo_delta` does: pair each recorded /// before-image with the entity's current (after) state. fn build_delta( scene: &Scene, rec: UndoRecording, ) -> Vec<(Handle, Option>, Option>)> { rec.into_before_images() .into_iter() .map(|(h, before)| { let after = scene.document.get_entity_arc(h); (h, before, after) }) .collect() } /// Occurrences of a handle in the *Model_Space block record's entity list. fn ms_occurrences(scene: &Scene, handle: Handle) -> usize { scene .document .block_records .get("*Model_Space") .map(|br| br.entity_handles.iter().filter(|&&x| x == handle).count()) .unwrap_or(0) } #[test] fn transform_delta_round_trips() { let mut scene = Scene::new(); let h = scene.add_entity(line(0.0, 0.0, 10.0, 0.0)); let orig = scene.document.get_entity(h).cloned().unwrap(); scene.begin_undo_recording(); scene.transform_entities(&[h], &EntityTransform::Translate(DVec3::new(5.0, 3.0, 0.0))); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned(), "a plain move must not poison"); let delta = build_delta(&scene, rec); let moved = scene.document.get_entity(h).cloned().unwrap(); assert_ne!(orig, moved, "the move must actually change the entity"); // Undo restores the pre-move image exactly, in place (same handle). scene.apply_entity_delta(&delta, true); assert_eq!(scene.document.get_entity(h).cloned().unwrap(), orig); assert_eq!(ms_occurrences(&scene, h), 1); // Redo re-applies the post-move image. scene.apply_entity_delta(&delta, false); assert_eq!(scene.document.get_entity(h).cloned().unwrap(), moved); assert_eq!(ms_occurrences(&scene, h), 1); } #[test] fn erase_delta_round_trips_with_handle_and_no_dup() { let mut scene = Scene::new(); let h1 = scene.add_entity(line(0.0, 0.0, 1.0, 0.0)); let h2 = scene.add_entity(line(2.0, 2.0, 3.0, 3.0)); let orig1 = scene.document.get_entity(h1).cloned().unwrap(); let orig2 = scene.document.get_entity(h2).cloned().unwrap(); let count = scene.document.entities().count(); scene.select_entity(h2, false); scene.set_hover_highlight(Some(h2)); let selection_generation = scene.selection_generation; scene.begin_undo_recording(); scene.erase_entities(&[h2]); let rec = scene.take_undo_recording().unwrap(); assert!( !rec.is_poisoned(), "erasing an ungrouped entity must not poison" ); let delta = build_delta(&scene, rec); assert!(scene.document.get_entity(h2).is_none(), "h2 must be erased"); assert!(!scene.selected.contains(&h2)); assert_eq!(scene.hover_highlight, None); assert_ne!( scene.selection_generation, selection_generation, "erase must invalidate the stale selection overlay" ); // Undo re-inserts h2 with its ORIGINAL handle and image, exactly once in // the block record (the strip prevents a duplicated dangling entry), // leaving h1 untouched. scene.apply_entity_delta(&delta, true); assert_eq!(scene.document.get_entity(h2).cloned().unwrap(), orig2); assert_eq!(scene.document.get_entity(h1).cloned().unwrap(), orig1); assert_eq!(scene.document.entities().count(), count); assert_eq!( ms_occurrences(&scene, h2), 1, "h2 must appear once, not duplicated" ); // Redo erases h2 again. (remove_entity leaves the handle dangling in // the block record — harmless, a lookup miss is skipped on render/save // — so we assert absence from the document, not from entity_handles.) scene.apply_entity_delta(&delta, false); assert!(scene.document.get_entity(h2).is_none()); } #[test] fn add_delta_round_trips() { let mut scene = Scene::new(); scene.begin_undo_recording(); let h = scene.add_entity(line(0.0, 0.0, 4.0, 4.0)); let rec = scene.take_undo_recording().unwrap(); assert!( !rec.is_poisoned(), "a plain add on an existing layer must not poison" ); let added = scene.document.get_entity(h).cloned().unwrap(); let delta = build_delta(&scene, rec); // Undo removes the freshly added entity from the document (a dangling // block-record entry may remain — see erase test — that's fine). scene.apply_entity_delta(&delta, true); assert!(scene.document.get_entity(h).is_none()); // Redo re-adds it with the same handle, exactly once (the strip clears // any dangling entry so add_entity doesn't duplicate it). scene.apply_entity_delta(&delta, false); assert_eq!(scene.document.get_entity(h).cloned().unwrap(), added); assert_eq!(ms_occurrences(&scene, h), 1); } #[test] fn raster_add_records_only_its_image_definition_object() { use codec::entities::RasterImage; use codec::objects::ObjectType; use codec::types::Vector3; let mut scene = Scene::new(); let image = RasterImage::with_size( "test.png", Vector3::new(0.0, 0.0, 0.0), 16.0, 16.0, 10.0, 10.0, ); scene.begin_undo_recording(); let handle = scene.add_entity(EntityType::RasterImage(image)); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); let (entities, objects, _parametric_constraints, _named_parameters, _designations) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, handle); assert_eq!(objects.len(), 1); assert!(objects[0].1.is_none()); assert!(matches!( scene.document.objects.get(&objects[0].0), Some(ObjectType::ImageDefinition(_)) )); } #[test] fn grouped_erase_records_group_object_without_poisoning() { use codec::objects::ObjectType; let mut scene = Scene::new(); let h1 = scene.add_entity(line(0.0, 0.0, 1.0, 0.0)); let h2 = scene.add_entity(line(2.0, 0.0, 3.0, 0.0)); let group = scene.create_group("G".to_string(), vec![h1, h2]); scene.begin_undo_recording(); scene.erase_entities(&[h1]); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); let (entities, objects, _parametric_constraints, _named_parameters, _designations) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, h1); assert_eq!(objects.len(), 1); assert_eq!(objects[0].0, group); assert!(matches!(objects[0].1, Some(ObjectType::Group(_)))); assert!(matches!( scene.document.objects.get(&group), Some(ObjectType::Group(current)) if current.entities == vec![h2] )); } #[test] fn copy_delta_round_trips() { let mut scene = Scene::new(); let src = scene.add_entity(line(0.0, 0.0, 1.0, 0.0)); scene.begin_undo_recording(); let new_handles = scene.copy_entities( &[src], &EntityTransform::Translate(DVec3::new(10.0, 0.0, 0.0)), ); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned(), "copying a plain entity must not poison"); assert_eq!(new_handles.len(), 1); let copy_h = new_handles[0]; let copy_img = scene.document.get_entity(copy_h).cloned().unwrap(); let delta = build_delta(&scene, rec); // Undo erases the copy from the document, leaving the source intact. scene.apply_entity_delta(&delta, true); assert!(scene.document.get_entity(copy_h).is_none()); assert!(scene.document.get_entity(src).is_some()); // Redo restores the copy with its handle, once. scene.apply_entity_delta(&delta, false); assert_eq!( scene.document.get_entity(copy_h).cloned().unwrap(), copy_img ); assert_eq!(ms_occurrences(&scene, copy_h), 1); } } #[cfg(test)] mod redraw_tests { use super::*; fn tile_inst(idx: usize, active: bool) -> ViewportInstance { ViewportInstance { handle: Handle::NULL, tile_idx: Some(idx), screen_rect: iced::Rectangle { x: 0.0, y: 0.0, width: 1.0, height: 1.0, }, camera: Camera::default(), render_mode: codec::entities::ViewportRenderMode::Wireframe2D, active, grid_on: false, paper_sheet: false, } } #[test] fn request_resolves_to_instance_ids_and_consumes_per_id() { let scene = Scene::new(); // default: one model tile, index 0, active let geom_before = scene.geometry_epoch; let block_before = scene.block_epoch; // Active -> the active model tile's instance_id. scene.request_refresh(ViewportRefreshScope::Active); let active_id = scene.instance_id_for(&tile_inst(0, true)); assert!(scene.refresh_pending_any(), "a request must be pending"); assert!( scene.refresh_consume(active_id), "active tile id must be consumed" ); assert!( !scene.refresh_consume(active_id), "second consume for the same id is empty (one-shot per id)" ); assert!( !scene.refresh_pending_any(), "id set empty after its only target consumed it" ); // REDRAW never touches geometry / block epochs. assert_eq!( scene.geometry_epoch, geom_before, "REDRAW must not bump geometry_epoch" ); assert_eq!( scene.block_epoch, block_before, "REDRAW must not bump block_epoch" ); } #[test] fn all_extinguishes_and_then_resurrects_active_after_consumption() { let scene = Scene::new(); let t0 = scene.instance_id_for(&tile_inst(0, false)); scene.request_refresh(ViewportRefreshScope::Active); assert!(scene.refresh_consume(t0), "Active targeted tile 0"); assert!( !scene.refresh_pending_any(), "Active consumed: set now empty" ); // A subsequent All must re-establish membership even after the Active // request was fully drained (the superset is resolved at request time). scene.request_refresh(ViewportRefreshScope::All); assert!( scene.refresh_pending_any(), "All re-pends after Active was consumed" ); assert!( scene.refresh_consume(t0), "All must re-force tile 0 after Active drained" ); } #[test] fn all_targets_every_existing_model_tile() { let mut scene = Scene::new(); // Create MULTIPLE real model tiles through the public split API so the // test exercises the per-id REDRAWALL guarantee (the very reason for the // FxHashSet design): every EXISTING tile id must be targeted and drained. scene.split_active_pane(false); // 2 tiles scene.split_active_pane(false); // 3 tiles assert!( scene.model_tiles.borrow().len() >= 3, "split must have produced tiles" ); scene.request_refresh(ViewportRefreshScope::All); for idx in 0..scene.model_tiles.borrow().len() { let id = scene.instance_id_for(&tile_inst(idx, false)); assert!( scene.refresh_consume(id), "All must target EXISTING tile {idx}, not just tile 0" ); } assert!( !scene.refresh_pending_any(), "All drained every existing tile" ); // A tile index that does not exist must NOT be drained. let ghost = scene.instance_id_for(&tile_inst(99, false)); assert!( !scene.refresh_consume(ghost), "nonexistent tile 99 has no membership" ); } } #[cfg(test)] mod selection_arc_tests { use super::Scene; #[test] fn selection_is_arc_not_refcell() { let scene = Scene::new(); let tn = std::any::type_name_of_val(&scene.selection); assert!( tn.contains("Arc"), "selection should be Arc, got {}", tn ); } #[test] fn selection_overlay_takes_arc() { assert!(true); } } #[cfg(test)] mod layout_cache_tests { use super::*; #[test] fn memoized_plain_line_matches_fresh_background() { let mut s = Scene::new(); s.bg_color = [0.1, 0.1, 0.1, 1.0]; let handle = s.add_entity(EntityType::Line(codec::entities::Line::from_points( codec::types::Vector3::new(0.0, 0.0, 0.0), codec::types::Vector3::new(10.0, 0.0, 0.0), ))); let build = |s: &Scene| { s.wires_for_block_culled( s.model_space_block_handle(), None, None, None, None, None, true, None, ) .into_iter() .find(|wire| wire.name == handle.value().to_string()) .unwrap() }; let original = build(&s); assert!(s.resident_tess_memo.borrow().contains_key(&handle)); s.paper_bg_color = [1.0; 4]; s.document.add_layout("Review").unwrap(); s.set_current_layout("Review".to_string()); let cached = build(&s); s.resident_tess_memo.borrow_mut().clear(); let fresh = build(&s); assert_ne!( original.color, fresh.color, "fixture must depend on background" ); assert_eq!( cached.color, fresh.color, "memo hit should match fresh tessellation on a white background" ); } #[test] fn model_extents_reuse_resident_geometry_in_every_layout() { let mut s = Scene::new(); s.add_entity(EntityType::Line(codec::entities::Line::from_points( codec::types::Vector3::new(1_000_000.0, 2_000_000.0, 30.0), codec::types::Vector3::new(1_000_010.0, 2_000_020.0, 40.0), ))); s.document.add_layout("Review").unwrap(); for layout in ["Model", "Review"] { s.set_current_layout(layout.to_string()); *s.model_extents_cache.borrow_mut() = None; let scale = crate::scene::annotative::scale_handle_by_name( &s.document, &s.document.header.current_annotation_scale, ); let source = s.resident_wires_for( s.model_space_block_handle(), Some(s.annotation_scale), scale, None, None, ); let generation = s.last_model_wire_gen.get(); let bounds = s.model_space_extents().unwrap(); assert_eq!(bounds.0, glam::Vec3::new(1_000_000.0, 2_000_000.0, 30.0)); assert_eq!(bounds.1, glam::Vec3::new(1_000_010.0, 2_000_020.0, 40.0)); assert_eq!( s.last_model_wire_gen.get(), generation, "fitting must reuse the resident source" ); assert!(!source.is_empty()); } } #[test] fn model_space_extents_mixed_entities_accuracy() { use codec::types::{Vector2, Vector3}; let mut s = Scene::new(); // Line from (0, 0, 10) to (50, 100, 20) let mut line = codec::entities::Line::new(); line.start = Vector3::new(0.0, 0.0, 10.0); line.end = Vector3::new(50.0, 100.0, 20.0); s.add_entity(EntityType::Line(line)); // Circle at (200, 200, 0) with radius 50 let mut circle = codec::entities::Circle::new(); circle.center = Vector3::new(200.0, 200.0, 0.0); circle.radius = 50.0; s.add_entity(EntityType::Circle(circle)); // Arc at (-100, -50, -5) with radius 25, angles 0 to PI let mut arc = codec::entities::Arc::new(); arc.center = Vector3::new(-100.0, -50.0, -5.0); arc.radius = 25.0; arc.start_angle = 0.0; arc.end_angle = std::f64::consts::PI; s.add_entity(EntityType::Arc(arc)); // LwPolyline from (300, -200) to (400, -100) let mut pl = codec::entities::LwPolyline::new(); pl.vertices = vec![ codec::entities::LwVertex::new(Vector2::new(300.0, -200.0)), codec::entities::LwVertex::new(Vector2::new(400.0, -100.0)), ]; s.add_entity(EntityType::LwPolyline(pl)); let bounds = s.model_space_extents().expect("Extents must exist"); // Every entity counts, curves included. The circle and the arc used to // contribute nothing at all: the walk read only `key_vertices`, which // is empty for anything tessellated, so the drawing looked 275 units // narrower and 150 shorter than it is. // Line: (0, 0, 10) .. (50, 100, 20) // Circle: (150, 150, 0) .. (250, 250, 0) centre 200,200 r50 // Arc: (-125, -50, -5) .. (-75, -25, -5) centre -100,-50 r25, 0..PI // LwPolyline: (300, -200, 0) .. (400, -100, 0) for (got, want, axis) in [ (bounds.0.x, -125.0, "min.x"), (bounds.1.x, 400.0, "max.x"), (bounds.0.y, -200.0, "min.y"), (bounds.1.y, 250.0, "max.y"), (bounds.0.z, -5.0, "min.z"), (bounds.1.z, 20.0, "max.z"), ] { assert!( (got - want).abs() < 1e-3, "{axis} mismatch: got {got}, want {want}" ); } } #[test] fn resident_wires_are_zoom_independent_and_gpu_analytical() { let mut s = Scene::new(); let mut circle = codec::entities::Circle::default(); circle.radius = 100.0; let handle = s.add_entity(EntityType::Circle(circle)); // Far camera: distance is large let mut cam_far = Camera::default(); cam_far.distance = 1000.0; // Close camera: distance is small let mut cam_close = Camera::default(); cam_close.distance = 1.0; let wires_far = s.model_tile_wires_arc(0, &cam_far, 1.0, 1000.0); let circle_wire_far = wires_far .iter() .find(|w| w.name == handle.value().to_string()) .unwrap(); let wires_close = s.model_tile_wires_arc(0, &cam_close, 1.0, 1000.0); // Zooming must reuse the exact same resident wire set (zero re-tessellation) assert!( Arc::ptr_eq(&wires_far, &wires_close), "Resident wires must be identical Arc across zoom levels" ); // Circle must carry TangentGeom for GPU analytical rendering assert_eq!(circle_wire_far.tangent_geoms.len(), 1); assert!(matches!( circle_wire_far.tangent_geoms[0], crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } | crate::scene::model::wire_model::TangentGeom::Circle { .. } )); } #[test] fn block_circles_and_arcs_extract_as_analytical_gpu_instances() { let mut s = Scene::new(); // Create initial entities let mut circle = codec::entities::Circle::default(); circle.radius = 50.0; let c_h = s.add_entity(EntityType::Circle(circle)); let mut arc = codec::entities::Arc::default(); arc.radius = 25.0; arc.start_angle = 0.0; arc.end_angle = std::f64::consts::PI; let a_h = s.add_entity(EntityType::Arc(arc)); let line = codec::entities::Line::from_points( codec::types::Vector3::new(0.0, 0.0, 0.0), codec::types::Vector3::new(100.0, 100.0, 0.0), ); let l_h = s.add_entity(EntityType::Line(line)); // Create block and place insert at (200, 300, 0) let xform = codec::types::Transform::from_translation(codec::types::Vector3::new( 200.0, 300.0, 0.0, )); let _ = s .create_block_from_entities( &[c_h, a_h, l_h], "TEST_ANALYTICAL_BLOCK", &codec::types::Transform::identity(), &xform, ) .unwrap(); let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); let depths = rustc_hash::FxHashMap::default(); let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); // Both the circle and the arc inside the block must be extracted as analytical GPU circle instances! assert!( partitioned.circle_instances.len() >= 2, "Expected at least 2 analytical circle instances from block, got {}", partitioned.circle_instances.len() ); } #[test] fn single_bulge_polyline_extracts_as_analytical_gpu_arc() { let mut s = Scene::new(); let mut pline = codec::entities::LwPolyline::new(); pline.vertices = vec![ codec::entities::LwVertex { location: codec::types::Vector2::new(0.0, 0.0), bulge: 1.0, // semicircle start_width: 0.0, end_width: 0.0, vertex_id: 0, }, codec::entities::LwVertex { location: codec::types::Vector2::new(100.0, 0.0), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 0, }, ]; let _ = s.add_entity(EntityType::LwPolyline(pline)); let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); let depths = rustc_hash::FxHashMap::default(); let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); assert_eq!( partitioned.circle_instances.len(), 1, "Single bulge arc polyline must be extracted as analytical GPU arc instance" ); } #[test] fn multi_bulge_polyline_extracts_as_analytical_gpu_arcs() { let mut s = Scene::new(); let mut pline = codec::entities::LwPolyline::new(); // A circle represented as a closed 2-vertex polyline with two semicircle bulges (standard CAD polyline circle) pline.is_closed = true; pline.vertices = vec![ codec::entities::LwVertex { location: codec::types::Vector2::new(0.0, 0.0), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 0, }, codec::entities::LwVertex { location: codec::types::Vector2::new(100.0, 0.0), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 1, }, ]; let _ = s.add_entity(EntityType::LwPolyline(pline)); let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); let depths = rustc_hash::FxHashMap::default(); let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); assert_eq!( partitioned.circle_instances.len(), 2, "Multi-bulge closed polyline circle must be extracted as 2 analytical GPU arc instances" ); assert_eq!( partitioned.regular.len(), 0, "Multi-bulge pure arc polyline must not be sent to regular line arena" ); } #[test] fn mixed_bulge_polyline_splits_into_analytical_arcs_and_straight_lines() { use codec::entities::{LwPolyline, LwVertex}; use codec::types::Vector2; let mut scene = Scene::new(); let mut pline = LwPolyline::new(); // Rounded slot: 2 straight lines and 2 semicircular ends (bulge = 1.0) // Vertex 0: (0, 0), bulge 1.0 (arc from 0,0 to 0,10) // Vertex 1: (0, 10), bulge 0.0 (straight from 0,10 to 50,10) // Vertex 2: (50, 10), bulge 1.0 (arc from 50,10 to 50,0) // Vertex 3: (50, 0), bulge 0.0 (straight from 50,0 to 0,0) let v0 = LwVertex::with_bulge(Vector2::new(0.0, 0.0), 1.0); let v1 = LwVertex::new(Vector2::new(0.0, 10.0)); let v2 = LwVertex::with_bulge(Vector2::new(50.0, 10.0), 1.0); let v3 = LwVertex::new(Vector2::new(50.0, 0.0)); pline.vertices = vec![v0, v1, v2, v3]; pline.is_closed = true; let _ = scene.add_entity(EntityType::LwPolyline(pline)); let camera = Camera::default(); let wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1000.0); let depths = rustc_hash::FxHashMap::default(); let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); assert_eq!( partitioned.circle_instances.len(), 2, "Mixed polyline should have its 2 bulge arcs routed to analytical CircleGpu" ); } #[test] fn cached_layout_names_returns_same_arc_until_bump() { let scene = Scene::new(); let first = scene.cached_layout_names(); let second = scene.cached_layout_names(); assert!(std::sync::Arc::ptr_eq(&first, &second)); // Fresh `CadDocument::new()` ships a default "Layout1" paper layout. assert_eq!(&first[..], &["Model".to_string(), "Layout1".to_string()]); } #[test] fn cached_layout_names_invalidated_by_add_layout() { let mut scene = Scene::new(); let before = scene.cached_layout_names(); scene.add_layout("EXTRA").unwrap(); let after = scene.cached_layout_names(); assert!( !std::sync::Arc::ptr_eq(&before, &after), "add_layout must bump the epoch so the cached Arc is replaced" ); assert!(after.iter().any(|n| n == "EXTRA")); } #[test] fn cached_scale_picker_returns_same_arc_until_bump() { let scene = Scene::new(); let first = scene.cached_scale_picker_list(); let second = scene.cached_scale_picker_list(); assert!(std::sync::Arc::ptr_eq(&first, &second)); } #[test] fn cached_scale_picker_invalidated_by_add_scale() { let mut scene = Scene::new(); let before = scene.cached_scale_picker_list(); assert!(scene.add_scale("TEST_CACHED_SCALE", 1.0, 100.0)); let after = scene.cached_scale_picker_list(); assert!( !std::sync::Arc::ptr_eq(&before, &after), "add_scale must bump the epoch so the cached Arc is replaced" ); assert!(after.iter().any(|(n, _, _)| n == "TEST_CACHED_SCALE")); } #[test] fn cached_scale_picker_invalidated_by_current_scale_change() { let mut scene = Scene::new(); // Force the metric family so the architectural entry is filtered out // unless it is the kept-active current scale. scene.document.header.insertion_units = 4; assert!(scene.add_scale("1:50", 1.0, 50.0)); assert!(scene.add_scale("1/2\" = 1'-0\"", 0.5, 12.0)); // Current = opposite-family entry with no metric equivalent factor, so // the keep-active path appends it to the visible list. assert!(scene.set_annotation_scale_named("1/2\" = 1'-0\"").is_some()); let before = scene.cached_scale_picker_list(); assert!( before.iter().any(|(n, _, _)| n == "1/2\" = 1'-0\""), "warm cache must contain the active opposite-family entry" ); // Production setter: changes `current_annotation_scale` without // bumping `scale_epoch` — the old (epoch, factor) key stayed hit here. assert!(scene.set_annotation_scale_named("1:50").is_some()); let after = scene.cached_scale_picker_list(); assert!( !std::sync::Arc::ptr_eq(&before, &after), "current-scale change must miss the cache (new Arc)" ); assert!( !after.iter().any(|(n, _, _)| n == "1/2\" = 1'-0\""), "after switching to 1:50 the architectural entry must drop out" ); assert!(after.iter().any(|(n, _, _)| n == "1:50")); } #[test] fn cached_ucs_names_returns_same_arc_until_bump() { // RED: `Scene::cached_ucs_names` does not exist yet (#34). let scene = Scene::new(); let first = scene.cached_ucs_names(); let second = scene.cached_ucs_names(); assert!(std::sync::Arc::ptr_eq(&first, &second)); } #[test] fn cached_ucs_names_invalidated_by_bump() { // RED: `Scene::bump_ucs_epoch` does not exist yet (#34). let mut scene = Scene::new(); let before = scene.cached_ucs_names(); scene .document .ucss .add_or_replace(codec::tables::Ucs::new("TEST_CACHED_UCS")); scene.bump_ucs_epoch(); let after = scene.cached_ucs_names(); assert!( !std::sync::Arc::ptr_eq(&before, &after), "ucs mutation + bump must replace the cached Arc" ); assert!(after.iter().any(|n| n == "TEST_CACHED_UCS")); } // #48: with no active/selected viewport, the pill number, label and sync // badge must all describe the SAME viewport (the first-content fallback // the number already used) — not a mix of viewport number, paper label // and hidden badge. #[test] fn scale_pill_sources_agree_without_targeted_viewport() { let mut scene = Scene::new(); scene.add_layout("P1").unwrap(); scene.current_layout = "P1".to_string(); assert!(scene.add_scale("1:50", 1.0, 50.0)); let mut vp = codec::entities::Viewport::new(); vp.width = 100.0; vp.height = 100.0; let handle = scene .document .add_entity_to_layout(codec::EntityType::Viewport(vp), "P1") .unwrap(); assert!(scene.set_viewport_scale_named_for(handle, "1:50").is_some()); assert!(scene.active_viewport.is_none()); let number = scene.first_viewport_scale(); let name = scene.displayed_annotation_scale_name(); let synced = scene.viewport_annotation_scale_synced(); eprintln!("number={number:?} name={name:?} synced={synced:?}"); assert!( number.is_some() && name == "1:50" && synced == Some(true), "pill trio must agree" ); } #[test] fn scale_pill_model_layout_unchanged() { // Model layout has no viewports: number None, header label, no badge. let scene = Scene::new(); assert!(scene.current_layout == "Model"); assert!(scene.first_viewport_scale().is_none()); assert_eq!( scene.displayed_annotation_scale_name(), scene.document.header.current_annotation_scale ); assert!(scene.viewport_annotation_scale_synced().is_none()); } } #[cfg(test)] mod update_entities_batch_tests { use super::Scene; use codec::entities::{Block, BlockEnd, Line}; use codec::types::Vector3; use codec::{EntityType, Handle}; /// Fresh scene holding two block sentinels (Block + BlockEnd) and one plain /// line; returns their handles in that order. fn scene_with_sentinels_and_line() -> (Scene, Handle, Handle, Handle) { let mut scene = Scene::new(); let bh = scene .document .add_entity(EntityType::Block(Block::new("BATCH_TEST", Vector3::ZERO))) .unwrap(); let be = scene .document .add_entity(EntityType::BlockEnd(BlockEnd::new())) .unwrap(); let mut line = Line::new(); line.start = Vector3::new(0.0, 0.0, 1.0); line.end = Vector3::new(3.0, 4.0, 2.0); let lh = scene.document.add_entity(EntityType::Line(line)).unwrap(); (scene, bh, be, lh) } /// Stored entity as-is — the update shape the command arms actually pass. fn snapshot(scene: &Scene, handle: Handle) -> EntityType { scene.document.get_entity(handle).unwrap().clone() } /// The same line with an edited Z, still carrying its original handle. fn edited_line(scene: &Scene, handle: Handle) -> EntityType { let mut entity = snapshot(scene, handle); if let EntityType::Line(line) = &mut entity { line.start.z = 5.0; line.end.z = 6.0; } entity } fn delta_count(scene: &Scene) -> usize { scene.geometry_deltas.borrow().len() } #[test] fn update_entities_batch_of_block_sentinels_collapses_to_one_full_delta() { // Batched path: one coarse publish for the whole set. let (mut batched, bh, be, _) = scene_with_sentinels_and_line(); let before = delta_count(&batched); let updates = vec![snapshot(&batched, bh), snapshot(&batched, be)]; let moved = batched.update_entities(updates); assert_eq!(moved, 2); { let ring = batched.geometry_deltas.borrow(); assert_eq!( ring.len() - before, 1, "batched sentinels must publish exactly one geometry delta" ); assert!( ring.back().unwrap().full, "the batched delta must be full (bump_geometry)" ); } // Legacy path: each sentinel bumps on its own. let (mut legacy, bh, be, _) = scene_with_sentinels_and_line(); let before = delta_count(&legacy); let u1 = snapshot(&legacy, bh); let u2 = snapshot(&legacy, be); assert!(legacy.update_entity(u1)); assert!(legacy.update_entity(u2)); assert_eq!( delta_count(&legacy) - before, 2, "legacy per-entity updates must publish one delta each" ); // Same final entity state either way. for h in [bh, be] { assert_eq!( format!("{:?}", batched.document.get_entity(h)), format!("{:?}", legacy.document.get_entity(h)), "entity {h:?} differs between batched and legacy updates" ); } } #[test] fn update_entities_mixed_batch_matches_legacy_states_with_one_coarse_delta() { // Batched: the sentinel's coarse (full) delta subsumes the line's // would-be per-entity delta, and both slots are still written. let (mut batched, bh, _, lh) = scene_with_sentinels_and_line(); let before = delta_count(&batched); let updates = vec![snapshot(&batched, bh), edited_line(&batched, lh)]; let moved = batched.update_entities(updates); assert_eq!(moved, 2); { let ring = batched.geometry_deltas.borrow(); assert_eq!( ring.len() - before, 1, "mixed batch with a sentinel must publish one coarse delta" ); assert!(ring.back().unwrap().full, "coarse delta must be full"); } // Legacy: full delta for the sentinel + per-entity delta for the line. let (mut legacy, bh, _, lh) = scene_with_sentinels_and_line(); let before = delta_count(&legacy); let u1 = snapshot(&legacy, bh); let u2 = edited_line(&legacy, lh); assert!(legacy.update_entity(u1)); assert!(legacy.update_entity(u2)); assert_eq!( delta_count(&legacy) - before, 2, "legacy publishes one delta per update" ); for h in [bh, lh] { assert_eq!( format!("{:?}", batched.document.get_entity(h)), format!("{:?}", legacy.document.get_entity(h)), "entity {h:?} differs between batched and legacy updates" ); } } } pub(crate) fn is_point_cloud(entity: &EntityType) -> bool { matches!( entity, EntityType::Extended(extended) if matches!(extended.data, codec::entities::ExtendedEntityData::PointCloudEx(_)) ) } /// A point cloud whose Locked property is set: edits leave it alone. pub(crate) fn is_locked_point_cloud(entity: &EntityType) -> bool { matches!( entity, EntityType::Extended(extended) if matches!(&extended.data, codec::entities::ExtendedEntityData::PointCloudEx(data) if data.locked) ) }