forked from 1b-agent/opencadstudio-upstream
Track selection order for directional subtraction and use the kernel fixes for periodic curved-face intersections.\n\nRefs #816
10215 lines
414 KiB
Rust
10215 lines
414 KiB
Rust
// 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 cache;
|
||
pub mod convert;
|
||
pub mod creation_style;
|
||
pub mod model;
|
||
pub mod pick;
|
||
pub mod pipeline;
|
||
pub(crate) mod render_graph;
|
||
pub mod text;
|
||
pub mod view;
|
||
|
||
// Topic submodules split out of this root (each contributes `impl Scene`
|
||
// blocks and/or free functions). Pure text-move from the original mod.rs.
|
||
mod boundary;
|
||
mod camera_ops;
|
||
mod entity;
|
||
mod group_layer;
|
||
mod layout;
|
||
mod limits;
|
||
mod modify;
|
||
mod mspace;
|
||
mod page_setup;
|
||
mod paper;
|
||
mod preview;
|
||
mod project;
|
||
mod scene_markers;
|
||
mod selection;
|
||
|
||
pub(crate) use boundary::{boundary_entities, ring_source_handles};
|
||
|
||
// 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,
|
||
tessellate_entity_dim_text,
|
||
};
|
||
|
||
/// 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<Handle>,
|
||
}
|
||
|
||
#[derive(Clone, Default)]
|
||
struct DependencyTargets {
|
||
render_handles: HashSet<Handle>,
|
||
source_handles: HashSet<Handle>,
|
||
touches_block_definition: bool,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct SceneDependencyIndex {
|
||
layers: HashMap<String, DependencyTargets>,
|
||
text_styles: HashMap<String, DependencyTargets>,
|
||
dim_styles: HashMap<String, DependencyTargets>,
|
||
object_styles: HashMap<Handle, DependencyTargets>,
|
||
points: DependencyTargets,
|
||
text_geometry: DependencyTargets,
|
||
annotation_geometry: DependencyTargets,
|
||
}
|
||
|
||
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 acadrust::entities::{Block, BlockEnd, Insert as DxfInsert};
|
||
use acadrust::entities::{
|
||
BoundaryEdge, BoundaryPath, Hatch as DxfHatch, PolylineEdge, Solid as DxfSolid,
|
||
};
|
||
use acadrust::objects::ObjectType;
|
||
use acadrust::tables::normalize_name;
|
||
use acadrust::types::Vector2;
|
||
use acadrust::{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::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<Handle>,
|
||
/// Objects retained by ISOLATEOBJECTS. `None` means no isolate filter.
|
||
pub keep: Option<HashSet<Handle>>,
|
||
}
|
||
|
||
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<Handle, Option<Arc<EntityType>>>,
|
||
order: Vec<Handle>,
|
||
/// First-touch before-images for the exact document.objects entries changed
|
||
/// by the command. `None` denotes a newly-created object.
|
||
object_before: HashMap<Handle, Option<ObjectType>>,
|
||
object_order: Vec<Handle>,
|
||
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 or object entry was recorded (nothing to undo).
|
||
pub fn is_empty(&self) -> bool {
|
||
self.order.is_empty() && self.object_order.is_empty()
|
||
}
|
||
|
||
/// Consume both recording directories in deterministic first-touch order.
|
||
/// A `None` image means the entity/object was added by the command.
|
||
pub fn into_recorded_images(
|
||
mut self,
|
||
) -> (
|
||
Vec<(Handle, Option<Arc<EntityType>>)>,
|
||
Vec<(Handle, Option<ObjectType>)>,
|
||
) {
|
||
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();
|
||
(entities, objects)
|
||
}
|
||
|
||
/// Entity-only convenience used by the focused Scene delta tests.
|
||
#[cfg(test)]
|
||
pub fn into_before_images(self) -> Vec<(Handle, Option<Arc<EntityType>>)> {
|
||
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<Handle, u16>,
|
||
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<Vec<WireModel>>,
|
||
layout: Option<ResidentWireLayout>,
|
||
}
|
||
|
||
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<Handle>,
|
||
/// Flat wire range `(start, len)` for each currently visible entity.
|
||
ranges: HashMap<Handle, (usize, usize)>,
|
||
/// 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<Handle, (usize, usize)>,
|
||
/// 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,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct PreparedOpenGeometry {
|
||
pub wires: Arc<Vec<WireModel>>,
|
||
pub interaction_index: Option<Arc<crate::scene::pick::interaction_index::InteractionIndex>>,
|
||
}
|
||
|
||
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())
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||
pub(crate) struct WireGpuPatch {
|
||
pub(crate) changes: Arc<Vec<(Handle, ChangeKind)>>,
|
||
pub(crate) runs: Arc<HashMap<Handle, Arc<Vec<WireModel>>>>,
|
||
pub(crate) index_edits: Arc<Vec<WireIndexEdit>>,
|
||
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<DrawDepthEntry> {
|
||
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,
|
||
}
|
||
}
|
||
|
||
struct DrawDepthCache {
|
||
epoch: u64,
|
||
depths: Arc<HashMap<u64, [f32; 2]>>,
|
||
/// Per-block stable order labels, including deleted tombstones retained for
|
||
/// symmetric Undo/Redo.
|
||
blocks: HashMap<Handle, Vec<DrawDepthEntry>>,
|
||
/// Reverse owner lookup retained across removal for tombstone restoration.
|
||
owners: HashMap<u64, Handle>,
|
||
}
|
||
|
||
struct PaperViewportCache {
|
||
epoch: u64,
|
||
layout: String,
|
||
layout_block: Handle,
|
||
sheet: Handle,
|
||
content: Arc<Vec<Handle>>,
|
||
paper_limits: Option<((f64, f64), (f64, f64))>,
|
||
}
|
||
|
||
struct PaperSheetRenderCache {
|
||
epoch: u64,
|
||
layout: String,
|
||
selected: u64,
|
||
paper_bg: [f32; 4],
|
||
hatches: Arc<Vec<HatchModel>>,
|
||
wipeouts: Arc<Vec<HatchModel>>,
|
||
images: Arc<Vec<ImageModel>>,
|
||
}
|
||
|
||
/// 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<bool> = 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` (model-space view extent) is the canonical source — it
|
||
/// is what AutoCAD actually uses to draw, and what we keep in sync on
|
||
/// every write. `custom_scale` is consulted only when `view_height` is
|
||
/// missing or zero (some third-party exporters omit it).
|
||
#[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<acadrust::ReadStats>,
|
||
pub source_sha256: Option<String>,
|
||
pub local_extent_max: f32,
|
||
pub local_center: [f64; 2],
|
||
pub hatches: HashMap<Handle, HatchModel>,
|
||
pub images: HashMap<Handle, ImageModel>,
|
||
pub meshes: HashMap<Handle, MeshLodSet>,
|
||
/// Block-definition solid meshes, block-local frame (instanced per INSERT). (#123)
|
||
pub block_meshes: HashMap<Handle, MeshLodSet>,
|
||
/// 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<crate::io::xref::XrefInfo>,
|
||
/// 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<PreparedOpenGeometry>,
|
||
/// 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,
|
||
}
|
||
|
||
/// 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 acadrust::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<Handle> = Vec::new();
|
||
let mut image_handles: Vec<Handle> = Vec::new();
|
||
let mut mesh_handles: Vec<Handle> = 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<Handle, HatchModel> = 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<Handle, ImageModel> = 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(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
|
||
ImageModel::from_underlay(u, def).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 = doc.header.isolines.max(0) as usize;
|
||
// 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<Handle> = doc
|
||
.objects
|
||
.values()
|
||
.filter_map(|o| match o {
|
||
acadrust::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<Handle, MeshLodSet> = HashMap::default();
|
||
let mut block_meshes: HashMap<Handle, MeshLodSet> = 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;
|
||
let cannoscale_value = scene.document.header.annotation_scale_value;
|
||
let unit_factor = scene.annotation_scale_unit_factor();
|
||
|
||
scene.annotation_scale = if cannoscale_value > 1e-9 {
|
||
((1.0 / cannoscale_value) / unit_factor) as f32
|
||
} else {
|
||
(1.0 / unit_factor) as f32
|
||
};
|
||
scene.current_layout = "Model".to_string();
|
||
let camera = scene.camera.borrow().clone();
|
||
let wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1.0);
|
||
let interaction_index = if scene.interaction_index_worthwhile(&wires) {
|
||
let index =
|
||
Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(&wires));
|
||
index.prepare_screen();
|
||
Some(index)
|
||
} else {
|
||
None
|
||
};
|
||
let doc = std::mem::replace(&mut scene.document, CadDocument::new());
|
||
(
|
||
doc,
|
||
PreparedOpenGeometry {
|
||
wires,
|
||
interaction_index,
|
||
},
|
||
)
|
||
}
|
||
|
||
/// 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<rustc_hash::FxHashSet<Handle>>,
|
||
any_enumerated: bool,
|
||
owned_by_other_block: rustc_hash::FxHashSet<Handle>,
|
||
}
|
||
|
||
fn offset_prep(doc: &acadrust::CadDocument, model_block: Handle) -> OffsetPrep {
|
||
let model_br = doc.block_records.iter().find(|br| br.handle == model_block);
|
||
let mspace_set: Option<rustc_hash::FxHashSet<Handle>> = 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<Handle> = 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: &acadrust::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>| -> 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<f64>, 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<f64> = centers.iter().map(|c| c[0]).collect();
|
||
let mut ys: Vec<f64> = centers.iter().map(|c| c[1]).collect();
|
||
let mx = median(&mut xs);
|
||
let my = median(&mut ys);
|
||
let mut dx: Vec<f64> = centers.iter().map(|c| (c[0] - mx).abs()).collect();
|
||
let mut dy: Vec<f64> = 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<ViewportInstance>` 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<usize>,
|
||
/// Screen rectangle (pixels, canvas-relative) this viewport fills.
|
||
pub screen_rect: iced::Rectangle,
|
||
pub camera: Camera,
|
||
pub render_mode: acadrust::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: acadrust::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<acadrust::entities::Viewport>,
|
||
},
|
||
}
|
||
|
||
/// 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: &acadrust::types::Transform,
|
||
) -> MeshLodSet {
|
||
use acadrust::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,
|
||
}
|
||
|
||
impl NavPerfOp {
|
||
pub(in crate::scene) fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Pan => "pan",
|
||
Self::Zoom => "zoom",
|
||
Self::Rotate => "rotate",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub(in crate::scene) struct NavPerfSample {
|
||
pub(in crate::scene) op: NavPerfOp,
|
||
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<String>,
|
||
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,
|
||
}
|
||
|
||
pub struct Scene {
|
||
pub camera: Rc<RefCell<Camera>>,
|
||
/// 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<ViewSnapshot>,
|
||
/// Model-space tiled viewport layout. One full-window tile by default;
|
||
/// the split buttons / VPORTS subdivide the active tile.
|
||
pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
|
||
/// Index of the active model tile (camera input + overlays target it).
|
||
pub(crate) active_model_tile: std::cell::Cell<usize>,
|
||
/// 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<HashMap<u64, std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>>>>,
|
||
/// 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<HashMap<u64, Arc<Vec<WireModel>>>>,
|
||
/// 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<Vec<crate::scene::pipeline::text_gpu::TextVertex>>,
|
||
),
|
||
>,
|
||
>,
|
||
/// 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<usize>,
|
||
/// 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<std::sync::atomic::AtomicU32>,
|
||
pub selection: Rc<RefCell<SelectionState>>,
|
||
/// The CAD document — single source of truth for all entities.
|
||
pub document: CadDocument,
|
||
/// 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<HashMap<(Handle, u64), Vec<SceneLight>>>,
|
||
/// Currently selected entity handles.
|
||
pub selected: HashSet<Handle>,
|
||
selected_order: Vec<Handle>,
|
||
/// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF.
|
||
pub object_isolation: ObjectIsolationState,
|
||
/// 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<Handle>,
|
||
/// 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<Handle>,
|
||
/// 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<HashSet<Handle>>,
|
||
/// 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<Handle>,
|
||
/// 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,
|
||
/// Selection filter: entity-type names excluded from interactive picking.
|
||
/// Empty = every type is selectable.
|
||
pub selection_filter: HashSet<String>,
|
||
/// In-progress preview wires while a command is active (rubber-band + object ghosts).
|
||
pub preview_wires: Vec<WireModel>,
|
||
/// 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<Vec<HatchModel>>,
|
||
/// 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<crate::scene::pipeline::text_gpu::TextVertex>,
|
||
/// Committed-segment wire drawn during multi-point commands (normal colour).
|
||
pub interim_wire: Option<WireModel>,
|
||
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 switches refresh those bounds through the same
|
||
/// filtered fit path when this falls behind `geometry_epoch`.
|
||
projection_bounds_epoch: std::cell::Cell<u64>,
|
||
/// 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 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<Option<((u64, u64), u64, Arc<Vec<WireModel>>)>>,
|
||
/// 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<Vec<WireModel>>,
|
||
Arc<crate::scene::pick::interaction_index::InteractionIndex>,
|
||
)>,
|
||
>,
|
||
/// 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<Option<(u64, usize)>>,
|
||
/// 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<crate::scene::pick::interaction_index::InteractionIndex>,
|
||
)>,
|
||
>,
|
||
/// 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<Vec<WireModel>>,
|
||
Arc<crate::scene::pick::interaction_index::InteractionIndex>,
|
||
)>,
|
||
>,
|
||
/// Auxiliary broad phase for hatch-only objects.
|
||
interaction_handle_index_cache: RefCell<
|
||
Option<(
|
||
u64,
|
||
u64,
|
||
Arc<crate::scene::pick::interaction_index::InteractionHandleIndex>,
|
||
)>,
|
||
>,
|
||
/// 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<Option<(u64, HashMap<Handle, HashMap<u64, u64>>)>>,
|
||
/// 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<Option<DrawDepthCache>>,
|
||
/// 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<HashMap<u64, [f32; 2]>>,
|
||
/// 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<HashMap<(Handle, String), (u64, u64, Arc<Vec<HatchModel>>)>>,
|
||
/// Cached wipeout fill models, keyed by target block and geometry_epoch. Same
|
||
/// reasoning as `hatch_cache`.
|
||
wipeout_cache: RefCell<HashMap<(Handle, String), (u64, Arc<Vec<HatchModel>>)>>,
|
||
/// Cached image models, keyed by target block and geometry_epoch. No camera
|
||
/// key needed here.
|
||
image_cache: RefCell<HashMap<Handle, (u64, Arc<Vec<ImageModel>>)>>,
|
||
/// Cached mesh models, keyed by target block and geometry_epoch.
|
||
mesh_cache: RefCell<HashMap<(Handle, String), (u64, Arc<Vec<MeshLodSet>>)>>,
|
||
/// 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<Option<(u64, u64, Arc<Vec<MeshLodSet>>)>>,
|
||
/// 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<Vec<MeshLodSet>>,
|
||
Arc<HashMap<Handle, Vec<u32>>>,
|
||
)>,
|
||
>,
|
||
/// 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<HashMap<(Handle, String, u64), (u64, u64, Arc<Vec<HatchModel>>)>>,
|
||
frozen_wipeout_cache:
|
||
RefCell<HashMap<(Handle, String, u64), (u64, Arc<Vec<HatchModel>>)>>,
|
||
frozen_image_cache: RefCell<HashMap<(Handle, u64), (u64, Arc<Vec<ImageModel>>)>>,
|
||
frozen_mesh_cache: RefCell<HashMap<(Handle, String, u64), (u64, Arc<Vec<MeshLodSet>>)>>,
|
||
/// 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<Option<(u64, HashSet<Handle>)>>,
|
||
/// 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<Option<(u64, u64, Arc<HashMap<Handle, Vec<HatchModel>>>)>>,
|
||
/// 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<HashMap<String, (u64, u64, Arc<Vec<WireModel>>)>>,
|
||
/// 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<HashMap<String, PaperViewportCache>>,
|
||
/// 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<HashMap<String, PaperSheetRenderCache>>,
|
||
/// Per-viewport projected wire cache for paper-space content viewports.
|
||
/// Stores projected + clipped wires in paper-space coordinates.
|
||
/// Maps vp_handle → (geometry_epoch, Vec<WireModel>).
|
||
paper_projected_cache: RefCell<HashMap<Handle, (u64, Vec<WireModel>)>>,
|
||
/// 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<Handle>,
|
||
/// 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<Handle, HatchModel>,
|
||
/// 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<Handle, MeshLodSet>,
|
||
/// Directory of the opened drawing, used to resolve relative AcDbMaterial
|
||
/// bitmap maps. `None` for unsaved drawings and browser uploads.
|
||
pub material_base_dir: Option<std::path::PathBuf>,
|
||
/// 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<Handle, MeshLodSet>,
|
||
/// Live B-reps for solids created this session by the Model tab, keyed by
|
||
/// entity handle. Backs the Design-group boolean tools (a solid must be
|
||
/// here to be combined) and the exact-geometry save path, which writes
|
||
/// each one back out as ACIS rather than as facets. Not persisted —
|
||
/// rebuilt only by creating or combining primitives in-session.
|
||
pub solid_models: HashMap<Handle, cadkernel::brep::Body>,
|
||
/// GPU render data for raster images (RasterImage entities), keyed by handle.
|
||
pub images: HashMap<Handle, ImageModel>,
|
||
/// The viewport that is currently "entered" (MSPACE mode).
|
||
/// `None` = paper space editing (PSPACE). Only meaningful when
|
||
/// `current_layout != "Model"`.
|
||
pub active_viewport: Option<Handle>,
|
||
/// 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],
|
||
/// Largest local-space coordinate expected from real geometry, derived from
|
||
/// EXTMIN/EXTMAX (10× safety margin). Used by fit_all() to ignore garbage
|
||
/// entity coordinates (origin-stuck entities, bad Ray/XLine direction vectors).
|
||
pub local_extent_max: f32,
|
||
/// Robust centre (median of entity centroids) of the dense model-space
|
||
/// cluster. Used together with `local_extent_max` to frame a viewport whose
|
||
/// saved view is missing — aiming at the raw extents centre would land in
|
||
/// the empty gap when a drawing has a second, far cluster.
|
||
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<Option<(u64, bool)>>,
|
||
/// 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<Option<(u64, Option<(glam::Vec3, glam::Vec3)>)>>,
|
||
/// 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<Option<(u64, HashMap<Handle, Handle>)>>,
|
||
/// 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<Option<SceneDependencyIndex>>,
|
||
/// Boundary source → associative hatch handles. Source edits reuse this
|
||
/// index instead of scanning the document during every drag step.
|
||
associative_hatch_source_cache: RefCell<Option<HashMap<Handle, Vec<Handle>>>>,
|
||
/// 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<HashMap<u64, (u64, Arc<cache::block_cache::BlockCache>)>>,
|
||
/// 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<Option<(u64, EntityIndex)>>,
|
||
/// 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<f32>,
|
||
/// 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<f32>,
|
||
/// 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<Option<usize>>,
|
||
/// 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<rustc_hash::FxHashSet<u64>>,
|
||
/// 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<f32>,
|
||
/// Wire count produced by that most recent re-tessellation.
|
||
pub(crate) last_tess_wires: std::cell::Cell<usize>,
|
||
/// 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<u64>,
|
||
/// 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<u64>,
|
||
nav_changed_at: std::cell::Cell<Option<iced::time::Instant>>,
|
||
/// 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<Option<NavPerfSample>>,
|
||
/// 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<HashMap<u64, ResidentWireSet>>,
|
||
/// 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<HashMap<u64, (Arc<Vec<WireModel>>, Option<Arc<Vec<WireModel>>>)>>,
|
||
/// 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<HashMap<Handle, Arc<Vec<WireModel>>>>,
|
||
/// 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<u64>,
|
||
/// Per-entity memo for the **resident** model wire set (`model_tile_wires_arc`,
|
||
/// the one the main GPU render holds). Kept separate from `tess_memo` because
|
||
/// the resident set is camera-INDEPENDENT (no view cull, no zoom LOD), so its
|
||
/// guard depends only on anno-scale / background — it survives pan/zoom, and a
|
||
/// single-entity edit re-tessellates just the changed entity instead of the
|
||
/// whole model. Sharing `tess_memo` would let the camera-dependent culled path
|
||
/// thrash it on every zoom. (#perf)
|
||
resident_tess_memo: RefCell<HashMap<Handle, Arc<Vec<WireModel>>>>,
|
||
/// Guard hash for `resident_tess_memo` (anno-scale / bg only).
|
||
resident_tess_guard: std::cell::Cell<u64>,
|
||
/// 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<std::collections::VecDeque<GeometryDelta>>,
|
||
/// Before-category hints staged by erase/history immediately before the
|
||
/// entity disappears, then consumed by the matching geometry delta.
|
||
pending_removed_categories: RefCell<HashMap<Handle, u16>>,
|
||
/// 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<u64>,
|
||
/// 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<u64>,
|
||
/// 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<Option<(u64, u64, Arc<WireGpuPatch>)>>,
|
||
/// 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<UndoRecording>,
|
||
}
|
||
|
||
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: acadrust::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: Rc::new(RefCell::new(SelectionState::default())),
|
||
document: CadDocument::new(),
|
||
object_data_cache: crate::entities::object_data::ObjectDataCache::default(),
|
||
lighting_cache: RefCell::new(HashMap::default()),
|
||
selected: HashSet::default(),
|
||
selected_order: Vec::new(),
|
||
object_isolation: ObjectIsolationState::default(),
|
||
preview_hidden: HashSet::default(),
|
||
command_preview_hidden: HashSet::default(),
|
||
refedit_keep: None,
|
||
hover_highlight: None,
|
||
transparency_display: true,
|
||
selection_filter: HashSet::default(),
|
||
preview_wires: vec![],
|
||
preview_hatches: Arc::new(Vec::new()),
|
||
preview_text: vec![],
|
||
interim_wire: None,
|
||
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,
|
||
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),
|
||
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()),
|
||
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()),
|
||
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),
|
||
dependency_index_cache: RefCell::new(None),
|
||
associative_hatch_source_cache: RefCell::new(None),
|
||
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),
|
||
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_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.
|
||
pub(super) fn world_per_pixel(&self) -> Option<f32> {
|
||
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<Handle>,
|
||
all_visible: bool,
|
||
viewport: Option<Handle>,
|
||
) -> Arc<cache::block_cache::BlockCache> {
|
||
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) {
|
||
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: None,
|
||
},
|
||
);
|
||
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<Vec<(Handle, ChangeKind)>> {
|
||
// 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<Handle, ChangeKind> = 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())
|
||
}
|
||
|
||
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;
|
||
}
|
||
// 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(_));
|
||
if self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) || is_insert
|
||
{
|
||
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
|
||
}
|
||
|
||
/// 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) and from block
|
||
/// references (their baked text moves with the instance) — an edit to any of
|
||
/// those, or any removal, invalidates it; a plain line / arc / polyline edit
|
||
/// does not.
|
||
/// 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()
|
||
}
|
||
|
||
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<UndoRecording> {
|
||
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.
|
||
pub(crate) fn record_undo_before(&mut self, handle: Handle, before: Option<Arc<EntityType>>) {
|
||
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<ObjectType>,
|
||
) {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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<Arc<EntityType>>, Option<Arc<EntityType>>)],
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !changes.is_empty() {
|
||
self.invalidate_dependency_index();
|
||
}
|
||
|
||
changes
|
||
}
|
||
|
||
pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) {
|
||
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();
|
||
for change in self.refresh_associative_hatches(&changes) {
|
||
if !changes.iter().any(|(handle, _)| *handle == change.0) {
|
||
changes.push(change);
|
||
}
|
||
}
|
||
if !changes.is_empty() {
|
||
// A Modified delta can change layer/style/block references as well
|
||
// as coordinates. Rebuild the reverse dependency map lazily on its
|
||
// next use so later targeted global-property updates never follow
|
||
// stale ownership.
|
||
self.invalidate_dependency_index();
|
||
}
|
||
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();
|
||
}
|
||
let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||
self.geometry_epoch = epoch;
|
||
{
|
||
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<Handle> = 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: &acadrust::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;
|
||
// A full structural change may move lights between blocks or alter
|
||
// layer visibility without naming the affected handles.
|
||
self.lighting_cache.borrow_mut().clear();
|
||
// 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);
|
||
// Structural change — drop both per-entity tessellation memos.
|
||
self.tess_memo.borrow_mut().clear();
|
||
self.resident_tess_memo.borrow_mut().clear();
|
||
// 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.lighting_cache.borrow_mut().clear();
|
||
self.push_geometry_delta(epoch, Vec::new(), true);
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
/// 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) {
|
||
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,
|
||
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<NavPerfSample> {
|
||
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<bool> = 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
|
||
})
|
||
}
|
||
|
||
/// Cache and display a Model-tab kernel solid.
|
||
pub fn register_solid_model(&mut self, handle: Handle, solid: cadkernel::brep::Body) {
|
||
let color = self
|
||
.document
|
||
.get_entity(handle)
|
||
.map(|e| self.render_style(e).0)
|
||
.unwrap_or([0.8, 0.8, 0.85, 1.0]);
|
||
if let Some((mut set, wires, center)) =
|
||
crate::scene::model::solid_model::display_from_solid(&solid, color)
|
||
{
|
||
if let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) {
|
||
entity.point_of_reference = acadrust::types::Vector3::new(
|
||
center[0], center[1], center[2],
|
||
);
|
||
entity.wires = wires;
|
||
entity.silhouettes.clear();
|
||
}
|
||
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,
|
||
);
|
||
}
|
||
self.meshes.insert(handle, set);
|
||
} else {
|
||
self.meshes.remove(&handle);
|
||
}
|
||
self.solid_models.insert(handle, solid);
|
||
// Only this solid's mesh changed — report just its handle so the mesh /
|
||
// wire caches patch it in rather than rebuilding the whole drawing (and
|
||
// so a bulk register loop stays O(n), not O(n²)).
|
||
self.bump_entities(&[(handle, ChangeKind::Modified)]);
|
||
}
|
||
|
||
/// `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<Handle, crate::scene::model::material_model::MeshMaterial> = 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::fade_toward_bg(
|
||
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<Handle, crate::scene::model::material_model::MeshMaterial> = handles
|
||
.iter()
|
||
.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::fade_toward_bg(
|
||
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<HashSet<Handle>>) {
|
||
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();
|
||
}
|
||
|
||
/// 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.
|
||
fn apply_refedit_fade(&self, wires: &mut [WireModel], bg: [f32; 4]) {
|
||
let Some(keep) = &self.refedit_keep else {
|
||
return;
|
||
};
|
||
for w in wires.iter_mut() {
|
||
let keep_bright =
|
||
Self::handle_from_wire_name(&w.name).is_some_and(|h| keep.contains(&h));
|
||
if !keep_bright {
|
||
w.color = crate::scene::cache::block_cache::fade_toward_bg(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.current_layout = name;
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
/// Returns true if this viewport should display model-space content
|
||
/// (i.e. it is a user viewport, not the sheet/overall viewport).
|
||
///
|
||
/// Rules:
|
||
/// - id=1 → always the sheet viewport → false
|
||
/// - id≥2 → always a user viewport → true
|
||
/// - id=0 or id<0 (DWG reader omits the id; some DXF exporters write -1):
|
||
/// use geometry: the sheet viewport is centred at the paper origin (0,0)
|
||
/// with scale≈1.0 (view_height ≈ paper-space height).
|
||
pub fn is_content_viewport(vp: &acadrust::entities::Viewport) -> bool {
|
||
if vp.id == 1 {
|
||
return false;
|
||
}
|
||
if vp.id > 1 {
|
||
return true;
|
||
}
|
||
// id ≤ 0: DWG files never write group-code 69 (viewport id), so all
|
||
// viewports arrive with id=0.
|
||
//
|
||
// In DWG format the sheet ("overall") viewport always has its center at
|
||
// the paper-space origin (0, 0). Content viewports are placed at their
|
||
// actual position on the paper and therefore have a non-zero center.
|
||
// Using center position is more reliable than a scale heuristic because
|
||
// the sheet viewport's scale is not always exactly 1:1 (observed: 0.8965
|
||
// in real-world files, which the old 0.02 tolerance missed entirely).
|
||
vp.center.x.abs() >= 0.5 || vp.center.y.abs() >= 0.5
|
||
}
|
||
|
||
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(layout.viewport)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.unwrap_or(Handle::NULL)
|
||
}
|
||
|
||
/// Guarantee that a paper layout has its full-screen overall (`id == 1`)
|
||
/// sheet viewport. AutoCAD always writes one, and `add_layout` creates it,
|
||
/// but this is a safety net for layouts that arrive without it. The sheet
|
||
/// viewport is the authoritative paper-space view and the canvas every
|
||
/// floating viewport overlays.
|
||
pub fn ensure_sheet_viewport(&mut self, layout_name: &str) {
|
||
if layout_name == "Model" {
|
||
return;
|
||
}
|
||
// Locate the layout: its object handle, block-record handle, current
|
||
// sheet-viewport link, and 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, l.viewport, l.min_limits, l.max_limits));
|
||
}
|
||
}
|
||
None
|
||
});
|
||
let Some((layout_handle, block_record, cur_vp, min_lim, max_lim)) = info else {
|
||
return;
|
||
};
|
||
if block_record.is_null() {
|
||
return;
|
||
}
|
||
|
||
// Normal files carry a valid direct Layout→Viewport link. This O(1)
|
||
// path is hit on every ordinary layout-tab switch.
|
||
if cur_vp.is_valid()
|
||
&& matches!(
|
||
self.document.get_entity(cur_vp),
|
||
Some(EntityType::Viewport(vp)) if vp.common.owner_handle == block_record
|
||
)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Already present? Accept either the linked viewport handle or any
|
||
// `id == 1` viewport owned by the layout block.
|
||
let has_sheet = self.document.entities().any(|e| {
|
||
matches!(e, EntityType::Viewport(vp)
|
||
if vp.common.owner_handle == block_record
|
||
&& (vp.id == 1 || vp.common.handle == cur_vp))
|
||
});
|
||
if has_sheet {
|
||
// Keep the layout's link in sync if it was missing.
|
||
if !cur_vp.is_valid() {
|
||
let h = self.document.entities().find_map(|e| match e {
|
||
EntityType::Viewport(vp)
|
||
if vp.common.owner_handle == block_record && vp.id == 1 =>
|
||
{
|
||
Some(vp.common.handle)
|
||
}
|
||
_ => None,
|
||
});
|
||
if let Some(h) = h {
|
||
if let Some(ObjectType::Layout(l)) =
|
||
self.document.objects.get_mut(&layout_handle)
|
||
{
|
||
l.viewport = h;
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Create the full-screen overall viewport covering the paper limits.
|
||
let pw = (max_lim.0 - min_lim.0).abs().max(1.0);
|
||
let ph = (max_lim.1 - min_lim.1).abs().max(1.0);
|
||
let mut vp = acadrust::entities::Viewport::new();
|
||
vp.id = 1;
|
||
vp.status = acadrust::entities::ViewportStatusFlags::default_on();
|
||
// Paper-space center is a 2D (x, y) point with z = 0 — the same
|
||
// convention MVIEW uses for floating viewports. AutoCAD/TrueView read
|
||
// the viewport center as (x, y); putting the paper-height midpoint in z
|
||
// (with y = 0) left the sheet view centered at y = 0, shifting the whole
|
||
// layout half a page down. See issue #156.
|
||
vp.center = acadrust::types::Vector3::new(
|
||
(min_lim.0 + max_lim.0) / 2.0,
|
||
(min_lim.1 + max_lim.1) / 2.0,
|
||
0.0,
|
||
);
|
||
vp.width = pw;
|
||
vp.height = ph;
|
||
// Frame the new layout on the whole sheet: look straight down at the
|
||
// paper centre with the visible height a touch taller than the page.
|
||
// Without this the viewport keeps `Viewport::new`'s default view
|
||
// (target 0,0 / height 210), so the first time a fresh drawing's
|
||
// layout is opened the camera sits on the paper's bottom-left corner
|
||
// instead of centring the sheet.
|
||
vp.view_target = acadrust::types::Vector3::new(
|
||
(min_lim.0 + max_lim.0) / 2.0,
|
||
(min_lim.1 + max_lim.1) / 2.0,
|
||
0.0,
|
||
);
|
||
vp.view_center = acadrust::types::Vector3::ZERO;
|
||
vp.view_height = ph * 1.1;
|
||
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.viewport = handle;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn is_content_viewport_in_layout(
|
||
&self,
|
||
vp: &acadrust::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_content_viewport(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()
|
||
}
|
||
|
||
/// 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<HatchModel> {
|
||
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 {
|
||
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,
|
||
boundary_exterior: None,
|
||
boundary_sources: None,
|
||
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. AutoCAD draws this guide on every layout; with the
|
||
/// margins now preserved we can reflect it too. `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<WireModel> {
|
||
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: a margin on a physical side moves
|
||
// to the displayed side that side rotates onto.
|
||
let (ml, mb, mr, mt) = match rot {
|
||
1 | 3 => (bottom, left, top, right),
|
||
2 => (right, top, left, bottom),
|
||
_ => (left, bottom, right, top),
|
||
};
|
||
// 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 for the current layout: a standalone
|
||
/// PlotSettings page setup if one exists, otherwise the settings embedded in
|
||
/// the LAYOUT object (paper size, margins, origin, rotation, scale). Loaded
|
||
/// AutoCAD files keep their settings embedded, so without this fallback the
|
||
/// plot/PDF path would ignore the file's rotation, origin and scale.
|
||
pub fn effective_plot_settings(&self) -> Option<acadrust::objects::PlotSettings> {
|
||
self.plot_settings_for(&self.current_layout)
|
||
}
|
||
|
||
/// Plot settings for a specific layout by name: its standalone
|
||
/// `PlotSettings` object if one exists, else synthesized from the `Layout`
|
||
/// object's embedded fields.
|
||
pub fn plot_settings_for(&self, name: &str) -> Option<acadrust::objects::PlotSettings> {
|
||
use acadrust::objects::{
|
||
ObjectType, PaperMargin, PlotPaperUnits, PlotRotation, PlotSettings, PlotType,
|
||
PlotWindow, ScaledType,
|
||
};
|
||
if let Some(ps) = self.document.objects.values().find_map(|o| {
|
||
if let ObjectType::PlotSettings(ps) = o {
|
||
if ps.page_name.as_str() == name {
|
||
return Some(ps.clone());
|
||
}
|
||
}
|
||
None
|
||
}) {
|
||
return Some(ps);
|
||
}
|
||
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.name.clone());
|
||
ps.paper_width = l.paper_width;
|
||
ps.paper_height = l.paper_height;
|
||
ps.paper_size = l.paper_size.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;
|
||
Some(ps)
|
||
})
|
||
}
|
||
|
||
/// 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: &acadrust::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 ox = l.min_limits.0.min(0.0);
|
||
let oy = 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<f64> {
|
||
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<bool> {
|
||
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
|
||
}
|
||
}
|
||
/// 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
|
||
};
|
||
|
||
crate::modules::draw::units::conversion_factor(
|
||
self.document.header.insertion_units,
|
||
paper_unit,
|
||
)
|
||
.unwrap_or(1.0)
|
||
}
|
||
|
||
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::<f64>().ok()?,
|
||
drawing.trim().parse::<f64>().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::<f64>().ok()?, d.trim().parse::<f64>().ok()?))
|
||
})
|
||
.filter(|&(p, d)| p > 0.0 && d > 0.0)
|
||
.unwrap_or((1.0, 1.0 / factor));
|
||
self.add_scale(label, paper, drawing);
|
||
}
|
||
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<Handle> {
|
||
use acadrust::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)
|
||
}
|
||
|
||
/// 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<Handle> {
|
||
use acadrust::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<Handle> {
|
||
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<Handle> {
|
||
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<Handle> {
|
||
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<Handle> {
|
||
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<Handle> {
|
||
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 acadrust::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 acadrust::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<Handle>,
|
||
mode: u8,
|
||
) -> usize {
|
||
if previous_scale == Some(scale) || !(1..=4).contains(&mode) {
|
||
return 0;
|
||
}
|
||
let viewport_frozen: HashSet<Handle> = 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
|
||
}
|
||
|
||
pub(crate) fn displayed_annotation_scale_handle(&self) -> Option<Handle> {
|
||
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<Handle> {
|
||
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::<f64>().ok()?;
|
||
let drawing = drawing.trim().parse::<f64>().ok()?;
|
||
(paper > 0.0 && drawing > 0.0).then_some((paper, drawing))
|
||
})
|
||
.or_else(|| {
|
||
let drawing = name.trim().parse::<f64>().ok()?;
|
||
(drawing > 0.0).then_some((1.0, drawing))
|
||
})?;
|
||
let (paper, drawing) = self.scale_paper_drawing(name).unwrap_or(fallback);
|
||
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 acadrust::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);
|
||
}
|
||
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 acadrust::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();
|
||
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 acadrust::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();
|
||
}
|
||
}
|
||
}
|
||
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<(acadrust::Handle, String, Vec<acadrust::Handle>)> {
|
||
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<(acadrust::Handle, String, Vec<acadrust::Handle>)> = 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::<Vec<_>>()
|
||
.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)
|
||
}
|
||
|
||
/// 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<Handle>) {
|
||
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<Handle>| {
|
||
hovered
|
||
.map(|handle| scene.handles_expanded_for_selectable_groups(&[handle]))
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.filter(|handle| !scene.selected.contains(handle))
|
||
.collect::<HashSet<_>>()
|
||
};
|
||
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<Handle> {
|
||
self.hover_highlight
|
||
.map(|handle| self.handles_expanded_for_selectable_groups(&[handle]))
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// 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<Handle> = 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);
|
||
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_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();
|
||
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<Handle> {
|
||
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<Handle> {
|
||
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<Handle> {
|
||
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<Handle> {
|
||
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();
|
||
}
|
||
self.explicit_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<Handle> {
|
||
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<Handle> {
|
||
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);
|
||
|
||
self.resident_wire_sets.borrow_mut().clear();
|
||
self.bump_geometry();
|
||
|
||
Some(scale_handle)
|
||
}
|
||
|
||
pub fn viewport_annotation_scale_synced(&self) -> Option<bool> {
|
||
let viewport = self.explicit_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);
|
||
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<String> {
|
||
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<String, (i16, Handle)> = 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
|
||
}
|
||
|
||
/// Collect closed polygon outlines (world XY) from the current layout.
|
||
pub fn closed_outlines(&self) -> Vec<Vec<[f64; 2]>> {
|
||
self.entity_wires()
|
||
.iter()
|
||
.filter_map(|wire| {
|
||
let pts: Vec<[f64; 2]> = wire
|
||
.points
|
||
.iter()
|
||
.copied()
|
||
.enumerate()
|
||
.filter_map(|(index, high)| {
|
||
if !high[0].is_finite() || !high[1].is_finite() {
|
||
return None;
|
||
}
|
||
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
let point = [
|
||
high[0] as f64 + low[0] as f64,
|
||
high[1] as f64 + low[1] as f64,
|
||
];
|
||
point.iter().all(|value| value.is_finite()).then_some(point)
|
||
})
|
||
.collect();
|
||
if pts.len() < 4 {
|
||
return None;
|
||
}
|
||
let f = pts.first()?;
|
||
let l = pts.last()?;
|
||
let dx = f[0] - l[0];
|
||
let dy = f[1] - l[1];
|
||
if (dx * dx + dy * dy).sqrt() > 1e-2 {
|
||
return None;
|
||
}
|
||
// Segment-list wires (e.g. LwPolyline) store each segment as an
|
||
// independent NaN-separated pair, so every shared corner repeats
|
||
// (`A B | B C | C D | D A`). Collapse that back into a clean ring:
|
||
// skip the NaN separators and any vertex coincident with the
|
||
// previous one, so consumers (point-in-polygon, the hatch /
|
||
// boundary commands) see one vertex per corner — not the doubled
|
||
// ring that otherwise shows two grips at every corner.
|
||
let mut ring: Vec<[f64; 2]> = Vec::with_capacity(pts.len());
|
||
for q in pts {
|
||
if let Some(&last) = ring.last() {
|
||
if (last[0] - q[0]).abs() < 1e-4 && (last[1] - q[1]).abs() < 1e-4 {
|
||
continue;
|
||
}
|
||
}
|
||
ring.push(q);
|
||
}
|
||
// Drop a trailing vertex equal to the first — the ring is closed
|
||
// implicitly, so keeping it would be a duplicate corner.
|
||
if ring.len() > 1 {
|
||
let first = ring[0];
|
||
let last = *ring.last().unwrap();
|
||
if (first[0] - last[0]).abs() < 1e-4 && (first[1] - last[1]).abs() < 1e-4 {
|
||
ring.pop();
|
||
}
|
||
}
|
||
if ring.len() < 3 {
|
||
return None;
|
||
}
|
||
Some(ring)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 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).
|
||
pub(super) fn model_tile_wires_arc(
|
||
&self,
|
||
_tile_idx: usize,
|
||
_cam: &Camera,
|
||
_cam_aspect: f32,
|
||
_tile_pixel_height: f32,
|
||
) -> Arc<Vec<WireModel>> {
|
||
// 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<f32>,
|
||
annotation_scale_handle: Option<Handle>,
|
||
frozen_layers: Option<&HashSet<Handle>>,
|
||
style_viewport: Option<Handle>,
|
||
) -> Arc<Vec<WireModel>> {
|
||
// 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).
|
||
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 (region = None), no zoom LOD
|
||
// (wpp = None) — for every space, exactly like the Model static-hold.
|
||
let t_tess = iced::time::Instant::now();
|
||
let mut wires =
|
||
self.wires_for_block_culled(
|
||
block,
|
||
None,
|
||
None,
|
||
frozen_layers,
|
||
anno_scale_override,
|
||
annotation_scale_handle,
|
||
all_visible,
|
||
style_viewport,
|
||
);
|
||
// 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);
|
||
}
|
||
self.apply_refedit_fade(&mut wires, bg);
|
||
let layout = Self::resident_wire_layout(&wires);
|
||
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);
|
||
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;
|
||
}
|
||
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);
|
||
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<f32>,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
frozen_layers: Option<&HashSet<Handle>>,
|
||
style_viewport: Option<Handle>,
|
||
) -> 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));
|
||
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
|
||
}
|
||
|
||
/// 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<WireGpuPatch>)> {
|
||
match &*self.model_wire_gpu_patch.borrow() {
|
||
Some((prev, new, patch)) if *new == gen => Some((*prev, Arc::clone(patch))),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn resident_wire_layout(wires: &[WireModel]) -> Option<ResidentWireLayout> {
|
||
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<f32>,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
frozen_layers: Option<&HashSet<Handle>>,
|
||
style_viewport: Option<Handle>,
|
||
) -> Option<Arc<Vec<WireModel>>> {
|
||
if self.viewport_style_key(style_viewport) != 0 {
|
||
return None;
|
||
}
|
||
let perf = crate::perf::enabled();
|
||
let t_patch = iced::time::Instant::now();
|
||
// 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 = {
|
||
let sets = self.resident_wire_sets.borrow();
|
||
let entry = sets.get(&key)?;
|
||
if entry.epoch == self.geometry_epoch || entry.layout.is_none() {
|
||
return None;
|
||
}
|
||
let strong = Arc::strong_count(&entry.wires);
|
||
if strong != 1 {
|
||
if perf {
|
||
crate::perf_record!("[perf] resident-shared strong={strong}");
|
||
}
|
||
return None;
|
||
}
|
||
entry.epoch
|
||
};
|
||
let deltas = self.replay_since(cached_epoch)?;
|
||
|
||
// 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, mut owned, mut layout) = {
|
||
let removed = self.resident_wire_sets.borrow_mut().remove(&key)?;
|
||
(
|
||
removed.gen,
|
||
Arc::try_unwrap(removed.wires).ok()?,
|
||
removed.layout?,
|
||
)
|
||
};
|
||
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<Handle> = HashSet::default();
|
||
let mut new_runs: HashMap<Handle, Vec<WireModel>> = HashMap::default();
|
||
let mut memo_updates: Vec<(Handle, Arc<Vec<WireModel>>)> = Vec::new();
|
||
let mut visible_changed: HashSet<Handle> = HashSet::default();
|
||
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 raw = tessellate_entity(
|
||
&self.document,
|
||
&empty_sel,
|
||
style_viewport.or(self.active_viewport),
|
||
bg,
|
||
anno,
|
||
annotation_scale_handle,
|
||
e,
|
||
Some(&blk),
|
||
None,
|
||
None,
|
||
anno_scale_override.is_some(),
|
||
);
|
||
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, 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())
|
||
};
|
||
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) && !layout.order.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<Handle, Arc<Vec<WireModel>>> = 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;
|
||
}
|
||
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<Handle> = 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(owned);
|
||
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,
|
||
}),
|
||
));
|
||
}
|
||
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<Vec<WireModel>> {
|
||
{
|
||
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<Vec<WireModel>> {
|
||
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<Vec<WireModel>> {
|
||
self.entity_wires_arc()
|
||
}
|
||
|
||
/// 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<acadrust::entities::ViewportRenderMode>,
|
||
) -> (Vec<WireModel>, Vec<WireModel>) {
|
||
let apply_mode = |wires: &mut Vec<WireModel>, 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 = self.entity_wires_arc().as_ref().clone();
|
||
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.status.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);
|
||
}
|
||
(self.paper_sheet_wires_arc().as_ref().clone(), model_wires)
|
||
}
|
||
|
||
/// 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.
|
||
pub(super) fn draw_depth_map(&self) -> Arc<HashMap<u64, [f32; 2]>> {
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
use acadrust::objects::ObjectType;
|
||
// Per-block SortEntitiesTable overrides: block -> (entity_val -> sort_val).
|
||
let mut overrides: HashMap<Handle, HashMap<u64, u64>> = 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<Handle, Vec<(u64, u64)>> = 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<u64, [f32; 2]> = HashMap::default();
|
||
let mut owners: HashMap<u64, Handle> = HashMap::default();
|
||
let mut blocks: HashMap<Handle, Vec<DrawDepthEntry>> = 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
|
||
}
|
||
|
||
pub(super) fn hatch_models_arc(&self) -> Arc<Vec<HatchModel>> {
|
||
// 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 = self.selected_hatch_sig();
|
||
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,
|
||
));
|
||
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<Vec<HatchModel>> {
|
||
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))
|
||
// wipeout_models scans the whole document for Wipeout
|
||
// entities; relevance = the changed handle is a Wipeout.
|
||
if self.category_cache_valid(
|
||
*cached_epoch,
|
||
CACHE_CATEGORY_WIPEOUT,
|
||
|h| {
|
||
matches!(
|
||
self.document.get_entity(h),
|
||
Some(EntityType::Wipeout(_))
|
||
)
|
||
},
|
||
) =>
|
||
{
|
||
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<Vec<ImageModel>> {
|
||
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<Handle>>,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
viewport: Option<Handle>,
|
||
) -> Vec<ImageModel> {
|
||
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_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| {
|
||
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 = acadrust::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 = acadrust::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(placed);
|
||
},
|
||
);
|
||
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<Vec<ImageModel>> {
|
||
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<Vec<MeshLodSet>> {
|
||
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)
|
||
|| matches!(
|
||
self.document.get_entity(h),
|
||
Some(EntityType::Insert(_))
|
||
)
|
||
},
|
||
) =>
|
||
{
|
||
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<Vec<MeshLodSet>> {
|
||
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<Handle> = 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<Vec<MeshLodSet>>) -> Arc<HashMap<Handle, Vec<u32>>> {
|
||
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<Handle, Vec<u32>> = 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<Handle>>,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
viewport: Option<Handle>,
|
||
) -> Vec<MeshLodSet> {
|
||
// 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<MeshLodSet> = 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)
|
||
})
|
||
.map(|(&handle, set)| {
|
||
let mut set = 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];
|
||
}
|
||
}
|
||
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,
|
||
));
|
||
all
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// 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<Handle>>) -> 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<Handle>) -> 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<Handle>) -> 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 acadrust::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<Handle>,
|
||
) -> Arc<Vec<HatchModel>> {
|
||
if viewport.is_null() && frozen.is_empty() {
|
||
return self.hatch_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)
|
||
^ 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 = self.selected_hatch_sig();
|
||
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),
|
||
));
|
||
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<Handle>,
|
||
) -> Arc<Vec<HatchModel>> {
|
||
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
|
||
}
|
||
|
||
/// Image / OLE models for a content viewport, with its frozen layers removed.
|
||
pub(super) fn images_for_viewport(
|
||
&self,
|
||
viewport: Handle,
|
||
frozen: &HashSet<Handle>,
|
||
) -> Arc<Vec<ImageModel>> {
|
||
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<Handle>,
|
||
) -> Arc<Vec<MeshLodSet>> {
|
||
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. Locked objects stay
|
||
/// visible, snappable and selectable, but mutation paths must skip them.
|
||
pub fn is_layer_locked(&self, handle: Handle) -> bool {
|
||
self.document
|
||
.get_entity(handle)
|
||
.map(|e| e.common().layer.clone())
|
||
.and_then(|name| self.document.layers.get(&name).map(|l| l.is_locked()))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// The name of the locked layer `handle` sits on, if any (for messages).
|
||
pub fn locked_layer_name(&self, handle: Handle) -> Option<String> {
|
||
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<Handle>>,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
viewport: Option<Handle>,
|
||
) -> Vec<MeshLodSet> {
|
||
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_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(set) = self.block_meshes.get(&handle) else {
|
||
return;
|
||
};
|
||
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(),
|
||
);
|
||
}
|
||
transformed.instance_handle = Some(context.root_handle);
|
||
out.push(transformed);
|
||
},
|
||
);
|
||
out
|
||
}
|
||
|
||
fn mesh_inherit_for_path(
|
||
&self,
|
||
path: &[DxfInsert],
|
||
) -> Option<BlockMeshInherit> {
|
||
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).
|
||
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: &acadrust::entities::Insert,
|
||
parent: &BlockMeshInherit,
|
||
) -> BlockMeshInherit {
|
||
use acadrust::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 acadrust::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::fade_toward_bg(c, self.current_bg());
|
||
}
|
||
}
|
||
Some(c)
|
||
}
|
||
|
||
fn block_mesh_override_material(
|
||
&self,
|
||
entity: &EntityType,
|
||
inherit: Option<&BlockMeshInherit>,
|
||
) -> Option<crate::scene::model::material_model::MeshMaterial> {
|
||
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<Handle>>,
|
||
) -> HashMap<Handle, HatchModel> {
|
||
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())
|
||
}
|
||
|
||
/// Per-Insert hatch models in the current layout, keyed by the Insert
|
||
/// handle so a click on a block-internal hatch can select the parent
|
||
/// Insert; block-owned leaves resolve to their owning instance.
|
||
|
||
pub fn insert_hatches_for_click(&self) -> Arc<HashMap<Handle, Vec<HatchModel>>> {
|
||
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| {
|
||
matches!(
|
||
self.document.get_entity(handle),
|
||
Some(EntityType::Insert(_))
|
||
)
|
||
},
|
||
)
|
||
{
|
||
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<Handle, Vec<HatchModel>> = 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<String, bool> =
|
||
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<Handle> = 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(),
|
||
);
|
||
for entity in self.document.entities() {
|
||
let contextual = crate::scene::annotative::entity_for_annotation_context(
|
||
&self.document,
|
||
entity,
|
||
annotation_scale_handle,
|
||
);
|
||
let EntityType::Insert(ins) = contextual.as_ref() else {
|
||
continue;
|
||
};
|
||
if ins.common.invisible
|
||
|| self.entity_temporarily_hidden(ins.common.handle)
|
||
|| layer_hidden(&ins.common.layer)
|
||
|| crate::scene::annotative::annotative_offscale_for(
|
||
&self.document,
|
||
&ins.common,
|
||
annotation_scale_handle,
|
||
all_visible,
|
||
)
|
||
{
|
||
continue;
|
||
}
|
||
if !self.belongs_to_visible_block(
|
||
ins.common.handle,
|
||
ins.common.owner_handle,
|
||
interaction_block,
|
||
) {
|
||
continue;
|
||
}
|
||
if !render_graph::block_contains_hatch(
|
||
&self.document,
|
||
&ins.block_name,
|
||
&mut hatch_memo,
|
||
) {
|
||
continue;
|
||
}
|
||
graph.walk_insert(
|
||
ins,
|
||
ins.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(ins.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<Vec<WireModel>> {
|
||
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<Vec<WireModel>>,
|
||
) -> Option<Arc<crate::scene::pick::interaction_index::InteractionIndex>> {
|
||
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<Vec<WireModel>>,
|
||
index: Arc<crate::scene::pick::interaction_index::InteractionIndex>,
|
||
) {
|
||
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<Vec<WireModel>>,
|
||
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<Vec<WireModel>>,
|
||
index: Arc<crate::scene::pick::interaction_index::InteractionIndex>,
|
||
) -> 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<crate::scene::pick::interaction_index::InteractionIndex>,
|
||
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<Vec<WireModel>>,
|
||
Arc<crate::scene::pick::interaction_index::InteractionIndex>,
|
||
) {
|
||
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 changed_live: Vec<Handle> = changes
|
||
.iter()
|
||
.filter_map(|(handle, kind)| {
|
||
(!matches!(kind, ChangeKind::Removed)).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<Item = (u64, u32)>,
|
||
changes: &[(Handle, ChangeKind)],
|
||
changed_wires: &[WireModel],
|
||
) -> (
|
||
Arc<Vec<WireModel>>,
|
||
HashMap<(u64, u32), u32>,
|
||
HashMap<(u64, u32), u32>,
|
||
) {
|
||
let changed: HashSet<Handle> = 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<Handle, Vec<u32>> = 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;
|
||
}
|
||
if let Some(wire) = memo
|
||
.get(&handle)
|
||
.and_then(|entity_wires| entity_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<u64, u32> = HashMap::default();
|
||
for wire in changed_wires {
|
||
if let Ok(handle) = wire.name.parse::<u64>() {
|
||
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<Vec<WireModel>>,
|
||
aabb: [f64; 4],
|
||
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_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 =
|
||
base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb);
|
||
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) {
|
||
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<Vec<WireModel>>,
|
||
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<Vec<WireModel>>,
|
||
base_radius_px: f32,
|
||
) -> 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))
|
||
} else {
|
||
self.cached_interaction_index(wires)
|
||
.map_or(base_radius_px, |index| index.pick_radius_px(base_radius_px))
|
||
}
|
||
}
|
||
|
||
fn interaction_source_is_resident(
|
||
&self,
|
||
wires: &Arc<Vec<WireModel>>,
|
||
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),
|
||
}
|
||
}
|
||
|
||
fn interaction_handle_index(
|
||
&self,
|
||
) -> Arc<crate::scene::pick::interaction_index::InteractionHandleIndex> {
|
||
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<Vec<WireModel>>,
|
||
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<Vec<WireModel>>,
|
||
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<Vec<WireModel>>,
|
||
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<Vec<WireModel>>,
|
||
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);
|
||
}
|
||
let radius_px = if include_line_weight {
|
||
self.indexed_interaction_pick_radius(&wires, radius_px)
|
||
} else {
|
||
radius_px
|
||
};
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
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)
|
||
}
|
||
|
||
/// 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.
|
||
pub fn interaction_candidates_in_aabb(
|
||
&self,
|
||
wires: Arc<Vec<WireModel>>,
|
||
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);
|
||
}
|
||
if flat_ortho {
|
||
self.indexed_interaction_candidates_xy(wires, aabb, false)
|
||
} else {
|
||
self.indexed_interaction_candidates_screen(
|
||
wires,
|
||
screen_rect,
|
||
view_rot,
|
||
eye,
|
||
bounds,
|
||
false,
|
||
)
|
||
}
|
||
}
|
||
|
||
pub fn interaction_candidate_handles(
|
||
&self,
|
||
candidates: &crate::scene::pick::interaction_index::InteractionCandidates,
|
||
) -> Option<HashSet<Handle>> {
|
||
let mut handles: HashSet<Handle> = 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<Handle> {
|
||
let wires = self.hit_test_wires();
|
||
let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false);
|
||
let mut handles: HashSet<Handle> = 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<Handle>>,
|
||
) -> Vec<Handle> {
|
||
let meshes = self.interaction_meshes_arc();
|
||
let lookup = self.mesh_pick_lookup(&meshes);
|
||
let handles: Vec<Handle> = 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;
|
||
};
|
||
if indices
|
||
.iter()
|
||
.filter_map(|&index| meshes.get(index as usize))
|
||
.any(|set| {
|
||
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()
|
||
})
|
||
})
|
||
{
|
||
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<Vec<WireModel>>,
|
||
view_rot: glam::Mat4,
|
||
eye: glam::DVec3,
|
||
bounds: iced::Rectangle,
|
||
to_world: impl Fn(iced::Point) -> glam::DVec3,
|
||
) -> Vec<Handle> {
|
||
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<Handle> = 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));
|
||
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<Handle>>,
|
||
) -> Vec<Handle> {
|
||
let meshes = self.interaction_meshes_arc();
|
||
let lookup = self.mesh_pick_lookup(&meshes);
|
||
let handles: Vec<Handle> = 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;
|
||
};
|
||
if indices
|
||
.iter()
|
||
.filter_map(|&index| meshes.get(index as usize))
|
||
.any(|set| {
|
||
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()
|
||
})
|
||
})
|
||
{
|
||
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<Handle>>,
|
||
) -> Option<Handle> {
|
||
self.solid_hit(
|
||
cursor,
|
||
view_rot,
|
||
eye,
|
||
bounds,
|
||
candidate_handles,
|
||
false,
|
||
)
|
||
}
|
||
|
||
pub fn view_center_surface_pivot(
|
||
&self,
|
||
bounds: iced::Rectangle,
|
||
) -> Option<glam::DVec3> {
|
||
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();
|
||
let center = iced::Point::new(bounds.width * 0.5, bounds.height * 0.5);
|
||
pick::hit_test::mesh_click_point(
|
||
center,
|
||
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<Handle>>,
|
||
) -> Option<Handle> {
|
||
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<Handle>>,
|
||
coarse_lod: bool,
|
||
) -> Option<Handle> {
|
||
// 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<Handle>>,
|
||
) -> Vec<Handle> {
|
||
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<Handle> = 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 hit = indices
|
||
.iter()
|
||
.filter_map(|&index| meshes.get(index as usize))
|
||
.any(|set| {
|
||
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()
|
||
})
|
||
});
|
||
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<Handle>>,
|
||
) -> Vec<Handle> {
|
||
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<Handle> = 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 hit = indices
|
||
.iter()
|
||
.filter_map(|&index| meshes.get(index as usize))
|
||
.any(|set| {
|
||
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()
|
||
})
|
||
});
|
||
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<Handle>>,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
) -> 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;
|
||
}
|
||
let layer = self.document.layers.get(&c.layer);
|
||
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 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(
|
||
&self,
|
||
block_handle: Handle,
|
||
view_aabb: Option<[f32; 4]>,
|
||
wpp: Option<f32>,
|
||
// 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<Handle>>,
|
||
// 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<f32>,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
style_viewport: Option<Handle>,
|
||
) -> Vec<WireModel> {
|
||
use acadrust::objects::ObjectType;
|
||
|
||
// ── 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<Handle, HashMap<u64, u64>> = 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).
|
||
let visibility_ok = |e: &EntityType| {
|
||
self.resident_entity_visible(
|
||
e,
|
||
block_handle,
|
||
frozen_layers,
|
||
annotation_scale_handle,
|
||
all_visible,
|
||
)
|
||
};
|
||
|
||
// 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 {
|
||
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<Handle>, Vec<Handle>) = {
|
||
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);
|
||
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.
|
||
for e in self.document.entities() {
|
||
if is_unindexable_entity(e) && visibility_ok(e) {
|
||
out.push(e);
|
||
}
|
||
}
|
||
out
|
||
} else {
|
||
self.document
|
||
.entities()
|
||
.filter(|e| visibility_ok(e))
|
||
.collect()
|
||
};
|
||
|
||
// 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<Handle> = HashSet::default();
|
||
let sel: &HashSet<Handle> = &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();
|
||
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 blk_cache = self.block_cache_arc_for(annotation_scale_handle, all_visible, style_viewport);
|
||
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;
|
||
// 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<bool> = std::sync::OnceLock::new();
|
||
*EN.get_or_init(|| std::env::var("OCS_NO_RESIDENT_MEMO").is_err())
|
||
}
|
||
let resident = base_ok && view_aabb.is_none() && wpp.is_none() && resident_memo_enabled();
|
||
let memo_active = base_ok && (view_aabb.is_some() || resident);
|
||
let mut wires: Vec<WireModel> = if memo_active {
|
||
// Guard hash of everything tessellate_entity output depends on
|
||
// besides the entity itself. A mismatch (zoom/tol, view, anno,
|
||
// offset, bg, entered viewport) means the memo is stale. For the
|
||
// resident path wpp/view_aabb are None, so this collapses to anno/bg.
|
||
let guard = {
|
||
let mut g: u64 = 0xcbf2_9ce4_8422_2325;
|
||
let mut mix = |x: u64| g = g.rotate_left(13) ^ x;
|
||
mix(wpp.map(|w| w.to_bits() as u64).unwrap_or(u64::MAX));
|
||
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);
|
||
for c in bg {
|
||
mix(c.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
|
||
};
|
||
let (memo_cell, guard_cell) = if resident {
|
||
(&self.resident_tess_memo, &self.resident_tess_guard)
|
||
} else {
|
||
(&self.tess_memo, &self.tess_memo_guard)
|
||
};
|
||
if guard_cell.get() != guard {
|
||
memo_cell.borrow_mut().clear();
|
||
guard_cell.set(guard);
|
||
}
|
||
// Classify (serial, cheap): reuse memoized Arcs, collect misses.
|
||
let mut hit_arcs: Vec<Arc<Vec<WireModel>>> = 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) {
|
||
Some(a) => hit_arcs.push(Arc::clone(a)),
|
||
None => misses.push(*e),
|
||
}
|
||
}
|
||
}
|
||
// Materialize hits + tessellate misses, both in parallel.
|
||
let hit_wires: Vec<WireModel> = hit_arcs
|
||
.par_iter()
|
||
.flat_map_iter(|a| a.iter().cloned())
|
||
.collect();
|
||
let miss_pairs: Vec<(Handle, Arc<Vec<WireModel>>)> = 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,
|
||
);
|
||
(e.common().handle, Arc::new(w))
|
||
})
|
||
.collect();
|
||
let mut out = hit_wires;
|
||
{
|
||
let mut memo = memo_cell.borrow_mut();
|
||
for (h, a) in &miss_pairs {
|
||
out.extend(a.iter().cloned());
|
||
memo.insert(*h, Arc::clone(a));
|
||
}
|
||
}
|
||
out
|
||
} else {
|
||
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,
|
||
)
|
||
})
|
||
.collect()
|
||
};
|
||
|
||
// Apply draw order via the cached index (O(1) block lookup).
|
||
{
|
||
let cache = self.sort_cache.borrow();
|
||
if let Some((_, ref idx)) = *cache {
|
||
if let Some(sort_map) = idx.get(&block_handle) {
|
||
wires.sort_by_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)
|
||
});
|
||
}
|
||
}
|
||
}
|
||
wires
|
||
}
|
||
|
||
/// Decide whether an entity should be drawn as direct content of `block_handle`.
|
||
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<Handle, Handle>> {
|
||
{
|
||
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<Handle, Handle> = 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 rebuild_dependency_index(&self) -> SceneDependencyIndex {
|
||
let layout_blocks: HashSet<Handle> = self
|
||
.document
|
||
.objects
|
||
.values()
|
||
.filter_map(|object| match object {
|
||
acadrust::objects::ObjectType::Layout(layout) if !layout.block_record.is_null() => {
|
||
Some(layout.block_record)
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let block_names: HashMap<Handle, String> = self
|
||
.document
|
||
.block_records
|
||
.iter()
|
||
.map(|record| (record.handle, normalize_name(&record.name)))
|
||
.collect();
|
||
let membership: HashMap<Handle, Handle> = 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<Handle, String> = self
|
||
.document
|
||
.text_styles
|
||
.iter()
|
||
.map(|style| (style.handle, style.name.clone()))
|
||
.collect();
|
||
|
||
let mut roots: HashMap<String, HashSet<Handle>> = HashMap::default();
|
||
let mut parents: HashMap<String, HashSet<String>> = HashMap::default();
|
||
for entity in self.document.entities() {
|
||
let EntityType::Insert(insert) = entity else {
|
||
continue;
|
||
};
|
||
let target = normalize_name(&insert.block_name);
|
||
let common = &insert.common;
|
||
let owner = if common.owner_handle.is_null() {
|
||
membership
|
||
.get(&common.handle)
|
||
.copied()
|
||
.unwrap_or(Handle::NULL)
|
||
} else {
|
||
common.owner_handle
|
||
};
|
||
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 top-level INSERT 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<Handle> = 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();
|
||
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<Handle> = 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 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<String, DependencyTargets>, 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;
|
||
};
|
||
let add_handle = |map: &mut HashMap<Handle, DependencyTargets>,
|
||
handle: Option<Handle>| {
|
||
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;
|
||
};
|
||
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.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
|
||
}
|
||
|
||
fn dependency_targets(&self, kind: &str, 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 {
|
||
"layer" => &index.layers,
|
||
"text" => &index.text_styles,
|
||
"dim" => &index.dim_styles,
|
||
_ => unreachable!(),
|
||
};
|
||
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.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<Handle> = 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(&changes);
|
||
}
|
||
|
||
pub fn invalidate_layer_dependencies(&mut self, names: &[String]) {
|
||
let targets = self.dependency_targets("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("text", 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("dim", 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<IdxOp> = 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 mut items: Vec<(Handle, [f64; 4])> = Vec::new();
|
||
let mut unbounded: Vec<Handle> = 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 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 mut tree = pick::quadtree::QuadTree::new(root);
|
||
for (h, ab) in items {
|
||
tree.insert(h, ab);
|
||
}
|
||
|
||
*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)
|
||
}
|
||
|
||
/// Full tessellation pipeline for one entity.
|
||
fn tessellate_one(&self, e: &EntityType) -> Vec<WireModel> {
|
||
let bg = if self.current_layout == "Model" {
|
||
self.bg_color
|
||
} else {
|
||
self.paper_bg_color
|
||
};
|
||
let anno = if self.current_layout == "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 self.current_layout == "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,
|
||
);
|
||
// tessellate_one is used for one-off lookups (hit test, properties).
|
||
// Skip culling here so the caller always gets the full geometry.
|
||
tessellate_entity(
|
||
&self.document,
|
||
&self.selected,
|
||
self.active_viewport,
|
||
bg,
|
||
anno,
|
||
annotation_scale_handle,
|
||
e,
|
||
Some(&blk_cache),
|
||
None,
|
||
None,
|
||
self.current_layout != "Model",
|
||
)
|
||
}
|
||
|
||
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<glam::DVec3> {
|
||
if self.selected.is_empty() {
|
||
return None;
|
||
}
|
||
let block = self.current_layout_block_handle();
|
||
let scale = if self.current_layout == "Model" {
|
||
crate::scene::annotative::scale_handle_by_name(
|
||
&self.document,
|
||
&self.document.header.current_annotation_scale,
|
||
)
|
||
} else {
|
||
self.paper_annotation_scale_handle()
|
||
};
|
||
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) * 0.5)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn compute_model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> {
|
||
let model_block = self.model_space_block_handle();
|
||
if model_block.is_null() {
|
||
return None;
|
||
}
|
||
let mut min = glam::Vec3::splat(f32::INFINITY);
|
||
let mut max = glam::Vec3::splat(f32::NEG_INFINITY);
|
||
let mut any = false;
|
||
|
||
// Prefer the already-computed wire AABB cache when available — avoids re-tessellating.
|
||
if self.current_layout == "Model" {
|
||
let cache = self.wire_cache.borrow();
|
||
if let Some(((epoch, _cam_gen), _gen, ref arc)) = *cache {
|
||
if epoch == self.geometry_epoch {
|
||
for wire in arc.iter() {
|
||
let [ax, ay, bx, by] = wire.aabb;
|
||
let lo = glam::Vec3::new(ax, ay, 0.0);
|
||
let hi = glam::Vec3::new(bx, by, 0.0);
|
||
// Reject the whole AABB unless every component is finite:
|
||
// rays/xlines carry an unbounded AABB, and checking only
|
||
// x let a vertical ray's infinite y poison the extents.
|
||
if lo.is_finite() && hi.is_finite() {
|
||
min = min.min(lo);
|
||
max = max.max(hi);
|
||
any = true;
|
||
}
|
||
}
|
||
// 3D solids render as meshes, not wires, so fold their
|
||
// complete 3D boxes in too — otherwise view fitting loses
|
||
// both solid-only drawings and their depth.
|
||
for (&handle, set) in &self.meshes {
|
||
if !self.mesh_entity_visible(handle)
|
||
|| !self.document.get_entity(handle).is_some_and(|entity| {
|
||
self.belongs_to_visible_block(
|
||
handle,
|
||
entity.common().owner_handle,
|
||
model_block,
|
||
)
|
||
})
|
||
{
|
||
continue;
|
||
}
|
||
let [ax, ay, bx, by] = set.world_aabb;
|
||
let [az, bz] = set.z_aabb;
|
||
let lo = glam::Vec3::new(ax, ay, az);
|
||
let hi = glam::Vec3::new(bx, by, bz);
|
||
if lo.is_finite() && hi.is_finite() {
|
||
min = min.min(lo);
|
||
max = max.max(hi);
|
||
any = true;
|
||
}
|
||
}
|
||
return if any { Some((min, max)) } else { None };
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback: tessellate (first call or paper-space context).
|
||
// wire.key_vertices live in offset-rel coords (world_offset
|
||
// already subtracted at tessellation time). Add it back so the
|
||
// result matches Path 1 above and the caller's expectation —
|
||
// callers (auto_fit_viewport) write the centroid directly to
|
||
// `Viewport.view_target`, which is a WCS field; storing
|
||
// offset-rel coords there silently double-subtracts world_offset
|
||
// inside `camera_for_viewport` and points the viewport at the
|
||
// wrong location on UTM-scale drawings.
|
||
for entity in self.document.entities() {
|
||
let c = entity.common();
|
||
if c.owner_handle != model_block
|
||
|| c.invisible
|
||
|| self.entity_temporarily_hidden(c.handle)
|
||
{
|
||
continue;
|
||
}
|
||
for wire in self.tessellate_one(entity) {
|
||
for &[x, y, z] in &wire.key_vertices {
|
||
let v = glam::Vec3::new(x as f32, y as f32, z as f32);
|
||
// Check finiteness *after* the f32 cast: a ray/xline endpoint
|
||
// is a huge-but-finite f64 that overflows to inf in f32, which
|
||
// the f64 `is_finite` test would have let through.
|
||
if v.is_finite() {
|
||
min = min.min(v);
|
||
max = max.max(v);
|
||
any = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Same mesh inclusion for the tessellate fallback path.
|
||
for (&handle, set) in &self.meshes {
|
||
if !self.mesh_entity_visible(handle)
|
||
|| !self.document.get_entity(handle).is_some_and(|entity| {
|
||
self.belongs_to_visible_block(
|
||
handle,
|
||
entity.common().owner_handle,
|
||
model_block,
|
||
)
|
||
})
|
||
{
|
||
continue;
|
||
}
|
||
let [ax, ay, bx, by] = set.world_aabb;
|
||
let [az, bz] = set.z_aabb;
|
||
let lo = glam::Vec3::new(ax, ay, az);
|
||
let hi = glam::Vec3::new(bx, by, bz);
|
||
if lo.is_finite() && hi.is_finite() {
|
||
min = min.min(lo);
|
||
max = max.max(hi);
|
||
any = true;
|
||
}
|
||
}
|
||
if any {
|
||
return Some((min, max));
|
||
}
|
||
// Last-resort: the header's saved EXTMIN/EXTMAX. AutoCAD writes these
|
||
// on save so opening a file gives ZOOM EXTENTS a useful answer 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) {
|
||
let extents = self.model_space_extents();
|
||
let (min, max) = match extents {
|
||
Some(e) => e,
|
||
None => return,
|
||
};
|
||
let center = (min + max) * 0.5;
|
||
let content_w = (max.x - min.x).max(1e-3);
|
||
let content_h = (max.y - min.y).max(1e-3);
|
||
|
||
let vp = match self.document.get_entity_mut(vp_handle) {
|
||
Some(acadrust::EntityType::Viewport(vp)) => vp,
|
||
_ => return,
|
||
};
|
||
// Set the view target to the model-space centroid (XY plane, z=0).
|
||
vp.view_target.x = center.x as f64;
|
||
vp.view_target.y = center.y as f64;
|
||
vp.view_target.z = 0.0;
|
||
|
||
// Choose the scale that fits both dimensions with a small margin.
|
||
let margin = 1.1_f64;
|
||
let scale_w = vp.width / (content_w as f64 * margin);
|
||
let scale_h = vp.height / (content_h as f64 * 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;
|
||
}
|
||
}
|
||
|
||
impl Default for Scene {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
#[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<Vec<(Handle, ChangeKind)>>) -> Option<HashMap<Handle, ChangeKind>> {
|
||
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 acadrust::entities::Insert;
|
||
use acadrust::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 acadrust::entities::{Line, MText, Point, Text};
|
||
use acadrust::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 acadrust::entities::Line;
|
||
use acadrust::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.
|
||
#[test]
|
||
fn entity_index_incremental_matches_full_rebuild() {
|
||
use acadrust::entities::Line;
|
||
use acadrust::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<Handle> {
|
||
let idx = s.entity_index();
|
||
let mut set: HashSet<Handle> = 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<Handle> {
|
||
*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 acadrust::entities::Line;
|
||
use acadrust::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<String> {
|
||
let cam = Camera::default();
|
||
let arc = s.model_tile_wires_arc(0, &cam, 1.0, 1.0);
|
||
let names: Vec<String> = 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<String> {
|
||
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 acadrust::entities::Line;
|
||
use acadrust::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 acadrust::entities::{Insert, Line};
|
||
use acadrust::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 acadrust::entities::Line;
|
||
use acadrust::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 acadrust::entities::Line;
|
||
use acadrust::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));
|
||
}
|
||
}
|
||
|
||
/// 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 acadrust::entities::Line;
|
||
use acadrust::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<Arc<EntityType>>, Option<Arc<EntityType>>)> {
|
||
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 acadrust::entities::RasterImage;
|
||
use acadrust::objects::ObjectType;
|
||
use acadrust::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) = 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 acadrust::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) = 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: acadrust::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");
|
||
}
|
||
}
|