Now that geometry reaches the GPU/CPU as absolute coordinates via the double-single relative-to-eye path, world_offset is always [0,0,0]. Strip the parameter that was threaded through the whole tessellation / block- expansion / fallback / camera-decode chain and the ExpandCtx field that carried it — ~340 references across 20 files, all subtracting zero. - truck_tess: to_local / to_local_low / tessellate_* drop the offset arg; to_local is now a pure double-single split. - tessellate: offset_to_ds → points_to_ds (pure DS split); tessellate(), tessellate_entity(), fallback_geometry(), solid_wire_fallback(), entity_aabb(), expand_insert(), expand_block_meshes(), the dimension helpers (vec3_local, dimension_snap_pts, …), text_support, leader, multileader, image_model, xclip and camera_from_view all lose the param. - block_cache: ExpandCtx loses its world_offset field. - Remove the now-dead offset_snap_pts and the unused set_grid_snap. Behaviour is unchanged (the offset was zero everywhere); the compiler verifies every call site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8790 lines
365 KiB
Rust
8790 lines
365 KiB
Rust
// Scene modules grouped by role:
|
||
// convert — DXF/ACIS entities → truck 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 cache;
|
||
pub mod convert;
|
||
pub mod model;
|
||
pub mod pick;
|
||
pub mod pipeline;
|
||
pub mod text;
|
||
pub mod view;
|
||
|
||
/// 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>,
|
||
}
|
||
|
||
use view::camera::Camera;
|
||
pub use view::camera::Projection;
|
||
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 pipeline::uniforms::Uniforms;
|
||
pub use pipeline::viewcube::{
|
||
hit_test, hover_id, CubeRegion, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||
};
|
||
pub use pick::selection::SelectionState;
|
||
pub use model::wire_model::WireModel;
|
||
|
||
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::types::Vector2;
|
||
use acadrust::{CadDocument, EntityType, Handle, TableEntry};
|
||
use glam;
|
||
use truck_modeling::{
|
||
base::{BoundedCurve, ParameterDivision1D},
|
||
BSplineCurve as TruckBSpline, KnotVec, NurbsCurve, Point3, Vector4,
|
||
};
|
||
|
||
use iced::time::Duration;
|
||
use std::cell::RefCell;
|
||
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
||
use std::rc::Rc;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
/// 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 on each Model-tile wire re-tessellation.
|
||
/// Reused (not re-stamped) when a pan reuses the cached tessellation, so it
|
||
/// uniquely identifies a wire-buffer's *content* across frames. The GPU
|
||
/// pipeline gates wire re-upload on it (Phase 3.2): a pan that reuses the
|
||
/// tessellation keeps the same id, so 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);
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// 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 local_extent_max: f32,
|
||
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>,
|
||
/// 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,
|
||
/// 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,
|
||
}
|
||
|
||
/// Build hatch / image / mesh caches from a document without needing `&mut Scene`.
|
||
/// Intended to run on a background thread during file load.
|
||
pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
|
||
// 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();
|
||
for e in doc.entities() {
|
||
let h = e.common().handle;
|
||
match e {
|
||
EntityType::Hatch(_) | EntityType::Solid(_) => hatch_handles.push(h),
|
||
EntityType::RasterImage(_) => image_handles.push(h),
|
||
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) => {
|
||
mesh_handles.push(h)
|
||
}
|
||
_ => {}
|
||
}
|
||
if let Some(c) = offset_centroid(e, model_block, &prep) {
|
||
centers.push(c);
|
||
}
|
||
}
|
||
let (_world_offset, local_extent_max) = world_offset_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] = [0.11, 0.11, 0.11, 1.0];
|
||
|
||
// hatches
|
||
let hatches: HashMap<Handle, HatchModel> = hatch_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 model = match e {
|
||
EntityType::Hatch(dxf) => Scene::hatch_model_from_dxf(dxf, color),
|
||
EntityType::Solid(solid) => Some(Scene::solid_hatch_model(solid, color)),
|
||
_ => None,
|
||
};
|
||
model.map(|m| (handle, m))
|
||
})
|
||
.collect();
|
||
|
||
// images
|
||
let images: HashMap<Handle, ImageModel> = image_handles
|
||
.par_iter()
|
||
.filter_map(|&handle| {
|
||
if let EntityType::RasterImage(img) = doc.get_entity(handle)? {
|
||
ImageModel::from_raster_image(img).map(|m| (handle, m))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// meshes (parallel tessellation). FACETRES (header.facet_resolution)
|
||
// scales the per-LOD segment counts so users with finer drawings get
|
||
// smoother solids; clamped to AutoCAD's [0.01, 10.0] range inside.
|
||
// 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;
|
||
// 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 top_level = layout_blocks.contains(&e.common().owner_handle);
|
||
crate::entities::solid3d::tessellate_volume(e, color, facet_res).map(|m| {
|
||
let m = if top_level { offset_mesh_lod_set(m) } else { m };
|
||
(handle, m, top_level)
|
||
})
|
||
})
|
||
.collect();
|
||
let mut meshes: HashMap<Handle, MeshLodSet> = HashMap::default();
|
||
let mut block_meshes: HashMap<Handle, MeshLodSet> = HashMap::default();
|
||
for (handle, m, top_level) in built {
|
||
if top_level {
|
||
meshes.insert(handle, m);
|
||
} else {
|
||
block_meshes.insert(handle, m);
|
||
}
|
||
}
|
||
|
||
DerivedCaches {
|
||
local_extent_max,
|
||
hatches,
|
||
images,
|
||
meshes,
|
||
block_meshes,
|
||
corrupt_dropped: 0,
|
||
timings: OpenTimings::default(),
|
||
}
|
||
}
|
||
|
||
/// Mirrors `cache::block_cache::SANE_EXTENT` — wire coords past this magnitude
|
||
/// are treated as corruption rather than precision-relevant geometry.
|
||
const WORLD_OFFSET_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() > WORLD_OFFSET_SANE_EXTENT || cy.abs() > WORLD_OFFSET_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.
|
||
fn world_offset_from_centers(
|
||
centers: Vec<[f64; 3]>,
|
||
header: &acadrust::document::HeaderVariables,
|
||
) -> ([f64; 3], f32) {
|
||
const SANE_EXTENT: f64 = WORLD_OFFSET_SANE_EXTENT;
|
||
let entity_ok = !centers.is_empty();
|
||
|
||
// Median of per-entity centroids → robust drawing center.
|
||
// For local_extent_max: 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 (ecx, ecy, ecz, 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 mut zs: Vec<f64> = centers.iter().map(|c| c[2]).collect();
|
||
let mx = median(&mut xs);
|
||
let my = median(&mut ys);
|
||
let mz = median(&mut zs);
|
||
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, mz, (p95 * 2.0).max(1.0) as f32)
|
||
} else {
|
||
(0.0, 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;
|
||
|
||
// Entity-derived offset is preferred whenever it's available — the
|
||
// median-of-centroids ignores Ray/orphan/duplicate-block-defn outliers
|
||
// that the header EXTMIN/EXTMAX (a min/max midpoint) bakes in. Header
|
||
// is the fallback only when the entity scan found nothing.
|
||
// world_offset is being retired: geometry now reaches the GPU as absolute
|
||
// coordinates and the double-single relative-to-eye path keeps it precise
|
||
// even at UTM scale, so there's no longer a coarse origin to subtract.
|
||
// Always return a zero offset; only `local_extent_max` (camera/cull span)
|
||
// is still derived from the content.
|
||
let _ = (ecx, ecy, ecz);
|
||
if entity_ok {
|
||
([0.0; 3], 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;
|
||
([0.0; 3], hw.max(hh).max(hz) * 10.0)
|
||
} else {
|
||
([0.0; 3], 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,
|
||
}
|
||
|
||
/// Tolerance for matching two normalized tile coordinates as "the same"
|
||
/// edge — drag math leaves small floating-point residue.
|
||
const TILE_EPS: f32 = 1e-4;
|
||
|
||
#[derive(Copy, Clone, Debug)]
|
||
pub enum TileEdgeOrient {
|
||
Vertical,
|
||
Horizontal,
|
||
}
|
||
|
||
/// One inner divider between Model tiles, exposed by [`Scene::model_tile_edges`].
|
||
/// `coord` is the fixed axis (x for vertical, y for horizontal) and `span`
|
||
/// is the perpendicular extent over which the divider actually separates
|
||
/// tiles. All values are normalized to the 0..1 canvas.
|
||
#[derive(Clone, Debug)]
|
||
pub struct TileEdge {
|
||
pub orient: TileEdgeOrient,
|
||
pub coord: f32,
|
||
pub span: (f32, f32),
|
||
}
|
||
|
||
#[derive(Copy, Clone, Debug)]
|
||
enum ContactSide {
|
||
Left,
|
||
Right,
|
||
Top,
|
||
Bottom,
|
||
}
|
||
|
||
fn overlap_len(a: (f32, f32), b: (f32, f32)) -> f32 {
|
||
(a.1.min(b.1) - a.0.max(b.0)).max(0.0)
|
||
}
|
||
|
||
/// 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 [ox, oy, oz] = [0.0_f64; 3];
|
||
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 - ox;
|
||
let ay = v[1] as f64 + vl[1] as f64 - oy;
|
||
let az = v[2] as f64 + vl[2] as f64 - oz;
|
||
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; }
|
||
}
|
||
}
|
||
if min_x.is_finite() {
|
||
set.world_aabb = [min_x, min_y, max_x, max_y];
|
||
}
|
||
set
|
||
}
|
||
|
||
/// Instance a block-local mesh into the render frame: apply the accumulated
|
||
/// INSERT transform (block-local → world/DXF) then subtract world_offset, so a
|
||
/// block scaled at the INSERT renders at the right size. Normals are rotated by
|
||
/// the transform's linear part and re-normalized. (#123)
|
||
fn transform_block_mesh_lod_set(
|
||
set: &MeshLodSet,
|
||
xform: &acadrust::types::Transform,
|
||
) -> MeshLodSet {
|
||
use acadrust::types::Vector3;
|
||
let [ox, oy, oz] = [0.0_f64; 3];
|
||
let mut out = set.clone();
|
||
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 out.lods {
|
||
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()) {
|
||
// Reconstruct the block-local f64, apply the INSERT transform and
|
||
// subtract world_offset in f64, then re-split into (high, low).
|
||
let w = xform.apply(Vector3::new(
|
||
v[0] as f64 + vl[0] as f64,
|
||
v[1] as f64 + vl[1] as f64,
|
||
v[2] as f64 + vl[2] as f64,
|
||
));
|
||
let ax = w.x - ox;
|
||
let ay = w.y - oy;
|
||
let az = w.z - oz;
|
||
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; }
|
||
}
|
||
for n in &mut lod.normals {
|
||
let d = xform.apply_rotation(Vector3::new(n[0] as f64, n[1] as f64, n[2] as f64));
|
||
let len = (d.x * d.x + d.y * d.y + d.z * d.z).sqrt();
|
||
if len > 1e-12 {
|
||
n[0] = (d.x / len) as f32;
|
||
n[1] = (d.y / len) as f32;
|
||
n[2] = (d.z / len) as f32;
|
||
}
|
||
}
|
||
}
|
||
if min_x.is_finite() {
|
||
out.world_aabb = [min_x, min_y, max_x, max_y];
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Stable hash of a `Camera`'s pose for use as a per-tile cache key.
|
||
/// Two cameras with bit-identical target / rotation / distance hash the
|
||
/// same; any orbit / pan / zoom on a tile bumps it.
|
||
/// Hash of the camera state that affects *tessellation output* on the Model
|
||
/// tile, deliberately EXCLUDING the pan target. Rotation governs the
|
||
/// projection; `wpp` (world-per-pixel) governs the zoom-adaptive curve tol
|
||
/// and the sub-pixel LOD cull. Two cameras with the same value differ only by
|
||
/// pan, so the same tessellation is valid for both as long as the new view
|
||
/// still fits inside the region the wires were culled to (see
|
||
/// `model_tile_wires_arc`). Target is the one varying input on a pure pan.
|
||
fn camera_pan_invariant_hash(c: &Camera, wpp: Option<f32>) -> u64 {
|
||
fn h(state: u64, x: f32) -> u64 {
|
||
state.rotate_left(13) ^ x.to_bits() as u64
|
||
}
|
||
let mut s: u64 = 0x9e37_79b9_7f4a_7c15;
|
||
s = h(s, c.rotation.x);
|
||
s = h(s, c.rotation.y);
|
||
s = h(s, c.rotation.z);
|
||
s = h(s, c.rotation.w);
|
||
s = h(s, wpp.unwrap_or(-1.0));
|
||
s
|
||
}
|
||
|
||
/// `outer` fully contains `inner` (both `[min_x, min_y, max_x, max_y]`).
|
||
#[inline]
|
||
fn aabb_contains(outer: [f32; 4], inner: [f32; 4]) -> bool {
|
||
outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3]
|
||
}
|
||
|
||
/// World-XY rectangle the model camera currently sees, expanded by `margin`
|
||
/// (1.0 = tight), as `[min_x, min_y, max_x, max_y]` for the entity R-tree cull.
|
||
///
|
||
/// The screen is a rectangle in the camera's right/up basis, not the world
|
||
/// axes — under a view twist or yaw that rectangle is rotated in world XY, so
|
||
/// projecting its four corners and taking their bounds gives the correct
|
||
/// enclosing box. A naive `target ± (w, h)` box (world-axis aligned) under-
|
||
/// covers a rotated view and culls the geometry that lands in the rotated
|
||
/// corners.
|
||
///
|
||
/// Returns `None` for a tilted (non-plan) view, where the view direction is
|
||
/// not vertical and a flat XY box cannot bound the visible region (depth
|
||
/// collapses onto the plane); callers then skip the frustum cull rather than
|
||
/// wrongly hide geometry.
|
||
fn view_cull_aabb(cam: &Camera, aspect: f32, margin: f32) -> Option<[f32; 4]> {
|
||
// Plan view ⇔ line of sight is (near) vertical. Anything else can't be
|
||
// bounded by a single world-XY rectangle.
|
||
let fwd = cam.rotation * glam::Vec3::Z;
|
||
if fwd.z.abs() < 0.999 {
|
||
return None;
|
||
}
|
||
let h = cam.ortho_size();
|
||
let w = h * aspect.max(0.01);
|
||
let right = cam.rotation * glam::Vec3::X;
|
||
let up = cam.rotation * glam::Vec3::Y;
|
||
let c = cam.target.as_vec3();
|
||
let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY);
|
||
let (mut max_x, mut max_y) = (f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||
for (sw, sh) in [(-w, -h), (w, -h), (-w, h), (w, h)] {
|
||
let p = c + right * sw + up * sh;
|
||
min_x = min_x.min(p.x);
|
||
max_x = max_x.max(p.x);
|
||
min_y = min_y.min(p.y);
|
||
max_y = max_y.max(p.y);
|
||
}
|
||
let (cx, cy) = ((min_x + max_x) * 0.5, (min_y + max_y) * 0.5);
|
||
let (hw, hh) = ((max_x - min_x) * 0.5 * margin, (max_y - min_y) * 0.5 * margin);
|
||
Some([cx - hw, cy - hh, cx + hw, cy + hh])
|
||
}
|
||
|
||
pub struct Scene {
|
||
pub camera: Rc<RefCell<Camera>>,
|
||
/// 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>,
|
||
pub selection: Rc<RefCell<SelectionState>>,
|
||
/// The CAD document — single source of truth for all entities.
|
||
pub document: CadDocument,
|
||
/// Currently selected entity handles.
|
||
pub selected: HashSet<Handle>,
|
||
/// Entity handles hidden by Isolate / Hide. Empty = nothing hidden.
|
||
/// `tessellate_block`'s visibility test skips these, so they neither
|
||
/// render nor hit-test until isolation ends.
|
||
pub 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>,
|
||
/// 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,
|
||
/// 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.
|
||
wire_cache: RefCell<Option<((u64, u64), Arc<Vec<WireModel>>)>>,
|
||
/// Per-Model-tile cached tessellation. Each tile has its own camera
|
||
/// (live for the active tile, stored snapshot for the others), so
|
||
/// LOD / frustum culling has to run independently — the shared
|
||
/// `wire_cache` would cull every tile against whichever camera was
|
||
/// current when it was last built. Keyed by tile index; the value
|
||
/// Carries `(geometry_epoch, pan_invariant_hash, tessellated_region)`.
|
||
/// A hit needs the epoch + the rotation/tol signature to match AND the
|
||
/// tile's current visible view to still fit inside the region the wires
|
||
/// were culled to — so a pure pan within that 1.25×-margin region reuses
|
||
/// the tessellation instead of rebuilding it. Zoom / orbit / edits change
|
||
/// the epoch or signature and rebuild as before.
|
||
model_tile_wire_cache:
|
||
RefCell<HashMap<usize, ((u64, u64, [f32; 4]), u64, Arc<Vec<WireModel>>)>>,
|
||
/// 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 normalized draw-order depth in (0,1), keyed by
|
||
/// entity_handle.value(). Higher = drawn on top. Built once per
|
||
/// geometry epoch by ranking every entity within its owning block by
|
||
/// effective sort key (SortEntitiesTable override or own handle), then
|
||
/// fed to the 2D pipelines as a small clip-z bias so entities of
|
||
/// *different* types order correctly against each other. 3D meshes are
|
||
/// excluded (they keep real geometric depth).
|
||
draw_depth_cache: RefCell<Option<(u64, Arc<HashMap<u64, f32>>)>>,
|
||
/// Cached hatch fill models, keyed by 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<Option<(u64, u64, Arc<Vec<HatchModel>>)>>,
|
||
/// Cached wipeout fill models, keyed by geometry_epoch. Same
|
||
/// reasoning as `hatch_cache`.
|
||
wipeout_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
|
||
/// Cached image models, keyed by geometry_epoch. Images do their own
|
||
/// per-frame culling in the GPU pipeline (vp_scissor); no camera key
|
||
/// needed here.
|
||
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
|
||
/// Cached mesh models, keyed by geometry_epoch.
|
||
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshLodSet>>)>>,
|
||
/// Per-viewport wire cache for paper-space rendering.
|
||
/// Maps vp_handle → (geometry_epoch, Arc<Vec<WireModel>>).
|
||
viewport_wire_cache: RefCell<HashMap<Handle, ((u64, u32, u64), Arc<Vec<WireModel>>)>>,
|
||
/// Cached tessellation of paper-space layout block entities (title block, annotations, etc.).
|
||
/// Separate from `wire_cache` so the GPU sheet viewport doesn't re-tessellate every frame.
|
||
/// Keyed by `(geometry_epoch, camera_generation)` — paper view changes
|
||
/// on zoom too, so culled wire output depends on camera.
|
||
paper_sheet_cache: RefCell<Option<((u64, u64), Arc<Vec<WireModel>>)>>,
|
||
/// 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,
|
||
/// 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 (truck Shell/Solid tessellation).
|
||
/// Top-level (layout-owned) solids only, stored in the offset-relative
|
||
/// render frame and drawn flat.
|
||
pub meshes: HashMap<Handle, MeshLodSet>,
|
||
/// 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 truck 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). Not persisted — rebuilt only by creating
|
||
/// or combining primitives in-session.
|
||
pub solid_models: HashMap<Handle, truck_modeling::Solid>,
|
||
/// 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,
|
||
/// 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 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>)>>,
|
||
/// Tessellated block definitions in block-local coords, keyed by geometry_epoch.
|
||
/// Lets Insert tessellation transform-copy cached wires instead of
|
||
/// clone+explode+re-tessellate per reference.
|
||
block_defn_cache: RefCell<Option<(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>>,
|
||
/// Wall time (ms) of the most recent wire re-tessellation — the work done
|
||
/// 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-tile wire set returned by
|
||
/// the most recent `model_tile_wires_arc` call — stamped on a miss, reused
|
||
/// on a pan-hit. `build_primitive` reads it right after the call to gate
|
||
/// GPU wire re-upload (Phase 3.2). 0 = none yet.
|
||
pub(crate) last_model_wire_gen: std::cell::Cell<u64>,
|
||
/// Monotonic per-build nonce for wire sources that must NOT be skipped by
|
||
/// the Phase 3.2 upload gate — the paper / per-viewport wire paths and any
|
||
/// frame carrying live preview / interim wires. High bit set so it can
|
||
/// never collide with a real [`WIRE_CONTENT_GEN`] id; incremented every
|
||
/// use so the GPU always sees a fresh id and re-uploads.
|
||
pub(crate) wire_force_nonce: std::cell::Cell<u64>,
|
||
/// Memoized `(face3d, other)` split of the Model-tile 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 pan that
|
||
/// reuses the tessellation (same id) reuses this split too.
|
||
#[allow(clippy::type_complexity)]
|
||
split_cache:
|
||
RefCell<Option<(u64, Arc<Vec<WireModel>>, 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>,
|
||
}
|
||
|
||
impl Scene {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
camera: Rc::new(RefCell::new(Camera::default())),
|
||
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),
|
||
selection: Rc::new(RefCell::new(SelectionState::default())),
|
||
document: CadDocument::new(),
|
||
selected: HashSet::default(),
|
||
hidden: HashSet::default(),
|
||
refedit_keep: None,
|
||
hover_highlight: None,
|
||
transparency_display: true,
|
||
selection_filter: HashSet::default(),
|
||
preview_wires: vec![],
|
||
interim_wire: None,
|
||
camera_generation: 0,
|
||
geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||
block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||
selection_generation: 0,
|
||
wire_cache: RefCell::new(None),
|
||
model_tile_wire_cache: RefCell::new(HashMap::default()),
|
||
sort_cache: RefCell::new(None),
|
||
draw_depth_cache: RefCell::new(None),
|
||
hatch_cache: RefCell::new(None),
|
||
wipeout_cache: RefCell::new(None),
|
||
image_cache: RefCell::new(None),
|
||
mesh_cache: RefCell::new(None),
|
||
viewport_wire_cache: RefCell::new(HashMap::default()),
|
||
paper_sheet_cache: RefCell::new(None),
|
||
paper_projected_cache: RefCell::new(HashMap::default()),
|
||
current_layout: "Model".to_string(),
|
||
viewcube_ucs: glam::Mat4::IDENTITY,
|
||
hatches: HashMap::default(),
|
||
meshes: HashMap::default(),
|
||
block_meshes: HashMap::default(),
|
||
solid_models: HashMap::default(),
|
||
images: HashMap::default(),
|
||
active_viewport: None,
|
||
bg_color: [0.11, 0.11, 0.11, 1.0],
|
||
paper_bg_color: [1.0, 1.0, 1.0, 1.0],
|
||
local_extent_max: 1e9,
|
||
annotation_scale: 1.0,
|
||
model_extents_cache: RefCell::new(None),
|
||
entity_block_map_cache: RefCell::new(None),
|
||
block_defn_cache: RefCell::new(None),
|
||
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),
|
||
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),
|
||
wire_force_nonce: std::cell::Cell::new(0),
|
||
split_cache: RefCell::new(None),
|
||
tess_memo: RefCell::new(HashMap::default()),
|
||
tess_memo_guard: std::cell::Cell::new(0),
|
||
}
|
||
}
|
||
|
||
/// Compute the current camera's world-space XY view AABB with
|
||
/// `world_offset` already subtracted (so the result is in the same f32
|
||
/// space as emitted wire points). Adds a 25% margin around the
|
||
/// frustum to absorb pan inertia and avoid clipped-edge popping.
|
||
pub(super) fn view_world_aabb(&self) -> Option<[f32; 4]> {
|
||
if self.current_layout != "Model" {
|
||
// Paper-space viewport composition handles its own culling; the
|
||
// top-level paper view is small enough not to need it.
|
||
return None;
|
||
}
|
||
// Until the first explicit camera move (typically `fit_all()` after
|
||
// file open), the camera sits at the default origin while geometry
|
||
// lives at large local offsets — culling against the default rect
|
||
// would discard everything and starve fit_all of points to fit to.
|
||
if self.camera_generation == 0 {
|
||
return None;
|
||
}
|
||
let cam = self.camera.borrow();
|
||
let aspect = self.last_render_aspect.get().max(0.01);
|
||
let h = cam.ortho_size();
|
||
let w = h * aspect;
|
||
let margin = 1.25_f32;
|
||
// `cam.target` is in the same local f32 space as emitted wire points
|
||
// (fit_to_bounds populates it from local wire coords). No further
|
||
// `world_offset` subtraction is needed.
|
||
let cx = cam.target.x as f32;
|
||
let cy = cam.target.y as f32;
|
||
Some([
|
||
cx - w * margin,
|
||
cy - h * margin,
|
||
cx + w * margin,
|
||
cy + h * margin,
|
||
])
|
||
}
|
||
|
||
/// Called by the render pipeline once per frame so `view_world_aabb` knows
|
||
/// 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.
|
||
/// Matches the same skip already in `view_world_aabb`.
|
||
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(&self) -> Arc<cache::block_cache::BlockCache> {
|
||
{
|
||
let cache = self.block_defn_cache.borrow();
|
||
if let Some((epoch, ref arc)) = *cache {
|
||
if epoch == self.block_epoch {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
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 {
|
||
1.0
|
||
};
|
||
let built = cache::block_cache::BlockCache::build(&self.document, anno, bg);
|
||
let arc = Arc::new(built);
|
||
*self.block_defn_cache.borrow_mut() = Some((self.block_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
pub fn bump_geometry(&mut self) {
|
||
self.geometry_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||
// 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 the whole per-entity tessellation memo.
|
||
self.tess_memo.borrow_mut().clear();
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
/// 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.
|
||
pub fn bump_geometry_no_blocks(&mut self) {
|
||
self.geometry_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
|
||
/// Re-evaluate every cached mesh's color through `render_style` so a
|
||
/// Register a Model-tab solid: cache its truck B-rep (for boolean ops) and
|
||
/// tessellate it into the shaded mesh pipeline under `handle`. The solid is
|
||
/// in the same offset-relative frame the mesh pipeline uses, so the mesh is
|
||
/// stored as-is (Model-tab geometry is authored at world_offset 0).
|
||
pub fn register_solid_model(&mut self, handle: Handle, solid: truck_modeling::Solid) {
|
||
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(set) = crate::scene::model::solid_model::mesh_from_solid(&solid, color) {
|
||
self.meshes.insert(handle, set);
|
||
}
|
||
self.solid_models.insert(handle, solid);
|
||
self.bump_geometry();
|
||
}
|
||
|
||
/// `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 colors: HashMap<Handle, [f32; 4]> = self
|
||
.meshes
|
||
.keys()
|
||
.chain(self.block_meshes.keys())
|
||
.filter_map(|&h| {
|
||
self.document.get_entity(h).map(|e| {
|
||
let mut c = self.render_style(e).0;
|
||
if let Some(keep) = &self.refedit_keep {
|
||
if !keep.contains(&h) {
|
||
c = crate::scene::cache::block_cache::fade_toward_bg(c, bg);
|
||
}
|
||
}
|
||
(h, c)
|
||
})
|
||
})
|
||
.collect();
|
||
for (h, set) in self.meshes.iter_mut().chain(self.block_meshes.iter_mut()) {
|
||
if let Some(&c) = colors.get(h) {
|
||
for lod in &mut set.lods {
|
||
lod.color = c;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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();
|
||
self.bump_geometry();
|
||
}
|
||
|
||
/// 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. Bumps `geometry_epoch` so the wire cache
|
||
/// re-tessellates — `render_style`'s `adapt_to_bg` picks the model or
|
||
/// paper background depending on `current_layout`, so cached wires
|
||
/// from the previous layout would be coloured against the wrong bg.
|
||
/// Also runs `recolor_meshes` so ACIS mesh colour tracks the new bg.
|
||
pub fn set_current_layout(&mut self, name: String) {
|
||
if self.current_layout != name {
|
||
self.current_layout = name;
|
||
self.recolor_meshes();
|
||
self.bump_geometry();
|
||
}
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
|
||
// 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;
|
||
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()
|
||
}
|
||
|
||
/// 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 {
|
||
// 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 {
|
||
world_origin: [0.0, 0.0],
|
||
boundary: Arc::new(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]),
|
||
pattern: crate::scene::model::hatch_model::HatchPattern::Solid,
|
||
name: "SOLID".to_string(),
|
||
color: self.paper_bg_color,
|
||
angle_offset: 0.0,
|
||
scale: 1.0,
|
||
vp_scissor: None,
|
||
// 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),
|
||
};
|
||
// 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.
|
||
wire.pattern_length = 7.0;
|
||
wire.pattern = [4.0, -3.0, 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> {
|
||
use acadrust::objects::{
|
||
ObjectType, PaperMargin, PlotPaperUnits, PlotRotation, PlotSettings, PlotType,
|
||
PlotWindow, ScaledType,
|
||
};
|
||
let name = &self.current_layout;
|
||
if let Some(ps) = self.document.objects.values().find_map(|o| {
|
||
if let ObjectType::PlotSettings(ps) = o {
|
||
if &ps.page_name == 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 != 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)
|
||
})
|
||
}
|
||
|
||
pub fn paper_limits(&self) -> Option<((f64, f64), (f64, f64))> {
|
||
if self.current_layout == "Model" {
|
||
return None;
|
||
}
|
||
|
||
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)
|
||
};
|
||
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))))
|
||
}
|
||
|
||
/// Scale of the first user viewport (id > 1) in the current paper layout,
|
||
/// used for the status-bar display. Returns `None` in Model space or if
|
||
/// no user viewport exists.
|
||
pub fn first_viewport_scale(&self) -> Option<f64> {
|
||
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_effective_scale(
|
||
vp.custom_scale,
|
||
vp.view_height,
|
||
vp.height,
|
||
));
|
||
}
|
||
}
|
||
None
|
||
})
|
||
}
|
||
|
||
/// 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). Empty when the drawing carries no scale list — the caller
|
||
/// substitutes its built-in defaults.
|
||
pub fn scale_list(&self) -> Vec<(String, f32, f64)> {
|
||
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() as f32, s.factor()))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
list.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
|
||
list
|
||
}
|
||
|
||
/// 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 = self.current_layout_block_handle();
|
||
if layout_block.is_null() {
|
||
return vec![];
|
||
}
|
||
let mut result: Vec<(acadrust::Handle, String, Vec<acadrust::Handle>)> = self
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let EntityType::Viewport(vp) = e {
|
||
if self.is_content_viewport_in_layout(vp, layout_block) {
|
||
Some((vp.common.handle, vp.id, vp.frozen_layers.clone()))
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.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 = self.current_layout_block_handle();
|
||
if layout_block.is_null() {
|
||
return 0;
|
||
}
|
||
self.document
|
||
.entities()
|
||
.filter(|e| {
|
||
if let EntityType::Viewport(vp) = e {
|
||
self.is_content_viewport_in_layout(vp, layout_block)
|
||
} else {
|
||
false
|
||
}
|
||
})
|
||
.count()
|
||
}
|
||
|
||
/// True when any entities are hidden by Isolate / Hide.
|
||
pub fn is_isolation_active(&self) -> bool {
|
||
!self.hidden.is_empty()
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
// Hover is folded into the highlight set (selected ∪ {hover}) that
|
||
// drives the xray overlay. A hover handle that's already selected
|
||
// contributes nothing, so the effective set is unchanged — skip the
|
||
// overlay refresh then. The field is still updated for hit-test / UI.
|
||
let contribution = |h: Option<Handle>| h.filter(|h| !self.selected.contains(h));
|
||
let changed = contribution(self.hover_highlight) != contribution(handle);
|
||
self.hover_highlight = handle;
|
||
if changed {
|
||
self.bump_selection();
|
||
}
|
||
}
|
||
|
||
/// Hide every drawable entity except the current selection (Isolate).
|
||
pub fn isolate_selected(&mut self) {
|
||
if self.selected.is_empty() {
|
||
return;
|
||
}
|
||
let keep = self.selected.clone();
|
||
self.hidden = self
|
||
.document
|
||
.entities()
|
||
.map(|e| e.common().handle)
|
||
.filter(|h| !h.is_null() && !keep.contains(h))
|
||
.collect();
|
||
self.selected.clear();
|
||
self.bump_geometry();
|
||
}
|
||
|
||
/// Hide the current selection (Hide Objects).
|
||
pub fn hide_selected(&mut self) {
|
||
if self.selected.is_empty() {
|
||
return;
|
||
}
|
||
for h in self.selected.iter().copied() {
|
||
self.hidden.insert(h);
|
||
}
|
||
self.selected.clear();
|
||
self.bump_geometry();
|
||
}
|
||
|
||
/// Clear isolation — bring every hidden entity back (End Isolation).
|
||
pub fn end_isolation(&mut self) {
|
||
if self.hidden.is_empty() {
|
||
return;
|
||
}
|
||
self.hidden.clear();
|
||
self.bump_geometry();
|
||
}
|
||
|
||
/// 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
|
||
})
|
||
}
|
||
|
||
/// Set the scale of the active/selected viewport.
|
||
/// Priority: active_viewport → first selected viewport → first viewport in layout.
|
||
pub fn set_viewport_scale(&mut self, scale: f64) {
|
||
let target =
|
||
self.active_viewport
|
||
.or_else(|| {
|
||
self.selected.iter().copied().find(|&h| {
|
||
matches!(self.document.get_entity(h), Some(EntityType::Viewport(_)))
|
||
})
|
||
})
|
||
.or_else(|| self.first_viewport_handle());
|
||
|
||
if let Some(handle) = target {
|
||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
|
||
if !vp.status.locked && scale > 1e-9 {
|
||
vp.custom_scale = scale;
|
||
vp.view_height = vp.height / scale;
|
||
}
|
||
}
|
||
self.viewport_wire_cache.borrow_mut().remove(&handle);
|
||
self.bump_geometry();
|
||
}
|
||
}
|
||
|
||
/// 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<[f32; 2]>> {
|
||
self.entity_wires()
|
||
.into_iter()
|
||
.filter_map(|wire| {
|
||
let pts = wire.points;
|
||
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;
|
||
}
|
||
Some(pts.iter().map(|p| [p[0], p[1]]).collect())
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Per-tile cached tessellation for the Model layout. Each tile has
|
||
/// its own camera (live for the active tile, stored snapshot for the
|
||
/// others), so LOD / frustum culling has to run independently — the
|
||
/// shared `entity_wires_arc` cache would cull every tile against
|
||
/// whichever camera was current when it was last built.
|
||
///
|
||
/// `cam_aspect` is the tile's pixel `width / height`; together with the
|
||
/// camera's `ortho_size` it determines the world-XY rectangle culled
|
||
/// against. Returns a clone of the cached `Arc` on a key match.
|
||
pub(super) fn model_tile_wires_arc(
|
||
&self,
|
||
tile_idx: usize,
|
||
cam: &Camera,
|
||
cam_aspect: f32,
|
||
tile_pixel_height: f32,
|
||
) -> Arc<Vec<WireModel>> {
|
||
let wpp = if tile_pixel_height > 0.0 {
|
||
Some((2.0 * cam.ortho_size()) / tile_pixel_height)
|
||
} else {
|
||
None
|
||
};
|
||
// Cache reuse is split from the exact-camera key: a pure pan keeps the
|
||
// geometry epoch and the pan-invariant signature (rotation + tol) but
|
||
// shifts the view. The cached wires were culled to a 1.25×-margin
|
||
// region; if the new *visible* view still sits inside that region they
|
||
// remain complete, so we can skip re-tessellation entirely — only the
|
||
// GPU re-uploads (camera_generation still bumps). No margin widening,
|
||
// so zoom (which changes the signature) costs exactly as before.
|
||
let pan_sig = camera_pan_invariant_hash(cam, wpp);
|
||
// Exact visible rect (margin 1.0) the reused wires must still cover.
|
||
// A tilted view returns no XY box (cull disabled) → require the cached
|
||
// region to be the full plane, so we only reuse a full tessellation.
|
||
let full = [f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY];
|
||
let need_region = if self.camera_generation == 0 {
|
||
full
|
||
} else {
|
||
view_cull_aabb(cam, cam_aspect, 1.0).unwrap_or(full)
|
||
};
|
||
{
|
||
let cache = self.model_tile_wire_cache.borrow();
|
||
if let Some(((epoch, sig, region), gen, arc)) = cache.get(&tile_idx) {
|
||
if *epoch == self.geometry_epoch
|
||
&& *sig == pan_sig
|
||
&& aabb_contains(*region, need_region)
|
||
{
|
||
// Pan-hit: reuse the tessellation AND its content id, so
|
||
// the GPU wire upload is skipped too (Phase 3.2).
|
||
self.last_model_wire_gen.set(*gen);
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
// Miss → cull/tessellate to the 1.25×-margin region (unchanged cost)
|
||
// and remember that region for the pan-reuse test above.
|
||
// A tilted view yields no XY box → `None` = cull nothing (full tess),
|
||
// which is correct, just heavier, for the less common 3D/elevation case.
|
||
let tess_region = if self.camera_generation == 0 {
|
||
None
|
||
} else {
|
||
view_cull_aabb(cam, cam_aspect, 1.25)
|
||
};
|
||
let block = self.model_space_block_handle();
|
||
let t_tess = iced::time::Instant::now();
|
||
let mut wires = self.wires_for_block_culled(block, tess_region, wpp, None, None);
|
||
self.apply_refedit_fade(&mut wires, self.bg_color);
|
||
let arc = Arc::new(wires);
|
||
self.last_tess_ms.set(t_tess.elapsed().as_secs_f32() * 1000.0);
|
||
self.last_tess_wires.set(arc.len());
|
||
let stored_region = tess_region
|
||
.unwrap_or([f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY]);
|
||
// New tessellation → fresh content id; the GPU will re-upload.
|
||
let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed);
|
||
self.last_model_wire_gen.set(gen);
|
||
self.model_tile_wire_cache.borrow_mut().insert(
|
||
tile_idx,
|
||
((self.geometry_epoch, pan_sig, stored_region), gen, Arc::clone(&arc)),
|
||
);
|
||
arc
|
||
}
|
||
|
||
/// Cached tessellation of the current layout block's paper-space entities,
|
||
/// shared by `entity_wires_arc()` and the GPU sheet viewport.
|
||
fn paper_sheet_wires_arc(&self) -> Arc<Vec<WireModel>> {
|
||
let key = (self.geometry_epoch, self.camera_generation);
|
||
{
|
||
let cache = self.paper_sheet_cache.borrow();
|
||
if let Some((cached_key, ref arc)) = *cache {
|
||
if cached_key == key {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
let layout_block = self.current_layout_block_handle();
|
||
let t_tess = iced::time::Instant::now();
|
||
let mut wires = self.wires_for_block(layout_block);
|
||
self.last_tess_ms.set(t_tess.elapsed().as_secs_f32() * 1000.0);
|
||
self.last_tess_wires.set(wires.len());
|
||
// 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);
|
||
}
|
||
let bg = if self.current_layout == "Model" {
|
||
self.bg_color
|
||
} else {
|
||
self.paper_bg_color
|
||
};
|
||
self.apply_refedit_fade(&mut wires, bg);
|
||
// 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);
|
||
*self.paper_sheet_cache.borrow_mut() = Some((key, 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, ref arc)) = *cache {
|
||
if cached_key == key {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
let layout_block = self.current_layout_block_handle();
|
||
// Model space: paper_sheet_wires_arc IS the full entity wire set — share the Arc,
|
||
// no Vec clone needed.
|
||
if self.current_layout == "Model" {
|
||
let arc = self.paper_sheet_wires_arc();
|
||
*self.wire_cache.borrow_mut() = Some((key, Arc::clone(&arc)));
|
||
return arc;
|
||
}
|
||
// Paper space: extend sheet wires with projected viewport content.
|
||
let mut wires = (*self.paper_sheet_wires_arc()).clone();
|
||
wires.extend(self.viewport_content_wires(layout_block, None, None));
|
||
let arc = Arc::new(wires);
|
||
*self.wire_cache.borrow_mut() = Some((key, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
/// Build WireModels from all document entities + optional preview wire.
|
||
pub fn entity_wires(&self) -> Vec<WireModel> {
|
||
(*self.entity_wires_arc()).clone()
|
||
}
|
||
|
||
/// Per-entity normalized draw-order depth, keyed by entity handle value.
|
||
/// Built (and cached per geometry epoch) by ranking every entity within
|
||
/// its owning block by effective sort key (SortEntitiesTable override or
|
||
/// own handle). The result feeds the 2D pipelines as a clip-z bias so
|
||
/// entities of different types order correctly against each other.
|
||
pub(super) fn draw_depth_map(&self) -> Arc<HashMap<u64, f32>> {
|
||
{
|
||
let cache = self.draw_depth_cache.borrow();
|
||
if let Some((epoch, ref arc)) = *cache {
|
||
if epoch == self.geometry_epoch {
|
||
return Arc::clone(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(_)
|
||
) {
|
||
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> = HashMap::default();
|
||
for (_block, mut v) in by_block {
|
||
v.sort_by_key(|(_, eff)| *eff);
|
||
let denom = (v.len() as f32) + 1.0;
|
||
for (rank, (hv, _)) in v.into_iter().enumerate() {
|
||
// Signed (-1,1): back ranks → negative, front → positive,
|
||
// mid → ~0. The shader applies `z -= draw_depth * BIAS`, so a
|
||
// default/unranked 0.0 means "no bias" (neutral) — which keeps
|
||
// 3D mesh faces and transient wires at their real depth.
|
||
let norm = (rank as f32 + 1.0) / denom; // (0,1)
|
||
depth_map.insert(hv, (norm - 0.5) * 2.0);
|
||
}
|
||
}
|
||
let arc = Arc::new(depth_map);
|
||
*self.draw_depth_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
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_set_sig();
|
||
{
|
||
let cache = self.hatch_cache.borrow();
|
||
if let Some((cached_epoch, cached_sel, ref arc)) = *cache {
|
||
if cached_epoch == self.geometry_epoch && cached_sel == sel_sig {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
let arc = Arc::new(self.synced_hatch_models());
|
||
*self.hatch_cache.borrow_mut() = Some((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
|
||
}
|
||
|
||
pub(super) fn wipeout_models_arc(&self) -> Arc<Vec<HatchModel>> {
|
||
{
|
||
let cache = self.wipeout_cache.borrow();
|
||
if let Some((cached_epoch, ref arc)) = *cache {
|
||
if cached_epoch == self.geometry_epoch {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
let arc = Arc::new(self.wipeout_models());
|
||
*self.wipeout_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
pub(super) fn images_arc(&self) -> Arc<Vec<ImageModel>> {
|
||
{
|
||
let cache = self.image_cache.borrow();
|
||
if let Some((cached_epoch, ref arc)) = *cache {
|
||
if cached_epoch == self.geometry_epoch {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
let depth_map = self.draw_depth_map();
|
||
let arc = Arc::new(
|
||
self.images
|
||
.iter()
|
||
.map(|(handle, model)| {
|
||
let mut m = model.clone();
|
||
m.draw_depth = depth_map.get(&handle.value()).copied().unwrap_or(0.0);
|
||
m
|
||
})
|
||
.collect(),
|
||
);
|
||
*self.image_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
/// 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();
|
||
let depth_map = self.draw_depth_map();
|
||
Arc::new(
|
||
self.images
|
||
.iter()
|
||
.filter_map(|(&handle, model)| {
|
||
let entity = self.document.get_entity(handle)?;
|
||
let c = entity.common();
|
||
if c.invisible
|
||
|| !self.belongs_to_visible_block(handle, c.owner_handle, layout_block)
|
||
{
|
||
return None;
|
||
}
|
||
let mut m = model.clone();
|
||
m.draw_depth = depth_map.get(&handle.value()).copied().unwrap_or(0.0);
|
||
Some(m)
|
||
})
|
||
.collect(),
|
||
)
|
||
}
|
||
|
||
pub(super) fn meshes_arc(&self) -> Arc<Vec<MeshLodSet>> {
|
||
{
|
||
let cache = self.mesh_cache.borrow();
|
||
if let Some((cached_epoch, ref arc)) = *cache {
|
||
if cached_epoch == self.geometry_epoch {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
// Top-level solids: drop those whose layer is off/frozen or that are
|
||
// flagged invisible / isolated-hidden, mirroring the 2D wire path.
|
||
let mut all: Vec<MeshLodSet> = self
|
||
.meshes
|
||
.iter()
|
||
.filter(|(&h, _)| self.mesh_entity_visible(h))
|
||
.map(|(_, set)| set.clone())
|
||
.collect();
|
||
// Block-definition solids are instanced per model-space INSERT so a
|
||
// block placed at an INSERT scale renders at the right size. (#123)
|
||
all.extend(self.instanced_block_meshes(self.model_space_block_handle()));
|
||
let arc = Arc::new(all);
|
||
*self.mesh_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Visibility test for a solid mesh entity, mirroring the 2D wire path:
|
||
/// honour the invisible flag, the isolate/hide set, and the layer's
|
||
/// off/frozen state.
|
||
fn mesh_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;
|
||
}
|
||
if !self.hidden.is_empty() && self.hidden.contains(&handle) {
|
||
return false;
|
||
}
|
||
!self.layer_hidden(&c.layer)
|
||
}
|
||
|
||
/// 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) -> Vec<MeshLodSet> {
|
||
if self.block_meshes.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let mut out = Vec::new();
|
||
for e in self.document.entities() {
|
||
if e.common().owner_handle != layout_block {
|
||
continue;
|
||
}
|
||
if let EntityType::Insert(ins) = e {
|
||
// INSERT on an off/frozen (or invisible) layer hides the whole
|
||
// instance, block-internal solids included.
|
||
if !self.mesh_entity_visible(ins.common.handle) {
|
||
continue;
|
||
}
|
||
let start = out.len();
|
||
self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, &mut out);
|
||
// Tag the instanced meshes with the parent INSERT handle so the
|
||
// hover / selection highlight (keyed on the mesh name) tints the
|
||
// block, not the inner solid's own handle which nothing selects.
|
||
let name = ins.common.handle.value().to_string();
|
||
for set in &mut out[start..] {
|
||
for m in &mut set.lods {
|
||
m.name = name.clone();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Recursively emit transformed instances of a block's solid meshes,
|
||
/// composing nested-INSERT transforms. (#123)
|
||
fn expand_block_meshes(
|
||
&self,
|
||
block_name: &str,
|
||
accum: &acadrust::types::Transform,
|
||
depth: usize,
|
||
out: &mut Vec<MeshLodSet>,
|
||
) {
|
||
if depth > 16 {
|
||
return;
|
||
}
|
||
let Some(br) = self.document.block_records.get(block_name) else {
|
||
return;
|
||
};
|
||
let handles: Vec<Handle> = br.entity_handles.clone();
|
||
for h in handles {
|
||
let Some(e) = self.document.get_entity(h) else {
|
||
continue;
|
||
};
|
||
// A block-internal solid / nested INSERT on an off/frozen layer
|
||
// (or flagged invisible) must not render, same as a top-level one.
|
||
if !self.mesh_entity_visible(h) {
|
||
continue;
|
||
}
|
||
if let EntityType::Insert(ins) = e {
|
||
let composed = ins.get_transform().then(accum);
|
||
self.expand_block_meshes(&ins.block_name, &composed, depth + 1, out);
|
||
} else if let Some(set) = self.block_meshes.get(&h) {
|
||
out.push(transform_block_mesh_lod_set(set, accum));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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) -> HashMap<Handle, HatchModel> {
|
||
let layout_block = self.current_layout_block_handle();
|
||
let model_block = self.model_space_block_handle();
|
||
let layer_hidden = |layer: &str| {
|
||
self.document
|
||
.layers
|
||
.get(layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
};
|
||
self.hatches
|
||
.iter()
|
||
.filter_map(|(&h, m)| {
|
||
let c = self.document.get_entity(h)?.common();
|
||
if c.invisible || layer_hidden(&c.layer) {
|
||
return None;
|
||
}
|
||
// Mirror `synced_hatch_models`' visibility test (which drives
|
||
// the fill render) so anything drawn is also clickable on its
|
||
// fill, not just its boundary wire. The model-space fallback
|
||
// matters when the layout block handle differs from the
|
||
// entity's owner (issue: hatch fill not selectable).
|
||
if self.belongs_to_visible_block(h, c.owner_handle, layout_block)
|
||
|| self.belongs_to_visible_block(h, c.owner_handle, model_block)
|
||
{
|
||
Some((h, m.clone()))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 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 (AutoCAD behaviour: sub-entities of a block aren't directly
|
||
/// selectable; the click resolves to the Insert).
|
||
pub fn insert_hatches_for_click(&self) -> Vec<(Handle, HatchModel)> {
|
||
let layout_block = self.current_layout_block_handle();
|
||
let layer_hidden = |layer: &str| {
|
||
self.document
|
||
.layers
|
||
.get(layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
};
|
||
let mut out: Vec<(Handle, HatchModel)> = Vec::new();
|
||
for entity in self.document.entities() {
|
||
let EntityType::Insert(ins) = entity else {
|
||
continue;
|
||
};
|
||
if ins.common.invisible || layer_hidden(&ins.common.layer) {
|
||
continue;
|
||
}
|
||
if !self.belongs_to_visible_block(
|
||
ins.common.handle,
|
||
ins.common.owner_handle,
|
||
layout_block,
|
||
) {
|
||
continue;
|
||
}
|
||
for sub in ins
|
||
.explode_from_document(&self.document)
|
||
.into_iter()
|
||
.map(crate::modules::draw::modify::explode::normalize_insert_entity)
|
||
{
|
||
let EntityType::Hatch(dxf) = sub else {
|
||
continue;
|
||
};
|
||
if dxf.common.invisible || layer_hidden(&dxf.common.layer) {
|
||
continue;
|
||
}
|
||
let color = self.render_style(&EntityType::Hatch(dxf.clone())).0;
|
||
if let Some(model) = Self::hatch_model_from_dxf(&dxf, color) {
|
||
out.push((ins.common.handle, model));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 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" {
|
||
// entity_wires_arc is culled to the current view and keyed on the
|
||
// camera, so it re-culls when the view changes — picking must reach
|
||
// entities that scroll into view after a pan/zoom.
|
||
return self.entity_wires_arc();
|
||
}
|
||
let layout_block = self.current_layout_block_handle();
|
||
match self.active_viewport {
|
||
None => Arc::new(self.wires_for_block(layout_block)),
|
||
Some(vp_handle) => {
|
||
Arc::new(self.viewport_content_wires(layout_block, Some(vp_handle), None))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pick a meshed 3D solid by clicking on its shaded body (face), not just
|
||
/// its thin projected edges. Returns the front-most mesh under `cursor`.
|
||
#[allow(dead_code)]
|
||
pub fn mesh_click_hit(
|
||
&self,
|
||
cursor: iced::Point,
|
||
view_rot: glam::Mat4,
|
||
eye: glam::DVec3,
|
||
bounds: iced::Rectangle,
|
||
) -> Option<Handle> {
|
||
let iter = self
|
||
.meshes
|
||
.iter()
|
||
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m)));
|
||
pick::hit_test::mesh_click_hit(cursor, iter, view_rot, eye, bounds)
|
||
}
|
||
|
||
/// True when any handle resolves to an ACIS volume entity (3D solid /
|
||
/// region / body / surface) — i.e. one whose render geometry is a cached
|
||
/// mesh that must be re-tessellated after an edit.
|
||
pub fn any_solid(&self, handles: &[Handle]) -> bool {
|
||
handles.iter().any(|&h| {
|
||
matches!(
|
||
self.document.get_entity(h),
|
||
Some(EntityType::Solid3D(_))
|
||
| Some(EntityType::Region(_))
|
||
| Some(EntityType::Body(_))
|
||
| Some(EntityType::Surface(_))
|
||
)
|
||
})
|
||
}
|
||
|
||
/// 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,
|
||
) -> Vec<Handle> {
|
||
let iter = self
|
||
.meshes
|
||
.iter()
|
||
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m)));
|
||
pick::hit_test::mesh_box_hit(a, b, crossing, iter, view_rot, eye, bounds)
|
||
}
|
||
|
||
/// Top-level solid handles caught by a lasso polygon.
|
||
pub fn mesh_poly_hit(
|
||
&self,
|
||
poly: &[iced::Point],
|
||
crossing: bool,
|
||
view_rot: glam::Mat4,
|
||
eye: glam::DVec3,
|
||
bounds: iced::Rectangle,
|
||
) -> Vec<Handle> {
|
||
let iter = self
|
||
.meshes
|
||
.iter()
|
||
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m)));
|
||
pick::hit_test::mesh_poly_hit(poly, crossing, iter, view_rot, eye, bounds)
|
||
}
|
||
|
||
/// 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,
|
||
) -> Option<Handle> {
|
||
// Block-internal instances must be owned (transformed copies); keep
|
||
// them in a Vec and chain references alongside the top-level meshes so
|
||
// neither set is cloned wholesale.
|
||
let mut block_owned: Vec<(Handle, crate::scene::model::mesh_model::MeshModel)> = Vec::new();
|
||
if !self.block_meshes.is_empty() {
|
||
let layout_block = self.current_layout_block_handle();
|
||
for e in self.document.entities() {
|
||
if e.common().owner_handle != layout_block {
|
||
continue;
|
||
}
|
||
let EntityType::Insert(ins) = e else { continue };
|
||
if !self.mesh_entity_visible(ins.common.handle) {
|
||
continue;
|
||
}
|
||
let mut sets = Vec::new();
|
||
self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, &mut sets);
|
||
for set in sets {
|
||
if let Some(m) = set.lods.into_iter().next() {
|
||
block_owned.push((ins.common.handle, m));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let top = self
|
||
.meshes
|
||
.iter()
|
||
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m)));
|
||
let blk = block_owned.iter().map(|(h, m)| (*h, m));
|
||
pick::hit_test::mesh_click_hit(cursor, top.chain(blk), 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,
|
||
) -> Vec<Handle> {
|
||
if self.block_meshes.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let layout_block = self.current_layout_block_handle();
|
||
let mut out = Vec::new();
|
||
for e in self.document.entities() {
|
||
if e.common().owner_handle != layout_block {
|
||
continue;
|
||
}
|
||
let EntityType::Insert(ins) = e else { continue };
|
||
if !self.mesh_entity_visible(ins.common.handle) {
|
||
continue;
|
||
}
|
||
let mut sets = Vec::new();
|
||
self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, &mut sets);
|
||
let hit = sets.iter().any(|set| {
|
||
set.lods.first().map_or(false, |m| {
|
||
!pick::hit_test::mesh_box_hit(
|
||
a,
|
||
b,
|
||
crossing,
|
||
std::iter::once((ins.common.handle, m)),
|
||
view_rot,
|
||
eye,
|
||
bounds,
|
||
)
|
||
.is_empty()
|
||
})
|
||
});
|
||
if hit {
|
||
out.push(ins.common.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,
|
||
) -> Vec<Handle> {
|
||
if self.block_meshes.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let layout_block = self.current_layout_block_handle();
|
||
let mut out = Vec::new();
|
||
for e in self.document.entities() {
|
||
if e.common().owner_handle != layout_block {
|
||
continue;
|
||
}
|
||
let EntityType::Insert(ins) = e else { continue };
|
||
if !self.mesh_entity_visible(ins.common.handle) {
|
||
continue;
|
||
}
|
||
let mut sets = Vec::new();
|
||
self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, &mut sets);
|
||
let hit = sets.iter().any(|set| {
|
||
set.lods.first().map_or(false, |m| {
|
||
!pick::hit_test::mesh_poly_hit(
|
||
poly,
|
||
crossing,
|
||
std::iter::once((ins.common.handle, m)),
|
||
view_rot,
|
||
eye,
|
||
bounds,
|
||
)
|
||
.is_empty()
|
||
})
|
||
});
|
||
if hit {
|
||
out.push(ins.common.handle);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Tessellate all non-invisible entities owned by `block_handle`.
|
||
fn wires_for_block(&self, block_handle: Handle) -> Vec<WireModel> {
|
||
// Default culling is driven by the live `Scene::camera`. Multi-tile
|
||
// Model layouts and paper-space content viewports call
|
||
// `wires_for_block_culled` directly with their own per-view cull
|
||
// parameters so each pane culls independently.
|
||
self.wires_for_block_culled(
|
||
block_handle,
|
||
self.view_world_aabb(),
|
||
self.world_per_pixel(),
|
||
None,
|
||
None,
|
||
)
|
||
}
|
||
|
||
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>,
|
||
) -> 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.
|
||
let visibility_ok = |e: &EntityType| -> bool {
|
||
let c = e.common();
|
||
if c.invisible {
|
||
return false;
|
||
}
|
||
// Isolate / Hide: skip entities the user has hidden.
|
||
if !self.hidden.is_empty() && self.hidden.contains(&c.handle) {
|
||
return false;
|
||
}
|
||
// Block/BlockEnd are block-defn sentinels, not drawable geometry.
|
||
// Without this skip they fall through to fallback_geometry's `_`
|
||
// arm and emit a 1-unit phantom segment at world_offset that
|
||
// poisons fit_all and shows up in selection.
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.belongs_to_visible_block(e.common().handle, c.owner_handle, block_handle)
|
||
};
|
||
|
||
// 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 [ox, oy, _] = [0.0_f64; 3];
|
||
let view_wcs: [f64; 4] = [
|
||
local_view[0] as f64 + ox,
|
||
local_view[1] as f64 + oy,
|
||
local_view[2] as f64 + ox,
|
||
local_view[3] as f64 + oy,
|
||
];
|
||
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 = 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
|
||
};
|
||
let blk_cache = self.block_cache_arc();
|
||
let blk_ref: &cache::block_cache::BlockCache = &blk_cache;
|
||
// Zoom-adaptive curve sampling for top-level Edge tessellation. Target
|
||
// ~0.5 px chord height — far-out arcs that used to emit hundreds of
|
||
// segments now collapse to a handful. The guard clears the override
|
||
// when this scope exits so off-render tessellation (snap previews,
|
||
// hit-test, block_cache rebuild) sees the default.
|
||
struct CurveTolGuard;
|
||
impl Drop for CurveTolGuard {
|
||
fn drop(&mut self) {
|
||
crate::scene::convert::truck_tess::set_curve_tol_override(None);
|
||
}
|
||
}
|
||
let _tol_guard = wpp.map(|w| {
|
||
crate::scene::convert::truck_tess::set_curve_tol_override(Some((w * 0.5) as f64));
|
||
CurveTolGuard
|
||
});
|
||
// Per-entity tessellation memo (Phase 2.2) — only on the culled Model
|
||
// render path. A single-entity edit re-tessellates just the changed
|
||
// entity (dropped from the memo via `mark_entity_dirty`) and reuses the
|
||
// rest, instead of re-running every visible entity. The hit-test path
|
||
// (`view_aabb == None`) and paper / per-viewport paths bypass it so
|
||
// their different cull parameters don't thrash the memo.
|
||
let memo_active = view_aabb.is_some()
|
||
&& is_model_block
|
||
&& frozen_layers.is_none()
|
||
&& anno_scale_override.is_none();
|
||
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.
|
||
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);
|
||
for c in bg {
|
||
mix(c.to_bits() as u64);
|
||
}
|
||
mix(avp.map(|h| h.value()).unwrap_or(0));
|
||
g
|
||
};
|
||
if self.tess_memo_guard.get() != guard {
|
||
self.tess_memo.borrow_mut().clear();
|
||
self.tess_memo_guard.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 = self.tess_memo.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, e, Some(blk_ref), view_aabb, wpp,
|
||
);
|
||
(e.common().handle, Arc::new(w))
|
||
})
|
||
.collect();
|
||
let mut out = hit_wires;
|
||
{
|
||
let mut memo = self.tess_memo.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, e, Some(blk_ref), view_aabb, wpp,
|
||
)
|
||
})
|
||
.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
|
||
})
|
||
}
|
||
|
||
/// 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
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
1.0
|
||
};
|
||
let blk_cache = self.block_cache_arc();
|
||
// 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,
|
||
e,
|
||
Some(&blk_cache),
|
||
None,
|
||
None,
|
||
)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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 [ox, oy, _] = [0.0_f64; 3];
|
||
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), ref arc)) = *cache {
|
||
if epoch == self.geometry_epoch {
|
||
for wire in arc.iter() {
|
||
let [ax, ay, bx, by] = wire.aabb;
|
||
if ax.is_finite() && bx.is_finite() {
|
||
min = min.min(glam::Vec3::new(ax + ox as f32, ay + oy as f32, 0.0));
|
||
max = max.max(glam::Vec3::new(bx + ox as f32, by + oy as f32, 0.0));
|
||
any = true;
|
||
}
|
||
}
|
||
// 3D solids render as meshes, not wires, so fold their
|
||
// XY AABBs in too — otherwise ZOOM EXTENTS ignores them.
|
||
for set in self.meshes.values() {
|
||
let [ax, ay, bx, by] = set.world_aabb;
|
||
if ax.is_finite() && bx.is_finite() {
|
||
min = min.min(glam::Vec3::new(ax + ox as f32, ay + oy as f32, 0.0));
|
||
max = max.max(glam::Vec3::new(bx + ox as f32, by + oy as f32, 0.0));
|
||
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.
|
||
let oz = [0.0_f64; 3][2] as f32;
|
||
for entity in self.document.entities() {
|
||
let c = entity.common();
|
||
if c.owner_handle != model_block || c.invisible {
|
||
continue;
|
||
}
|
||
for wire in self.tessellate_one(entity) {
|
||
for &[x, y, z] in &wire.key_vertices {
|
||
if x.is_finite() && y.is_finite() && z.is_finite() {
|
||
min = min.min(glam::Vec3::new(
|
||
(x + ox) as f32,
|
||
(y + oy) as f32,
|
||
(z + oz as f64) as f32,
|
||
));
|
||
max = max.max(glam::Vec3::new(
|
||
(x + ox) as f32,
|
||
(y + oy) as f32,
|
||
(z + oz as f64) as f32,
|
||
));
|
||
any = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Same mesh inclusion for the tessellate fallback path.
|
||
for set in self.meshes.values() {
|
||
let [ax, ay, bx, by] = set.world_aabb;
|
||
if ax.is_finite() && bx.is_finite() {
|
||
min = min.min(glam::Vec3::new(ax + ox as f32, ay + oy as f32, oz));
|
||
max = max.max(glam::Vec3::new(bx + ox as f32, by + oy as f32, oz));
|
||
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;
|
||
}
|
||
|
||
/// Collect model-space wires projected into paper space for all (or one specific)
|
||
/// user viewports. `only_vp = Some(h)` restricts output to that viewport.
|
||
fn viewport_content_wires(
|
||
&self,
|
||
paper_block: Handle,
|
||
only_vp: Option<Handle>,
|
||
exclude_vp: Option<Handle>,
|
||
) -> Vec<WireModel> {
|
||
use acadrust::entities::Viewport;
|
||
|
||
let viewports: Vec<&Viewport> = self
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let EntityType::Viewport(vp) = e {
|
||
Some(vp)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.filter(|vp| {
|
||
self.is_content_viewport_in_layout(vp, paper_block)
|
||
&& vp.status.is_on
|
||
&& only_vp.map_or(true, |h| vp.common.handle == h)
|
||
&& exclude_vp.map_or(true, |h| vp.common.handle != h)
|
||
})
|
||
.collect();
|
||
|
||
if viewports.is_empty() {
|
||
return vec![];
|
||
}
|
||
|
||
let mut result = Vec::new();
|
||
|
||
for vp in viewports {
|
||
let vp_handle = vp.common.handle;
|
||
|
||
// ── Fast path: return cached projected wires ──────────────────
|
||
{
|
||
let cache = self.paper_projected_cache.borrow();
|
||
if let Some((cached_epoch, ref wires)) = cache.get(&vp_handle) {
|
||
if *cached_epoch == self.geometry_epoch {
|
||
result.extend_from_slice(wires);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Cache miss: compute projection ────────────────────────────
|
||
|
||
// Use camera_for_viewport so the axes match the GPU renderer exactly.
|
||
let cam_frame = match self.camera_for_viewport(vp_handle) {
|
||
Some(c) => c,
|
||
None => continue,
|
||
};
|
||
let view_right = cam_frame.rotation * glam::Vec3::X;
|
||
let view_up = cam_frame.rotation * glam::Vec3::Y;
|
||
|
||
// Scale (paper units per model unit) comes straight from the camera
|
||
// the GPU uses: the model height shown is `2 * ortho_size`, mapped
|
||
// onto `vp.height` of paper. `camera_for_viewport` already made the
|
||
// saved-view-vs-auto-fit decision (with the twist-correct overlap
|
||
// test), so deriving scale from it keeps the CPU projection (used
|
||
// for hit-test / snap / fit) locked to the GPU render — no second,
|
||
// independently-computed scale that could disagree under a twist.
|
||
let view_height_eff = (cam_frame.ortho_size() * 2.0) as f64;
|
||
let scale = if view_height_eff > 1e-9 {
|
||
(vp.height / view_height_eff) as f32
|
||
} else {
|
||
1.0
|
||
};
|
||
|
||
let pcx = vp.center.x as f32;
|
||
let pcy = vp.center.y as f32;
|
||
let pcz = vp.center.z as f32;
|
||
let hw = (vp.width / 2.0) as f32;
|
||
let hh = (vp.height / 2.0) as f32;
|
||
|
||
// ── Use cached tessellation (model_wires_for_viewport_arc) ────
|
||
// This eliminates the per-frame tessellate_one() loop that was here
|
||
// previously; tessellation is now O(1) on navigation frames.
|
||
// Pass 0.0 for screen height — the CPU-projection / hit-test
|
||
// path wants the full-fidelity (no-LOD-stub) wire list,
|
||
// regardless of paper zoom.
|
||
let model_wires = self.model_wires_for_viewport_arc(vp_handle, 0.0);
|
||
|
||
// ── Project and clip wires into viewport ──────────────────────
|
||
let vp_x0 = pcx - hw;
|
||
let vp_x1 = pcx + hw;
|
||
let vp_y0 = pcy - hh;
|
||
let vp_y1 = pcy + hh;
|
||
|
||
// camera_dist: how far the camera is from the target plane.
|
||
let use_perspective = vp.status.perspective && vp.lens_length > 1.0;
|
||
let camera_dist = if use_perspective {
|
||
(vp.view_height as f32 * vp.lens_length as f32 / 24.0).max(0.001)
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
let mut projected: Vec<WireModel> = Vec::new();
|
||
|
||
// Precompute precision-stable WCS-space projection inputs in
|
||
// f64. The previous f32 inner loop suffered catastrophic
|
||
// cancellation on UTM-scale drawings: `(wire_offset_rel -
|
||
// target_offset_rel).dot(view_right) - view_center` is a
|
||
// small paper offset computed by subtracting two values at
|
||
// ~5e6 magnitude — f32 ULP there is ~0.5 m, so paper output
|
||
// jittered by cm even when the actual model was clean.
|
||
//
|
||
// Do everything WCS-relative in f64; cast to f32 only at the
|
||
// final paper position.
|
||
// Display centre = the camera's target, in WCS. `camera_for_viewport`
|
||
// already folded view_center through the (twisted) view basis and
|
||
// applied the empty-WCS auto-fit, so taking its target keeps the CPU
|
||
// projection identical to the GPU renderer under any twist.
|
||
let display_center_x = cam_frame.target.x as f64 + [0.0_f64; 3][0];
|
||
let display_center_y = cam_frame.target.y as f64 + [0.0_f64; 3][1];
|
||
let display_center_z = cam_frame.target.z as f64 + [0.0_f64; 3][2];
|
||
let view_right_d = (
|
||
view_right.x as f64,
|
||
view_right.y as f64,
|
||
view_right.z as f64,
|
||
);
|
||
let view_up_d = (view_up.x as f64, view_up.y as f64, view_up.z as f64);
|
||
let view_fwd = cam_frame.rotation * glam::Vec3::Z;
|
||
let view_fwd_d = (view_fwd.x as f64, view_fwd.y as f64, view_fwd.z as f64);
|
||
let camera_dist_d = camera_dist as f64;
|
||
let scale_d = scale as f64;
|
||
let pcx_d = pcx as f64;
|
||
let pcy_d = pcy as f64;
|
||
// Project one ABSOLUTE-WCS model point (f64) onto the paper sheet.
|
||
// Shared by the polyline points, snap points and key vertices so the
|
||
// hit-test / snap geometry lands in the same paper frame the wire is
|
||
// drawn in — otherwise snaps and the click-AABB stay in model (UTM)
|
||
// space and the cursor never reaches them.
|
||
let proj_abs = |ax: f64, ay: f64, az: f64| -> [f32; 3] {
|
||
let mp_x = ax - display_center_x;
|
||
let mp_y = ay - display_center_y;
|
||
let mp_z = az - display_center_z;
|
||
let u = mp_x * view_right_d.0 + mp_y * view_right_d.1 + mp_z * view_right_d.2;
|
||
let v = mp_x * view_up_d.0 + mp_y * view_up_d.1 + mp_z * view_up_d.2;
|
||
if use_perspective {
|
||
let d_vd = mp_x * view_fwd_d.0 + mp_y * view_fwd_d.1 + mp_z * view_fwd_d.2;
|
||
let fwd = camera_dist_d - d_vd;
|
||
if fwd <= 0.001 {
|
||
return [f32::NAN; 3];
|
||
}
|
||
let factor = camera_dist_d / fwd;
|
||
[
|
||
(pcx_d + u * factor * scale_d) as f32,
|
||
(pcy_d + v * factor * scale_d) as f32,
|
||
pcz,
|
||
]
|
||
} else {
|
||
[(pcx_d + u * scale_d) as f32, (pcy_d + v * scale_d) as f32, pcz]
|
||
}
|
||
};
|
||
let in_vp = |x: f32, y: f32| x >= vp_x0 && x <= vp_x1 && y >= vp_y0 && y <= vp_y1;
|
||
|
||
for wire in model_wires.iter() {
|
||
let projected_pts: Vec<[f32; 3]> = wire
|
||
.points
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(pi, &[mx, my, mz])| {
|
||
if mx.is_nan() || my.is_nan() || mz.is_nan() {
|
||
return [f32::NAN; 3];
|
||
}
|
||
// Reconstruct absolute WCS from the double-single high
|
||
// (`points`) + low (`points_low`) pair — the high f32
|
||
// alone is ~0.5 m off at UTM scale.
|
||
let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]);
|
||
proj_abs(mx as f64 + lo[0] as f64, my as f64 + lo[1] as f64, mz as f64 + lo[2] as f64)
|
||
})
|
||
.collect();
|
||
|
||
// Fast AABB pre-reject.
|
||
let any_near = projected_pts.iter().any(|&[x, y, _]| {
|
||
x.is_finite()
|
||
&& y.is_finite()
|
||
&& x >= vp_x0 - 1.0
|
||
&& x <= vp_x1 + 1.0
|
||
&& y >= vp_y0 - 1.0
|
||
&& y <= vp_y1 + 1.0
|
||
});
|
||
let (min_x, max_x, min_y, max_y) =
|
||
projected_pts.iter().filter(|p| p[0].is_finite()).fold(
|
||
(
|
||
f32::INFINITY,
|
||
f32::NEG_INFINITY,
|
||
f32::INFINITY,
|
||
f32::NEG_INFINITY,
|
||
),
|
||
|(mnx, mxx, mny, mxy), &[x, y, _]| {
|
||
(mnx.min(x), mxx.max(x), mny.min(y), mxy.max(y))
|
||
},
|
||
);
|
||
let aabb_hits =
|
||
max_x >= vp_x0 && min_x <= vp_x1 && max_y >= vp_y0 && min_y <= vp_y1;
|
||
if !any_near && !aabb_hits {
|
||
continue;
|
||
}
|
||
|
||
let clipped =
|
||
clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz);
|
||
if clipped.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Paper-space AABB of the clipped polyline — the cloned model
|
||
// (UTM) AABB would make click_hit's screen-projected pre-reject
|
||
// discard the wire (box selection has no pre-reject, which is why
|
||
// it kept working while picking didn't).
|
||
let mut pmnx = f32::INFINITY;
|
||
let mut pmny = f32::INFINITY;
|
||
let mut pmxx = f32::NEG_INFINITY;
|
||
let mut pmxy = f32::NEG_INFINITY;
|
||
for &[x, y, _] in clipped.iter().filter(|p| p[0].is_finite()) {
|
||
pmnx = pmnx.min(x);
|
||
pmny = pmny.min(y);
|
||
pmxx = pmxx.max(x);
|
||
pmxy = pmxy.max(y);
|
||
}
|
||
|
||
// Project snap points + key vertices into the same paper frame,
|
||
// keeping only those inside the viewport rect, so endpoint /
|
||
// midpoint / centre snaps land on the visible sheet geometry
|
||
// instead of the model's UTM coordinates.
|
||
let snap_pts: Vec<(glam::DVec3, model::wire_model::SnapHint)> = wire
|
||
.snap_pts
|
||
.iter()
|
||
.filter_map(|(w, h)| {
|
||
let p = proj_abs(w.x, w.y, w.z);
|
||
(p[0].is_finite() && in_vp(p[0], p[1]))
|
||
.then(|| (glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), *h))
|
||
})
|
||
.collect();
|
||
let key_vertices: Vec<[f64; 3]> = wire
|
||
.key_vertices
|
||
.iter()
|
||
.filter_map(|&[kx, ky, kz]| {
|
||
let p = proj_abs(kx, ky, kz);
|
||
(p[0].is_finite() && in_vp(p[0], p[1]))
|
||
.then(|| [p[0] as f64, p[1] as f64, p[2] as f64])
|
||
})
|
||
.collect();
|
||
|
||
let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color);
|
||
let [r, g, b, a] = adapted;
|
||
let mut out = wire.clone();
|
||
out.points = clipped;
|
||
// Paper coordinates are small sheet units — no relative-to-eye
|
||
// residual is needed, and keeping the model wire's points_low
|
||
// here would add a model-scale offset to the paper points.
|
||
out.points_low = Vec::new();
|
||
out.snap_pts = snap_pts;
|
||
out.key_vertices = key_vertices;
|
||
// Tangent geometry is in model space and can't be trivially
|
||
// re-expressed in paper coords — drop it (no tangent snap on
|
||
// projected viewport content) rather than snap to UTM.
|
||
out.tangent_geoms = Vec::new();
|
||
out.aabb = if pmnx.is_finite() {
|
||
[pmnx, pmny, pmxx, pmxy]
|
||
} else {
|
||
WireModel::UNBOUNDED_AABB
|
||
};
|
||
out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
|
||
out.line_weight_px = wire.line_weight_px;
|
||
// Wire's pattern was sized for model-space coords during
|
||
// tessellation; we just projected points into paper coords
|
||
// (× scale), so rescale the dash pattern by the same factor
|
||
// to keep dimensional consistency in the GPU shader.
|
||
out.pattern_length = wire.pattern_length * scale;
|
||
out.pattern = wire.pattern.map(|v| v * scale);
|
||
out.vp_scissor = Some([vp_x0, vp_y0, vp_x1, vp_y1]);
|
||
projected.push(out);
|
||
}
|
||
|
||
// Store in cache, then extend result.
|
||
self.paper_projected_cache
|
||
.borrow_mut()
|
||
.insert(vp_handle, (self.geometry_epoch, projected.clone()));
|
||
result.extend(projected);
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
// ── MSPACE helpers ───────────────────────────────────────────────────
|
||
|
||
/// Convert a **paper-space** world coordinate to **model-space** using the
|
||
/// geometry of the currently active viewport. Returns the input unchanged
|
||
/// when there is no active viewport.
|
||
/// Convert a paper-space point to model space (precise at UTM scale).
|
||
pub fn paper_to_model(&self, paper_pt: glam::DVec3) -> glam::DVec3 {
|
||
let vp_handle = match self.active_viewport {
|
||
Some(h) => h,
|
||
None => return paper_pt,
|
||
};
|
||
let vp = match self.document.get_entity(vp_handle) {
|
||
Some(acadrust::EntityType::Viewport(vp)) => vp,
|
||
_ => return paper_pt,
|
||
};
|
||
// Uses the viewport's own `view_target` — kept valid by
|
||
// `normalize_active_viewport_view` on entry, which folds a stale UTM
|
||
// saved view onto the auto-fit centre so the display, pan/zoom and this
|
||
// inverse all agree. Cheap (no per-call camera rebuild).
|
||
let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||
if scale.abs() < 1e-9 {
|
||
return paper_pt;
|
||
}
|
||
let tx = vp.view_target.x;
|
||
let ty = vp.view_target.y;
|
||
let pcx = vp.center.x;
|
||
let pcy = vp.center.y;
|
||
glam::DVec3::new(
|
||
(paper_pt.x - pcx) / scale + tx,
|
||
(paper_pt.y - pcy) / scale + ty,
|
||
paper_pt.z,
|
||
)
|
||
}
|
||
|
||
/// Inverse of [`paper_to_model`]: map a model-space point to the paper
|
||
/// sheet through the active viewport. Returns the input unchanged when
|
||
/// there is no active viewport. Kept as the inverse companion to
|
||
/// `paper_to_model`; in-viewport overlays now project via the viewport
|
||
/// camera ([`viewport_edit_frame`]) rather than mapping onto the sheet.
|
||
#[allow(dead_code)]
|
||
pub fn model_to_paper(&self, model_pt: glam::DVec3) -> glam::DVec3 {
|
||
let vp_handle = match self.active_viewport {
|
||
Some(h) => h,
|
||
None => return model_pt,
|
||
};
|
||
let vp = match self.document.get_entity(vp_handle) {
|
||
Some(acadrust::EntityType::Viewport(vp)) => vp,
|
||
_ => return model_pt,
|
||
};
|
||
let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||
glam::DVec3::new(
|
||
(model_pt.x - vp.view_target.x) * scale + vp.center.x,
|
||
(model_pt.y - vp.view_target.y) * scale + vp.center.y,
|
||
model_pt.z,
|
||
)
|
||
}
|
||
|
||
/// In-viewport (MSPACE) editing frame: the active floating viewport's own
|
||
/// camera — *exactly* the one the GPU renders its content with
|
||
/// ([`camera_for_viewport`]) — together with the viewport's full screen
|
||
/// rectangle in canvas pixels ([`viewport_screen_rect`]).
|
||
///
|
||
/// This is the unified editing adapter (the "süzgeç"). Inside a viewport,
|
||
/// editing IS model-space: treat the returned camera as *the* camera, the
|
||
/// returned rect as *the* pane, and the cursor relative to that rect — then
|
||
/// the existing model-space snap / hit-test / grip / preview / plane-pick
|
||
/// code runs unchanged and lands on the same pixels the GPU draws. Results
|
||
/// come back as model coordinates directly (no paper round-trip).
|
||
///
|
||
/// Because the camera is the real GPU camera, this tracks the viewport's
|
||
/// pan / zoom / twist / oblique view correctly — unlike a linear
|
||
/// paper-projection, whose auto-fit / saved-view / crop divergence left the
|
||
/// snap stale after pan/zoom. Returns `None` when not editing inside a
|
||
/// floating viewport, or the camera / rect cannot be derived.
|
||
pub fn viewport_edit_frame(
|
||
&self,
|
||
canvas_px: (f32, f32),
|
||
) -> Option<(view::camera::Camera, iced::Rectangle)> {
|
||
let vp_handle = self.active_viewport?;
|
||
let cam = self.camera_for_viewport(vp_handle)?;
|
||
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
|
||
Some((cam, full))
|
||
}
|
||
|
||
/// Fold the active viewport's saved view onto the effective camera (the
|
||
/// auto-fit centre for stale UTM views) and persist it into `view_target` /
|
||
/// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the
|
||
/// rendered content all share one valid view — otherwise a stale `(0,0,0)`
|
||
/// target left the camera auto-fitting to the model centre while the cursor
|
||
/// math used the origin, and pan toggled the two (jitter).
|
||
pub fn normalize_active_viewport_view(&mut self) {
|
||
let Some(vp_handle) = self.active_viewport else {
|
||
return;
|
||
};
|
||
let Some(cam) = self.camera_for_viewport(vp_handle) else {
|
||
return;
|
||
};
|
||
let eff_h = cam.ortho_size() as f64 * 2.0;
|
||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||
vp.view_target.x = cam.target.x;
|
||
vp.view_target.y = cam.target.y;
|
||
vp.view_center.x = 0.0;
|
||
vp.view_center.y = 0.0;
|
||
if eff_h > 1e-9 {
|
||
vp.view_height = eff_h;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pan the active viewport's model-space view by `(screen_dx, screen_dy)` pixels.
|
||
/// The delta is converted to model-space units using the camera and viewport scale.
|
||
/// No-op when there is no active viewport.
|
||
pub fn pan_active_viewport(&mut self, screen_dx: f32, screen_dy: f32, bounds: iced::Rectangle) {
|
||
let vp_handle = match self.active_viewport {
|
||
Some(h) => h,
|
||
None => return,
|
||
};
|
||
// Use the viewport's own camera for the pan axes (matches 3-D view orientation).
|
||
let vp_cam = match self.camera_for_viewport(vp_handle) {
|
||
Some(c) => c,
|
||
None => return,
|
||
};
|
||
|
||
// Read viewport dims (immutable borrow ends here).
|
||
let (view_height, vp_height, locked) = match self.document.get_entity(vp_handle) {
|
||
Some(acadrust::EntityType::Viewport(vp)) => {
|
||
(vp.view_height as f32, vp.height as f32, vp.status.locked)
|
||
}
|
||
_ => return,
|
||
};
|
||
if locked {
|
||
return;
|
||
}
|
||
|
||
// Correct pan speed: how many model units correspond to one screen pixel.
|
||
//
|
||
// The paper camera's ortho_size() gives the visible paper-space half-height
|
||
// (in paper mm). One screen pixel = 2*half_h / canvas_height paper mm.
|
||
// Inside the viewport, one paper mm = view_height / vp_height model units.
|
||
// Together: model_per_pixel = (2*half_h / canvas_height) * (view_height / vp_height)
|
||
let paper_half_h = self.camera.borrow().ortho_size();
|
||
let speed = if bounds.height > 0.0 && paper_half_h > 1e-6 && vp_height > 1e-6 {
|
||
(2.0 * paper_half_h / bounds.height) * (view_height / vp_height)
|
||
} else {
|
||
vp_cam.distance * 0.001
|
||
};
|
||
|
||
let cam_right = vp_cam.rotation * glam::Vec3::X;
|
||
let cam_up = vp_cam.rotation * glam::Vec3::Y;
|
||
let model_delta = -(cam_right * screen_dx * speed) + (cam_up * screen_dy * speed);
|
||
|
||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||
vp.view_target.x += model_delta.x as f64;
|
||
vp.view_target.y += model_delta.y as f64;
|
||
vp.view_target.z += model_delta.z as f64;
|
||
}
|
||
}
|
||
|
||
/// Zoom the active viewport's model-space view by `steps` notches.
|
||
/// Positive = zoom in (increase detail), negative = zoom out.
|
||
/// `cursor_paper`: optional paper-space XY of the cursor; when supplied the
|
||
/// model point under the cursor is kept stationary (AutoCAD-style zoom).
|
||
/// No-op when there is no active viewport.
|
||
pub fn zoom_active_viewport(&mut self, steps: f32, cursor_paper: Option<glam::Vec2>) {
|
||
let vp_handle = match self.active_viewport {
|
||
Some(h) => h,
|
||
None => return,
|
||
};
|
||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||
if vp.status.locked {
|
||
return;
|
||
}
|
||
// Zoom in = shrink view_height → higher scale → objects appear larger.
|
||
let factor = (1.0_f64 - 0.15 * steps as f64).clamp(0.1, 10.0);
|
||
|
||
if let Some(cp) = cursor_paper {
|
||
// Compute the model-space point under the cursor before zoom.
|
||
let scale_before =
|
||
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height) as f32;
|
||
let cx = vp.center.x as f32;
|
||
let cy = vp.center.y as f32;
|
||
let tx = vp.view_target.x as f32;
|
||
let ty = vp.view_target.y as f32;
|
||
let mx = (cp.x - cx) / scale_before + tx;
|
||
let my = (cp.y - cy) / scale_before + ty;
|
||
|
||
// Apply zoom.
|
||
vp.view_height = (vp.view_height * factor).max(1e-6);
|
||
if vp.view_height.abs() > 1e-9 {
|
||
vp.custom_scale = vp.height / vp.view_height;
|
||
}
|
||
let scale_after = vp.custom_scale as f32;
|
||
|
||
// Adjust view_target so the model point under cursor stays there.
|
||
let mx_after = (cp.x - cx) / scale_after + vp.view_target.x as f32;
|
||
let my_after = (cp.y - cy) / scale_after + vp.view_target.y as f32;
|
||
vp.view_target.x += (mx - mx_after) as f64;
|
||
vp.view_target.y += (my - my_after) as f64;
|
||
} else {
|
||
vp.view_height = (vp.view_height * factor).max(1e-6);
|
||
if vp.view_height.abs() > 1e-9 {
|
||
vp.custom_scale = vp.height / vp.view_height;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Orbit the active viewport's view direction by the given screen-pixel delta.
|
||
/// No-op when there is no active viewport or it is locked.
|
||
pub fn orbit_active_viewport(&mut self, delta_x: f32, delta_y: f32) {
|
||
let vp_handle = match self.active_viewport {
|
||
Some(h) => h,
|
||
None => return,
|
||
};
|
||
let mut cam = match self.camera_for_viewport(vp_handle) {
|
||
Some(c) => c,
|
||
None => return,
|
||
};
|
||
cam.orbit(delta_x, delta_y);
|
||
// yaw_pitch_to_quat(y,p)*Z = (cos(p)*sin(y), -cos(p)*cos(y), sin(p))
|
||
// `camera_for_viewport` reconstructs the rotation so that
|
||
// `rotation * Z == view_direction` exactly (its `yaw = atan2(x, -y)`
|
||
// cancels the sign). Store `eye` directly so the orbit round-trips —
|
||
// negating Y here made each drag step read back a Y-mirrored camera,
|
||
// flipping the model between a rotation and its opposite every frame.
|
||
let eye = cam.rotation * glam::Vec3::Z;
|
||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||
if vp.status.locked {
|
||
return;
|
||
}
|
||
vp.view_direction.x = eye.x as f64;
|
||
vp.view_direction.y = eye.y as f64;
|
||
vp.view_direction.z = eye.z as f64;
|
||
}
|
||
}
|
||
|
||
/// Snap the active viewport's view direction to `eye_dir` (unit
|
||
/// vector from target toward camera). Twist angle is left at its
|
||
/// current value so the up-sense is preserved across successive
|
||
/// snaps. No-op when there is no active viewport or it is locked.
|
||
pub fn snap_active_viewport_to_direction(&mut self, eye_dir: glam::Vec3) {
|
||
let vp_handle = match self.active_viewport {
|
||
Some(h) => h,
|
||
None => return,
|
||
};
|
||
let eye = eye_dir.normalize_or(glam::Vec3::Z);
|
||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||
if vp.status.locked {
|
||
return;
|
||
}
|
||
vp.view_direction.x = eye.x as f64;
|
||
vp.view_direction.y = eye.y as f64;
|
||
vp.view_direction.z = eye.z as f64;
|
||
}
|
||
}
|
||
|
||
/// Render mode of the active paper-space viewport, or `None` when no
|
||
/// viewport is active (PSPACE / model layout).
|
||
pub fn active_viewport_render_mode(
|
||
&self,
|
||
) -> Option<acadrust::entities::ViewportRenderMode> {
|
||
let h = self.active_viewport?;
|
||
match self.document.get_entity(h) {
|
||
Some(acadrust::EntityType::Viewport(vp)) => Some(vp.render_mode),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Set the active paper-space viewport's render mode. Returns `true`
|
||
/// when a viewport was active and updated; `false` (no-op) otherwise,
|
||
/// so the caller can fall back to the model-layout render mode.
|
||
pub fn set_active_viewport_render_mode(
|
||
&mut self,
|
||
mode: acadrust::entities::ViewportRenderMode,
|
||
) -> bool {
|
||
let Some(h) = self.active_viewport else {
|
||
return false;
|
||
};
|
||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(h) {
|
||
vp.render_mode = mode;
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
/// Visual style of the active Model tile (for the render-mode picker).
|
||
pub fn active_model_tile_render_mode(
|
||
&self,
|
||
) -> acadrust::entities::ViewportRenderMode {
|
||
let tiles = self.model_tiles.borrow();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
tiles
|
||
.get(active)
|
||
.map(|t| t.render_mode)
|
||
.unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D)
|
||
}
|
||
|
||
/// Set only the active Model tile's render mode. Other tiles keep theirs.
|
||
pub fn set_active_model_tile_render_mode(
|
||
&self,
|
||
mode: acadrust::entities::ViewportRenderMode,
|
||
) {
|
||
let mut tiles = self.model_tiles.borrow_mut();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
if let Some(t) = tiles.get_mut(active) {
|
||
t.render_mode = mode;
|
||
}
|
||
}
|
||
|
||
/// View-rotation matrix for the active viewport (MSPACE), or the
|
||
/// paper-space camera's matrix when not in MSPACE.
|
||
/// Used by ViewCube hit-testing so clicks map to the correct camera.
|
||
pub fn active_view_rotation_mat(&self) -> glam::Mat4 {
|
||
if let Some(h) = self.active_viewport {
|
||
if let Some(cam) = self.camera_for_viewport(h) {
|
||
return cam.view_rotation_mat();
|
||
}
|
||
}
|
||
self.camera.borrow().view_rotation_mat() * self.viewcube_ucs_mat()
|
||
}
|
||
|
||
/// The UCS→world rotation the ViewCube should compose with the camera —
|
||
/// the active UCS in model space, identity everywhere else. Render,
|
||
/// hit-test, and click-snap all go through this so they stay in lock-step.
|
||
pub fn viewcube_ucs_mat(&self) -> glam::Mat4 {
|
||
// UCS applies in model space and inside a floating viewport (MSPACE);
|
||
// plain paper space stays WCS.
|
||
if self.current_layout == "Model" || self.active_viewport.is_some() {
|
||
self.viewcube_ucs
|
||
} else {
|
||
glam::Mat4::IDENTITY
|
||
}
|
||
}
|
||
|
||
/// Return the handle of the user viewport whose bounding rectangle contains
|
||
/// the given paper-space point, or `None` if no viewport matches.
|
||
pub fn viewport_at_paper_point(&self, px: f32, py: f32) -> Option<Handle> {
|
||
let layout_block = self.current_layout_block_handle();
|
||
self.document.entities().find_map(|e| {
|
||
let EntityType::Viewport(vp) = e else {
|
||
return None;
|
||
};
|
||
if !self.is_content_viewport_in_layout(vp, layout_block)
|
||
|| !vp.status.is_on
|
||
{
|
||
return None;
|
||
}
|
||
let hw = (vp.width / 2.0) as f32;
|
||
let hh = (vp.height / 2.0) as f32;
|
||
let cx = vp.center.x as f32;
|
||
let cy = vp.center.y as f32;
|
||
if px >= cx - hw && px <= cx + hw && py >= cy - hh && py <= cy + hh {
|
||
Some(vp.common.handle)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Return the handle of the first active user viewport in the current layout,
|
||
/// or `None` if there are none. Used by the MS command.
|
||
pub fn first_user_viewport(&self) -> Option<Handle> {
|
||
let layout_block = self.current_layout_block_handle();
|
||
self.document.entities().find_map(|e| {
|
||
let EntityType::Viewport(vp) = e else {
|
||
return None;
|
||
};
|
||
if self.is_content_viewport_in_layout(vp, layout_block)
|
||
&& vp.status.is_on
|
||
{
|
||
Some(vp.common.handle)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
// ── Layout management ─────────────────────────────────────────────────
|
||
|
||
/// Rename a paper-space layout. Updates the Layout object name in the document.
|
||
pub fn rename_layout(&mut self, old_name: &str, new_name: &str) {
|
||
for obj in self.document.objects.values_mut() {
|
||
if let ObjectType::Layout(l) = obj {
|
||
if l.name == old_name {
|
||
l.name = new_name.to_string();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Delete a paper-space layout and all entities owned by it.
|
||
/// Returns `false` if the layout was not found or is "Model".
|
||
pub fn delete_layout(&mut self, name: &str) -> bool {
|
||
if name == "Model" {
|
||
return false;
|
||
}
|
||
|
||
let layout_info = self.document.objects.values().find_map(|obj| {
|
||
if let ObjectType::Layout(l) = obj {
|
||
if l.name == name {
|
||
return Some((l.handle, l.block_record));
|
||
}
|
||
}
|
||
None
|
||
});
|
||
|
||
let (layout_handle, block_handle) = match layout_info {
|
||
Some(info) => info,
|
||
None => return false,
|
||
};
|
||
|
||
// Remove all entities that belong to this layout's block record.
|
||
let to_remove: Vec<Handle> = self
|
||
.document
|
||
.entities()
|
||
.filter(|e| e.common().owner_handle == block_handle)
|
||
.map(|e| e.common().handle)
|
||
.collect();
|
||
for h in &to_remove {
|
||
self.hatches.remove(h);
|
||
self.meshes.remove(h);
|
||
self.solid_models.remove(h);
|
||
self.document.remove_entity(*h);
|
||
}
|
||
|
||
// Remove the Layout object itself.
|
||
self.document.objects.remove(&layout_handle);
|
||
|
||
// Drop the layout's entry from the ACAD_LAYOUT dictionary so it does not
|
||
// dangle (and so AutoCAD doesn't try to recover a now-missing layout).
|
||
let dict_handle = self.document.header.acad_layout_dict_handle;
|
||
if let Some(ObjectType::Dictionary(d)) = self.document.objects.get_mut(&dict_handle) {
|
||
d.entries.retain(|(k, _)| k != name);
|
||
}
|
||
|
||
// Remove the now-empty paper-space block record.
|
||
let block_name = self
|
||
.document
|
||
.block_records
|
||
.iter()
|
||
.find(|b| b.handle == block_handle)
|
||
.map(|b| b.name.clone());
|
||
if let Some(bn) = block_name {
|
||
self.document.block_records.remove(&bn);
|
||
}
|
||
|
||
// Drop any standalone PlotSettings page setup tied to this layout.
|
||
let ps_handles: Vec<Handle> = self
|
||
.document
|
||
.objects
|
||
.iter()
|
||
.filter_map(|(h, o)| match o {
|
||
ObjectType::PlotSettings(ps) if ps.page_name == name => Some(*h),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
for h in ps_handles {
|
||
self.document.objects.remove(&h);
|
||
}
|
||
|
||
// If the deleted layout was active, fall back to Model space.
|
||
if self.current_layout == name {
|
||
self.current_layout = "Model".to_string();
|
||
}
|
||
|
||
self.bump_geometry();
|
||
true
|
||
}
|
||
|
||
/// Swap the `tab_order` of two paper layouts so they appear in swapped order.
|
||
pub fn swap_layout_order(&mut self, name_a: &str, name_b: &str) {
|
||
let mut order_a: Option<i16> = None;
|
||
let mut order_b: Option<i16> = None;
|
||
for obj in self.document.objects.values() {
|
||
if let ObjectType::Layout(l) = obj {
|
||
if l.name == name_a {
|
||
order_a = Some(l.tab_order);
|
||
}
|
||
if l.name == name_b {
|
||
order_b = Some(l.tab_order);
|
||
}
|
||
}
|
||
}
|
||
if let (Some(oa), Some(ob)) = (order_a, order_b) {
|
||
for obj in self.document.objects.values_mut() {
|
||
if let ObjectType::Layout(l) = obj {
|
||
if l.name == name_a {
|
||
l.tab_order = ob;
|
||
} else if l.name == name_b {
|
||
l.tab_order = oa;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Entity management ─────────────────────────────────────────────────
|
||
|
||
pub fn add_entity(&mut self, mut entity: EntityType) -> Handle {
|
||
// Only Insert / Block entities can introduce or reference a block
|
||
// definition that the block cache must (re)build. Adding a plain
|
||
// top-level entity (line, arc, text, …) leaves every block defn intact,
|
||
// so it keeps the cache and skips the all-blocks re-tessellation.
|
||
let affects_blocks = matches!(
|
||
&entity,
|
||
EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_)
|
||
);
|
||
let hatch_seed = if let EntityType::Hatch(dxf) = &entity {
|
||
let color = self.render_style(&entity).0;
|
||
Self::hatch_model_from_dxf(dxf, color)
|
||
} else if let EntityType::Solid(solid) = &entity {
|
||
let color = self.render_style(&entity).0;
|
||
Some(Self::solid_hatch_model(solid, color))
|
||
} else {
|
||
None
|
||
};
|
||
let image_seed = if let EntityType::RasterImage(img) = &entity {
|
||
ImageModel::from_raster_image(img)
|
||
} else {
|
||
None
|
||
};
|
||
let facet_res = self.document.header.facet_resolution;
|
||
let mesh_seed = if matches!(
|
||
&entity,
|
||
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_)
|
||
) {
|
||
let color = self.render_style(&entity).0;
|
||
crate::entities::solid3d::tessellate_volume(&entity, color, facet_res)
|
||
.map(|m| offset_mesh_lod_set(m))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Auto-create an ImageDefinition object for new RasterImage entities
|
||
// that don't already reference one.
|
||
if let EntityType::RasterImage(ref mut img) = entity {
|
||
if img.definition_handle.is_none() {
|
||
use acadrust::objects::{ImageDefinition, ObjectType};
|
||
let def_handle = Handle::new(self.document.next_handle());
|
||
let mut img_def = ImageDefinition::with_dimensions(
|
||
&img.file_path,
|
||
img.size.x as u32,
|
||
img.size.y as u32,
|
||
);
|
||
img_def.handle = def_handle;
|
||
img_def.is_loaded = true;
|
||
self.document
|
||
.objects
|
||
.insert(def_handle, ObjectType::ImageDefinition(img_def));
|
||
img.definition_handle = Some(def_handle);
|
||
}
|
||
}
|
||
|
||
// Route to the correct block based on current editing mode:
|
||
// - PSPACE (paper layout, no active viewport): paper-space layout block.
|
||
// - MSPACE or model layout: model space (document default).
|
||
let handle = if self.current_layout != "Model" && self.active_viewport.is_none() {
|
||
let layout_name = self.current_layout.clone();
|
||
self.document
|
||
.add_entity_to_layout(entity, &layout_name)
|
||
.unwrap_or(Handle::NULL)
|
||
} else {
|
||
self.document.add_entity(entity).unwrap_or(Handle::NULL)
|
||
};
|
||
|
||
if !handle.is_null() {
|
||
if let Some(model) = hatch_seed {
|
||
self.hatches.insert(handle, model);
|
||
}
|
||
if let Some(model) = image_seed {
|
||
self.images.insert(handle, model);
|
||
}
|
||
if let Some(model) = mesh_seed {
|
||
self.meshes.insert(handle, model);
|
||
}
|
||
if affects_blocks {
|
||
self.bump_geometry();
|
||
} else {
|
||
self.bump_geometry_no_blocks();
|
||
}
|
||
}
|
||
handle
|
||
}
|
||
|
||
/// Returns the RGBA color for the given layer name.
|
||
pub fn layer_color(&self, layer: &str) -> [f32; 4] {
|
||
let layer_entry = self.document.layers.get(layer);
|
||
let color = layer_entry
|
||
.map(|l| &l.color)
|
||
.unwrap_or(&acadrust::types::Color::WHITE);
|
||
let [r, g, b, _] = crate::scene::convert::tess_util::aci_to_rgba(color);
|
||
[r, g, b, 1.0]
|
||
}
|
||
|
||
pub fn custom_block_names(&self) -> Vec<String> {
|
||
self.document
|
||
.block_records
|
||
.iter()
|
||
.filter(|br| !br.is_standard() && !br.is_layout())
|
||
.map(|br| br.name.clone())
|
||
.collect()
|
||
}
|
||
|
||
pub fn create_block_from_entities(
|
||
&mut self,
|
||
handles: &[Handle],
|
||
name: &str,
|
||
base: glam::Vec3,
|
||
) -> Result<Handle, String> {
|
||
let name = name.trim();
|
||
if name.is_empty() {
|
||
return Err("Block name cannot be empty.".into());
|
||
}
|
||
if name.starts_with('*') {
|
||
return Err("Block name cannot start with '*'.".into());
|
||
}
|
||
if self.document.block_records.get(name).is_some() {
|
||
return Err(format!("Block \"{name}\" already exists."));
|
||
}
|
||
|
||
let source_entities: Vec<_> = handles
|
||
.iter()
|
||
.filter_map(|&h| self.document.get_entity(h).cloned().map(|e| (h, e)))
|
||
.collect();
|
||
if source_entities.is_empty() {
|
||
return Err("No valid entities selected for block creation.".into());
|
||
}
|
||
|
||
let next = self.document.next_handle();
|
||
let br_handle = Handle::new(next);
|
||
let block_handle = Handle::new(next + 1);
|
||
let end_handle = Handle::new(next + 2);
|
||
|
||
let mut block_record = acadrust::tables::BlockRecord::new(name);
|
||
block_record.handle = br_handle;
|
||
block_record.block_entity_handle = block_handle;
|
||
block_record.block_end_handle = end_handle;
|
||
self.document
|
||
.block_records
|
||
.add(block_record)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let mut block = Block::new(name, acadrust::types::Vector3::ZERO);
|
||
block.common.handle = block_handle;
|
||
block.common.owner_handle = br_handle;
|
||
self.document
|
||
.add_entity(EntityType::Block(block))
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let mut block_end = BlockEnd::new();
|
||
block_end.common.handle = end_handle;
|
||
block_end.common.owner_handle = br_handle;
|
||
self.document
|
||
.add_entity(EntityType::BlockEnd(block_end))
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let local = EntityTransform::Translate((-base).as_dvec3());
|
||
for (old_handle, mut entity) in source_entities {
|
||
view::dispatch::apply_transform(&mut entity, &local);
|
||
entity = crate::modules::draw::modify::explode::normalize_entity_for_block(entity);
|
||
entity.common_mut().handle = Handle::NULL;
|
||
entity.common_mut().owner_handle = br_handle;
|
||
self.document
|
||
.add_entity(entity)
|
||
.map_err(|e| e.to_string())?;
|
||
self.erase_entities(&[old_handle]);
|
||
}
|
||
|
||
let insert = DxfInsert::new(
|
||
name,
|
||
acadrust::types::Vector3::new(base.x as f64, base.y as f64, base.z as f64),
|
||
);
|
||
Ok(self.add_entity(EntityType::Insert(insert)))
|
||
}
|
||
|
||
/// Define a new block named `name` from `entities` (owned, not yet in the
|
||
/// document), with `base` as its insertion origin. Unlike
|
||
/// [`create_block_from_entities`] this does NOT place an insert — the
|
||
/// caller starts an interactive insert so paste-as-block can prompt for the
|
||
/// drop point. The geometry comes from the clipboard rather than live
|
||
/// entities, so there is nothing to stage or erase. (#129)
|
||
pub fn define_block_from_owned_entities(
|
||
&mut self,
|
||
entities: Vec<EntityType>,
|
||
name: &str,
|
||
base: glam::DVec3,
|
||
) -> Result<(), String> {
|
||
let name = name.trim();
|
||
if name.is_empty() {
|
||
return Err("Block name cannot be empty.".into());
|
||
}
|
||
if name.starts_with('*') {
|
||
return Err("Block name cannot start with '*'.".into());
|
||
}
|
||
if self.document.block_records.get(name).is_some() {
|
||
return Err(format!("Block \"{name}\" already exists."));
|
||
}
|
||
if entities.is_empty() {
|
||
return Err("Nothing to make into a block.".into());
|
||
}
|
||
|
||
let next = self.document.next_handle();
|
||
let br_handle = Handle::new(next);
|
||
let block_handle = Handle::new(next + 1);
|
||
let end_handle = Handle::new(next + 2);
|
||
|
||
let mut block_record = acadrust::tables::BlockRecord::new(name);
|
||
block_record.handle = br_handle;
|
||
block_record.block_entity_handle = block_handle;
|
||
block_record.block_end_handle = end_handle;
|
||
self.document
|
||
.block_records
|
||
.add(block_record)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let mut block = Block::new(name, acadrust::types::Vector3::ZERO);
|
||
block.common.handle = block_handle;
|
||
block.common.owner_handle = br_handle;
|
||
self.document
|
||
.add_entity(EntityType::Block(block))
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let mut block_end = BlockEnd::new();
|
||
block_end.common.handle = end_handle;
|
||
block_end.common.owner_handle = br_handle;
|
||
self.document
|
||
.add_entity(EntityType::BlockEnd(block_end))
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let local = EntityTransform::Translate(-base);
|
||
for mut entity in entities {
|
||
view::dispatch::apply_transform(&mut entity, &local);
|
||
entity = crate::modules::draw::modify::explode::normalize_entity_for_block(entity);
|
||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||
entity.common_mut().handle = Handle::NULL;
|
||
entity.common_mut().owner_handle = br_handle;
|
||
self.document
|
||
.add_entity(entity)
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
// Block defns don't render on their own, but the geometry cache must
|
||
// pick up the new definition so the interactive insert can preview it.
|
||
self.bump_geometry();
|
||
Ok(())
|
||
}
|
||
|
||
/// Recreate a block definition verbatim — the entities are already in
|
||
/// block-local coordinates (unlike `define_block_from_owned_entities`,
|
||
/// which folds in a base offset). No-op if the block already exists.
|
||
/// Used when pasting an INSERT whose block this drawing lacks. (#135)
|
||
pub fn define_block_raw(
|
||
&mut self,
|
||
name: &str,
|
||
base_point: acadrust::types::Vector3,
|
||
entities: Vec<EntityType>,
|
||
) {
|
||
if name.is_empty() || self.document.block_records.get(name).is_some() {
|
||
return;
|
||
}
|
||
let next = self.document.next_handle();
|
||
let br_handle = Handle::new(next);
|
||
let block_handle = Handle::new(next + 1);
|
||
let end_handle = Handle::new(next + 2);
|
||
|
||
let mut block_record = acadrust::tables::BlockRecord::new(name);
|
||
block_record.handle = br_handle;
|
||
block_record.block_entity_handle = block_handle;
|
||
block_record.block_end_handle = end_handle;
|
||
if self.document.block_records.add(block_record).is_err() {
|
||
return;
|
||
}
|
||
|
||
let mut block = Block::new(name, base_point);
|
||
block.common.handle = block_handle;
|
||
block.common.owner_handle = br_handle;
|
||
let _ = self.document.add_entity(EntityType::Block(block));
|
||
|
||
let mut block_end = BlockEnd::new();
|
||
block_end.common.handle = end_handle;
|
||
block_end.common.owner_handle = br_handle;
|
||
let _ = self.document.add_entity(EntityType::BlockEnd(block_end));
|
||
|
||
for mut entity in entities {
|
||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||
entity.common_mut().handle = Handle::NULL;
|
||
entity.common_mut().owner_handle = br_handle;
|
||
let _ = self.document.add_entity(entity);
|
||
}
|
||
self.bump_geometry();
|
||
}
|
||
|
||
fn synced_hatch_models(&self) -> Vec<HatchModel> {
|
||
let layout_block = self.current_layout_block_handle();
|
||
|
||
let layer_hidden = |layer: &str| {
|
||
self.document
|
||
.layers
|
||
.get(layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
};
|
||
|
||
// synced_hatch_models is cached on geometry_epoch and the GPU
|
||
// upload is keyed on geometry_epoch only (see render.rs — hatch
|
||
// buffers are "static"). Don't view-cull here; the per-frame
|
||
// skip flag in compute_hatch_lod handles frustum + sub-pixel
|
||
// culling at draw time, which keeps the GPU upload set stable
|
||
// across pan/zoom.
|
||
//
|
||
// We INCLUDE hatches from blocks other than `current_layout`'s
|
||
// own block (specifically: paper-layout content viewports want
|
||
// model-block hatches). Every hatch's `world_origin` is already
|
||
// baked into the correct block coord-space at
|
||
// `populate_hatches_from_document` time (offset for model, 0 for
|
||
// paper), so projecting them through a camera built for the
|
||
// wrong block lands them outside the frustum and the per-vp
|
||
// scissor / LOD culls them out — no double-rendering.
|
||
let depth_map = self.draw_depth_map();
|
||
let mut models: Vec<HatchModel> = self
|
||
.hatches
|
||
.iter()
|
||
.filter(|(&handle, _)| {
|
||
let Some(entity) = self.document.get_entity(handle) else {
|
||
return true;
|
||
};
|
||
let c = entity.common();
|
||
if c.invisible || layer_hidden(&c.layer) {
|
||
return false;
|
||
}
|
||
// Reject block-defn-only hatches (entities owned by a
|
||
// BLOCK record that's neither model nor a paper layout
|
||
// block) — they're tessellated separately via Insert
|
||
// explosion and only the laid-out copies should appear.
|
||
self.belongs_to_visible_block(handle, c.owner_handle, layout_block)
|
||
|| self.belongs_to_visible_block(
|
||
handle,
|
||
c.owner_handle,
|
||
self.model_space_block_handle(),
|
||
)
|
||
})
|
||
.map(|(&handle, model)| {
|
||
let entity = self.document.get_entity(handle);
|
||
let mut m = model.clone();
|
||
if let Some(e) = entity {
|
||
m.color = self.render_style(e).0;
|
||
if let EntityType::Hatch(dxf) = e {
|
||
match &mut m.pattern {
|
||
model::hatch_model::HatchPattern::Pattern(_) => {
|
||
m.angle_offset = dxf.pattern_angle as f32;
|
||
let anno = if self.current_layout == "Model" {
|
||
self.annotation_scale
|
||
} else {
|
||
1.0
|
||
};
|
||
m.scale = dxf.pattern_scale as f32 * anno;
|
||
}
|
||
model::hatch_model::HatchPattern::Gradient { angle_deg, .. } => {
|
||
*angle_deg = dxf.pattern_angle.to_degrees() as f32;
|
||
}
|
||
model::hatch_model::HatchPattern::Solid => {}
|
||
}
|
||
}
|
||
}
|
||
if self.selected.contains(&handle) {
|
||
m.color = [0.15, 0.55, 1.00, m.color[3]];
|
||
}
|
||
m.draw_depth = depth_map.get(&handle.value()).copied().unwrap_or(0.0);
|
||
m
|
||
})
|
||
.collect();
|
||
|
||
for entity in self.document.entities() {
|
||
let EntityType::Insert(ins) = entity else {
|
||
continue;
|
||
};
|
||
if ins.common.invisible || layer_hidden(&ins.common.layer) {
|
||
continue;
|
||
}
|
||
if !self.belongs_to_visible_block(
|
||
ins.common.handle,
|
||
ins.common.owner_handle,
|
||
layout_block,
|
||
) {
|
||
continue;
|
||
}
|
||
let selected = self.selected.contains(&ins.common.handle);
|
||
// XCLIP: clip this insert's exploded hatch fills to the boundary,
|
||
// matching how the line geometry is clipped in expand_insert.
|
||
let clip_poly = pick::xclip::insert_spatial_filter(&self.document, ins)
|
||
.map(|sf| pick::xclip::world_clip_polygon(sf, ins));
|
||
// Walk the full block tree: `explode_from_document` only descends
|
||
// one level, so nested INSERTs are re-exploded here. Each level
|
||
// bakes its transform into the children it returns, so nested
|
||
// hatches land in the correct world position. A depth guard keeps
|
||
// a malformed cyclic block reference from looping forever.
|
||
let normalize = crate::modules::draw::modify::explode::normalize_insert_entity;
|
||
let mut stack: Vec<(EntityType, usize)> = ins
|
||
.explode_from_document(&self.document)
|
||
.into_iter()
|
||
.map(|e| (normalize(e), 0usize))
|
||
.collect();
|
||
while let Some((sub, depth)) = stack.pop() {
|
||
match sub {
|
||
EntityType::Insert(nins) => {
|
||
if depth >= 32 {
|
||
continue;
|
||
}
|
||
for e in nins.explode_from_document(&self.document) {
|
||
stack.push((normalize(e), depth + 1));
|
||
}
|
||
}
|
||
EntityType::Hatch(dxf) => {
|
||
if dxf.common.invisible || layer_hidden(&dxf.common.layer) {
|
||
continue;
|
||
}
|
||
let color = self.render_style(&EntityType::Hatch(dxf.clone())).0;
|
||
if let Some(mut model) =
|
||
Self::hatch_model_from_dxf(&dxf, color)
|
||
{
|
||
if let Some(poly) = &clip_poly {
|
||
let clipped = pick::xclip::clip_hatch_boundary(
|
||
&model.boundary,
|
||
model.world_origin,
|
||
poly,
|
||
);
|
||
if clipped.is_empty() {
|
||
continue;
|
||
}
|
||
model.boundary = std::sync::Arc::new(clipped);
|
||
}
|
||
if selected {
|
||
model.color = [0.15, 0.55, 1.00, model.color[3]];
|
||
}
|
||
models.push(model);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Wide LWPolyline and Polyline2D fills
|
||
let [ox, oy, _] = [0.0_f64; 3];
|
||
let ox = ox as f32;
|
||
let oy = oy as f32;
|
||
for entity in self.document.entities() {
|
||
let (common, fills) = match entity {
|
||
EntityType::LwPolyline(pl) => (&pl.common, crate::entities::lwpolyline::wide_fills(pl)),
|
||
EntityType::Polyline2D(pl) => (&pl.common, crate::entities::polyline::wide_fills(pl)),
|
||
_ => continue,
|
||
};
|
||
if fills.is_empty() {
|
||
continue;
|
||
}
|
||
if common.invisible || layer_hidden(&common.layer) {
|
||
continue;
|
||
}
|
||
if !self.belongs_to_visible_block(common.handle, common.owner_handle, layout_block) {
|
||
continue;
|
||
}
|
||
let base_color = self.render_style(entity).0;
|
||
let selected = self.selected.contains(&common.handle);
|
||
let color = if selected {
|
||
[0.15, 0.55, 1.00, 1.0]
|
||
} else {
|
||
base_color
|
||
};
|
||
for mut boundary in fills {
|
||
// Wires subtract world_offset during tessellation; fills must
|
||
// match or the band drifts away from the centerline on
|
||
// drawings far from origin.
|
||
for p in boundary.iter_mut() {
|
||
p[0] -= ox;
|
||
p[1] -= oy;
|
||
}
|
||
models.push(HatchModel {
|
||
boundary: Arc::new(boundary),
|
||
pattern: model::hatch_model::HatchPattern::Solid,
|
||
name: "SOLID".into(),
|
||
color,
|
||
angle_offset: 0.0,
|
||
scale: 1.0,
|
||
world_origin: [0.0; 2],
|
||
vp_scissor: None,
|
||
draw_depth: depth_map.get(&common.handle.value()).copied().unwrap_or(0.0),
|
||
});
|
||
}
|
||
}
|
||
|
||
models
|
||
}
|
||
|
||
/// Wipeout fill models — rendered in a separate pass AFTER wires so that
|
||
/// wipeouts correctly mask everything below them in the draw order.
|
||
pub(super) fn wipeout_models(&self) -> Vec<HatchModel> {
|
||
let is_paper = self.current_layout != "Model";
|
||
let bg_color: [f32; 4] = if is_paper {
|
||
self.paper_bg_color
|
||
} else {
|
||
self.bg_color
|
||
};
|
||
// No per-frame view-cull here: GPU wipeout buffer upload is
|
||
// gated on geometry_epoch only (see render.rs), so any cull at
|
||
// build time would freeze the visible subset at the geometry
|
||
// epoch boundary and never re-evaluate as the user pans. The
|
||
// pipeline's `wipeout_skip_flags` (compute_wipeout_lod) does
|
||
// the per-frame skip at draw time instead.
|
||
let mut models = Vec::new();
|
||
for entity in self.document.entities() {
|
||
let EntityType::Wipeout(wo) = entity else {
|
||
continue;
|
||
};
|
||
if entity.common().invisible {
|
||
continue;
|
||
}
|
||
if self
|
||
.document
|
||
.layers
|
||
.get(&entity.common().layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
{
|
||
continue;
|
||
}
|
||
// Per-entity world_offset selection so paper-layout content
|
||
// viewports still see model-block wipeouts at the right local
|
||
// coordinates (same rationale as hatches).
|
||
let boundary = Self::wipeout_boundary_2d(wo);
|
||
if boundary.len() >= 3 {
|
||
let mut fill_color = bg_color;
|
||
if self.selected.contains(&wo.common.handle) {
|
||
fill_color = [0.15, 0.55, 1.00, 0.35];
|
||
}
|
||
models.push(HatchModel {
|
||
boundary: Arc::new(boundary),
|
||
pattern: model::hatch_model::HatchPattern::Solid,
|
||
name: "WIPEOUT_FILL".into(),
|
||
color: fill_color,
|
||
angle_offset: 0.0,
|
||
scale: 1.0,
|
||
world_origin: [0.0; 2],
|
||
vp_scissor: None,
|
||
draw_depth: 0.0,
|
||
});
|
||
}
|
||
}
|
||
models
|
||
}
|
||
|
||
/// Compute the 2D (XY) boundary polygon for a Wipeout entity.
|
||
fn wipeout_boundary_2d(
|
||
wo: &acadrust::entities::Wipeout,
|
||
) -> Vec<[f32; 2]> {
|
||
use acadrust::entities::WipeoutClipType;
|
||
|
||
let [wox, woy, _woz] = [0.0_f64; 3];
|
||
let is_polygon = wo.clipping_enabled
|
||
&& wo.clip_boundary_vertices.len() >= 3
|
||
&& matches!(wo.clip_type, WipeoutClipType::Polygonal);
|
||
|
||
if is_polygon {
|
||
let ox = (wo.insertion_point.x - wox) as f32;
|
||
let oy = (wo.insertion_point.y - woy) as f32;
|
||
// DXF clip vertices live in image-pixel space, centred on the
|
||
// image (range −size/2 … +size/2). Image-bottom-left → insertion,
|
||
// image-y-axis points DOWN (per the DXF "v_vector points down the
|
||
// image" convention), so map:
|
||
// x_off = (clip.x + size.x/2) × u_vec
|
||
// y_off = (size.y/2 − clip.y) × v_vec ← y flipped
|
||
let cx_of = |v: &acadrust::types::Vector2| v.x + wo.size.x * 0.5;
|
||
let cy_of = |v: &acadrust::types::Vector2| wo.size.y * 0.5 - v.y;
|
||
let mut poly: Vec<[f32; 2]> = wo
|
||
.clip_boundary_vertices
|
||
.iter()
|
||
.map(|v| {
|
||
let cx = cx_of(v);
|
||
let cy = cy_of(v);
|
||
let wx = (wo.u_vector.x * cx + wo.v_vector.x * cy) as f32;
|
||
let wy = (wo.u_vector.y * cx + wo.v_vector.y * cy) as f32;
|
||
[ox + wx, oy + wy]
|
||
})
|
||
.collect();
|
||
// Close the loop: the GPU `in_polygon` ray-cast walks
|
||
// sequential pairs and doesn't wrap, so without an explicit
|
||
// closing vertex the last edge (vN-1 → v0) is never tested and
|
||
// the fill bleeds far past the boundary.
|
||
if let Some(&first) = poly.first() {
|
||
if poly.last() != Some(&first) {
|
||
poly.push(first);
|
||
}
|
||
}
|
||
poly
|
||
} else {
|
||
// Rectangular boundary from 4 corners.
|
||
let ox = (wo.insertion_point.x - wox) as f32;
|
||
let oy = (wo.insertion_point.y - woy) as f32;
|
||
let oz = wo.insertion_point.z as f32;
|
||
let ux = (wo.u_vector.x * wo.size.x) as f32;
|
||
let uy = (wo.u_vector.y * wo.size.x) as f32;
|
||
let vx = (wo.v_vector.x * wo.size.y) as f32;
|
||
let vy = (wo.v_vector.y * wo.size.y) as f32;
|
||
let _ = oz;
|
||
// Close the loop (repeat corner 0): the GPU `in_polygon` ray-cast
|
||
// walks sequential vertex pairs and never wraps last→first, so an
|
||
// unclosed quad leaves the v3→v0 edge untested and the solid mask
|
||
// bleeds past the boundary — same reason the polygon branch closes.
|
||
vec![
|
||
[ox, oy],
|
||
[ox + ux, oy + uy],
|
||
[ox + ux + vx, oy + uy + vy],
|
||
[ox + vx, oy + vy],
|
||
[ox, oy],
|
||
]
|
||
}
|
||
}
|
||
|
||
fn hatch_model_from_dxf(
|
||
dxf: &DxfHatch,
|
||
color: [f32; 4],
|
||
) -> Option<HatchModel> {
|
||
let [ox, oy, _oz] = [0.0_f64; 3];
|
||
let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z);
|
||
// Build the boundary in f64 first so the precision-preserving
|
||
// origin computation below sees full WCS precision. We only cast
|
||
// to f32 once at the end, after subtracting the AABB centre, so
|
||
// the stored offsets are small-magnitude with high f32 precision
|
||
// even on large UTM-scale drawings.
|
||
let to_xy = |x: f64, y: f64| -> [f64; 2] {
|
||
let (wx, wy, _) =
|
||
crate::scene::view::transform::ocs_point_to_wcs((x, y, dxf.elevation), normal);
|
||
[wx - ox, wy - oy]
|
||
};
|
||
if dxf.paths.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut boundary: Vec<[f64; 2]> = Vec::new();
|
||
|
||
for path in &dxf.paths {
|
||
let before_path = boundary.len();
|
||
if !boundary.is_empty() {
|
||
boundary.push([f64::NAN, f64::NAN]);
|
||
}
|
||
let path_start = boundary.len();
|
||
|
||
for edge in &path.edges {
|
||
match edge {
|
||
BoundaryEdge::Polyline(poly) => {
|
||
let verts = &poly.vertices;
|
||
let count = verts.len();
|
||
if count == 0 {
|
||
continue;
|
||
}
|
||
let seg_count = if poly.is_closed {
|
||
count
|
||
} else {
|
||
count.saturating_sub(1)
|
||
};
|
||
for i in 0..seg_count {
|
||
let v0 = &verts[i];
|
||
let v1 = &verts[(i + 1) % count];
|
||
let bulge = v0.z;
|
||
// Tess in f64 to preserve ~1 cm precision at
|
||
// UTM-scale WCS (the f32 path used to produce
|
||
// visibly wavy hatch arcs at 1e5+ magnitude).
|
||
let arc = if bulge.abs() < 1e-9 {
|
||
None
|
||
} else {
|
||
crate::entities::common::BulgeArc::from_bulge(
|
||
[v0.x, v0.y],
|
||
[v1.x, v1.y],
|
||
bulge,
|
||
)
|
||
};
|
||
let Some(arc) = arc else {
|
||
boundary.push(to_xy(v0.x, v0.y));
|
||
continue;
|
||
};
|
||
let segs = convert::tess_util::arc_segments(
|
||
arc.radius,
|
||
arc.sweep.abs(),
|
||
convert::tess_util::fill_chord_tol(arc.radius),
|
||
);
|
||
for j in 0..segs {
|
||
let s = arc.sample(j as f64 / segs as f64);
|
||
boundary.push(to_xy(s[0], s[1]));
|
||
}
|
||
}
|
||
if poly.is_closed {
|
||
if let Some(&first) = boundary.get(path_start) {
|
||
boundary.push(first);
|
||
}
|
||
}
|
||
}
|
||
BoundaryEdge::Line(line) => {
|
||
boundary.push(to_xy(line.start.x, line.start.y));
|
||
boundary.push(to_xy(line.end.x, line.end.y));
|
||
}
|
||
BoundaryEdge::CircularArc(arc) => {
|
||
let (sa, span) = convert::tess_util::arc_signed_span(
|
||
arc.start_angle,
|
||
arc.end_angle,
|
||
arc.counter_clockwise,
|
||
);
|
||
let segs = convert::tess_util::arc_segments(
|
||
arc.radius,
|
||
span.abs(),
|
||
convert::tess_util::fill_chord_tol(arc.radius),
|
||
);
|
||
for i in 0..=segs {
|
||
let t = sa + span * (i as f64 / segs as f64);
|
||
boundary.push(to_xy(
|
||
arc.center.x + arc.radius * t.cos(),
|
||
arc.center.y + arc.radius * t.sin(),
|
||
));
|
||
}
|
||
}
|
||
BoundaryEdge::EllipticArc(ell) => {
|
||
let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x
|
||
+ ell.major_axis_endpoint.y * ell.major_axis_endpoint.y)
|
||
.sqrt();
|
||
let r_min = r_maj * ell.minor_axis_ratio;
|
||
let rot = ell
|
||
.major_axis_endpoint
|
||
.y
|
||
.atan2(ell.major_axis_endpoint.x);
|
||
let (sa, span) = convert::tess_util::arc_signed_span(
|
||
ell.start_angle,
|
||
ell.end_angle,
|
||
ell.counter_clockwise,
|
||
);
|
||
let segs = convert::tess_util::arc_segments(
|
||
r_maj,
|
||
span.abs(),
|
||
convert::tess_util::fill_chord_tol(r_maj),
|
||
);
|
||
let (cr, sr) = (rot.cos(), rot.sin());
|
||
for i in 0..=segs {
|
||
let t = sa + span * (i as f64 / segs as f64);
|
||
let lx = r_maj * t.cos();
|
||
let ly = r_min * t.sin();
|
||
boundary.push(to_xy(
|
||
ell.center.x + lx * cr - ly * sr,
|
||
ell.center.y + lx * sr + ly * cr,
|
||
));
|
||
}
|
||
}
|
||
BoundaryEdge::Spline(spline) => {
|
||
// DXF spline control_points pack (x, y, weight) into
|
||
// a Vector3 — the z field is the rational weight, NOT
|
||
// a Z coordinate. The legacy code dropped weight and
|
||
// sampled with a fixed 16 segments; both bugs
|
||
// produced visibly wrong fill regions for spline-
|
||
// bounded hatches (especially block-internal ones,
|
||
// where boundaries are often spline curves with
|
||
// rational weights and short cubic segments).
|
||
//
|
||
// Build a NurbsCurve when `rational`, otherwise a
|
||
// plain BSplineCurve, and sample adaptively via
|
||
// truck's `parameter_division` at the same chord
|
||
// tolerance the fill polygon uses for arcs.
|
||
let degree = spline.degree.max(0) as usize;
|
||
let knot_vec = if !spline.knots.is_empty() {
|
||
KnotVec::from(spline.knots.clone())
|
||
} else if spline.control_points.len() >= degree + 1 {
|
||
KnotVec::uniform_knot(degree, spline.control_points.len() - 1)
|
||
} else {
|
||
KnotVec::from(vec![])
|
||
};
|
||
let knot_ok = spline.control_points.len() >= 2
|
||
&& degree >= 1
|
||
&& knot_vec.len() == spline.control_points.len() + degree + 1;
|
||
|
||
// Rough chord-tolerance: 0.1% of the control-poly
|
||
// diagonal so adaptive sampling produces enough
|
||
// points to follow the curve without exploding on
|
||
// huge splines.
|
||
let (mut sp_min_x, mut sp_min_y) = (f64::INFINITY, f64::INFINITY);
|
||
let (mut sp_max_x, mut sp_max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||
for cp in &spline.control_points {
|
||
sp_min_x = sp_min_x.min(cp.x);
|
||
sp_min_y = sp_min_y.min(cp.y);
|
||
sp_max_x = sp_max_x.max(cp.x);
|
||
sp_max_y = sp_max_y.max(cp.y);
|
||
}
|
||
let diag = ((sp_max_x - sp_min_x).powi(2)
|
||
+ (sp_max_y - sp_min_y).powi(2))
|
||
.sqrt();
|
||
let tol = convert::tess_util::fill_chord_tol(diag.max(1.0));
|
||
|
||
let mut sampled = false;
|
||
if knot_ok {
|
||
if spline.rational {
|
||
// NURBS: pack (x, y, 0, w) into Vector4.
|
||
let cps: Vec<Vector4> = spline
|
||
.control_points
|
||
.iter()
|
||
.map(|p| {
|
||
let w = if p.z.abs() > 1e-12 { p.z } else { 1.0 };
|
||
Vector4::new(p.x * w, p.y * w, 0.0, w)
|
||
})
|
||
.collect();
|
||
let bspl = TruckBSpline::new(knot_vec.clone(), cps);
|
||
let curve = NurbsCurve::new(bspl);
|
||
let (t0, t1) = curve.range_tuple();
|
||
let (_, pts) = curve.parameter_division((t0, t1), tol);
|
||
for p in pts {
|
||
boundary.push(to_xy(p.x, p.y));
|
||
}
|
||
sampled = true;
|
||
} else {
|
||
let cps: Vec<Point3> = spline
|
||
.control_points
|
||
.iter()
|
||
.map(|p| Point3::new(p.x, p.y, 0.0))
|
||
.collect();
|
||
let bspl = TruckBSpline::new(knot_vec, cps);
|
||
let (t0, t1) = bspl.range_tuple();
|
||
let (_, pts) = bspl.parameter_division((t0, t1), tol);
|
||
for p in pts {
|
||
boundary.push(to_xy(p.x, p.y));
|
||
}
|
||
sampled = true;
|
||
}
|
||
}
|
||
if !sampled {
|
||
// Fallback: prefer fit_points (which lie on the
|
||
// curve) over control_points (which usually
|
||
// don't). A control-point polyline would draw
|
||
// the convex-hull silhouette — visibly wrong.
|
||
let pts: &[_] = if !spline.fit_points.is_empty() {
|
||
&spline.fit_points
|
||
} else {
|
||
&[]
|
||
};
|
||
if !pts.is_empty() {
|
||
for p in pts {
|
||
boundary.push(to_xy(p.x, p.y));
|
||
}
|
||
} else {
|
||
for cp in &spline.control_points {
|
||
boundary.push(to_xy(cp.x, cp.y));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if boundary.len() == path_start {
|
||
boundary.truncate(before_path);
|
||
continue;
|
||
}
|
||
if boundary.len() >= path_start + 3 {
|
||
let first = boundary[path_start];
|
||
let last = *boundary.last().unwrap();
|
||
if (first[0] - last[0]).abs() > 1e-5 || (first[1] - last[1]).abs() > 1e-5 {
|
||
boundary.push(first);
|
||
}
|
||
}
|
||
}
|
||
|
||
if boundary.is_empty() {
|
||
return None;
|
||
}
|
||
boundary.truncate(model::hatch_model::MAX_HATCH_BOUNDARY_VERTS);
|
||
|
||
let pattern = if dxf.gradient_color.is_enabled() {
|
||
let color2 = dxf
|
||
.gradient_color
|
||
.colors
|
||
.get(1)
|
||
.and_then(|e| e.color.rgb())
|
||
.map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
|
||
.unwrap_or(color);
|
||
let angle_deg = dxf.pattern_angle.to_degrees() as f32;
|
||
model::hatch_model::HatchPattern::Gradient { angle_deg, color2 }
|
||
} else if dxf.is_solid {
|
||
model::hatch_model::HatchPattern::Solid
|
||
} else {
|
||
let pat_name = &dxf.pattern.name;
|
||
if let Some(entry) = crate::scene::model::hatch_patterns::find(pat_name) {
|
||
entry.gpu.clone()
|
||
} else {
|
||
model::hatch_model::HatchPattern::Pattern(vec![model::hatch_model::PatFamily {
|
||
angle_deg: 0.0,
|
||
x0: 0.0,
|
||
y0: 0.0,
|
||
dx: 0.0,
|
||
dy: 5.0 * dxf.pattern_scale as f32,
|
||
dashes: vec![],
|
||
}])
|
||
}
|
||
};
|
||
|
||
let name = if dxf.gradient_color.is_enabled() {
|
||
dxf.gradient_color.name.clone()
|
||
} else if dxf.is_solid {
|
||
"SOLID".into()
|
||
} else {
|
||
dxf.pattern.name.clone()
|
||
};
|
||
|
||
// Precision-preserving cast f64 → f32: pick an `world_origin`
|
||
// anchor (boundary AABB centre in f64) and store every vertex
|
||
// as a small f32 offset from it. NaN separators are preserved
|
||
// so the in_polygon ray-cast still sees the path breaks.
|
||
let mut min_x = f64::INFINITY;
|
||
let mut min_y = f64::INFINITY;
|
||
let mut max_x = f64::NEG_INFINITY;
|
||
let mut max_y = f64::NEG_INFINITY;
|
||
for &[x, y] in &boundary {
|
||
if x.is_finite() && y.is_finite() {
|
||
if x < min_x { min_x = x; }
|
||
if y < min_y { min_y = y; }
|
||
if x > max_x { max_x = x; }
|
||
if y > max_y { max_y = y; }
|
||
}
|
||
}
|
||
let world_origin = if min_x.is_finite() && min_y.is_finite() {
|
||
[(min_x + max_x) * 0.5, (min_y + max_y) * 0.5]
|
||
} else {
|
||
[0.0, 0.0]
|
||
};
|
||
let boundary_f32: Vec<[f32; 2]> = boundary
|
||
.iter()
|
||
.map(|&[x, y]| {
|
||
if x.is_finite() && y.is_finite() {
|
||
[(x - world_origin[0]) as f32, (y - world_origin[1]) as f32]
|
||
} else {
|
||
[f32::NAN, f32::NAN]
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
Some(HatchModel {
|
||
boundary: std::sync::Arc::new(boundary_f32),
|
||
pattern,
|
||
name,
|
||
color,
|
||
angle_offset: dxf.pattern_angle as f32,
|
||
scale: dxf.pattern_scale as f32,
|
||
world_origin,
|
||
vp_scissor: None,
|
||
draw_depth: 0.0,
|
||
})
|
||
}
|
||
|
||
/// Decode and cache all RasterImage entities from the current document.
|
||
/// Silently skips images whose files cannot be read.
|
||
pub fn populate_images_from_document(&mut self) {
|
||
self.images.clear();
|
||
let entries: Vec<(Handle, acadrust::entities::RasterImage)> = self
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let EntityType::RasterImage(img) = e {
|
||
Some((img.common.handle, img.clone()))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
for (handle, img) in entries {
|
||
if let Some(model) = ImageModel::from_raster_image(&img) {
|
||
self.images.insert(handle, model);
|
||
}
|
||
}
|
||
self.bump_geometry();
|
||
}
|
||
|
||
pub fn populate_hatches_from_document(&mut self) {
|
||
self.hatches.clear();
|
||
|
||
let entries: Vec<(Handle, EntityType)> = self
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| match e {
|
||
EntityType::Hatch(h) => Some((h.common.handle, e.clone())),
|
||
EntityType::Solid(s) => Some((s.common.handle, e.clone())),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
|
||
use crate::par::prelude::*;
|
||
self.hatches = entries
|
||
.into_par_iter()
|
||
.filter_map(|(handle, kind)| {
|
||
// Paper-space entities live in sheet coordinates — world_offset must not
|
||
let model = match &kind {
|
||
EntityType::Hatch(dxf) => {
|
||
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
||
Self::hatch_model_from_dxf(dxf, color)
|
||
}
|
||
EntityType::Solid(solid) => {
|
||
let color = convert::tess_util::aci_to_rgba(&solid.common.color);
|
||
Some(Self::solid_hatch_model(solid, color))
|
||
}
|
||
_ => None,
|
||
};
|
||
model.map(|m| (handle, m))
|
||
})
|
||
.collect();
|
||
|
||
self.bump_geometry();
|
||
}
|
||
|
||
/// Tessellate all `Solid3D` entities in the current document into
|
||
/// GPU-ready `MeshModel`s and store them in `self.meshes`.
|
||
///
|
||
/// Called after loading a document or after undo/redo so that every
|
||
/// `Solid3D` entity is represented in the mesh cache.
|
||
pub fn populate_meshes_from_document(&mut self) {
|
||
self.meshes.clear();
|
||
self.block_meshes.clear();
|
||
// BLOCK-entity handles of the layout (model + paper) blocks. A solid
|
||
// owned by one of these is top-level; anything else lives in a block
|
||
// definition and is instanced per INSERT instead. (#123)
|
||
let layout_blocks: std::collections::HashSet<Handle> = self
|
||
.document
|
||
.objects
|
||
.values()
|
||
.filter_map(|o| match o {
|
||
acadrust::objects::ObjectType::Layout(l) if !l.block_record.is_null() => {
|
||
Some(l.block_record)
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
// Resolve color through `render_style` so the same bg adaptation
|
||
// wires use kicks in (pure black on dark bg → white, pure white
|
||
// on light bg → black). Without this, ACIS meshes ignore
|
||
// `adapt_to_bg` and stay invisible against matching bg colours.
|
||
let entries: Vec<(Handle, EntityType, [f32; 4], bool)> = self
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| match e {
|
||
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) => {
|
||
let color = self.render_style(e).0;
|
||
let top_level = layout_blocks.contains(&e.common().owner_handle);
|
||
Some((e.common().handle, e.clone(), color, top_level))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
|
||
use crate::par::prelude::*;
|
||
let facet_res = self.document.header.facet_resolution;
|
||
// Top-level solids: offset into the render frame, drawn flat.
|
||
// Block-definition solids: keep block-local coords for per-INSERT
|
||
// instancing (no offset applied here).
|
||
let built: Vec<(Handle, MeshLodSet, bool)> = entries
|
||
.into_par_iter()
|
||
.filter_map(|(handle, entity, color, top_level)| {
|
||
crate::entities::solid3d::tessellate_volume(&entity, color, facet_res).map(|m| {
|
||
let m = if top_level { offset_mesh_lod_set(m) } else { m };
|
||
(handle, m, top_level)
|
||
})
|
||
})
|
||
.collect();
|
||
for (handle, m, top_level) in built {
|
||
if top_level {
|
||
self.meshes.insert(handle, m);
|
||
} else {
|
||
self.block_meshes.insert(handle, m);
|
||
}
|
||
}
|
||
|
||
self.bump_geometry();
|
||
}
|
||
|
||
/// Rebuild hatch / image / mesh caches after the document is modified
|
||
/// outside the normal `add_entity` path (e.g. REFCLOSE SAVE).
|
||
pub fn rebuild_derived_caches(&mut self) {
|
||
self.populate_hatches_from_document();
|
||
self.populate_images_from_document();
|
||
self.populate_meshes_from_document();
|
||
}
|
||
|
||
/// Build a solid-fill HatchModel for a DXF Solid entity.
|
||
/// DXF SOLID corners are in "Z-order": p0-p1 top, p2-p3 bottom.
|
||
/// Visual quad is p0→p1→p3→p2 (closed).
|
||
fn solid_hatch_model(solid: &DxfSolid, color: [f32; 4]) -> HatchModel {
|
||
let [ox, oy, _oz] = [0.0_f64; 3];
|
||
let boundary = vec![
|
||
[
|
||
(solid.first_corner.x - ox) as f32,
|
||
(solid.first_corner.y - oy) as f32,
|
||
],
|
||
[
|
||
(solid.second_corner.x - ox) as f32,
|
||
(solid.second_corner.y - oy) as f32,
|
||
],
|
||
[
|
||
(solid.fourth_corner.x - ox) as f32,
|
||
(solid.fourth_corner.y - oy) as f32,
|
||
],
|
||
[
|
||
(solid.third_corner.x - ox) as f32,
|
||
(solid.third_corner.y - oy) as f32,
|
||
],
|
||
];
|
||
HatchModel {
|
||
boundary: std::sync::Arc::new(boundary),
|
||
pattern: model::hatch_model::HatchPattern::Solid,
|
||
name: "SOLID".into(),
|
||
color,
|
||
angle_offset: 0.0,
|
||
scale: 1.0,
|
||
world_origin: [0.0; 2],
|
||
vp_scissor: None,
|
||
draw_depth: 0.0,
|
||
}
|
||
}
|
||
|
||
pub fn add_hatch(&mut self, model: HatchModel) -> Handle {
|
||
let mut dxf = DxfHatch::new();
|
||
dxf.is_solid = matches!(
|
||
model.pattern,
|
||
crate::scene::model::hatch_model::HatchPattern::Solid
|
||
);
|
||
// The boundary points arrive in local render space (world_offset
|
||
// already subtracted). The stored DXF entity must hold WCS, so add the
|
||
// offset back — otherwise the boundary wire, re-projected through the
|
||
// normal entity path, lands `world_offset` away from the fill.
|
||
let wx = model.world_origin[0];
|
||
let wy = model.world_origin[1];
|
||
let verts: Vec<Vector2> = model
|
||
.boundary
|
||
.iter()
|
||
.filter(|v| v[0].is_finite() && v[1].is_finite())
|
||
.map(|&[x, y]| Vector2::new(x as f64 + wx, y as f64 + wy))
|
||
.collect();
|
||
let edge = PolylineEdge::new(verts, true);
|
||
let mut path = BoundaryPath::external();
|
||
path.add_edge(BoundaryEdge::Polyline(edge));
|
||
dxf.paths.push(path);
|
||
if let Some(entry) = crate::scene::model::hatch_patterns::find(&model.name) {
|
||
dxf.pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry);
|
||
}
|
||
dxf.pattern_angle = model.angle_offset as f64;
|
||
dxf.pattern_scale = if model.scale.abs() > 1e-6 {
|
||
model.scale as f64
|
||
} else {
|
||
1.0
|
||
};
|
||
|
||
let handle = self.add_entity(EntityType::Hatch(dxf));
|
||
if !handle.is_null() {
|
||
self.hatches.insert(handle, model);
|
||
}
|
||
handle
|
||
}
|
||
|
||
pub fn clear(&mut self) {
|
||
self.document = CadDocument::new();
|
||
self.selected = HashSet::default();
|
||
self.preview_wires = vec![];
|
||
self.current_layout = "Model".to_string();
|
||
self.hatches = HashMap::default();
|
||
self.meshes = HashMap::default();
|
||
*self.camera.borrow_mut() = Camera::default();
|
||
self.camera_generation += 1;
|
||
self.bump_geometry();
|
||
}
|
||
|
||
// ── Preview wire ──────────────────────────────────────────────────────
|
||
|
||
pub fn set_preview_wires(&mut self, wires: Vec<WireModel>) {
|
||
// Preview wires are an overlay appended to the cached base wire set in
|
||
// `build_primitive`; they are NOT part of the tessellation cache. So a
|
||
// preview update must NOT bump `geometry_epoch` — that would re-
|
||
// tessellate the whole model on every rubber-band frame. The overlay
|
||
// forces a GPU wire re-upload on its own (the `has_overlay` content-id
|
||
// path), and iced redraws after the message that set the preview.
|
||
self.preview_wires = wires;
|
||
}
|
||
|
||
pub fn clear_preview_wire(&mut self) {
|
||
// No geometry bump — see `set_preview_wires`. Dropping the overlay
|
||
// flips the wire content id back to the base tessellation id, which
|
||
// re-uploads the base wires (without the preview) on the next frame.
|
||
self.preview_wires = vec![];
|
||
self.interim_wire = None;
|
||
}
|
||
|
||
pub fn wire_models_for(&self, handles: &[acadrust::Handle]) -> Vec<WireModel> {
|
||
handles
|
||
.iter()
|
||
.flat_map(|h| {
|
||
self.document
|
||
.entities()
|
||
.find(|e| e.common().handle == *h)
|
||
.map(|e| self.tessellate_one(e))
|
||
.unwrap_or_default()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Build wire models for an arbitrary slice of entities (e.g. clipboard contents).
|
||
/// Entities need not be in the document — they are tessellated directly.
|
||
pub fn wires_for_entities(&self, entities: &[acadrust::EntityType]) -> Vec<WireModel> {
|
||
entities
|
||
.iter()
|
||
.flat_map(|e| self.tessellate_one(e))
|
||
.collect()
|
||
}
|
||
|
||
pub fn set_interim_wire(&mut self, w: WireModel) {
|
||
// Overlay wire — same reasoning as `set_preview_wires`: no geometry
|
||
// bump, so the model isn't re-tessellated on every interim update.
|
||
self.interim_wire = Some(w);
|
||
}
|
||
|
||
// ── Selection ─────────────────────────────────────────────────────────
|
||
|
||
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
|
||
if exclusive {
|
||
self.selected.clear();
|
||
}
|
||
self.selected.insert(handle);
|
||
self.bump_selection();
|
||
}
|
||
|
||
pub fn deselect_all(&mut self) {
|
||
self.selected.clear();
|
||
self.bump_selection();
|
||
}
|
||
|
||
/// Remove a single entity from the selection (Shift+click subtractive pick).
|
||
pub fn deselect_entity(&mut self, handle: Handle) {
|
||
if self.selected.remove(&handle) {
|
||
self.bump_selection();
|
||
}
|
||
}
|
||
|
||
pub fn selected_entities(&self) -> Vec<(Handle, &EntityType)> {
|
||
self.selected
|
||
.iter()
|
||
.filter_map(|&h| self.document.get_entity(h).map(|e| (h, e)))
|
||
.collect()
|
||
}
|
||
|
||
/// Iterates every entity owned by the current layout's block-record.
|
||
/// Returns an empty vec when the block-record is missing or holds no
|
||
/// entity handles (legacy DXF without group-code 330 — we err on the
|
||
/// side of "no candidates" instead of scanning the whole document, so
|
||
/// model-block entities don't leak into a paper-layout selection).
|
||
fn current_layout_entity_handles(&self) -> Vec<Handle> {
|
||
let block = self.current_layout_block_handle();
|
||
self.document
|
||
.block_records
|
||
.iter()
|
||
.find(|br| br.handle == block)
|
||
.map(|br| br.entity_handles.clone())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Extends the current selection with every entity in the active
|
||
/// layout that matches one of the selected entities by `(variant,
|
||
/// layer)`. The seed selection stays selected. No-op when nothing is
|
||
/// selected. Returns the number of newly-added entities.
|
||
pub fn select_similar(&mut self) -> usize {
|
||
use crate::entities::traits::entity_type_name;
|
||
if self.selected.is_empty() {
|
||
return 0;
|
||
}
|
||
let pairs: rustc_hash::FxHashSet<(&'static str, String)> = self
|
||
.selected
|
||
.iter()
|
||
.filter_map(|h| self.document.get_entity(*h))
|
||
.map(|e| (entity_type_name(e), e.as_entity().layer().to_string()))
|
||
.collect();
|
||
let handles = self.current_layout_entity_handles();
|
||
let mut added = 0;
|
||
for h in handles {
|
||
if self.selected.contains(&h) {
|
||
continue;
|
||
}
|
||
if let Some(e) = self.document.get_entity(h) {
|
||
let key = (entity_type_name(e), e.as_entity().layer().to_string());
|
||
if pairs.contains(&key) {
|
||
self.selected.insert(h);
|
||
added += 1;
|
||
}
|
||
}
|
||
}
|
||
if added > 0 {
|
||
self.bump_selection();
|
||
}
|
||
added
|
||
}
|
||
|
||
/// Replace the selection with its complement: every selectable object
|
||
/// in the active layout that isn't currently selected. The candidate
|
||
/// set is the visible wire set, so objects on off/frozen layers (which
|
||
/// can't be picked anyway) are excluded. Returns the new count.
|
||
pub fn invert_selection(&mut self) -> usize {
|
||
let prev: rustc_hash::FxHashSet<Handle> = self.selected.iter().copied().collect();
|
||
let all: Vec<Handle> = self
|
||
.entity_wires()
|
||
.iter()
|
||
.filter_map(|w| Self::handle_from_wire_name(&w.name))
|
||
.collect();
|
||
self.selected.clear();
|
||
for h in all {
|
||
if !prev.contains(&h) {
|
||
self.selected.insert(h);
|
||
}
|
||
}
|
||
self.bump_selection();
|
||
self.selected.len()
|
||
}
|
||
|
||
/// Replaces (or extends, when `append` is true) the current
|
||
/// selection with every entity in the active layout that matches
|
||
/// the filter. Returns the number of newly-matching entities.
|
||
///
|
||
/// `type_name` of `None` means "any type". `property_field` of
|
||
/// `None` skips the property test (only the type filter applies).
|
||
/// The operator's `Any` variant also skips the property test.
|
||
/// Numeric operators (`Gt` / `Lt`) parse both sides as `f64` and
|
||
/// reject anything non-numeric.
|
||
pub fn qselect(
|
||
&mut self,
|
||
type_name: Option<&str>,
|
||
property_field: Option<&str>,
|
||
op: crate::app::QSelectOp,
|
||
value: &str,
|
||
append: bool,
|
||
) -> usize {
|
||
use crate::app::QSelectOp;
|
||
use crate::entities::traits::entity_type_name;
|
||
if !append {
|
||
self.selected.clear();
|
||
}
|
||
let handles = self.current_layout_entity_handles();
|
||
let mut matched = 0;
|
||
for h in handles {
|
||
let Some(e) = self.document.get_entity(h) else {
|
||
continue;
|
||
};
|
||
if let Some(t) = type_name {
|
||
if entity_type_name(e) != t {
|
||
continue;
|
||
}
|
||
}
|
||
let prop_ok = match (property_field, op) {
|
||
(None, _) | (_, QSelectOp::Any) => true,
|
||
(Some(field), op) => {
|
||
let Some(actual) = self.entity_property_value(e, field) else {
|
||
continue;
|
||
};
|
||
match op {
|
||
QSelectOp::Eq => actual.eq_ignore_ascii_case(value),
|
||
QSelectOp::Neq => !actual.eq_ignore_ascii_case(value),
|
||
QSelectOp::Gt | QSelectOp::Lt => {
|
||
let (Ok(a), Ok(b)) =
|
||
(actual.parse::<f64>(), value.parse::<f64>())
|
||
else {
|
||
continue;
|
||
};
|
||
if matches!(op, QSelectOp::Gt) {
|
||
a > b
|
||
} else {
|
||
a < b
|
||
}
|
||
}
|
||
QSelectOp::Any => true,
|
||
}
|
||
}
|
||
};
|
||
if prop_ok {
|
||
self.selected.insert(h);
|
||
matched += 1;
|
||
}
|
||
}
|
||
self.bump_selection();
|
||
matched
|
||
}
|
||
|
||
/// Returns the sorted set of entity-type names present in the active
|
||
/// layout. Used to populate the Quick Select "Object type" dropdown
|
||
/// with only the types that actually exist in the drawing.
|
||
pub fn entity_type_names_in_layout(&self) -> Vec<&'static str> {
|
||
use crate::entities::traits::entity_type_name;
|
||
let mut names: std::collections::BTreeSet<&'static str> =
|
||
std::collections::BTreeSet::new();
|
||
for h in self.current_layout_entity_handles() {
|
||
if let Some(e) = self.document.get_entity(h) {
|
||
names.insert(entity_type_name(e));
|
||
}
|
||
}
|
||
names.into_iter().collect()
|
||
}
|
||
|
||
/// True when `handle`'s entity type is allowed by the selection filter.
|
||
/// The filter stores excluded type names; empty = everything allowed.
|
||
pub fn passes_selection_filter(&self, handle: Handle) -> bool {
|
||
if self.selection_filter.is_empty() {
|
||
return true;
|
||
}
|
||
match self.document.get_entity(handle) {
|
||
Some(e) => !self
|
||
.selection_filter
|
||
.contains(crate::entities::traits::entity_type_name(e)),
|
||
None => true,
|
||
}
|
||
}
|
||
|
||
/// True when the selection filter is excluding at least one type.
|
||
pub fn selection_filter_active(&self) -> bool {
|
||
!self.selection_filter.is_empty()
|
||
}
|
||
|
||
/// Returns the list of `(field, label)` pairs the Quick Select
|
||
/// "Properties" dropdown should show given the current type filter:
|
||
///
|
||
/// * Common properties (Layer, Color, Linetype, Lineweight) are
|
||
/// always included.
|
||
/// * When `type_name` names a specific entity type present in the
|
||
/// active layout, the first entity of that type contributes its
|
||
/// `geometry_properties()` rows (Start X, Length, Radius, …) so
|
||
/// type-specific filtering works.
|
||
pub fn qselect_properties(
|
||
&self,
|
||
type_name: Option<&str>,
|
||
) -> Vec<(String, String)> {
|
||
use crate::entities::traits::{entity_type_name, EntityTypeOps};
|
||
let mut out: Vec<(String, String)> = vec![
|
||
("layer".to_string(), "Layer".to_string()),
|
||
("color".to_string(), "Color".to_string()),
|
||
("linetype".to_string(), "Linetype".to_string()),
|
||
("lineweight".to_string(), "Lineweight".to_string()),
|
||
];
|
||
if let Some(t) = type_name {
|
||
let text_style_names: Vec<String> = self
|
||
.document
|
||
.text_styles
|
||
.iter()
|
||
.map(|s| s.name.clone())
|
||
.collect();
|
||
let sample = self
|
||
.current_layout_entity_handles()
|
||
.into_iter()
|
||
.filter_map(|h| self.document.get_entity(h))
|
||
.find(|e| entity_type_name(e) == t);
|
||
if let Some(sample) = sample {
|
||
if let Some(section) = sample.geometry_properties(&text_style_names) {
|
||
for prop in section.props {
|
||
// Skip rows that don't sensibly compare via
|
||
// `entity_property_value` (read-only labels are
|
||
// fine — users can still match against them).
|
||
out.push((prop.field.to_string(), prop.label.clone()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Reads a property value from an entity for QSELECT comparison.
|
||
/// Returns the canonical string used as the left-hand side of the
|
||
/// operator test. Common properties have hand-rolled formatting so
|
||
/// `"ByLayer"` / `"7"` / `"0.30mm"` are stable; everything else
|
||
/// goes through `geometry_properties()` and pulls the matching
|
||
/// row's value out.
|
||
pub fn entity_property_value(
|
||
&self,
|
||
entity: &acadrust::EntityType,
|
||
field: &str,
|
||
) -> Option<String> {
|
||
use crate::entities::traits::EntityTypeOps;
|
||
use crate::scene::model::object::PropValue;
|
||
match field {
|
||
"layer" => Some(entity.common().layer.clone()),
|
||
"color" => Some(Self::format_color(entity.common().color)),
|
||
"linetype" => Some(entity.common().linetype.clone()),
|
||
"lineweight" => Some(Self::format_lineweight(entity.common().line_weight)),
|
||
_ => {
|
||
let text_style_names: Vec<String> = self
|
||
.document
|
||
.text_styles
|
||
.iter()
|
||
.map(|s| s.name.clone())
|
||
.collect();
|
||
let section = entity.geometry_properties(&text_style_names)?;
|
||
let prop = section.props.into_iter().find(|p| p.field == field)?;
|
||
Some(match prop.value {
|
||
PropValue::ReadOnly(s) | PropValue::EditText(s) => s,
|
||
PropValue::LayerChoice(s) => s,
|
||
PropValue::Choice { selected, .. } => selected,
|
||
PropValue::ColorChoice(c) => Self::format_color(c),
|
||
PropValue::LwChoice(lw) => Self::format_lineweight(lw),
|
||
PropValue::LinetypeChoice(s) => s,
|
||
PropValue::HatchPatternChoice(s) => s,
|
||
PropValue::BoolToggle { value, .. } => value.to_string(),
|
||
PropValue::ColorVaries | PropValue::LwVaries => return None,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
fn format_color(c: acadrust::types::Color) -> String {
|
||
use acadrust::types::Color;
|
||
match c {
|
||
Color::ByLayer => "ByLayer".to_string(),
|
||
Color::ByBlock => "ByBlock".to_string(),
|
||
Color::Index(i) => i.to_string(),
|
||
Color::Rgb { r, g, b } => format!("{},{},{}", r, g, b),
|
||
}
|
||
}
|
||
|
||
fn format_lineweight(lw: acadrust::types::LineWeight) -> String {
|
||
use acadrust::types::LineWeight;
|
||
match lw {
|
||
LineWeight::ByLayer => "ByLayer".to_string(),
|
||
LineWeight::ByBlock => "ByBlock".to_string(),
|
||
LineWeight::Default => "Default".to_string(),
|
||
LineWeight::Value(v) => format!("{:.2}mm", v as f64 / 100.0),
|
||
}
|
||
}
|
||
|
||
// ── Erase ─────────────────────────────────────────────────────────────
|
||
|
||
pub fn erase_entities(&mut self, handles: &[Handle]) {
|
||
for &h in handles {
|
||
self.document.remove_entity(h);
|
||
self.selected.remove(&h);
|
||
self.hatches.remove(&h);
|
||
self.meshes.remove(&h);
|
||
self.solid_models.remove(&h);
|
||
self.mark_entity_dirty(h);
|
||
}
|
||
// Remove erased handles from all groups; delete groups that become empty.
|
||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||
let to_remove: Vec<Handle> = self
|
||
.document
|
||
.objects
|
||
.values_mut()
|
||
.filter_map(|obj| match obj {
|
||
ObjectType::Group(g) => {
|
||
g.entities.retain(|h| !handles.contains(h));
|
||
if g.entities.is_empty() {
|
||
Some(g.handle)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
for gh in &to_remove {
|
||
if let Some(ObjectType::Dictionary(dict)) =
|
||
self.document.objects.get_mut(&group_dict_handle)
|
||
{
|
||
dict.entries.retain(|(_, h)| h != gh);
|
||
}
|
||
self.document.objects.remove(gh);
|
||
}
|
||
// Deleting top-level entities/inserts leaves block definitions intact;
|
||
// the erased handles were already dropped from the memo above.
|
||
self.bump_geometry_no_blocks();
|
||
}
|
||
|
||
// ── Group helpers ──────────────────────────────────────────────────────
|
||
|
||
pub fn groups(&self) -> impl Iterator<Item = &acadrust::objects::Group> {
|
||
self.document.objects.values().filter_map(|obj| match obj {
|
||
ObjectType::Group(g) => Some(g),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// Returns the names of all groups that contain `handle`.
|
||
pub fn group_names_for_entity(&self, handle: Handle) -> Vec<String> {
|
||
self.groups()
|
||
.filter(|g| g.contains(handle))
|
||
.map(|g| g.name.clone())
|
||
.collect()
|
||
}
|
||
|
||
/// Creates a named group from the given handles and registers it in the group dictionary.
|
||
pub fn create_group(&mut self, name: String, handles: Vec<Handle>) -> Handle {
|
||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||
let mut group = acadrust::objects::Group::new(&name);
|
||
group.handle = self.document.allocate_handle();
|
||
group.owner = group_dict_handle;
|
||
group.add_entities(handles);
|
||
let gh = group.handle;
|
||
self.document.objects.insert(gh, ObjectType::Group(group));
|
||
if let Some(ObjectType::Dictionary(dict)) =
|
||
self.document.objects.get_mut(&group_dict_handle)
|
||
{
|
||
dict.add_entry(&name, gh);
|
||
}
|
||
gh
|
||
}
|
||
|
||
/// Dissolves all groups that contain any of the given handles.
|
||
/// Returns the number of groups removed.
|
||
pub fn delete_groups_containing(&mut self, handles: &[Handle]) -> usize {
|
||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||
let to_delete: Vec<Handle> = self
|
||
.document
|
||
.objects
|
||
.values()
|
||
.filter_map(|obj| match obj {
|
||
ObjectType::Group(g) if handles.iter().any(|h| g.contains(*h)) => Some(g.handle),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let count = to_delete.len();
|
||
for gh in &to_delete {
|
||
if let Some(ObjectType::Dictionary(dict)) =
|
||
self.document.objects.get_mut(&group_dict_handle)
|
||
{
|
||
dict.entries.retain(|(_, h)| h != gh);
|
||
}
|
||
self.document.objects.remove(gh);
|
||
}
|
||
count
|
||
}
|
||
|
||
/// If `handle` belongs to any selectable groups, also select all other members of those groups.
|
||
pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) {
|
||
let to_add: Vec<Handle> = self
|
||
.document
|
||
.objects
|
||
.values()
|
||
.filter_map(|obj| match obj {
|
||
ObjectType::Group(g) if g.selectable && handles.iter().any(|h| g.contains(*h)) => {
|
||
Some(g.entities.clone())
|
||
}
|
||
_ => None,
|
||
})
|
||
.flatten()
|
||
.collect();
|
||
for h in to_add {
|
||
self.selected.insert(h);
|
||
}
|
||
self.bump_selection();
|
||
}
|
||
|
||
// ── Layer helpers ──────────────────────────────────────────────────────
|
||
|
||
pub fn toggle_layer_visibility(&mut self, name: &str) {
|
||
if let Some(layer) = self.document.layers.get_mut(name) {
|
||
layer.flags.off = !layer.flags.off;
|
||
}
|
||
self.bump_geometry();
|
||
}
|
||
|
||
pub fn toggle_layer_lock(&mut self, name: &str) {
|
||
if let Some(layer) = self.document.layers.get_mut(name) {
|
||
layer.flags.locked = !layer.flags.locked;
|
||
}
|
||
}
|
||
|
||
// ── Modify (transform / copy) ─────────────────────────────────────────
|
||
|
||
pub fn transform_entities(&mut self, handles: &[Handle], t: &EntityTransform) {
|
||
// MIRRTEXT (header.mirror_text): when false AutoCAD positions text /
|
||
// mtext / shape by the mirror but keeps the original rotation +
|
||
// oblique so the text stays right-reading. Capture before the
|
||
// transform and re-apply afterwards.
|
||
let preserve_text_orientation =
|
||
matches!(t, EntityTransform::Mirror { .. }) && !self.document.header.mirror_text;
|
||
let mut text_orient_backup: Vec<(Handle, f64, f64, f64)> = Vec::new();
|
||
if preserve_text_orientation {
|
||
for &h in handles {
|
||
if let Some(entity) = self.document.get_entity(h) {
|
||
match entity {
|
||
EntityType::Text(t) => {
|
||
text_orient_backup.push((h, t.rotation, t.oblique_angle, 0.0))
|
||
}
|
||
EntityType::MText(m) => {
|
||
text_orient_backup.push((h, m.rotation, 0.0, 0.0))
|
||
}
|
||
EntityType::Shape(s) => text_orient_backup.push((
|
||
h,
|
||
s.rotation,
|
||
s.oblique_angle,
|
||
s.relative_x_scale,
|
||
)),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// A dimension's final geometry is baked into a per-instance `*D`
|
||
// block, and the render draws those sub-entities directly (not the
|
||
// definition points). Transform them with the dimension, or it would
|
||
// stay drawn in place while only its def points move.
|
||
let dim_block_subs: Vec<Handle> = handles
|
||
.iter()
|
||
.filter_map(|&h| match self.document.get_entity(h) {
|
||
Some(EntityType::Dimension(d)) => {
|
||
let bn = d.base().block_name.clone();
|
||
if bn.trim().is_empty() {
|
||
None
|
||
} else {
|
||
Some(bn)
|
||
}
|
||
}
|
||
_ => None,
|
||
})
|
||
.filter_map(|bn| {
|
||
self.document
|
||
.block_records
|
||
.iter()
|
||
.find(|br| br.name.eq_ignore_ascii_case(&bn))
|
||
.map(|br| br.entity_handles.clone())
|
||
})
|
||
.flatten()
|
||
.collect();
|
||
for &h in handles {
|
||
if let Some(entity) = self.document.get_entity_mut(h) {
|
||
view::dispatch::apply_transform(entity, t);
|
||
}
|
||
if self.hatches.contains_key(&h) {
|
||
let existing_color = self.hatches[&h].color;
|
||
let new_model = match self.document.get_entity(h) {
|
||
Some(EntityType::Hatch(dxf)) => {
|
||
Self::hatch_model_from_dxf(dxf, existing_color)
|
||
}
|
||
// A DXF SOLID renders as a solid-fill hatch; rebuild it from
|
||
// the moved corners so the fill follows the transform.
|
||
Some(EntityType::Solid(s)) => {
|
||
Some(Self::solid_hatch_model(s, existing_color))
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(model) = new_model {
|
||
self.hatches.insert(h, model);
|
||
}
|
||
}
|
||
}
|
||
if preserve_text_orientation {
|
||
for (h, rot, oblique, x_scale) in text_orient_backup {
|
||
if let Some(entity) = self.document.get_entity_mut(h) {
|
||
match entity {
|
||
EntityType::Text(t) => {
|
||
t.rotation = rot;
|
||
t.oblique_angle = oblique;
|
||
}
|
||
EntityType::MText(m) => {
|
||
m.rotation = rot;
|
||
}
|
||
EntityType::Shape(s) => {
|
||
s.rotation = rot;
|
||
s.oblique_angle = oblique;
|
||
s.relative_x_scale = x_scale;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Move the baked dimension-block sub-entities too (collected above).
|
||
for h in &dim_block_subs {
|
||
if let Some(entity) = self.document.get_entity_mut(*h) {
|
||
view::dispatch::apply_transform(entity, t);
|
||
}
|
||
}
|
||
// Only the transformed entities changed (a top-level move/rotate/scale/
|
||
// mirror never edits a block definition) — re-tessellate just those and
|
||
// keep the block cache + every other entity's memoized wires.
|
||
for &h in handles {
|
||
self.mark_entity_dirty(h);
|
||
}
|
||
self.bump_geometry_no_blocks();
|
||
}
|
||
|
||
/// Give a freshly-cloned entity brand-new handles for every *inline*
|
||
/// sub-entity that stores one (INSERT attributes, 3D-polyline vertices).
|
||
/// `document.add_entity` only assigns the top-level handle, so without this
|
||
/// a copy keeps its source's sub-handles — duplicate handles that corrupt
|
||
/// the saved DWG (file won't reopen in other CAD apps). Vertices that don't
|
||
/// store a handle (LwPolyline / heavy 2D polyline) get one from the writer,
|
||
/// so they need no fix-up here. (#129)
|
||
fn reset_clone_subhandles(doc: &mut acadrust::CadDocument, entity: &mut EntityType) {
|
||
match entity {
|
||
EntityType::Insert(ins) => {
|
||
for att in ins.attributes.iter_mut() {
|
||
att.common.handle = doc.allocate_handle();
|
||
}
|
||
}
|
||
EntityType::Polyline3D(p) => {
|
||
for v in p.vertices.iter_mut() {
|
||
v.handle = doc.allocate_handle();
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Add a freshly-cloned entity, allocating a new handle for it *and* every
|
||
/// inline sub-entity so the copy never shares a handle with its source.
|
||
/// Use this (not `add_entity`) whenever inserting a duplicate. (#129)
|
||
pub fn add_entity_clone(&mut self, mut entity: EntityType) -> Handle {
|
||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||
entity.common_mut().handle = Handle::NULL;
|
||
self.add_entity(entity)
|
||
}
|
||
|
||
pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec<Handle> {
|
||
let clones: Vec<EntityType> = handles
|
||
.iter()
|
||
.filter_map(|&h| self.document.get_entity(h).cloned())
|
||
.collect();
|
||
let mut new_handles = Vec::with_capacity(clones.len());
|
||
for mut entity in clones {
|
||
view::dispatch::apply_transform(&mut entity, t);
|
||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||
entity.common_mut().handle = Handle::NULL;
|
||
let h = self.document.add_entity(entity).unwrap_or(Handle::NULL);
|
||
if !h.is_null() {
|
||
let new_model = match self.document.get_entity(h) {
|
||
Some(EntityType::Hatch(dxf)) => {
|
||
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
||
Self::hatch_model_from_dxf(dxf, color)
|
||
}
|
||
Some(EntityType::Solid(s)) => {
|
||
let color = convert::tess_util::aci_to_rgba(&s.common.color);
|
||
Some(Self::solid_hatch_model(s, color))
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(model) = new_model {
|
||
self.hatches.insert(h, model);
|
||
}
|
||
}
|
||
new_handles.push(h);
|
||
}
|
||
// The copies are new handles (natural memo misses, tessellated fresh)
|
||
// and reference only already-cached blocks — no block defn changes.
|
||
self.bump_geometry_no_blocks();
|
||
new_handles
|
||
}
|
||
|
||
// ── Grip editing ──────────────────────────────────────────────────────
|
||
|
||
pub fn apply_grip(&mut self, handle: Handle, grip_id: usize, apply: GripApply) {
|
||
// For Solid3D / Region / Body, record the old point_of_reference so we
|
||
// can translate the pre-tessellated MeshModel by the same delta after
|
||
// the grip is applied (the ACIS data itself is not modified).
|
||
let old_por: Option<[f64; 3]> = self
|
||
.document
|
||
.get_entity(handle)
|
||
.and_then(crate::entities::solid3d::point_of_reference)
|
||
.map(|p| [p.x, p.y, p.z]);
|
||
|
||
if let Some(entity) = self.document.get_entity_mut(handle) {
|
||
view::dispatch::apply_grip(entity, grip_id, apply);
|
||
}
|
||
|
||
// Translate MeshModel vertices by the same delta the grip applied.
|
||
if let Some(old) = old_por {
|
||
let new_por: Option<[f64; 3]> = self
|
||
.document
|
||
.get_entity(handle)
|
||
.and_then(crate::entities::solid3d::point_of_reference)
|
||
.map(|p| [p.x, p.y, p.z]);
|
||
if let Some(new) = new_por {
|
||
let dx = (new[0] - old[0]) as f32;
|
||
let dy = (new[1] - old[1]) as f32;
|
||
let dz = (new[2] - old[2]) as f32;
|
||
if let Some(set) = self.meshes.get_mut(&handle) {
|
||
for lod in &mut set.lods {
|
||
for v in &mut lod.verts {
|
||
v[0] += dx;
|
||
v[1] += dy;
|
||
v[2] += dz;
|
||
}
|
||
}
|
||
set.world_aabb[0] += dx;
|
||
set.world_aabb[1] += dy;
|
||
set.world_aabb[2] += dx;
|
||
set.world_aabb[3] += dy;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Rebuild GPU hatch/solid model when a boundary vertex or corner moves.
|
||
match self.document.get_entity(handle) {
|
||
Some(EntityType::Hatch(dxf)) => {
|
||
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
||
if let Some(model) = Self::hatch_model_from_dxf(dxf, color) {
|
||
self.hatches.insert(handle, model);
|
||
} else {
|
||
self.hatches.remove(&handle);
|
||
}
|
||
}
|
||
Some(EntityType::Solid(solid)) => {
|
||
let color = convert::tess_util::aci_to_rgba(&solid.common.color);
|
||
self.hatches
|
||
.insert(handle, Self::solid_hatch_model(solid, color));
|
||
}
|
||
_ => {}
|
||
}
|
||
// NOTE: no `bump_geometry()` here. The grip-drag caller hides the
|
||
// edited entity and previews it as an overlay during the drag (so a
|
||
// move doesn't re-tessellate the whole model), then bumps once on
|
||
// commit. Any other caller must bump geometry itself.
|
||
}
|
||
|
||
// ── Hit-test convenience: wire name → Handle ──────────────────────────
|
||
|
||
pub fn handle_from_wire_name(name: &str) -> Option<Handle> {
|
||
name.parse::<u64>().ok().map(Handle::new)
|
||
}
|
||
|
||
/// Restore camera to a named view from the document view table.
|
||
pub fn restore_named_view(&mut self, view: &acadrust::tables::View) {
|
||
use glam::Vec3;
|
||
let cam = &mut *self.camera.borrow_mut();
|
||
// view.target is the look-at point; view.direction is eye→target direction.
|
||
cam.target = glam::DVec3::new(view.target.x, view.target.y, view.target.z);
|
||
// direction in acadrust = from-target-to-eye (same as AutoCAD convention).
|
||
let eye_dir = Vec3::new(
|
||
view.direction.x as f32,
|
||
view.direction.y as f32,
|
||
view.direction.z as f32,
|
||
);
|
||
let eye_dir = if eye_dir.length_squared() > 1e-10 {
|
||
eye_dir.normalize()
|
||
} else {
|
||
Vec3::Z
|
||
};
|
||
// Build rotation: canonical eye is +Z, rotate to eye_dir.
|
||
cam.rotation = glam::Quat::from_rotation_arc(Vec3::Z, eye_dir);
|
||
// Sync yaw/pitch from new rotation (for ViewCube).
|
||
let pitch = eye_dir.z.clamp(-1.0, 1.0).asin();
|
||
let yaw = eye_dir.x.atan2(eye_dir.y);
|
||
cam.yaw = yaw;
|
||
cam.pitch = pitch;
|
||
// Derive distance from view height and fov.
|
||
let h = view.height as f32;
|
||
cam.distance = if h > 0.0 {
|
||
h / (2.0 * (cam.fov_y * 0.5).tan())
|
||
} else {
|
||
cam.distance
|
||
};
|
||
self.camera_generation += 1;
|
||
}
|
||
|
||
/// Save the current camera state into a new named view entry.
|
||
/// Returns the view; caller must push it into document.views.
|
||
pub fn current_as_named_view(&self, name: &str) -> acadrust::tables::View {
|
||
use acadrust::types::Vector3;
|
||
let cam = self.camera.borrow();
|
||
let eye_dir = cam.rotation * glam::Vec3::Z;
|
||
let height = cam.ortho_size() * 2.0;
|
||
let width = height; // caller can adjust; rough square
|
||
acadrust::tables::View {
|
||
handle: acadrust::types::Handle::NULL,
|
||
name: name.to_string(),
|
||
center: Vector3 {
|
||
x: cam.target.x as f64,
|
||
y: cam.target.y as f64,
|
||
z: 0.0,
|
||
},
|
||
target: Vector3 {
|
||
x: cam.target.x as f64,
|
||
y: cam.target.y as f64,
|
||
z: cam.target.z as f64,
|
||
},
|
||
direction: Vector3 {
|
||
x: eye_dir.x as f64,
|
||
y: eye_dir.y as f64,
|
||
z: eye_dir.z as f64,
|
||
},
|
||
height: height as f64,
|
||
width: width as f64,
|
||
lens_length: 50.0,
|
||
front_clip: 0.0,
|
||
back_clip: 0.0,
|
||
twist_angle: 0.0,
|
||
}
|
||
}
|
||
|
||
/// Zoom the model-space camera in/out by a percentage.
|
||
/// factor > 1 = zoom out, factor < 1 = zoom in.
|
||
pub fn zoom_camera(&mut self, factor: f32) {
|
||
let mut cam = self.camera.borrow_mut();
|
||
cam.distance = (cam.distance * factor).max(0.001);
|
||
drop(cam);
|
||
self.camera_generation += 1;
|
||
}
|
||
|
||
/// Fit the camera to a world-space bounding box (corners p1, p2).
|
||
pub fn zoom_to_window(&mut self, p1: glam::Vec3, p2: glam::Vec3) {
|
||
let min = p1.min(p2);
|
||
let max = p1.max(p2);
|
||
if min == max {
|
||
return;
|
||
}
|
||
self.camera.borrow_mut().fit_to_bounds(min, max);
|
||
self.camera_generation += 1;
|
||
}
|
||
|
||
/// Apply camera state from an acadrust View table entry, through the shared
|
||
/// `camera_from_view` decoder so the twist round-trips like every other
|
||
/// saved view. `model_space`: if true, subtracts world_offset from target
|
||
/// (wire-space); paper-space entries carry no offset.
|
||
fn apply_camera_from_view_entry(
|
||
&mut self,
|
||
view: &acadrust::tables::View,
|
||
model_space: bool,
|
||
) -> bool {
|
||
let _ = model_space;
|
||
let Some(cam) = self.camera_from_view(
|
||
view.direction,
|
||
view.target,
|
||
acadrust::types::Vector2 {
|
||
x: view.center.x,
|
||
y: view.center.y,
|
||
},
|
||
view.height,
|
||
view.twist_angle,
|
||
) else {
|
||
return false;
|
||
};
|
||
*self.camera.borrow_mut() = cam;
|
||
self.camera_generation += 1;
|
||
true
|
||
}
|
||
|
||
/// Set the model-space camera from the VPORT table's *Active entry.
|
||
/// Returns true if the entry was found and the camera was set.
|
||
fn apply_active_vport_camera(&mut self) -> bool {
|
||
// Restore the single tile's visual style + grid/snap from the *Active
|
||
// entry, independent of where the camera itself comes from below.
|
||
if let Some(vp) = self.document.vports.iter().find(|v| v.name == "*Active") {
|
||
let mode = vp.render_mode;
|
||
let (grid_on, snap_on) = (vp.grid_on, vp.snap_on);
|
||
let mut tiles = self.model_tiles.borrow_mut();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
if let Some(t) = tiles.get_mut(active) {
|
||
t.render_mode = mode;
|
||
t.grid_on = grid_on;
|
||
t.snap_on = snap_on;
|
||
}
|
||
}
|
||
// Prefer our named View entry — survives DWG save without being overridden.
|
||
let saved_view = self
|
||
.document
|
||
.views
|
||
.iter()
|
||
.find(|v| v.name == "OpenCADStudio_Camera_Model")
|
||
.cloned();
|
||
if let Some(view) = saved_view {
|
||
return self.apply_camera_from_view_entry(&view, true);
|
||
}
|
||
let vp = match self.document.vports.iter().find(|v| v.name == "*Active") {
|
||
Some(v) => v.clone(),
|
||
None => return false,
|
||
};
|
||
let Some(new_cam) = self.camera_from_vport(&vp) else {
|
||
return false;
|
||
};
|
||
*self.camera.borrow_mut() = new_cam;
|
||
self.camera_generation += 1;
|
||
true
|
||
}
|
||
|
||
/// Decode a saved view into a `Camera`. This is the single shared decoder
|
||
/// for both a model-space VPORT table entry (tiled) and a paper-space
|
||
/// VIEWPORT entity (floating): tiled vs floating only changes *where* the
|
||
/// fields come from and the floating auto-fit fallback — the projection
|
||
/// math (view direction → yaw/pitch, twist → roll, view_center fold,
|
||
/// view_height → distance) is identical, so it lives here once. Callers pass
|
||
/// their already-effective `view_target` / `view_center` / `view_height`.
|
||
///
|
||
/// Returns `None` for a zero `view_height` (an uninitialised entry).
|
||
fn camera_from_view(
|
||
&self,
|
||
view_direction: acadrust::types::Vector3,
|
||
view_target: acadrust::types::Vector3,
|
||
view_center: acadrust::types::Vector2,
|
||
view_height: f64,
|
||
twist: f64,
|
||
// Subtracted from `view_target` to reach wire-space. Model views pass
|
||
// `[0.0_f64; 3]`; paper-space views (whose entities carry no
|
||
// offset) pass `[0; 3]`.
|
||
) -> Option<Camera> {
|
||
if view_height.abs() < 1e-9 {
|
||
return None;
|
||
}
|
||
let vd = glam::Vec3::new(
|
||
view_direction.x as f32,
|
||
view_direction.y as f32,
|
||
view_direction.z as f32,
|
||
)
|
||
.normalize_or(glam::Vec3::Z);
|
||
let pitch = vd.z.clamp(-1.0, 1.0).asin();
|
||
// view_dir = (sin(yaw)*cos(pitch), -cos(yaw)*cos(pitch), sin(pitch))
|
||
// → yaw = atan2(x, -y), but when looking straight up/down cos(pitch)≈0
|
||
// both x and y are near zero and atan2(0, -0.0) = π due to IEEE 754.
|
||
let yaw = if vd.x.abs() < 1e-6 && vd.y.abs() < 1e-6 {
|
||
0.0_f32 // plan/nadir view: yaw is undefined, default to 0
|
||
} else {
|
||
vd.x.atan2(-vd.y)
|
||
};
|
||
// The saved view can carry a twist (rotation about the view axis), set
|
||
// when the view was aligned to a rotated UCS. The twist is the angle
|
||
// that rotates world-X onto screen-right, so the world direction that
|
||
// ends up horizontal is its negative; feed that in as the camera roll
|
||
// so the drawing opens square, the way it was saved, instead of in raw
|
||
// world orientation (which looks tilted).
|
||
let rotation = view::camera::yaw_pitch_to_quat(yaw, pitch, -twist as f32);
|
||
let view_right = rotation * glam::Vec3::X;
|
||
let view_up = rotation * glam::Vec3::Y;
|
||
// view_target is WCS; wire-space subtracts world_offset. view_center is
|
||
// a DCS (screen-plane) offset, so fold it through the view basis.
|
||
// Keep the target in f64: casting it to f32 first quantizes the camera
|
||
// to ~0.5 m at UTM scale, so panning/zooming inside a floating viewport
|
||
// (which nudges view_target by sub-metre f64 steps) made the content
|
||
// jump on the f32 grid. The axis directions stay f32 (orientation only);
|
||
// only the position must stay precise — matching the model camera.
|
||
let base = glam::DVec3::new(
|
||
view_target.x,
|
||
view_target.y,
|
||
view_target.z,
|
||
);
|
||
let target = base
|
||
+ view_right.as_dvec3() * view_center.x
|
||
+ view_up.as_dvec3() * view_center.y;
|
||
let fov_y = 45.0_f32.to_radians();
|
||
let distance = ((view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001);
|
||
Some(Camera {
|
||
target,
|
||
rotation,
|
||
distance,
|
||
fov_y,
|
||
projection: view::camera::Projection::Orthographic,
|
||
yaw,
|
||
pitch,
|
||
})
|
||
}
|
||
|
||
/// Decode a VPort table entry (model-space tiled view) into a `Camera`.
|
||
fn camera_from_vport(&self, vp: &acadrust::tables::VPort) -> Option<Camera> {
|
||
self.camera_from_view(
|
||
vp.view_direction,
|
||
vp.view_target,
|
||
vp.view_center,
|
||
vp.view_height,
|
||
vp.view_twist,
|
||
)
|
||
}
|
||
|
||
/// Reverse of `camera_from_vport`: write `cam`'s view target / direction
|
||
/// / height onto a fresh VPort entry with the given `name` and screen
|
||
/// rectangle (0..1 normalized, DXF bottom-left origin convention).
|
||
fn vport_from_camera(
|
||
&self,
|
||
name: &str,
|
||
cam: &Camera,
|
||
lower_left: acadrust::types::Vector2,
|
||
upper_right: acadrust::types::Vector2,
|
||
) -> acadrust::tables::VPort {
|
||
let view_dir = cam.rotation * glam::Vec3::Z;
|
||
let view_height = cam.ortho_size() * 2.0;
|
||
let target_wcs = acadrust::types::Vector3 {
|
||
x: (cam.target.x as f64) + [0.0_f64; 3][0],
|
||
y: (cam.target.y as f64) + [0.0_f64; 3][1],
|
||
z: (cam.target.z as f64) + [0.0_f64; 3][2],
|
||
};
|
||
let mut entry = acadrust::tables::VPort::new(name);
|
||
entry.lower_left = lower_left;
|
||
entry.upper_right = upper_right;
|
||
entry.view_target = target_wcs;
|
||
entry.view_direction = acadrust::types::Vector3 {
|
||
x: view_dir.x as f64,
|
||
y: view_dir.y as f64,
|
||
z: view_dir.z as f64,
|
||
};
|
||
entry.view_height = view_height as f64;
|
||
entry.view_center = acadrust::types::Vector2::ZERO;
|
||
// Stored twist = -roll, matching the decoder (roll = -twist).
|
||
entry.view_twist = -cam.roll() as f64;
|
||
entry
|
||
}
|
||
|
||
/// Convert a `ModelTile`'s normalized iced rectangle (top-left origin) to
|
||
/// the (lower_left, upper_right) pair the VPort table uses (bottom-left
|
||
/// origin).
|
||
fn tile_rect_to_vport(rect: iced::Rectangle) -> (acadrust::types::Vector2, acadrust::types::Vector2) {
|
||
let lower_left = acadrust::types::Vector2 {
|
||
x: rect.x as f64,
|
||
y: (1.0 - rect.y - rect.height) as f64,
|
||
};
|
||
let upper_right = acadrust::types::Vector2 {
|
||
x: (rect.x + rect.width) as f64,
|
||
y: (1.0 - rect.y) as f64,
|
||
};
|
||
(lower_left, upper_right)
|
||
}
|
||
|
||
/// Inverse of `tile_rect_to_vport`.
|
||
fn vport_to_tile_rect(lower_left: acadrust::types::Vector2, upper_right: acadrust::types::Vector2) -> iced::Rectangle {
|
||
iced::Rectangle {
|
||
x: lower_left.x as f32,
|
||
y: (1.0 - upper_right.y) as f32,
|
||
width: (upper_right.x - lower_left.x) as f32,
|
||
height: (upper_right.y - lower_left.y) as f32,
|
||
}
|
||
}
|
||
|
||
/// Restore `model_tiles` from VPort entries that a previous save left in
|
||
/// the document. Native AutoCAD tiled model-space layouts are represented
|
||
/// by duplicate `*Active` VPort entries.
|
||
/// Returns true on success — the caller skips `apply_active_vport_camera`
|
||
/// in that case because the active tile's camera has already been loaded
|
||
/// into `self.camera`.
|
||
fn restore_model_tiles_from_vports(&mut self) -> bool {
|
||
let active_vports: Vec<acadrust::tables::VPort> = self
|
||
.document
|
||
.vports
|
||
.iter()
|
||
.filter(|v| v.name == "*Active")
|
||
.cloned()
|
||
.collect();
|
||
|
||
if active_vports.len() <= 1 {
|
||
return false;
|
||
}
|
||
|
||
let tiles: Vec<ModelTile> = active_vports
|
||
.iter()
|
||
.filter_map(|vp| {
|
||
self.camera_from_vport(vp).map(|cam| ModelTile {
|
||
rect: Self::vport_to_tile_rect(vp.lower_left, vp.upper_right),
|
||
camera: cam,
|
||
render_mode: vp.render_mode,
|
||
grid_on: vp.grid_on,
|
||
snap_on: vp.snap_on,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
if tiles.len() <= 1 {
|
||
return false;
|
||
}
|
||
|
||
let active_cam = tiles[0].camera.clone();
|
||
*self.model_tiles.borrow_mut() = tiles;
|
||
self.active_model_tile.set(0);
|
||
*self.camera.borrow_mut() = active_cam;
|
||
self.camera_generation += 1;
|
||
true
|
||
}
|
||
|
||
/// Persist `model_tiles` to the VPort table. Native AutoCAD tiled model
|
||
/// viewports are written as duplicate `*Active` entries.
|
||
fn save_model_tiles_to_vports(&mut self) {
|
||
// Stash the live camera into the active tile so the about-to-write
|
||
// snapshot reflects the user's most recent orbit / pan / zoom.
|
||
{
|
||
let live_cam = self.camera.borrow().clone();
|
||
let mut tiles = self.model_tiles.borrow_mut();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
if let Some(t) = tiles.get_mut(active) {
|
||
t.camera = live_cam;
|
||
}
|
||
}
|
||
|
||
let table_handle = self.document.vports.handle();
|
||
let preserved_vps: Vec<acadrust::tables::VPort> = self
|
||
.document
|
||
.vports
|
||
.iter()
|
||
.filter(|v| v.name != "*Active")
|
||
.cloned()
|
||
.collect();
|
||
let mut new_vports = acadrust::tables::Table::with_handle(table_handle);
|
||
for vp in preserved_vps {
|
||
new_vports.add_or_replace(vp);
|
||
}
|
||
self.document.vports = new_vports;
|
||
|
||
let tiles = self.model_tiles.borrow().clone();
|
||
if tiles.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
let mut ordered_tiles = Vec::with_capacity(tiles.len());
|
||
ordered_tiles.push(tiles[active].clone());
|
||
for (i, tile) in tiles.iter().enumerate() {
|
||
if i != active {
|
||
ordered_tiles.push(tile.clone());
|
||
}
|
||
}
|
||
|
||
for tile in ordered_tiles {
|
||
let (ll, ur) = Self::tile_rect_to_vport(tile.rect);
|
||
let mut entry = self.vport_from_camera("*Active", &tile.camera, ll, ur);
|
||
entry.render_mode = tile.render_mode;
|
||
// Each viewport persists its own grid display + grid-snap (#121).
|
||
entry.grid_on = tile.grid_on;
|
||
entry.snap_on = tile.snap_on;
|
||
entry.handle = self.document.allocate_handle();
|
||
self.document.vports.add_allow_duplicate(entry);
|
||
}
|
||
}
|
||
|
||
/// Mirror the live grid/snap toggles onto the active view's own store so the
|
||
/// state stays independent per viewport: a model tile in model space, the
|
||
/// layout's sheet viewport in paper space. (#121)
|
||
pub fn set_active_tile_grid_snap(&mut self, grid_on: bool, snap_on: bool) {
|
||
if self.current_layout != "Model" {
|
||
// Paper space: target the active floating viewport if the user is
|
||
// working inside one, otherwise the layout's sheet viewport. Each
|
||
// viewport keeps its own grid/snap (round-tripped via status flags).
|
||
let h = self
|
||
.active_viewport
|
||
.filter(|h| h.is_valid())
|
||
.unwrap_or_else(|| self.current_layout_sheet_viewport_handle());
|
||
if h.is_valid() {
|
||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(h) {
|
||
vp.status.grid_on = grid_on;
|
||
vp.status.snap_on = snap_on;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
let mut tiles = self.model_tiles.borrow_mut();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
if let Some(t) = tiles.get_mut(active) {
|
||
t.grid_on = grid_on;
|
||
t.snap_on = snap_on;
|
||
}
|
||
}
|
||
|
||
/// The active view's grid display + grid-snap, adopted into the live toggles
|
||
/// on load and whenever the active viewport / tab / layout changes. Reads the
|
||
/// model tile in model space, the sheet viewport in paper space. (#121)
|
||
pub fn active_tile_grid_snap(&self) -> Option<(bool, bool)> {
|
||
if self.current_layout != "Model" {
|
||
let h = self
|
||
.active_viewport
|
||
.filter(|h| h.is_valid())
|
||
.unwrap_or_else(|| self.current_layout_sheet_viewport_handle());
|
||
if h.is_valid() {
|
||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(h) {
|
||
return Some((vp.status.grid_on, vp.status.snap_on));
|
||
}
|
||
}
|
||
return Some((false, false));
|
||
}
|
||
let tiles = self.model_tiles.borrow();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
tiles.get(active).map(|t| (t.grid_on, t.snap_on))
|
||
}
|
||
|
||
/// Set the paper-space camera from the sheet viewport's stored view.
|
||
/// Returns true if a valid sheet viewport was found and the camera was set.
|
||
///
|
||
/// The sheet viewport entity is the authoritative paper-space view (it
|
||
/// round-trips through both the DXF and DWG writers). An older
|
||
/// `OpenCADStudio_Camera_<layout>` named View is honoured only as a
|
||
/// backward-compatible fallback for files saved under the previous scheme.
|
||
fn apply_sheet_viewport_camera(&mut self) -> bool {
|
||
let layout_block = self.current_layout_block_handle();
|
||
let sheet_vp = if layout_block.is_null() {
|
||
None
|
||
} else {
|
||
self.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let EntityType::Viewport(vp) = e {
|
||
Some(vp)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.find(|vp| {
|
||
vp.common.owner_handle == layout_block
|
||
&& !self.is_content_viewport_in_layout(vp, layout_block)
|
||
})
|
||
.cloned()
|
||
};
|
||
|
||
let vp = match sheet_vp {
|
||
Some(v) if v.view_height.abs() >= 1e-9 => v,
|
||
_ => {
|
||
// Back-compat: files OCS saved with the named-View side-channel.
|
||
let view_name = format!("OpenCADStudio_Camera_{}", self.current_layout);
|
||
let fallback =
|
||
self.document.views.iter().find(|v| v.name == view_name).cloned();
|
||
if let Some(view) = fallback {
|
||
return self.apply_camera_from_view_entry(&view, false);
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// Paper-space entities carry no world_offset → decode with a zero
|
||
// offset, through the same shared decoder (twist included).
|
||
let Some(cam) = self.camera_from_view(
|
||
vp.view_direction,
|
||
vp.view_target,
|
||
acadrust::types::Vector2 {
|
||
x: vp.view_center.x,
|
||
y: vp.view_center.y,
|
||
},
|
||
vp.view_height,
|
||
vp.twist_angle,
|
||
) else {
|
||
return false;
|
||
};
|
||
*self.camera.borrow_mut() = cam;
|
||
self.camera_generation += 1;
|
||
true
|
||
}
|
||
|
||
/// Write the current camera back into the document (VPort or sheet viewport)
|
||
/// so it is saved with the file. Returns true if the document was modified.
|
||
pub fn sync_camera_to_document(&mut self) -> bool {
|
||
let cam = self.camera.borrow().clone();
|
||
let view_dir = cam.rotation * glam::Vec3::Z;
|
||
let view_height = cam.ortho_size() * 2.0;
|
||
// Stored twist is the negative of the camera roll (the decoder applies
|
||
// roll = -twist), so the saved view round-trips square.
|
||
let twist = -cam.roll() as f64;
|
||
let vd3 = acadrust::types::Vector3 {
|
||
x: view_dir.x as f64,
|
||
y: view_dir.y as f64,
|
||
z: view_dir.z as f64,
|
||
};
|
||
|
||
if self.current_layout == "Model" {
|
||
let target_wcs = acadrust::types::Vector3 {
|
||
x: (cam.target.x as f64) + [0.0_f64; 3][0],
|
||
y: (cam.target.y as f64) + [0.0_f64; 3][1],
|
||
z: (cam.target.z as f64) + [0.0_f64; 3][2],
|
||
};
|
||
|
||
// Write back to the *Active VPort entry (may be overridden by DWG writer).
|
||
if let Some(vp) = self
|
||
.document
|
||
.vports
|
||
.iter_mut()
|
||
.find(|v| v.name == "*Active")
|
||
{
|
||
vp.view_target = target_wcs;
|
||
vp.view_center = acadrust::types::Vector2::ZERO;
|
||
vp.view_direction = vd3;
|
||
vp.view_height = view_height as f64;
|
||
vp.view_twist = twist;
|
||
}
|
||
|
||
// Persist the tiled layout as duplicate `*Active` VPort entries.
|
||
self.save_model_tiles_to_vports();
|
||
|
||
// Also write to View table — survives DWG save without override.
|
||
self.write_camera_view_entry(
|
||
"OpenCADStudio_Camera_Model",
|
||
target_wcs,
|
||
vd3,
|
||
view_height,
|
||
twist,
|
||
);
|
||
true
|
||
} else {
|
||
let target_wcs = acadrust::types::Vector3 {
|
||
x: cam.target.x as f64,
|
||
y: cam.target.y as f64,
|
||
z: cam.target.z as f64,
|
||
};
|
||
|
||
// The sheet viewport entity is the authoritative paper-space view;
|
||
// it round-trips natively, so no named-View side-channel is needed.
|
||
let layout_block = self.current_layout_block_handle();
|
||
if !layout_block.is_null() {
|
||
let sheet_handle = self
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let EntityType::Viewport(vp) = e {
|
||
Some(vp)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.find(|vp| {
|
||
vp.common.owner_handle == layout_block && !self.is_content_viewport_in_layout(vp, layout_block)
|
||
})
|
||
.map(|vp| vp.common.handle);
|
||
|
||
if let Some(handle) = sheet_handle {
|
||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
|
||
// AutoCAD stores the paper-space view position in
|
||
// `view_center` (DCS) with `view_target` at the origin —
|
||
// writing it the other way round shifts the layout and
|
||
// crashes nothing but renders the sheet off-place. Paper
|
||
// space is always a plan view, so DCS == WCS XY here.
|
||
vp.view_center =
|
||
acadrust::types::Vector3::new(target_wcs.x, target_wcs.y, 0.0);
|
||
vp.view_target = acadrust::types::Vector3::ZERO;
|
||
vp.view_direction = vd3;
|
||
vp.view_height = view_height as f64;
|
||
vp.twist_angle = twist;
|
||
}
|
||
}
|
||
}
|
||
true
|
||
}
|
||
}
|
||
|
||
/// Upsert a named View entry with the given camera fields.
|
||
fn write_camera_view_entry(
|
||
&mut self,
|
||
name: &str,
|
||
target: acadrust::types::Vector3,
|
||
direction: acadrust::types::Vector3,
|
||
height: f32,
|
||
twist: f64,
|
||
) {
|
||
let existing_handle = self
|
||
.document
|
||
.views
|
||
.iter()
|
||
.find(|v| v.name == name)
|
||
.map(|v| v.handle);
|
||
let mut entry = acadrust::tables::View::new(name);
|
||
entry.handle = existing_handle.unwrap_or_else(|| self.document.allocate_handle());
|
||
entry.target = target;
|
||
entry.direction = direction;
|
||
entry.height = height as f64;
|
||
entry.width = height as f64;
|
||
entry.center = acadrust::types::Vector3::ZERO;
|
||
entry.twist_angle = twist;
|
||
self.document.views.add_or_replace(entry);
|
||
}
|
||
|
||
/// Restore the camera from the file's saved view (called once on open).
|
||
/// Falls back to fit_all() if no saved view is available.
|
||
pub fn restore_saved_camera(&mut self) {
|
||
let restored = if self.current_layout == "Model" {
|
||
// Tiled-layout restore takes precedence — it sets the camera too.
|
||
// Single-tile files fall through to the *Active branch.
|
||
self.restore_model_tiles_from_vports() || self.apply_active_vport_camera()
|
||
} else {
|
||
// Every paper layout has a full-screen sheet viewport that holds
|
||
// its view; create one if a loaded file lacks it.
|
||
let layout = self.current_layout.clone();
|
||
self.ensure_sheet_viewport(&layout);
|
||
self.apply_sheet_viewport_camera()
|
||
};
|
||
if !restored {
|
||
self.fit_all();
|
||
}
|
||
}
|
||
|
||
pub fn fit_all(&mut self) {
|
||
// Use the FULL, un-culled wire set — not `entity_wires()`, which is
|
||
// frustum-culled to the current view. Culled input would fit only the
|
||
// entities already on screen, so each call would zoom out a little and
|
||
// reveal more, converging on the true extent only after several uses
|
||
// (issue #51). `wpp = None` also tessellates at a fixed tolerance so
|
||
// the bounds don't drift with zoom-adaptive curve sampling.
|
||
let layout_block = self.current_layout_block_handle();
|
||
let mut wires = self.wires_for_block_culled(layout_block, None, None, None, None);
|
||
if self.current_layout != "Model" {
|
||
wires.extend(self.viewport_content_wires(layout_block, None, None));
|
||
}
|
||
// 3D solids render as meshes, not wires, so collect their (offset-rel)
|
||
// XY AABBs separately — a drawing of only solids has no wires to fit.
|
||
let mesh_aabbs: Vec<[f32; 4]> = self
|
||
.meshes
|
||
.iter()
|
||
.filter(|(h, _)| {
|
||
self.document
|
||
.get_entity(**h)
|
||
.map(|e| e.common().owner_handle == layout_block)
|
||
.unwrap_or(false)
|
||
})
|
||
.map(|(_, set)| set.world_aabb)
|
||
.filter(|a| a[0].is_finite() && a[2].is_finite())
|
||
.collect();
|
||
if wires.is_empty() && mesh_aabbs.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// Per-wire centroid pass — used both for the absolute-magnitude reject
|
||
// (`local_extent_max`) and for the IQR-based outlier reject below.
|
||
// A wire whose centroid sits far outside the drawing's consensus
|
||
// cluster is an orphan (block-defn entity that leaked into MSPACE,
|
||
// bogus hatch boundary, Ray/XLine far point) and must not poison the
|
||
// bounding box.
|
||
struct WireCent {
|
||
idx: usize,
|
||
cx: f32,
|
||
cy: f32,
|
||
}
|
||
let lim = self.local_extent_max;
|
||
let mut cents: Vec<WireCent> = Vec::with_capacity(wires.len());
|
||
for (idx, wire) in wires.iter().enumerate() {
|
||
let mut sx = 0.0_f64;
|
||
let mut sy = 0.0_f64;
|
||
let mut n = 0_usize;
|
||
for &[x, y, _] in &wire.points {
|
||
if !x.is_finite() || !y.is_finite() {
|
||
continue;
|
||
}
|
||
sx += x as f64;
|
||
sy += y as f64;
|
||
n += 1;
|
||
}
|
||
if n > 0 {
|
||
cents.push(WireCent {
|
||
idx,
|
||
cx: (sx / n as f64) as f32,
|
||
cy: (sy / n as f64) as f32,
|
||
});
|
||
}
|
||
}
|
||
if cents.is_empty() && mesh_aabbs.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// Robust drawing centre (median centroid). `lim` is a span RELATIVE to
|
||
// this centre, so every reject below is distance-from-centre — geometry
|
||
// now reaches fit_all as absolute coordinates (no world_offset), which
|
||
// at UTM scale are ~5.7e6; an absolute `|x| > lim` test would reject the
|
||
// entire drawing and make ZOOM Extents a no-op.
|
||
let (mcx, mcy) = {
|
||
let mut xs: Vec<f32> = cents.iter().map(|c| c.cx).collect();
|
||
let mut ys: Vec<f32> = cents.iter().map(|c| c.cy).collect();
|
||
if xs.is_empty() {
|
||
(0.0_f32, 0.0_f32)
|
||
} else {
|
||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||
(xs[xs.len() / 2], ys[ys.len() / 2])
|
||
}
|
||
};
|
||
|
||
// IQR-based reject only kicks in with enough samples for the quartiles
|
||
// to be meaningful. Below that, the centre-relative `lim` filter is the
|
||
// only gate (legacy behavior).
|
||
let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 {
|
||
let mut xs: Vec<f32> = cents.iter().map(|c| c.cx).collect();
|
||
let mut ys: Vec<f32> = cents.iter().map(|c| c.cy).collect();
|
||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||
let q = |v: &[f32], frac: f32| v[((v.len() as f32 - 1.0) * frac) as usize];
|
||
let q1x = q(&xs, 0.25);
|
||
let q3x = q(&xs, 0.75);
|
||
let q1y = q(&ys, 0.25);
|
||
let q3y = q(&ys, 0.75);
|
||
// k=10× the inter-quartile span is permissive enough to keep
|
||
// legitimate sparse outlying geometry (annotation labels, scattered
|
||
// dim leaders) but tight enough to drop a single wire stranded at
|
||
// -world_offset. `max(1.0)` guards against a degenerate IQR=0
|
||
// (e.g. all wires at the same centroid).
|
||
const K: f32 = 10.0;
|
||
let dx = (q3x - q1x).max(1.0) * K;
|
||
let dy = (q3y - q1y).max(1.0) * K;
|
||
(q1x - dx, q3x + dx, q1y - dy, q3y + dy)
|
||
} else {
|
||
(mcx - lim, mcx + lim, mcy - lim, mcy + lim)
|
||
};
|
||
|
||
let mut min = glam::Vec3::splat(f32::MAX);
|
||
let mut max = glam::Vec3::splat(f32::MIN);
|
||
for c in ¢s {
|
||
if c.cx < rx_lo || c.cx > rx_hi || c.cy < ry_lo || c.cy > ry_hi {
|
||
continue;
|
||
}
|
||
let wire = &wires[c.idx];
|
||
for &[x, y, z] in &wire.points {
|
||
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
|
||
continue;
|
||
}
|
||
if (x - mcx).abs() > lim || (y - mcy).abs() > lim {
|
||
continue;
|
||
}
|
||
min = min.min(glam::Vec3::new(x, y, z));
|
||
max = max.max(glam::Vec3::new(x, y, z));
|
||
}
|
||
}
|
||
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
|
||
for [ax, ay, bx, by] in &mesh_aabbs {
|
||
min = min.min(glam::Vec3::new(*ax, *ay, 0.0));
|
||
max = max.max(glam::Vec3::new(*bx, *by, 0.0));
|
||
}
|
||
// If no usable points found, leave the camera unchanged.
|
||
if min.x > max.x {
|
||
return;
|
||
}
|
||
if min == max {
|
||
max += glam::Vec3::splat(1.0);
|
||
}
|
||
self.camera.borrow_mut().fit_to_bounds(min, max);
|
||
self.camera_generation += 1;
|
||
}
|
||
|
||
pub fn update(&mut self, _dt: Duration) {}
|
||
|
||
// ── Paper-space coordinate helpers ───────────────────────────────────
|
||
|
||
/// Discover the inner divider edges between Model tiles. Each entry
|
||
/// is one draggable horizontal or vertical edge, with the span along
|
||
/// the perpendicular axis that the edge actually covers (the union
|
||
/// of touching tiles' extents). Coordinates are in normalized 0..1
|
||
/// canvas space. Returns an empty list outside Model or for a
|
||
/// single-tile layout.
|
||
pub fn model_tile_edges(&self) -> Vec<TileEdge> {
|
||
if self.current_layout != "Model" {
|
||
return vec![];
|
||
}
|
||
let tiles = self.model_tiles.borrow();
|
||
if tiles.len() < 2 {
|
||
return vec![];
|
||
}
|
||
let mut out = Vec::new();
|
||
// Collect candidate inner x's: any tile edge that's strictly
|
||
// inside (0, 1). Dedup by epsilon.
|
||
let mut xs: Vec<f32> = Vec::new();
|
||
let mut ys: Vec<f32> = Vec::new();
|
||
for t in tiles.iter() {
|
||
for x in [t.rect.x, t.rect.x + t.rect.width] {
|
||
if x > TILE_EPS && x < 1.0 - TILE_EPS {
|
||
xs.push(x);
|
||
}
|
||
}
|
||
for y in [t.rect.y, t.rect.y + t.rect.height] {
|
||
if y > TILE_EPS && y < 1.0 - TILE_EPS {
|
||
ys.push(y);
|
||
}
|
||
}
|
||
}
|
||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||
xs.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS);
|
||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||
ys.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS);
|
||
for x in xs {
|
||
let mut y0 = f32::INFINITY;
|
||
let mut y1 = f32::NEG_INFINITY;
|
||
let mut has_left = false;
|
||
let mut has_right = false;
|
||
for t in tiles.iter() {
|
||
if ((t.rect.x + t.rect.width) - x).abs() < TILE_EPS {
|
||
has_left = true;
|
||
y0 = y0.min(t.rect.y);
|
||
y1 = y1.max(t.rect.y + t.rect.height);
|
||
}
|
||
if (t.rect.x - x).abs() < TILE_EPS {
|
||
has_right = true;
|
||
y0 = y0.min(t.rect.y);
|
||
y1 = y1.max(t.rect.y + t.rect.height);
|
||
}
|
||
}
|
||
if has_left && has_right && y1 > y0 {
|
||
out.push(TileEdge {
|
||
orient: TileEdgeOrient::Vertical,
|
||
coord: x,
|
||
span: (y0, y1),
|
||
});
|
||
}
|
||
}
|
||
for y in ys {
|
||
let mut x0 = f32::INFINITY;
|
||
let mut x1 = f32::NEG_INFINITY;
|
||
let mut has_top = false;
|
||
let mut has_bot = false;
|
||
for t in tiles.iter() {
|
||
if ((t.rect.y + t.rect.height) - y).abs() < TILE_EPS {
|
||
has_top = true;
|
||
x0 = x0.min(t.rect.x);
|
||
x1 = x1.max(t.rect.x + t.rect.width);
|
||
}
|
||
if (t.rect.y - y).abs() < TILE_EPS {
|
||
has_bot = true;
|
||
x0 = x0.min(t.rect.x);
|
||
x1 = x1.max(t.rect.x + t.rect.width);
|
||
}
|
||
}
|
||
if has_top && has_bot && x1 > x0 {
|
||
out.push(TileEdge {
|
||
orient: TileEdgeOrient::Horizontal,
|
||
coord: y,
|
||
span: (x0, x1),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Hit-test the inner Model-tile dividers against a pixel cursor.
|
||
/// `bounds` is the canvas pixel rectangle (origin = canvas top-left).
|
||
/// Returns the closest edge within `tolerance_px` pixels of the cursor
|
||
/// along its perpendicular axis, also requiring the cursor to lie
|
||
/// within the edge's actual span.
|
||
pub fn hit_model_tile_edge(
|
||
&self,
|
||
cursor_px: iced::Point,
|
||
bounds: iced::Rectangle,
|
||
tolerance_px: f32,
|
||
) -> Option<TileEdge> {
|
||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
||
return None;
|
||
}
|
||
let cx = cursor_px.x - bounds.x;
|
||
let cy = cursor_px.y - bounds.y;
|
||
let nx = cx / bounds.width;
|
||
let ny = cy / bounds.height;
|
||
let tol_nx = tolerance_px / bounds.width;
|
||
let tol_ny = tolerance_px / bounds.height;
|
||
let mut best: Option<(f32, TileEdge)> = None;
|
||
for e in self.model_tile_edges() {
|
||
let (dist, in_span) = match e.orient {
|
||
TileEdgeOrient::Vertical => (
|
||
(e.coord - nx).abs() / tol_nx.max(1e-9),
|
||
ny >= e.span.0 && ny <= e.span.1,
|
||
),
|
||
TileEdgeOrient::Horizontal => (
|
||
(e.coord - ny).abs() / tol_ny.max(1e-9),
|
||
nx >= e.span.0 && nx <= e.span.1,
|
||
),
|
||
};
|
||
if in_span && dist <= 1.0 {
|
||
if best.as_ref().map_or(true, |(d, _)| dist < *d) {
|
||
best = Some((dist, e));
|
||
}
|
||
}
|
||
}
|
||
best.map(|(_, e)| e)
|
||
}
|
||
|
||
/// Move the inner divider edge from `old_coord` to `new_coord`, both
|
||
/// in normalized 0..1 space. Adjusts every tile that touches the
|
||
/// edge on either side. `min_size` clamps the new coordinate so no
|
||
/// tile crosses into a non-positive width / height — caller still
|
||
/// runs `collapse_small_model_tiles` afterward to merge any tiles
|
||
/// that fell below the viewcube threshold.
|
||
pub fn move_model_tile_edge(
|
||
&self,
|
||
orient: TileEdgeOrient,
|
||
old_coord: f32,
|
||
new_coord: f32,
|
||
min_size: f32,
|
||
) {
|
||
let mut tiles = self.model_tiles.borrow_mut();
|
||
// Clamp the new coordinate so no tile becomes ≤ 0 wide / tall.
|
||
// (Sub-`min_size` results are still allowed — the collapse pass
|
||
// handles those.)
|
||
let new_coord = match orient {
|
||
TileEdgeOrient::Vertical => {
|
||
let mut lo = 0.0_f32;
|
||
let mut hi = 1.0_f32;
|
||
for t in tiles.iter() {
|
||
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
|
||
lo = lo.max(t.rect.x + min_size * 0.25);
|
||
}
|
||
if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||
hi = hi.min(t.rect.x + t.rect.width - min_size * 0.25);
|
||
}
|
||
}
|
||
new_coord.clamp(lo, hi)
|
||
}
|
||
TileEdgeOrient::Horizontal => {
|
||
let mut lo = 0.0_f32;
|
||
let mut hi = 1.0_f32;
|
||
for t in tiles.iter() {
|
||
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
|
||
lo = lo.max(t.rect.y + min_size * 0.25);
|
||
}
|
||
if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||
hi = hi.min(t.rect.y + t.rect.height - min_size * 0.25);
|
||
}
|
||
}
|
||
new_coord.clamp(lo, hi)
|
||
}
|
||
};
|
||
for t in tiles.iter_mut() {
|
||
match orient {
|
||
TileEdgeOrient::Vertical => {
|
||
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
|
||
t.rect.width = (new_coord - t.rect.x).max(0.0);
|
||
} else if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||
let old_right = t.rect.x + t.rect.width;
|
||
t.rect.x = new_coord;
|
||
t.rect.width = (old_right - new_coord).max(0.0);
|
||
}
|
||
}
|
||
TileEdgeOrient::Horizontal => {
|
||
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
|
||
t.rect.height = (new_coord - t.rect.y).max(0.0);
|
||
} else if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||
let old_bottom = t.rect.y + t.rect.height;
|
||
t.rect.y = new_coord;
|
||
t.rect.height = (old_bottom - new_coord).max(0.0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Remove every tile whose width or height has dropped below the
|
||
/// supplied minima, absorbing each one's area into the neighbour
|
||
/// that shares the longest contact edge. Iterates until no tile is
|
||
/// too small (handles chains of collapses). Adjusts
|
||
/// `active_model_tile` so the live camera stays bound to a real
|
||
/// tile (preferring the neighbour that absorbed the active tile).
|
||
pub fn collapse_small_model_tiles(&self, min_w: f32, min_h: f32) {
|
||
let mut tiles = self.model_tiles.borrow_mut();
|
||
loop {
|
||
if tiles.len() < 2 {
|
||
break;
|
||
}
|
||
let small = tiles
|
||
.iter()
|
||
.enumerate()
|
||
.find(|(_, t)| t.rect.width < min_w || t.rect.height < min_h)
|
||
.map(|(i, _)| i);
|
||
let Some(idx) = small else { break };
|
||
let removed = tiles[idx].rect;
|
||
// Find the neighbour with the longest shared contact edge.
|
||
let mut best: Option<(usize, f32, ContactSide)> = None;
|
||
for (j, t) in tiles.iter().enumerate() {
|
||
if j == idx {
|
||
continue;
|
||
}
|
||
let probes = [
|
||
(
|
||
ContactSide::Left,
|
||
((t.rect.x + t.rect.width) - removed.x).abs() < TILE_EPS,
|
||
overlap_len(
|
||
(t.rect.y, t.rect.y + t.rect.height),
|
||
(removed.y, removed.y + removed.height),
|
||
),
|
||
),
|
||
(
|
||
ContactSide::Right,
|
||
(t.rect.x - (removed.x + removed.width)).abs() < TILE_EPS,
|
||
overlap_len(
|
||
(t.rect.y, t.rect.y + t.rect.height),
|
||
(removed.y, removed.y + removed.height),
|
||
),
|
||
),
|
||
(
|
||
ContactSide::Top,
|
||
((t.rect.y + t.rect.height) - removed.y).abs() < TILE_EPS,
|
||
overlap_len(
|
||
(t.rect.x, t.rect.x + t.rect.width),
|
||
(removed.x, removed.x + removed.width),
|
||
),
|
||
),
|
||
(
|
||
ContactSide::Bottom,
|
||
(t.rect.y - (removed.y + removed.height)).abs() < TILE_EPS,
|
||
overlap_len(
|
||
(t.rect.x, t.rect.x + t.rect.width),
|
||
(removed.x, removed.x + removed.width),
|
||
),
|
||
),
|
||
];
|
||
for (side, touches, c) in probes {
|
||
if touches && c > 0.0 {
|
||
if best.map_or(true, |(_, len, _)| c > len) {
|
||
best = Some((j, c, side));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some((nbr_idx, _, side)) = best {
|
||
match side {
|
||
ContactSide::Left => {
|
||
tiles[nbr_idx].rect.width =
|
||
(removed.x + removed.width) - tiles[nbr_idx].rect.x;
|
||
}
|
||
ContactSide::Right => {
|
||
let old_right =
|
||
tiles[nbr_idx].rect.x + tiles[nbr_idx].rect.width;
|
||
tiles[nbr_idx].rect.x = removed.x;
|
||
tiles[nbr_idx].rect.width = old_right - removed.x;
|
||
}
|
||
ContactSide::Top => {
|
||
tiles[nbr_idx].rect.height =
|
||
(removed.y + removed.height) - tiles[nbr_idx].rect.y;
|
||
}
|
||
ContactSide::Bottom => {
|
||
let old_bottom =
|
||
tiles[nbr_idx].rect.y + tiles[nbr_idx].rect.height;
|
||
tiles[nbr_idx].rect.y = removed.y;
|
||
tiles[nbr_idx].rect.height = old_bottom - removed.y;
|
||
}
|
||
}
|
||
let active = self.active_model_tile.get();
|
||
let new_active = if active == idx {
|
||
if nbr_idx > idx { nbr_idx - 1 } else { nbr_idx }
|
||
} else if active > idx {
|
||
active - 1
|
||
} else {
|
||
active
|
||
};
|
||
tiles.remove(idx);
|
||
self.active_model_tile
|
||
.set(new_active.min(tiles.len().saturating_sub(1)));
|
||
} else {
|
||
// Isolated tile (shouldn't happen with axis-aligned
|
||
// splits) — drop it and stretch the first remaining
|
||
// tile to fill the canvas so we don't leave a hole.
|
||
tiles.remove(idx);
|
||
let active = self.active_model_tile.get();
|
||
self.active_model_tile
|
||
.set(active.saturating_sub(if active > idx { 1 } else { 0 }).min(tiles.len().saturating_sub(1)));
|
||
if let Some(first) = tiles.first_mut() {
|
||
first.rect = iced::Rectangle {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
};
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Split the active Model tile in two. `horizontal` → a horizontal
|
||
/// divider (top / bottom halves); otherwise a vertical divider (left /
|
||
/// right). Both halves inherit the active tile's current camera; the
|
||
/// active tile stays the first half. No-op outside the Model layout.
|
||
pub fn split_active_model_tile(&self, horizontal: bool) {
|
||
if self.current_layout != "Model" {
|
||
return;
|
||
}
|
||
let cam_now = self.camera.borrow().clone();
|
||
let mut tiles = self.model_tiles.borrow_mut();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
let r = tiles[active].rect;
|
||
let (a, b) = if horizontal {
|
||
(
|
||
iced::Rectangle { height: r.height / 2.0, ..r },
|
||
iced::Rectangle {
|
||
y: r.y + r.height / 2.0,
|
||
height: r.height / 2.0,
|
||
..r
|
||
},
|
||
)
|
||
} else {
|
||
(
|
||
iced::Rectangle { width: r.width / 2.0, ..r },
|
||
iced::Rectangle {
|
||
x: r.x + r.width / 2.0,
|
||
width: r.width / 2.0,
|
||
..r
|
||
},
|
||
)
|
||
};
|
||
let mode = tiles[active].render_mode;
|
||
let (grid_on, snap_on) = (tiles[active].grid_on, tiles[active].snap_on);
|
||
tiles[active] = ModelTile {
|
||
rect: a,
|
||
camera: cam_now.clone(),
|
||
render_mode: mode,
|
||
grid_on,
|
||
snap_on,
|
||
};
|
||
tiles.insert(
|
||
active + 1,
|
||
ModelTile {
|
||
rect: b,
|
||
camera: cam_now,
|
||
render_mode: mode,
|
||
grid_on,
|
||
snap_on,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Make the Model tile containing normalized point `(nx, ny)` active,
|
||
/// swapping cameras so the live `Scene::camera` follows the new tile.
|
||
/// Returns `true` when the active tile changed. No-op outside Model.
|
||
pub fn set_active_model_tile_at(&self, nx: f32, ny: f32) -> bool {
|
||
if self.current_layout != "Model" {
|
||
return false;
|
||
}
|
||
let new = {
|
||
let tiles = self.model_tiles.borrow();
|
||
tiles.iter().position(|t| {
|
||
nx >= t.rect.x
|
||
&& nx < t.rect.x + t.rect.width
|
||
&& ny >= t.rect.y
|
||
&& ny < t.rect.y + t.rect.height
|
||
})
|
||
};
|
||
let Some(new) = new else { return false };
|
||
let old = self.active_model_tile.get();
|
||
if new == old {
|
||
return false;
|
||
}
|
||
// Stash the live camera into the outgoing tile, load the incoming.
|
||
let incoming = {
|
||
let mut tiles = self.model_tiles.borrow_mut();
|
||
if let Some(t) = tiles.get_mut(old) {
|
||
t.camera = self.camera.borrow().clone();
|
||
}
|
||
tiles.get(new).map(|t| t.camera.clone())
|
||
};
|
||
if let Some(cam) = incoming {
|
||
*self.camera.borrow_mut() = cam;
|
||
}
|
||
self.active_model_tile.set(new);
|
||
// Caller bumps camera_generation (it needs &mut Scene).
|
||
true
|
||
}
|
||
|
||
/// Replace the Model tiled layout with the given normalized rectangles
|
||
/// (each in 0..1). Every tile inherits the current camera; the first
|
||
/// tile becomes active. Used by VPORTS presets and `reset_model_tiles`.
|
||
pub fn set_model_tile_layout(&self, rects: Vec<iced::Rectangle>) {
|
||
let cam_now = self.camera.borrow().clone();
|
||
// Every new pane inherits the active tile's current visual style.
|
||
let (mode, grid_on, snap_on) = {
|
||
let tiles = self.model_tiles.borrow();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
tiles
|
||
.get(active)
|
||
.map(|t| (t.render_mode, t.grid_on, t.snap_on))
|
||
.unwrap_or((
|
||
acadrust::entities::ViewportRenderMode::Wireframe2D,
|
||
false,
|
||
false,
|
||
))
|
||
};
|
||
let tiles: Vec<ModelTile> = rects
|
||
.into_iter()
|
||
.map(|rect| ModelTile {
|
||
rect,
|
||
camera: cam_now.clone(),
|
||
render_mode: mode,
|
||
grid_on,
|
||
snap_on,
|
||
})
|
||
.collect();
|
||
*self.model_tiles.borrow_mut() = if tiles.is_empty() {
|
||
vec![ModelTile {
|
||
rect: iced::Rectangle {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
},
|
||
camera: cam_now,
|
||
render_mode: mode,
|
||
grid_on,
|
||
snap_on,
|
||
}]
|
||
} else {
|
||
tiles
|
||
};
|
||
self.active_model_tile.set(0);
|
||
}
|
||
|
||
/// Screen-pixel rectangle of the active Model tile within a canvas of
|
||
/// `(vw, vh)`. Full canvas outside the Model layout or for a single
|
||
/// tile. Used to map cursor coordinates into the active tile so pick /
|
||
/// pan / ViewCube work per-pane in a tiled layout.
|
||
/// Canvas bounds + camera for every Model tile whose grid display is on.
|
||
/// Each pane renders its own grid independently of which tile is active or
|
||
/// hovered, so the grid never flickers as the cursor crosses panes. The
|
||
/// active tile uses the live camera (mid-orbit/pan); others use their
|
||
/// stored camera. (#121)
|
||
/// Screen rect + camera for every grid-on sub-view in the current layout —
|
||
/// model tiles in model space, the sheet plus each floating viewport
|
||
/// (clipped to its rectangle) in paper space. Derived from the same
|
||
/// `active_viewports` enumeration the renderer uses, so the grid overlay can
|
||
/// never drift from the views actually on screen (issue #121). The grid
|
||
/// ignores render mode, so any value passes through.
|
||
pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> {
|
||
self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D)
|
||
.into_iter()
|
||
.filter(|inst| inst.grid_on)
|
||
.map(|inst| (inst.screen_rect, inst.camera, inst.handle))
|
||
.collect()
|
||
}
|
||
|
||
pub fn active_model_tile_bounds(&self, vw: f32, vh: f32) -> iced::Rectangle {
|
||
if self.current_layout != "Model" {
|
||
return iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh };
|
||
}
|
||
let tiles = self.model_tiles.borrow();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
match tiles.get(active) {
|
||
Some(t) => iced::Rectangle {
|
||
x: t.rect.x * vw,
|
||
y: t.rect.y * vh,
|
||
width: (t.rect.width * vw).max(1.0),
|
||
height: (t.rect.height * vh).max(1.0),
|
||
},
|
||
None => iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh },
|
||
}
|
||
}
|
||
|
||
/// The viewports to render this frame, one entry per scissor pass.
|
||
///
|
||
/// - **Model layout**: a single full-canvas instance driven by the
|
||
/// scene camera (tiled splits will append more later). `model_mode`
|
||
/// supplies its render mode (held on the tab, not the scene).
|
||
/// - **Paper layout**: one instance per content viewport entity
|
||
/// (`id > 1`, owned by the current layout block, switched on),
|
||
/// using each viewport's own camera and render mode.
|
||
pub fn active_viewports(
|
||
&self,
|
||
canvas_w: f32,
|
||
canvas_h: f32,
|
||
model_mode: acadrust::entities::ViewportRenderMode,
|
||
) -> Vec<ViewportInstance> {
|
||
if self.current_layout == "Model" {
|
||
let tiles = self.model_tiles.borrow();
|
||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||
return tiles
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, tile)| {
|
||
// The active tile renders the live camera (orbit/pan act
|
||
// on it); inactive tiles use their stored snapshot.
|
||
let camera = if i == active {
|
||
self.camera.borrow().clone()
|
||
} else {
|
||
tile.camera.clone()
|
||
};
|
||
ViewportInstance {
|
||
handle: Handle::NULL,
|
||
tile_idx: Some(i),
|
||
screen_rect: iced::Rectangle {
|
||
x: tile.rect.x * canvas_w,
|
||
y: tile.rect.y * canvas_h,
|
||
width: tile.rect.width * canvas_w,
|
||
height: tile.rect.height * canvas_h,
|
||
},
|
||
camera,
|
||
// The active tile shows the live mode the picker
|
||
// drives; every other tile keeps its own stored
|
||
// style so editing one never disturbs the rest.
|
||
render_mode: if i == active { model_mode } else { tile.render_mode },
|
||
active: i == active,
|
||
grid_on: tile.grid_on,
|
||
paper_sheet: false,
|
||
}
|
||
})
|
||
.collect();
|
||
}
|
||
let layout_block = self.current_layout_block_handle();
|
||
let mut out: Vec<ViewportInstance> = Vec::new();
|
||
// The full-canvas sheet viewport renders the paper-space entities
|
||
// themselves — the layout's own view, drawn first so the floating
|
||
// content viewports overlay it. Its camera keeps the paper pan/zoom
|
||
// (target + ortho size) but is LOCKED to the top/plan orientation:
|
||
// paper is 2-D, so the sheet never orbits.
|
||
let mut sheet_cam = self.camera.borrow().clone();
|
||
sheet_cam.yaw = 0.0;
|
||
sheet_cam.pitch = std::f32::consts::FRAC_PI_2;
|
||
sheet_cam.rotation = view::camera::yaw_pitch_to_quat(0.0, std::f32::consts::FRAC_PI_2, 0.0);
|
||
sheet_cam.projection = view::camera::Projection::Orthographic;
|
||
let sheet_grid_on = match self
|
||
.document
|
||
.get_entity(self.current_layout_sheet_viewport_handle())
|
||
{
|
||
Some(EntityType::Viewport(vp)) => vp.status.grid_on,
|
||
_ => false,
|
||
};
|
||
out.push(ViewportInstance {
|
||
handle: Handle::NULL,
|
||
tile_idx: None,
|
||
screen_rect: iced::Rectangle {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: canvas_w,
|
||
height: canvas_h,
|
||
},
|
||
camera: sheet_cam,
|
||
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
|
||
active: false,
|
||
grid_on: sheet_grid_on,
|
||
paper_sheet: true,
|
||
});
|
||
for e in self.document.entities() {
|
||
let EntityType::Viewport(vp) = e else {
|
||
continue;
|
||
};
|
||
if !self.is_content_viewport_in_layout(vp, layout_block)
|
||
|| !vp.status.is_on
|
||
{
|
||
continue;
|
||
}
|
||
let h = vp.common.handle;
|
||
let (Some(screen_rect), Some(camera)) = (
|
||
self.viewport_screen_rect(h, (canvas_w, canvas_h)),
|
||
self.camera_for_viewport(h),
|
||
) else {
|
||
continue;
|
||
};
|
||
out.push(ViewportInstance {
|
||
handle: h,
|
||
tile_idx: None,
|
||
screen_rect,
|
||
camera,
|
||
render_mode: vp.render_mode,
|
||
active: self.active_viewport == Some(h),
|
||
grid_on: vp.status.grid_on,
|
||
paper_sheet: false,
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Convert a paper-space Viewport entity's position/size into a pixel
|
||
/// `Rectangle` relative to the top-left of the canvas.
|
||
///
|
||
/// Uses the same top-down ortho transform as the GPU sheet viewport so the
|
||
/// overlay lands exactly over the drawn viewport border regardless of zoom
|
||
/// or pan level.
|
||
pub fn viewport_screen_rect(
|
||
&self,
|
||
vp_handle: Handle,
|
||
canvas_px: (f32, f32),
|
||
) -> Option<iced::Rectangle> {
|
||
let vp = match self.document.get_entity(vp_handle) {
|
||
Some(EntityType::Viewport(vp)) => vp,
|
||
_ => return None,
|
||
};
|
||
|
||
let (canvas_w, canvas_h) = canvas_px;
|
||
if canvas_w < 1.0 || canvas_h < 1.0 {
|
||
return None;
|
||
}
|
||
|
||
let cam = self.camera.borrow();
|
||
let aspect = canvas_w / canvas_h;
|
||
let half_h = cam.ortho_size();
|
||
let half_w = half_h * aspect;
|
||
let tx = cam.target.x as f32;
|
||
let ty = cam.target.y as f32;
|
||
drop(cam);
|
||
|
||
// Top-down ortho mapping matching the GPU sheet viewport's camera.
|
||
let to_px = |wx: f32, wy: f32| -> (f32, f32) {
|
||
let x = (wx - tx + half_w) / (2.0 * half_w) * canvas_w;
|
||
let y = (ty + half_h - wy) / (2.0 * half_h) * canvas_h;
|
||
(x, y)
|
||
};
|
||
|
||
let cx = vp.center.x as f32;
|
||
let cy = vp.center.y as f32;
|
||
let hw = (vp.width / 2.0) as f32;
|
||
let hh = (vp.height / 2.0) as f32;
|
||
|
||
let (x0, y0) = to_px(cx - hw, cy + hh); // top-left in screen
|
||
let (x1, y1) = to_px(cx + hw, cy - hh); // bottom-right in screen
|
||
|
||
let w = (x1 - x0).max(1.0);
|
||
let h = (y1 - y0).max(1.0);
|
||
|
||
Some(iced::Rectangle {
|
||
x: x0,
|
||
y: y0,
|
||
width: w,
|
||
height: h,
|
||
})
|
||
}
|
||
|
||
// ── Paper-space helpers ───────────────────────────────────────────────
|
||
|
||
/// Paper-layout hatch fills, restricted to the active layout block (used by
|
||
/// paper-space hatch hit-testing / export). The GPU-rendered
|
||
/// content viewports already draw model-block hatches inside their
|
||
/// own scissor; including those here would also draw them on the
|
||
/// paper sheet through the paper camera (huge / off-position), so
|
||
/// restrict the canvas list to entities owned by the active paper
|
||
/// layout block. Iterates the source `self.hatches` map (keyed by
|
||
/// entity handle) rather than the already-flattened arc — the
|
||
/// flattened arc carries pattern names, not handles, so filtering
|
||
/// there is unreliable.
|
||
pub fn paper_canvas_hatches(&self) -> Arc<Vec<HatchModel>> {
|
||
let layout_block = self.current_layout_block_handle();
|
||
let layer_hidden = |layer: &str| {
|
||
self.document
|
||
.layers
|
||
.get(layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
};
|
||
let mut models: Vec<HatchModel> = Vec::new();
|
||
for (&handle, model) in self.hatches.iter() {
|
||
let Some(entity) = self.document.get_entity(handle) else {
|
||
continue;
|
||
};
|
||
let c = entity.common();
|
||
if c.invisible || layer_hidden(&c.layer) {
|
||
continue;
|
||
}
|
||
if !self.belongs_to_visible_block(handle, c.owner_handle, layout_block) {
|
||
continue;
|
||
}
|
||
let mut m = model.clone();
|
||
m.color = self.render_style(entity).0;
|
||
if let EntityType::Hatch(dxf) = entity {
|
||
if let model::hatch_model::HatchPattern::Pattern(_) = &m.pattern {
|
||
m.angle_offset = dxf.pattern_angle as f32;
|
||
m.scale = dxf.pattern_scale as f32;
|
||
}
|
||
}
|
||
if self.selected.contains(&handle) {
|
||
m.color = [0.15, 0.55, 1.00, m.color[3]];
|
||
}
|
||
models.push(m);
|
||
}
|
||
Arc::new(models)
|
||
}
|
||
|
||
/// Paper-layout wipeout fills (paper hit-testing / export). Same rationale as
|
||
/// `paper_canvas_hatches` — only include wipeouts owned by the
|
||
/// active paper layout block, so model wipeouts (drawn through their
|
||
/// content viewport's GPU pipeline) don't get a second mis-projected
|
||
/// copy on the paper sheet.
|
||
pub fn paper_canvas_wipeouts(&self) -> Arc<Vec<HatchModel>> {
|
||
let layout_block = self.current_layout_block_handle();
|
||
let bg_color = self.paper_bg_color;
|
||
let mut models = Vec::new();
|
||
for entity in self.document.entities() {
|
||
let EntityType::Wipeout(wo) = entity else {
|
||
continue;
|
||
};
|
||
if wo.common.invisible {
|
||
continue;
|
||
}
|
||
if self
|
||
.document
|
||
.layers
|
||
.get(&wo.common.layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
{
|
||
continue;
|
||
}
|
||
if !self.belongs_to_visible_block(wo.common.handle, wo.common.owner_handle, layout_block)
|
||
{
|
||
continue;
|
||
}
|
||
// Paper-block wipeouts live in paper coords — no `world_offset`.
|
||
let boundary = Self::wipeout_boundary_2d(wo);
|
||
if boundary.len() < 3 {
|
||
continue;
|
||
}
|
||
let mut fill_color = bg_color;
|
||
if self.selected.contains(&wo.common.handle) {
|
||
fill_color = [0.15, 0.55, 1.00, 0.35];
|
||
}
|
||
models.push(HatchModel {
|
||
boundary: Arc::new(boundary),
|
||
pattern: model::hatch_model::HatchPattern::Solid,
|
||
name: "WIPEOUT_FILL".into(),
|
||
color: fill_color,
|
||
angle_offset: 0.0,
|
||
scale: 1.0,
|
||
world_origin: [0.0; 2],
|
||
vp_scissor: None,
|
||
draw_depth: 0.0,
|
||
});
|
||
}
|
||
Arc::new(models)
|
||
}
|
||
|
||
/// Build a Camera oriented and scaled to match a paper-space Viewport entity.
|
||
/// Used by `active_viewports` to render model-space content through each
|
||
/// content viewport's own view direction and scale.
|
||
fn camera_for_viewport(&self, vp_handle: Handle) -> Option<view::camera::Camera> {
|
||
let vp = match self.document.get_entity(vp_handle) {
|
||
Some(EntityType::Viewport(vp)) => vp,
|
||
_ => return None,
|
||
};
|
||
|
||
// Floating-viewport–specific step: decide saved-view vs auto-fit, then
|
||
// hand the effective view to the shared `camera_from_view` decoder so
|
||
// twist / view_center / distance behave identically to a model VPORT.
|
||
//
|
||
// UTM / coordinate-shifted drawings often arrive with
|
||
// `view_target = (0, 0, 0)` and a stale `view_center` from before the
|
||
// file was geo-referenced; the saved view points at empty WCS while the
|
||
// actual model sits ~`world_offset` away. Decode the saved view first
|
||
// and keep it only if its target actually frames the model cluster.
|
||
//
|
||
// The overlap test runs on the *decoded* target (wire-space, so the
|
||
// cluster is `±cluster_half` about the origin), NOT a raw
|
||
// `view_target + view_center` sum: under a view twist `view_center` is a
|
||
// DCS offset, so the raw sum lands far from the real WCS centre and
|
||
// would wrongly trip the auto-fit — replacing the saved view_height with
|
||
// the whole-cluster fit and rendering the content at the wrong zoom.
|
||
let saved_h = vp.view_height.abs();
|
||
let aspect_d = (vp.width / vp.height.max(1.0)).max(1e-9);
|
||
let cluster_half = self.local_extent_max.max(1.0) as f64;
|
||
// Absolute drawing centre. Geometry now reaches the scene at absolute
|
||
// (UTM) coordinates — the old code centred the overlap test and the
|
||
// auto-fit on the origin, which was right only while world_offset
|
||
// re-centred the model there. Without it a UTM drawing sits ~5.7e6 away,
|
||
// so a stale `(0,0,0)` saved view failed the overlap test AND the
|
||
// auto-fit aimed at empty origin → blank viewports.
|
||
let (cx, cy) = self
|
||
.model_space_extents()
|
||
.map(|(mn, mx)| {
|
||
(((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)
|
||
})
|
||
.unwrap_or((0.0, 0.0));
|
||
|
||
if let Some(cam) = self.camera_from_view(
|
||
vp.view_direction,
|
||
vp.view_target,
|
||
acadrust::types::Vector2 {
|
||
x: vp.view_center.x,
|
||
y: vp.view_center.y,
|
||
},
|
||
saved_h,
|
||
vp.twist_angle,
|
||
) {
|
||
let half_h = saved_h * 0.5;
|
||
let half_w = half_h * aspect_d;
|
||
let (tx, ty) = (cam.target.x as f64, cam.target.y as f64);
|
||
let overlaps = tx + half_w >= cx - cluster_half
|
||
&& tx - half_w <= cx + cluster_half
|
||
&& ty + half_h >= cy - cluster_half
|
||
&& ty - half_h <= cy + cluster_half;
|
||
if overlaps {
|
||
return Some(cam);
|
||
}
|
||
}
|
||
|
||
// Auto-fit: aim at the content cluster centre, drop the stale view_center.
|
||
let fit_h = cluster_half * 2.0 * 1.05;
|
||
let tgt = acadrust::types::Vector3 {
|
||
x: cx,
|
||
y: cy,
|
||
z: vp.view_target.z,
|
||
};
|
||
self.camera_from_view(
|
||
vp.view_direction,
|
||
tgt,
|
||
acadrust::types::Vector2::ZERO,
|
||
fit_h,
|
||
vp.twist_angle,
|
||
)
|
||
}
|
||
|
||
/// Collect model-space WireModels visible through `vp_handle`, respecting
|
||
/// global layer visibility, the viewport's per-viewport layer freeze list,
|
||
/// and the per-viewport frustum + LOD cull derived from
|
||
/// `screen_height_px` (the on-paper pixel height of this viewport).
|
||
fn model_wires_for_viewport(
|
||
&self,
|
||
vp_handle: Handle,
|
||
screen_height_px: f32,
|
||
) -> Vec<WireModel> {
|
||
use rustc_hash::FxHashSet as HSet;
|
||
|
||
let (frozen, vp_anno_scale, vp_aspect) = match self.document.get_entity(vp_handle) {
|
||
Some(EntityType::Viewport(vp)) => {
|
||
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
|
||
let vp_scale =
|
||
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||
let anno = if vp_scale > 1e-9 {
|
||
(1.0 / vp_scale) as f32
|
||
} else {
|
||
1.0_f32
|
||
};
|
||
let aspect = if vp.height > 1e-9 {
|
||
(vp.width / vp.height) as f32
|
||
} else {
|
||
1.0_f32
|
||
};
|
||
(f, anno, aspect)
|
||
}
|
||
_ => (HSet::default(), 1.0_f32, 1.0_f32),
|
||
};
|
||
|
||
// Drive the per-viewport view_aabb / wpp from the *effective* camera
|
||
// `camera_for_viewport` produces — it folds in the auto-fit
|
||
// fallback for UTM-style files whose saved `view_target` sits at
|
||
// empty WCS. Without that, the GPU pass would frustum-cull every
|
||
// entity (saved-view rect doesn't overlap the offset-subtracted
|
||
// model cluster) and the viewport would render blank.
|
||
let Some(cam) = self.camera_for_viewport(vp_handle) else {
|
||
return Vec::new();
|
||
};
|
||
let vp_ortho_h = cam.ortho_size();
|
||
// Rotation-aware cull box: a twisted/rotated viewport sees a rotated
|
||
// rectangle in world XY, so derive the box from the camera basis.
|
||
// `None` (tilted view) disables the cull — render everything.
|
||
let view_aabb = view_cull_aabb(&cam, vp_aspect, 1.25);
|
||
// World units per on-screen pixel for LOD substitution + curve
|
||
// tolerance. Tracks the paper-zoom-driven pixel height the
|
||
// viewport currently occupies.
|
||
let wpp = if screen_height_px > 1.0 {
|
||
Some((2.0 * vp_ortho_h) / screen_height_px)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
self.wires_for_block_culled(
|
||
self.model_space_block_handle(),
|
||
view_aabb,
|
||
wpp,
|
||
Some(&frozen),
|
||
Some(vp_anno_scale),
|
||
)
|
||
}
|
||
|
||
/// Cached per-paper-viewport tessellation. Each viewport's wpp tracks
|
||
/// the on-paper pixel height (paper-zoom dependent), so the cache key
|
||
/// includes a quantized form of that height in addition to the
|
||
/// geometry epoch — every paper zoom step that actually changes the
|
||
/// LOD bucket invalidates this viewport's entry.
|
||
pub(super) fn model_wires_for_viewport_arc(
|
||
&self,
|
||
vp_handle: Handle,
|
||
screen_height_px: f32,
|
||
) -> Arc<Vec<WireModel>> {
|
||
// Drop sub-pixel noise so trivial paper-zoom jitter does not
|
||
// re-tessellate a 100k-entity drawing every frame; round to an
|
||
// integer pixel.
|
||
let height_key = screen_height_px.max(1.0).round() as u32;
|
||
// Hash the viewport's own view (pan + zoom + orbit) into the key.
|
||
// Editing inside the viewport (MSPACE) changes its frustum but does NOT
|
||
// bump geometry_epoch, so without this the stale frustum-culled subset
|
||
// is returned and newly-revealed lines stay invisible until the layout
|
||
// re-tessellates. Quantize to ~1 px / fine steps to ignore jitter.
|
||
let view_key = {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) {
|
||
let vh = vp.view_height.abs().max(1e-6);
|
||
let q = vh / (screen_height_px.max(1.0) as f64); // model units / px
|
||
(((vp.view_target.x + vp.view_center.x) / q).round() as i64).hash(&mut h);
|
||
(((vp.view_target.y + vp.view_center.y) / q).round() as i64).hash(&mut h);
|
||
((vh * 1000.0).round() as i64).hash(&mut h);
|
||
((vp.view_direction.x * 1000.0).round() as i64).hash(&mut h);
|
||
((vp.view_direction.y * 1000.0).round() as i64).hash(&mut h);
|
||
((vp.view_direction.z * 1000.0).round() as i64).hash(&mut h);
|
||
}
|
||
h.finish()
|
||
};
|
||
let key = (self.geometry_epoch, height_key, view_key);
|
||
{
|
||
let cache = self.viewport_wire_cache.borrow();
|
||
if let Some((cached_key, ref arc)) = cache.get(&vp_handle) {
|
||
if *cached_key == key {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
let arc = Arc::new(self.model_wires_for_viewport(vp_handle, screen_height_px));
|
||
self.viewport_wire_cache
|
||
.borrow_mut()
|
||
.insert(vp_handle, (key, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
}
|
||
|
||
impl Default for Scene {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
// ── Paper boundary wire ────────────────────────────────────────────────────
|
||
|
||
// ── Cohen-Sutherland line clipping ───────────────────────────────────────
|
||
|
||
/// Clip a single segment (x0,y0)→(x1,y1) against the axis-aligned rectangle
|
||
/// [xmin,xmax]×[ymin,ymax]. Returns the clipped endpoints or `None` if the
|
||
/// segment is entirely outside.
|
||
fn cs_clip(
|
||
mut x0: f32,
|
||
mut y0: f32,
|
||
mut x1: f32,
|
||
mut y1: f32,
|
||
xmin: f32,
|
||
ymin: f32,
|
||
xmax: f32,
|
||
ymax: f32,
|
||
) -> Option<(f32, f32, f32, f32)> {
|
||
const LEFT: u8 = 1;
|
||
const RIGHT: u8 = 2;
|
||
const BOTTOM: u8 = 4;
|
||
const TOP: u8 = 8;
|
||
|
||
let code = |x: f32, y: f32| -> u8 {
|
||
let mut c = 0u8;
|
||
if x < xmin {
|
||
c |= LEFT;
|
||
} else if x > xmax {
|
||
c |= RIGHT;
|
||
}
|
||
if y < ymin {
|
||
c |= BOTTOM;
|
||
} else if y > ymax {
|
||
c |= TOP;
|
||
}
|
||
c
|
||
};
|
||
|
||
let mut c0 = code(x0, y0);
|
||
let mut c1 = code(x1, y1);
|
||
|
||
loop {
|
||
if c0 | c1 == 0 {
|
||
return Some((x0, y0, x1, y1));
|
||
}
|
||
if c0 & c1 != 0 {
|
||
return None;
|
||
}
|
||
let cout = if c0 != 0 { c0 } else { c1 };
|
||
let (x, y);
|
||
if cout & TOP != 0 {
|
||
x = x0 + (x1 - x0) * (ymax - y0) / (y1 - y0);
|
||
y = ymax;
|
||
} else if cout & BOTTOM != 0 {
|
||
x = x0 + (x1 - x0) * (ymin - y0) / (y1 - y0);
|
||
y = ymin;
|
||
} else if cout & RIGHT != 0 {
|
||
y = y0 + (y1 - y0) * (xmax - x0) / (x1 - x0);
|
||
x = xmax;
|
||
} else {
|
||
y = y0 + (y1 - y0) * (xmin - x0) / (x1 - x0);
|
||
x = xmin;
|
||
}
|
||
if cout == c0 {
|
||
x0 = x;
|
||
y0 = y;
|
||
c0 = code(x0, y0);
|
||
} else {
|
||
x1 = x;
|
||
y1 = y;
|
||
c1 = code(x1, y1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Clip a projected polyline (NaN-separated segments) to the viewport rectangle.
|
||
/// Returns a new points vec with proper NaN separators at clip boundaries.
|
||
fn clip_polyline_to_rect(
|
||
pts: &[[f32; 3]],
|
||
xmin: f32,
|
||
ymin: f32,
|
||
xmax: f32,
|
||
ymax: f32,
|
||
z: f32,
|
||
) -> Vec<[f32; 3]> {
|
||
const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN];
|
||
let mut result: Vec<[f32; 3]> = Vec::new();
|
||
let mut i = 0;
|
||
|
||
while i < pts.len() {
|
||
// Skip NaN separators.
|
||
if pts[i][0].is_nan() || pts[i][1].is_nan() {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
// Gather contiguous run of finite points.
|
||
let start = i;
|
||
while i < pts.len() && pts[i][0].is_finite() && pts[i][1].is_finite() {
|
||
i += 1;
|
||
}
|
||
let seg = &pts[start..i];
|
||
if seg.len() < 2 {
|
||
continue;
|
||
}
|
||
|
||
// Clip each edge and track pen state to insert NaN on lift.
|
||
let mut pen_down = false;
|
||
for j in 0..seg.len() - 1 {
|
||
let [x0, y0, _] = seg[j];
|
||
let [x1, y1, _] = seg[j + 1];
|
||
match cs_clip(x0, y0, x1, y1, xmin, ymin, xmax, ymax) {
|
||
None => {
|
||
pen_down = false;
|
||
}
|
||
Some((cx0, cy0, cx1, cy1)) => {
|
||
if !pen_down {
|
||
if !result.is_empty() {
|
||
result.push(NAN3);
|
||
}
|
||
result.push([cx0, cy0, z]);
|
||
pen_down = true;
|
||
} else if let Some(&[lx, ly, _]) = result.last() {
|
||
if (lx - cx0).abs() > 1e-4 || (ly - cy0).abs() > 1e-4 {
|
||
result.push(NAN3);
|
||
result.push([cx0, cy0, z]);
|
||
}
|
||
}
|
||
result.push([cx1, cy1, z]);
|
||
// If the exit point was clipped, lift pen.
|
||
if (cx1 - x1).abs() > 1e-4 || (cy1 - y1).abs() > 1e-4 {
|
||
pen_down = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Remove trailing NaN.
|
||
while result
|
||
.last()
|
||
.map(|p: &[f32; 3]| p[0].is_nan())
|
||
.unwrap_or(false)
|
||
{
|
||
result.pop();
|
||
}
|
||
result
|
||
}
|
||
|
||
// ── Parallel tessellation free function ──────────────────────────────────────
|
||
//
|
||
// Takes only the `Send + Sync` data needed for tessellation so that
|
||
// `wires_for_block` can dispatch work across rayon's thread pool without
|
||
// requiring `Scene` (which contains `Rc<RefCell<...>>` and is `!Send`) to
|
||
// cross thread boundaries.
|
||
|
||
/// Tessellate a synthesised dimension-text entity through `tessellate_entity`
|
||
/// so it picks up the standard text LOD ladder (baseline / greek / full),
|
||
/// then re-color the returned wires with the dimension's resolved text colour
|
||
/// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults).
|
||
pub(crate) fn tessellate_entity_dim_text(
|
||
document: &acadrust::CadDocument,
|
||
selected: &HashSet<Handle>,
|
||
active_viewport: Option<Handle>,
|
||
bg_color: [f32; 4],
|
||
anno_scale: f32,
|
||
e: &EntityType,
|
||
view_aabb: Option<[f32; 4]>,
|
||
world_per_pixel: Option<f32>,
|
||
text_color: [f32; 4],
|
||
) -> Vec<WireModel> {
|
||
let mut wires = tessellate_entity(
|
||
document, selected, active_viewport, bg_color,
|
||
anno_scale, e, None, view_aabb, world_per_pixel,
|
||
);
|
||
for w in &mut wires {
|
||
// Synth dim text carries no real entity colour — paint everything
|
||
// (including greek-LOD fill tris which read `wire.color`) with the
|
||
// dim's text colour. Selection highlight already baked in by
|
||
// tessellate_entity, so leave that alone.
|
||
if !w.selected {
|
||
w.color = text_color;
|
||
}
|
||
}
|
||
wires
|
||
}
|
||
|
||
fn tessellate_entity(
|
||
document: &acadrust::CadDocument,
|
||
selected: &HashSet<Handle>,
|
||
active_viewport: Option<Handle>,
|
||
bg_color: [f32; 4],
|
||
anno_scale: f32,
|
||
e: &EntityType,
|
||
block_cache: Option<&cache::block_cache::BlockCache>,
|
||
// World-space XY view AABB (post `world_offset` subtraction). When
|
||
// `Some`, entities whose AABB doesn't intersect this rect are skipped.
|
||
view_aabb: Option<[f32; 4]>,
|
||
// World units per screen pixel for LOD culling. `None` = no LOD.
|
||
world_per_pixel: Option<f32>,
|
||
) -> Vec<WireModel> {
|
||
let h = e.common().handle;
|
||
let sel = selected.contains(&h);
|
||
|
||
// Frustum + LOD cull for non-Insert, non-Viewport entities. Insert is
|
||
// handled separately (its WCS bbox depends on the block defn AABB ×
|
||
// Insert transform — done inside expand_insert). Viewports always emit
|
||
// so the viewport frame stays visible regardless of zoom.
|
||
let needs_cull = view_aabb.is_some() || world_per_pixel.is_some();
|
||
if needs_cull {
|
||
match e {
|
||
EntityType::Viewport(_) | EntityType::Insert(_) => {}
|
||
_ => {
|
||
let ab = entity_aabb(e);
|
||
if ab != WireModel::UNBOUNDED_AABB {
|
||
if let Some(view) = view_aabb {
|
||
if cache::block_cache::aabb_disjoint_xy(ab, view) {
|
||
return vec![];
|
||
}
|
||
}
|
||
if let Some(wpp) = world_per_pixel {
|
||
let w_px = (ab[2] - ab[0]).abs();
|
||
let h_px = (ab[3] - ab[1]).abs();
|
||
// Keep in sync with `cache::block_cache::MIN_PIXEL_SIZE`.
|
||
// Text/MText have their own LOD ladder below
|
||
// (baseline-line / greek / full) and must reach it
|
||
// even when projected size is sub-5 px.
|
||
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
|
||
let is_3d_entity = matches!(
|
||
e,
|
||
EntityType::Face3D(_)
|
||
| EntityType::Solid3D(_)
|
||
| EntityType::Mesh(_)
|
||
| EntityType::PolyfaceMesh(_)
|
||
| EntityType::PolygonMesh(_)
|
||
| EntityType::Body(_)
|
||
| EntityType::Region(_)
|
||
| EntityType::Surface(_)
|
||
);
|
||
if !is_text && w_px.max(h_px) / wpp < 5.0 {
|
||
// Sub-pixel entity: emit a stub instead of
|
||
// nothing so it stays visible / selectable /
|
||
// hit-test'able at any zoom. 2-D entities
|
||
// get the cheap diagonal segment; 3-D
|
||
// entities get an AABB cube so their
|
||
// footprint doesn't drift when the camera
|
||
// crosses the LOD threshold. See #19.
|
||
let (entity_color, _, _, _, aci_idx) =
|
||
view::render::render_style_for(document, e);
|
||
let entity_color = view::render::adapt_to_bg(entity_color, bg_color);
|
||
if is_3d_entity {
|
||
// `ab` is already in the local frame
|
||
// (entity_aabb subtracted world_offset
|
||
// XY). The bbox z fields are still in
|
||
// WCS, so subtract `world_offset[2]` to
|
||
// match — otherwise the stub sits at a
|
||
// different z than the full tessellation
|
||
// and the geometry visibly shifts when
|
||
// the camera crosses the LOD threshold.
|
||
let bbox = e.as_entity().bounding_box();
|
||
let oz = 0.0_f64;
|
||
let z_min = (bbox.min.z - oz) as f32;
|
||
let z_max = (bbox.max.z - oz) as f32;
|
||
return vec![lod_stub_wire_3d(
|
||
h.value().to_string(),
|
||
entity_color,
|
||
sel,
|
||
aci_idx,
|
||
ab,
|
||
z_min,
|
||
z_max,
|
||
)];
|
||
}
|
||
return vec![lod_stub_wire(
|
||
h.value().to_string(),
|
||
entity_color,
|
||
sel,
|
||
aci_idx,
|
||
ab,
|
||
0.0,
|
||
0.0,
|
||
)];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if let EntityType::Viewport(vp) = e {
|
||
// The sheet viewport (overall/id=1) is never shown — it represents the
|
||
// paper boundary, not a user-defined content window.
|
||
if !Scene::is_content_viewport(vp) {
|
||
return vec![];
|
||
}
|
||
let is_active = active_viewport == Some(h);
|
||
let is_locked = vp.status.locked;
|
||
let color = if sel {
|
||
[1.0, 1.0, 1.0, 1.0]
|
||
} else if is_active {
|
||
[1.0, 0.90, 0.20, 1.0]
|
||
} else if is_locked {
|
||
[0.90, 0.55, 0.10, 1.0]
|
||
} else {
|
||
[0.0, 0.75, 0.75, 1.0]
|
||
};
|
||
let (pattern_length, pattern) = if is_active {
|
||
(1.5_f32, [0.8, -0.4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0_f32])
|
||
} else {
|
||
(0.0_f32, [0.0f32; 8])
|
||
};
|
||
let mut wires = convert::tessellate::tessellate(
|
||
document,
|
||
h,
|
||
e,
|
||
sel,
|
||
color,
|
||
pattern_length,
|
||
pattern,
|
||
1.5,
|
||
1.0,
|
||
world_per_pixel,
|
||
);
|
||
let ab = entity_aabb(e);
|
||
for w in &mut wires {
|
||
w.aabb = ab;
|
||
}
|
||
return wires;
|
||
}
|
||
|
||
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
|
||
view::render::render_style_for(document, e);
|
||
let entity_color = view::render::adapt_to_bg(entity_color, bg_color);
|
||
let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32;
|
||
let lt_name = view::render::linetype_name_for(document, e);
|
||
// PSLTSCALE: scale linetype dashes by viewport anno_scale so they appear uniform in paper space.
|
||
let pslt_factor = if document.header.paper_space_linetype_scaling {
|
||
anno_scale
|
||
} else {
|
||
1.0
|
||
};
|
||
let pattern_length = pattern_length * pslt_factor;
|
||
let pattern = pattern.map(|v| v * pslt_factor);
|
||
|
||
// ── Dimension baked-block fast path ─────────────────────────────────────
|
||
//
|
||
// AutoCAD bakes each dimension's final geometry (extension lines, dim
|
||
// line, arrows, text MText) into a per-instance block — usually
|
||
// `*D<n>`, but custom names like `DIMBLOCK###-4NP` also occur. When the
|
||
// block exists we render its contents through `tessellate_entity` so
|
||
// sub-Text/MText get the standard baseline/greek/full LOD ladder, and
|
||
// DIMTXT × DIMSCALE isn't re-applied on already-baked geometry.
|
||
if let EntityType::Dimension(dim) = e {
|
||
let block_name = &dim.base().block_name;
|
||
if !block_name.trim().is_empty() {
|
||
if let Some(br) = document
|
||
.block_records
|
||
.iter()
|
||
.find(|br| br.name.eq_ignore_ascii_case(block_name))
|
||
{
|
||
if !br.entity_handles.is_empty() {
|
||
let mut wires: Vec<WireModel> =
|
||
Vec::with_capacity(br.entity_handles.len());
|
||
for &eh in &br.entity_handles {
|
||
let Some(sub) = document.get_entity(eh) else { continue };
|
||
// Sub-entities inside *D### / DIMBLOCK## blocks
|
||
// typically use ByBlock color/linetype/lineweight —
|
||
// they should inherit from the Dimension entity.
|
||
let sub_color_is_byblock =
|
||
sub.common().color == acadrust::types::Color::ByBlock;
|
||
let sub_wires = tessellate_entity(
|
||
document, selected, active_viewport, bg_color,
|
||
// Block contents are baked at the final WCS size —
|
||
// don't let downstream paths re-apply anno_scale.
|
||
1.0, sub, block_cache, view_aabb, world_per_pixel,
|
||
);
|
||
for mut w in sub_wires {
|
||
w.name = h.value().to_string();
|
||
// Override ByBlock colour with the dim's resolved
|
||
// colour so text matches `DIMCLRT`-style behaviour
|
||
// (or layer colour) instead of the raw ByBlock
|
||
// fallback that render_style_for produces.
|
||
if sub_color_is_byblock {
|
||
w.color = if sel { WireModel::SELECTED } else { entity_color };
|
||
w.aci = aci;
|
||
}
|
||
wires.push(w);
|
||
}
|
||
}
|
||
if !wires.is_empty() {
|
||
let aabb = entity_aabb(e);
|
||
for w in &mut wires {
|
||
w.aabb = aabb;
|
||
}
|
||
return wires;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Fall through to the synthesis path below when no block is attached.
|
||
}
|
||
|
||
if let EntityType::Dimension(dim) = e {
|
||
let aabb = entity_aabb(e);
|
||
use crate::entities::dimension::DimensionTess;
|
||
let mut wires = dim.tessellate(
|
||
document,
|
||
h,
|
||
sel,
|
||
entity_color,
|
||
line_weight_px,
|
||
anno_scale,
|
||
selected,
|
||
active_viewport,
|
||
bg_color,
|
||
view_aabb,
|
||
world_per_pixel,
|
||
);
|
||
for w in &mut wires {
|
||
w.aci = aci;
|
||
w.aabb = aabb;
|
||
}
|
||
return wires;
|
||
}
|
||
|
||
if let EntityType::MultiLeader(ml) = e {
|
||
let aabb = entity_aabb(e);
|
||
use crate::entities::multileader::MultiLeaderTess;
|
||
let mut wires = ml.tessellate(
|
||
document,
|
||
h,
|
||
sel,
|
||
entity_color,
|
||
line_weight_px,
|
||
anno_scale,
|
||
world_per_pixel,
|
||
);
|
||
for w in &mut wires {
|
||
w.aci = aci;
|
||
w.aabb = aabb;
|
||
}
|
||
return wires;
|
||
}
|
||
|
||
// ── Table baked-block fast path ─────────────────────────────────────────
|
||
//
|
||
// AutoCAD bakes a Table's final rendered geometry (cell text, gridlines,
|
||
// fill) into a per-instance block (usually `*T###`) referenced through
|
||
// `table.block_record_handle`. The block's text uses the *displayed*
|
||
// height; synthesising cells from `self.rows + TableStyle` instead would
|
||
// re-apply the table's scale factor on top of already-baked geometry.
|
||
// When the block exists we render it directly. Same pattern as
|
||
// Dimension's `block_name`.
|
||
if let EntityType::Table(tab) = e {
|
||
if let Some(br_h) = tab.block_record_handle {
|
||
if let Some(br) = document
|
||
.block_records
|
||
.iter()
|
||
.find(|br| br.handle == br_h)
|
||
{
|
||
if !br.entity_handles.is_empty() {
|
||
let mut wires: Vec<WireModel> =
|
||
Vec::with_capacity(br.entity_handles.len());
|
||
for &eh in &br.entity_handles {
|
||
let Some(sub) = document.get_entity(eh) else { continue };
|
||
let sub_color_is_byblock =
|
||
sub.common().color == acadrust::types::Color::ByBlock;
|
||
let sub_wires = tessellate_entity(
|
||
document, selected, active_viewport, bg_color,
|
||
anno_scale, sub, block_cache, view_aabb, world_per_pixel,
|
||
);
|
||
for mut w in sub_wires {
|
||
w.name = h.value().to_string();
|
||
if sub_color_is_byblock {
|
||
w.color = if sel { WireModel::SELECTED } else { entity_color };
|
||
w.aci = aci;
|
||
}
|
||
wires.push(w);
|
||
}
|
||
}
|
||
if !wires.is_empty() {
|
||
let aabb = entity_aabb(e);
|
||
for w in &mut wires {
|
||
w.aabb = aabb;
|
||
}
|
||
return wires;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// No baked block (e.g. a table created in-app) — synthesise coloured
|
||
// geometry from the rows + TableStyle so fills/colours/borders/margins
|
||
// are honoured instead of the monochrome fallback.
|
||
let mut wires = crate::entities::table::tessellate_table(
|
||
tab, document, sel, entity_color, line_weight_px,
|
||
);
|
||
if !wires.is_empty() {
|
||
let aabb = entity_aabb(e);
|
||
for w in &mut wires {
|
||
w.aci = aci;
|
||
w.aabb = aabb;
|
||
}
|
||
return wires;
|
||
}
|
||
}
|
||
|
||
if let EntityType::Insert(ins) = e {
|
||
// Resolve the INSERT's own style so ByBlock sub-entities can inherit it.
|
||
let (ins_color, ins_pat_len, ins_pat, ins_lw_px, _) = view::render::render_style_for(document, e);
|
||
let ins_color = view::render::adapt_to_bg(ins_color, bg_color);
|
||
let [ox, oy, oz] = [0.0_f64; 3];
|
||
let ip = glam::Vec3::new(
|
||
(ins.insert_point.x - ox) as f32,
|
||
(ins.insert_point.y - oy) as f32,
|
||
(ins.insert_point.z - oz) as f32,
|
||
);
|
||
let marker = WireModel {
|
||
name: h.value().to_string(),
|
||
points: vec![],
|
||
points_low: Vec::new(),
|
||
color: entity_color,
|
||
selected: sel,
|
||
aci: 0,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![(ip.as_dvec3(), model::wire_model::SnapHint::Insertion)],
|
||
tangent_geoms: vec![],
|
||
key_vertices: vec![],
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
};
|
||
|
||
if let Some(cache) = block_cache {
|
||
// Xrefs render with the same hue but faded toward `bg_color` so
|
||
// the user can recognise external-reference geometry at a glance.
|
||
let is_xref = document
|
||
.block_records
|
||
.get(&ins.block_name)
|
||
.map(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||
.unwrap_or(false);
|
||
if let Some(mut wires) = cache::block_cache::expand_insert(
|
||
cache,
|
||
ins,
|
||
h,
|
||
ins_color,
|
||
ins_pat_len,
|
||
ins_pat,
|
||
ins_lw_px,
|
||
sel,
|
||
pslt_factor,
|
||
view_aabb,
|
||
world_per_pixel,
|
||
is_xref,
|
||
bg_color,
|
||
) {
|
||
// XCLIP: if this INSERT carries an enabled spatial filter,
|
||
// clip the expanded block geometry to the boundary polygon so
|
||
// only the portion inside the clip is drawn.
|
||
if let Some(sf) = pick::xclip::insert_spatial_filter(document, ins) {
|
||
let poly = pick::xclip::world_clip_polygon_f64(sf, ins);
|
||
pick::xclip::clip_wires(&mut wires, &poly);
|
||
}
|
||
|
||
// Per-INSERT attribute values. The block defn carries the
|
||
// AttributeDefinitions (templates) which expand_insert skips;
|
||
// the AttributeEntity instances live on the Insert itself in
|
||
// WCS and need their own tessellation so the user sees the
|
||
// values they actually filled in. See #20.
|
||
crate::entities::insert::append_insert_attribute_wires(
|
||
&mut wires,
|
||
document,
|
||
ins,
|
||
h,
|
||
sel,
|
||
ins_color,
|
||
ins_pat_len,
|
||
ins_pat,
|
||
ins_lw_px,
|
||
bg_color,
|
||
is_xref,
|
||
pslt_factor,
|
||
anno_scale,
|
||
);
|
||
wires.push(marker);
|
||
return wires;
|
||
}
|
||
}
|
||
|
||
// Cache miss / unavailable: fall back to the original explode path.
|
||
// The block_cache primary path covers all typical Inserts; this
|
||
// branch only fires for pathological cache failures.
|
||
let br = document.block_records.get(&ins.block_name);
|
||
let is_xref = br
|
||
.map(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||
.unwrap_or(false);
|
||
let mut wires: Vec<WireModel> = ins
|
||
.explode_from_document(document)
|
||
.iter()
|
||
.cloned()
|
||
.map(crate::modules::draw::modify::explode::normalize_insert_entity)
|
||
.flat_map(|sub| {
|
||
let (sub_color, sub_pattern_length, sub_pattern, sub_line_weight_px, sub_aci) =
|
||
view::render::render_style_for_block_sub(
|
||
document,
|
||
&sub,
|
||
ins_color,
|
||
ins_pat_len,
|
||
ins_pat,
|
||
ins_lw_px,
|
||
);
|
||
let sub_color = view::render::adapt_to_bg(sub_color, bg_color);
|
||
let sub_color = if is_xref && !sel {
|
||
cache::block_cache::fade_toward_bg(sub_color, bg_color)
|
||
} else {
|
||
sub_color
|
||
};
|
||
let sub_aabb = entity_aabb(&sub);
|
||
let sub_pattern_length = sub_pattern_length * pslt_factor;
|
||
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
|
||
let mut wires = convert::tessellate::tessellate(
|
||
document,
|
||
h,
|
||
&sub,
|
||
sel,
|
||
sub_color,
|
||
sub_pattern_length,
|
||
sub_pattern,
|
||
sub_line_weight_px,
|
||
anno_scale,
|
||
world_per_pixel,
|
||
);
|
||
for w in &mut wires {
|
||
w.name = h.value().to_string();
|
||
w.aci = sub_aci;
|
||
w.aabb = sub_aabb;
|
||
}
|
||
wires
|
||
})
|
||
.collect();
|
||
crate::entities::insert::append_insert_attribute_wires(
|
||
&mut wires,
|
||
document,
|
||
ins,
|
||
h,
|
||
sel,
|
||
ins_color,
|
||
ins_pat_len,
|
||
ins_pat,
|
||
ins_lw_px,
|
||
bg_color,
|
||
is_xref,
|
||
pslt_factor,
|
||
anno_scale,
|
||
);
|
||
wires.push(marker);
|
||
return wires;
|
||
}
|
||
|
||
let aabb = entity_aabb(e);
|
||
|
||
// Text-specific LOD ladder, keyed off the entity's glyph height in
|
||
// pixels (anno-scaled):
|
||
// < 1 px → baseline line in the text's color (text-here hint)
|
||
// 1–5 px → greeked OBB rect in the text's color
|
||
// ≥ 5 px → full per-glyph stroke tessellation
|
||
//
|
||
// Applies to every entity that is "primarily a piece of text" — Text,
|
||
// MText, ATTDEF, ATTRIB, Tolerance — so far-out drawings don't pay the
|
||
// full glyph-tessellation cost. Composite entities (Dimension, Table,
|
||
// MultiLeader) carry non-text geometry and have their own LOD paths.
|
||
if let Some(wpp) = world_per_pixel {
|
||
let text_height: Option<f64> = match e {
|
||
EntityType::Text(t) => Some(t.height * anno_scale as f64),
|
||
EntityType::MText(m) => Some(m.height * anno_scale as f64),
|
||
EntityType::AttributeDefinition(a) => Some(a.height * anno_scale as f64),
|
||
EntityType::AttributeEntity(a) => Some(a.height * anno_scale as f64),
|
||
EntityType::Tolerance(t) => {
|
||
// Tolerance text_height defaults to 0.18 from creation; treat
|
||
// 0 as missing and fall back to the AutoCAD default so the
|
||
// pixel check still kicks in for legitimately tiny dimensions.
|
||
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
|
||
Some(raw * anno_scale as f64)
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(h_world) = text_height {
|
||
let h_px = (h_world as f32) / wpp;
|
||
// Wrap-expanded line count for MText (Text = 1).
|
||
let n_lines = match e {
|
||
EntityType::MText(m) => {
|
||
crate::entities::text_support::mtext_line_count(m, document, anno_scale)
|
||
}
|
||
_ => 1,
|
||
};
|
||
if h_px < 1.0 {
|
||
let pts = crate::entities::text_support::text_baseline_points(e, anno_scale, n_lines);
|
||
if pts.len() < 2 {
|
||
return vec![];
|
||
}
|
||
// Skip the baseline too if the line itself projects under
|
||
// 2 px (e.g. a 1-char text seen edge-on). All wrap lines
|
||
// share the same baseline length, so the first segment is
|
||
// a representative sample.
|
||
let dx = pts[1][0] - pts[0][0];
|
||
let dy = pts[1][1] - pts[0][1];
|
||
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
|
||
if len_px < 2.0 {
|
||
// Text projects to under 2 px — fall back to the
|
||
// generic LOD stub so the entity stays visible /
|
||
// selectable. #19. Text is 2-D in the XY plane so
|
||
// z_min = z_max = 0 keeps the historical behaviour.
|
||
return vec![lod_stub_wire(
|
||
h.value().to_string(),
|
||
entity_color,
|
||
sel,
|
||
aci,
|
||
aabb,
|
||
0.0,
|
||
0.0,
|
||
)];
|
||
}
|
||
return vec![WireModel {
|
||
name: h.value().to_string(),
|
||
points: pts,
|
||
points_low: Vec::new(),
|
||
color: entity_color,
|
||
selected: sel,
|
||
aci,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
key_vertices: vec![],
|
||
aabb,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}];
|
||
}
|
||
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
|
||
let fill_tris = crate::entities::text_support::text_greek_obb_tris(e, anno_scale, n_lines);
|
||
if fill_tris.is_empty() {
|
||
// Text greek fallback: also 2-D, keep stub at z=0.
|
||
return vec![lod_stub_wire(
|
||
h.value().to_string(),
|
||
entity_color,
|
||
sel,
|
||
aci,
|
||
aabb,
|
||
0.0,
|
||
0.0,
|
||
)];
|
||
}
|
||
// Greek text renders via the face3d fill batch, which colours
|
||
// each tri with `wire.color`. Bake the selected colour in so
|
||
// a selected text stays highlighted across the LOD boundary.
|
||
// hit_test's AABB fallback handles window / crossing. #19.
|
||
let fill_color = if sel { WireModel::SELECTED } else { entity_color };
|
||
return vec![WireModel {
|
||
name: h.value().to_string(),
|
||
points: vec![],
|
||
points_low: Vec::new(),
|
||
color: fill_color,
|
||
selected: sel,
|
||
aci,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
key_vertices: vec![],
|
||
aabb,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris,
|
||
fill_tris_low: Vec::new(),
|
||
}];
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut bases = convert::tessellate::tessellate(
|
||
document,
|
||
h,
|
||
e,
|
||
sel,
|
||
entity_color,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
anno_scale,
|
||
world_per_pixel,
|
||
);
|
||
for b in &mut bases {
|
||
b.aci = aci;
|
||
b.aabb = aabb;
|
||
}
|
||
|
||
// Complex linetypes (with embedded shapes / text) expand the *base*
|
||
// polyline along its tangent. Text-type entities never have a complex
|
||
// linetype assigned, so we only consult the first wire here — multi-wire
|
||
// returns come exclusively from MTEXT colour splits which can't trigger
|
||
// this path.
|
||
if let Some(clt) = crate::linetypes::complex_lt(lt_name) {
|
||
if let Some(base) = bases.first() {
|
||
let mut wires = text::complex_lt::apply_along(
|
||
&base.name,
|
||
&base.points,
|
||
clt,
|
||
(lt_scale * pslt_factor).max(1e-4),
|
||
entity_color,
|
||
sel,
|
||
base.line_weight_px,
|
||
);
|
||
if !wires.is_empty() {
|
||
for w in &mut wires {
|
||
w.aabb = aabb;
|
||
}
|
||
return wires;
|
||
}
|
||
}
|
||
}
|
||
|
||
bases
|
||
}
|
||
|
||
/// Build the 4 OBB corners (CCW: bl, br, tr, tl) of a Text / MText entity
|
||
/// in its **native frame** — for top-level entities this is world coords,
|
||
/// for block-defn subs it's block-local. No offset/transform applied.
|
||
/// Width is approximated from glyph height × character count (TEXT) or
|
||
/// from `rectangle_width` (MTEXT). Returns `None` for non-text entities.
|
||
///
|
||
/// `mtext_lines_override` lets the caller plug in a wrap-aware line count
|
||
/// (from `text_support::mtext_line_count`). Without it, MText's OBB
|
||
/// height collapses to a single line when the file omits `rectangle_height`,
|
||
/// which makes downstream per-line LOD math degenerate.
|
||
|
||
/// Build a "low-LOD stub" wire for an entity that would otherwise be culled
|
||
/// to nothing — the entity's AABB diagonal as a 2-point segment, plus the
|
||
/// AABB itself so window / crossing selection picks the entity up. The
|
||
/// stored `selected` flag tracks across zoom levels so highlight visuals
|
||
/// don't disappear when the LOD level changes. See #19.
|
||
fn lod_stub_wire(
|
||
name: String,
|
||
color: [f32; 4],
|
||
selected: bool,
|
||
aci: u8,
|
||
aabb: [f32; 4],
|
||
z_min: f32,
|
||
z_max: f32,
|
||
) -> WireModel {
|
||
let [ax, ay, bx, by] = aabb;
|
||
let cx = (ax + bx) * 0.5;
|
||
let cy = (ay + by) * 0.5;
|
||
let cz = (z_min + z_max) * 0.5;
|
||
// Mirror what tessellate.rs does for the non-stub paths: bake the
|
||
// selection-highlight colour into the wire so a re-tessellate triggered
|
||
// by a zoom-induced LOD change keeps the entity highlighted. Without
|
||
// this swap the wire's `selected` flag is true but its colour stays at
|
||
// the entity's own hue, so the user sees the highlight vanish at the
|
||
// LOD boundary. #19.
|
||
let stored_color = if selected { WireModel::SELECTED } else { color };
|
||
WireModel {
|
||
name,
|
||
// Diagonal of the entity's 3D AABB so depth tests against
|
||
// shaded / hidden-line geometry are correct — the stub doesn't
|
||
// flatten to z=0 and pop in front of objects that sit at a
|
||
// different elevation. 2D entities (text fallbacks) pass
|
||
// z_min = z_max = 0 to keep the historical behaviour.
|
||
points: vec![[ax, ay, z_min], [bx, by, z_max]],
|
||
points_low: Vec::new(),
|
||
color: stored_color,
|
||
selected,
|
||
aci,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
key_vertices: vec![[cx as f64, cy as f64, cz as f64]],
|
||
aabb,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Sub-pixel LOD stub for 3D entities. Emits the entity's 3D AABB as a
|
||
/// 12-edge cube so the geometry occupies the same screen footprint and
|
||
/// depth range as the full tessellation, just with a tiny constant cost
|
||
/// (12 line segments). Without this, the diagonal stub used by
|
||
/// `lod_stub_wire` cuts off at two opposite bbox corners and drifts
|
||
/// visibly when the camera crosses the LOD threshold.
|
||
fn lod_stub_wire_3d(
|
||
name: String,
|
||
color: [f32; 4],
|
||
selected: bool,
|
||
aci: u8,
|
||
aabb: [f32; 4],
|
||
z_min: f32,
|
||
z_max: f32,
|
||
) -> WireModel {
|
||
let [x0, y0, x1, y1] = aabb;
|
||
let (z0, z1) = if z_min <= z_max { (z_min, z_max) } else { (z_max, z_min) };
|
||
let p = [
|
||
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
|
||
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
|
||
];
|
||
// 12 edges = 4 bottom-face + 4 top-face + 4 vertical connectors.
|
||
const EDGES: [(usize, usize); 12] = [
|
||
(0, 1), (1, 2), (2, 3), (3, 0),
|
||
(4, 5), (5, 6), (6, 7), (7, 4),
|
||
(0, 4), (1, 5), (2, 6), (3, 7),
|
||
];
|
||
let mut points: Vec<[f32; 3]> = Vec::with_capacity(EDGES.len() * 3);
|
||
for (a, b) in EDGES {
|
||
if !points.is_empty() {
|
||
points.push([f32::NAN; 3]);
|
||
}
|
||
points.push(p[a]);
|
||
points.push(p[b]);
|
||
}
|
||
let stored_color = if selected { WireModel::SELECTED } else { color };
|
||
WireModel {
|
||
name,
|
||
points,
|
||
points_low: Vec::new(),
|
||
color: stored_color,
|
||
selected,
|
||
aci,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
// No `key_vertices` — Face3DGpu requires 4 corners to emit a
|
||
// fill quad, and we don't want this stub painted as a solid
|
||
// face. The wire pass still draws its 12 edges.
|
||
key_vertices: vec![],
|
||
aabb,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Tessellate each visible AttributeEntity attached to an Insert and append
|
||
/// the resulting wires. AttributeEntity positions are already in WCS — the
|
||
/// INSERT only stamps the geometry once, attribute text sits at the world
|
||
/// position recorded on each ATTRIB. See #20.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] {
|
||
let bbox = e.as_entity().bounding_box();
|
||
let [ox, oy, _] = [0.0_f64; 3];
|
||
let min_x = (bbox.min.x - ox) as f32;
|
||
let min_y = (bbox.min.y - oy) as f32;
|
||
let max_x = (bbox.max.x - ox) as f32;
|
||
let max_y = (bbox.max.y - oy) as f32;
|
||
// A degenerate box (min == max == 0) means bounding_box() returned Default —
|
||
// use UNBOUNDED so the wire is never wrongly pre-rejected.
|
||
if min_x == max_x && min_y == max_y {
|
||
return WireModel::UNBOUNDED_AABB;
|
||
}
|
||
[min_x, min_y, max_x, max_y]
|
||
}
|
||
|
||
/// AABB of `e` in WCS f64 (no world_offset subtraction). `None` for
|
||
/// entities whose `bounding_box()` returned the degenerate default
|
||
/// (which `entity_aabb` collapses to `UNBOUNDED_AABB`). Quadtree
|
||
/// indexing uses this so changing `world_offset` doesn't invalidate
|
||
/// the index.
|
||
fn entity_world_aabb_f64(e: &acadrust::EntityType) -> Option<[f64; 4]> {
|
||
let bbox = e.as_entity().bounding_box();
|
||
let (xmin, ymin, xmax, ymax) = (bbox.min.x, bbox.min.y, bbox.max.x, bbox.max.y);
|
||
if xmin == xmax && ymin == ymax {
|
||
return None;
|
||
}
|
||
if !xmin.is_finite() || !ymin.is_finite() || !xmax.is_finite() || !ymax.is_finite() {
|
||
return None;
|
||
}
|
||
Some([xmin, ymin, xmax, ymax])
|
||
}
|
||
|
||
/// True if `e` is a type the quadtree should skip. `Insert` and
|
||
/// `Viewport` are sized only after extra transformation; tessellation
|
||
/// already handles them via dedicated code paths. `Block`/`BlockEnd`
|
||
/// are block-defn sentinels with no geometry.
|
||
fn is_unindexable_entity(e: &acadrust::EntityType) -> bool {
|
||
use acadrust::EntityType as E;
|
||
matches!(
|
||
e,
|
||
E::Insert(_) | E::Viewport(_) | E::Block(_) | E::BlockEnd(_)
|
||
)
|
||
}
|
||
|