forked from 1b-agent/opencadstudio-upstream
* io/xref.rs — replaced the 7-line "BlockRecord::is_loaded turned out to be unreliable" rationale with a one-liner. The reverted experiment lives in git history (e88a946,0996b75). * refedit::RefEditSession.insert_handle — set at construction but never read; the "kept for future REFCLOSE DISCARD" TODO can come back via git history when that feature lands. * scene::mod — dropped the placeholder MeshModel re-export; nothing outside `mesh_model` consumes the bare name and MeshLodSet is the pipeline-facing type now. Co-Authored-By: Claude Opus 4.7 <[email protected]>
4924 lines
188 KiB
Rust
4924 lines
188 KiB
Rust
pub mod acad_to_truck;
|
||
pub mod block_cache;
|
||
mod camera;
|
||
pub mod complex_lt;
|
||
pub mod cxf;
|
||
pub mod dispatch;
|
||
pub mod grip;
|
||
pub mod hatch_model;
|
||
pub mod hatch_patterns;
|
||
pub mod hit_test;
|
||
pub mod image_model;
|
||
pub mod mesh_model;
|
||
pub mod object;
|
||
pub mod paper_canvas;
|
||
pub mod pipeline;
|
||
pub mod properties;
|
||
mod render;
|
||
mod selection;
|
||
pub mod solid3d_tess;
|
||
pub mod tessellate;
|
||
pub mod transform;
|
||
pub mod truck_tess;
|
||
pub mod viewport_pane;
|
||
pub mod wire_model;
|
||
|
||
use camera::Camera;
|
||
pub use camera::Projection;
|
||
pub use hatch_model::HatchModel;
|
||
pub use image_model::ImageModel;
|
||
pub use mesh_model::MeshLodSet;
|
||
pub use object::{GripApply, GripDef};
|
||
pub use pipeline::uniforms::Uniforms;
|
||
pub use pipeline::viewcube::{hit_test, CubeRegion, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX};
|
||
pub use selection::SelectionState;
|
||
pub use 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, ParametricCurve},
|
||
BSplineCurve as TruckBSpline, KnotVec, Point3,
|
||
};
|
||
|
||
use iced::time::Duration;
|
||
use std::cell::RefCell;
|
||
use std::collections::{HashMap, 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);
|
||
|
||
/// 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 world_offset: [f64; 3],
|
||
pub local_extent_max: f32,
|
||
pub hatches: HashMap<Handle, HatchModel>,
|
||
pub images: HashMap<Handle, ImageModel>,
|
||
pub 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,
|
||
}
|
||
|
||
/// 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 Civil-3D-style 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 `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.
|
||
let (world_offset, local_extent_max) = compute_world_offset(doc, model_block);
|
||
|
||
use rayon::prelude::*;
|
||
|
||
// Single pass over entities — collect only handles per kind. No clones.
|
||
// Heavy tessellation runs in parallel below, reading entities via
|
||
// `doc.get_entity(h)` (O(1) HashMap lookup).
|
||
let mut hatch_handles: Vec<Handle> = Vec::new();
|
||
let mut image_handles: Vec<Handle> = Vec::new();
|
||
let mut mesh_handles: Vec<Handle> = 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(_) => {
|
||
mesh_handles.push(h)
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// hatches
|
||
let hatches: HashMap<Handle, HatchModel> = hatch_handles
|
||
.par_iter()
|
||
.filter_map(|&handle| {
|
||
let e = doc.get_entity(handle)?;
|
||
let owner = e.common().owner_handle;
|
||
let offset = if owner == model_block {
|
||
world_offset
|
||
} else {
|
||
[0.0; 3]
|
||
};
|
||
let model = match e {
|
||
EntityType::Hatch(dxf) => {
|
||
let color = tessellate::aci_to_rgba(&dxf.common.color);
|
||
Scene::hatch_model_from_dxf(dxf, color, offset)
|
||
}
|
||
EntityType::Solid(solid) => {
|
||
let color = tessellate::aci_to_rgba(&solid.common.color);
|
||
Some(Scene::solid_hatch_model(solid, color, offset))
|
||
}
|
||
_ => 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)
|
||
let meshes: HashMap<Handle, MeshLodSet> = mesh_handles
|
||
.par_iter()
|
||
.filter_map(|&handle| {
|
||
let e = doc.get_entity(handle)?;
|
||
let color = tessellate::aci_to_rgba(&e.common().color);
|
||
let model = match e {
|
||
EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color),
|
||
EntityType::Region(r) => solid3d_tess::tessellate_region(r, color),
|
||
EntityType::Body(b) => solid3d_tess::tessellate_body(b, color),
|
||
_ => None,
|
||
};
|
||
model.map(|m| (handle, m))
|
||
})
|
||
.collect();
|
||
|
||
DerivedCaches {
|
||
world_offset,
|
||
local_extent_max,
|
||
hatches,
|
||
images,
|
||
meshes,
|
||
corrupt_dropped: 0,
|
||
}
|
||
}
|
||
|
||
/// Pick the model-space precision-preserving offset and the `fit_all`
|
||
/// outlier-rejection limit.
|
||
///
|
||
/// Tries header `$EXTMIN/$EXTMAX` first, then cross-checks against a direct
|
||
/// MSPACE entity AABB scan. The entity scan wins when the header is invalid
|
||
/// (sentinel / sub-empty) or when the header center has drifted more than
|
||
/// 10× its own half-span from the entity centroid (a stale-extents DXF).
|
||
fn compute_world_offset(
|
||
doc: &acadrust::CadDocument,
|
||
model_block: Handle,
|
||
) -> ([f64; 3], f32) {
|
||
// Mirrors `block_cache::SANE_EXTENT` — wire coords past this magnitude
|
||
// are treated as corruption rather than precision-relevant geometry.
|
||
const SANE_EXTENT: f64 = 1.0e8;
|
||
|
||
// 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 the geometry that's actually on
|
||
// screen, and direct WCS-coordinate wires drag f32 precision to its
|
||
// knees. Likewise, including block-defn entities that the render
|
||
// path strictly drops would pull the centroid toward block-local
|
||
// origins.
|
||
//
|
||
// Authoritative path: if the model BlockRecord enumerates its
|
||
// entities via `entity_handles`, use that set directly. Falls back
|
||
// to the legacy permissive interpretation when no BlockRecord
|
||
// enumerates anything (legacy DXF without group-code 330) — match
|
||
// `belongs_to_visible_block`'s permissive default for that case.
|
||
let model_br = doc
|
||
.block_records
|
||
.iter()
|
||
.find(|br| br.handle == model_block);
|
||
let mspace_set: Option<std::collections::HashSet<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: std::collections::HashSet<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 {
|
||
std::collections::HashSet::new()
|
||
};
|
||
|
||
// ── Pass 1: collect per-entity centroids ─────────────────────────────
|
||
// Min/max midpoint is wrecked by a single bogus entity at WCS distance
|
||
// (e.g. a Ray with bad direction, an orphan reference at WCS x=-510k
|
||
// when the real drawing sits at WCS x=+510k — the midpoint lands
|
||
// halfway between, far from both clusters). Per-entity centroids let
|
||
// us take the median, which is immune to single outliers regardless
|
||
// of how far they sit.
|
||
let mut centers: Vec<[f64; 3]> = Vec::new();
|
||
for e in doc.entities() {
|
||
let c = e.common();
|
||
let h = c.handle;
|
||
let include = if let Some(ref set) = mspace_set {
|
||
set.contains(&h)
|
||
} else if c.owner_handle == model_block {
|
||
true
|
||
} else if !c.owner_handle.is_null() {
|
||
false
|
||
} else if 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).
|
||
!any_enumerated
|
||
};
|
||
if !include {
|
||
continue;
|
||
}
|
||
// Skip block-defn sentinels and AttributeDefinition — same as
|
||
// block_cache::build_defn. Their bboxes don't represent drawable
|
||
// MSPACE geometry.
|
||
if matches!(
|
||
e,
|
||
EntityType::Block(_)
|
||
| EntityType::BlockEnd(_)
|
||
| EntityType::AttributeDefinition(_)
|
||
) {
|
||
continue;
|
||
}
|
||
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
|
||
{
|
||
continue;
|
||
}
|
||
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() {
|
||
continue;
|
||
}
|
||
if cx.abs() > SANE_EXTENT || cy.abs() > SANE_EXTENT {
|
||
continue;
|
||
}
|
||
centers.push([cx, cy, cz]);
|
||
}
|
||
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)
|
||
};
|
||
|
||
// ── Pass 2: read header extents ──────────────────────────────────────
|
||
let h = &doc.header;
|
||
let hmin = h.model_space_extents_min;
|
||
let hmax = h.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.
|
||
if entity_ok {
|
||
([ecx, ecy, ecz], espan_max)
|
||
} else if header_ok {
|
||
let offset = [
|
||
(hmin.x + hmax.x) * 0.5,
|
||
(hmin.y + hmax.y) * 0.5,
|
||
(hmin.z + hmax.z) * 0.5,
|
||
];
|
||
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;
|
||
(offset, hw.max(hh).max(hz) * 10.0)
|
||
} else {
|
||
([0.0; 3], 1e9_f32)
|
||
}
|
||
}
|
||
|
||
pub struct Scene {
|
||
pub camera: Rc<RefCell<Camera>>,
|
||
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>,
|
||
/// 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,
|
||
/// 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>>)>>,
|
||
/// 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>>)>>,
|
||
/// Cached hatch fill models, keyed by geometry_epoch.
|
||
hatch_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
|
||
/// Cached wipeout fill models, keyed by geometry_epoch.
|
||
wipeout_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
|
||
/// Cached image models, keyed by geometry_epoch.
|
||
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, Arc<Vec<WireModel>>)>>,
|
||
/// Cached tessellation of paper-space layout block entities (title block, annotations, etc.).
|
||
/// Separate from `wire_cache` so paper_canvas_wires() doesn't re-tessellate on 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 the paper canvas (2-D Iced widget).
|
||
/// Stores projected + clipped wires in paper-space coordinates.
|
||
/// Maps vp_handle → (geometry_epoch, Vec<WireModel>).
|
||
paper_projected_cache: RefCell<HashMap<Handle, (u64, Vec<WireModel>)>>,
|
||
/// Full paper-canvas wire list (sheet + inactive-viewport projections + interim/preview).
|
||
/// Keyed by geometry_epoch — valid for all navigation frames (pan/zoom do not bump epoch).
|
||
/// Returns Arc so paper_canvas_wires() is O(1) on cache hits.
|
||
paper_canvas_cache: RefCell<Option<(u64, Arc<Vec<WireModel>>)>>,
|
||
/// Active layout name — "Model" or a paper space layout name.
|
||
pub current_layout: String,
|
||
/// 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).
|
||
pub meshes: HashMap<Handle, MeshLodSet>,
|
||
/// 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],
|
||
/// Scene centroid subtracted from all coordinates before f32 conversion.
|
||
/// Eliminates f32 precision loss at large world coordinates (e.g. UTM 4,000,000 m).
|
||
pub world_offset: [f64; 3],
|
||
/// 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<block_cache::BlockCache>)>>,
|
||
/// 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>,
|
||
}
|
||
|
||
impl Scene {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
camera: Rc::new(RefCell::new(Camera::default())),
|
||
selection: Rc::new(RefCell::new(SelectionState::default())),
|
||
document: CadDocument::new(),
|
||
selected: HashSet::new(),
|
||
preview_wires: vec![],
|
||
interim_wire: None,
|
||
camera_generation: 0,
|
||
geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||
wire_cache: RefCell::new(None),
|
||
sort_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::new()),
|
||
paper_sheet_cache: RefCell::new(None),
|
||
paper_projected_cache: RefCell::new(HashMap::new()),
|
||
paper_canvas_cache: RefCell::new(None),
|
||
current_layout: "Model".to_string(),
|
||
hatches: HashMap::new(),
|
||
meshes: HashMap::new(),
|
||
images: HashMap::new(),
|
||
active_viewport: None,
|
||
bg_color: [0.11, 0.11, 0.11, 1.0],
|
||
paper_bg_color: [1.0, 1.0, 1.0, 1.0],
|
||
world_offset: [0.0; 3],
|
||
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),
|
||
last_render_aspect: std::cell::Cell::new(16.0 / 9.0),
|
||
last_world_per_pixel: std::cell::Cell::new(0.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 canvas 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;
|
||
let cy = cam.target.y;
|
||
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: `set_render_pixel_scale` is only
|
||
/// fed by the shader pipeline (`build_primitive`), and the paper layout
|
||
/// renders through `PaperCanvas` (an Iced 2-D canvas) which never touches
|
||
/// it. Whatever value is cached would be a stale model-world wpp and,
|
||
/// applied to mm-sheet entity AABBs, would 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<block_cache::BlockCache> {
|
||
{
|
||
let cache = self.block_defn_cache.borrow();
|
||
if let Some((epoch, ref arc)) = *cache {
|
||
if epoch == self.geometry_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 = block_cache::BlockCache::build(&self.document, anno, bg);
|
||
let arc = Arc::new(built);
|
||
*self.block_defn_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
pub fn bump_geometry(&mut self) {
|
||
self.geometry_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// 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.
|
||
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(vp) && vp.common.owner_handle == layout_block {
|
||
let scale = if vp.custom_scale.abs() > 1e-9 {
|
||
vp.custom_scale
|
||
} else if vp.view_height.abs() > 1e-9 {
|
||
vp.height / vp.view_height
|
||
} else {
|
||
1.0
|
||
};
|
||
return Some(scale);
|
||
}
|
||
}
|
||
None
|
||
})
|
||
}
|
||
|
||
/// List of user viewports in the current layout: (handle, label, frozen_layer_handles).
|
||
pub fn viewport_list(&self) -> Vec<(acadrust::Handle, String, Vec<acadrust::Handle>)> {
|
||
if self.current_layout == "Model" {
|
||
return vec![];
|
||
}
|
||
let layout_block = 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(vp) && vp.common.owner_handle == 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(vp) && vp.common.owner_handle == layout_block
|
||
} else {
|
||
false
|
||
}
|
||
})
|
||
.count()
|
||
}
|
||
|
||
/// 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(vp) && vp.common.owner_handle == 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: std::collections::HashMap<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()
|
||
}
|
||
|
||
/// Cached tessellation of the current layout block's paper-space entities.
|
||
/// Shared by both `entity_wires_arc()` and `paper_canvas_wires()` so a single
|
||
/// cache miss triggers only one tessellation pass, not two.
|
||
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 arc = Arc::new(self.wires_for_block(layout_block));
|
||
*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()
|
||
}
|
||
|
||
pub(super) fn hatch_models_arc(&self) -> Arc<Vec<HatchModel>> {
|
||
{
|
||
let cache = self.hatch_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.synced_hatch_models());
|
||
*self.hatch_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
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 arc = Arc::new(self.images.values().cloned().collect());
|
||
*self.image_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
let arc = Arc::new(self.meshes.values().cloned().collect());
|
||
*self.mesh_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
/// Wires that should participate in hit-testing, snapping, and selection.
|
||
///
|
||
/// - Model layout: all entity wires (same as entity_wires).
|
||
/// - PSPACE (paper layout, no active viewport): paper-space entities only —
|
||
/// viewport content is NOT interactive.
|
||
/// - MSPACE (active viewport set): model-space content of the active viewport
|
||
/// only — paper-space entities are NOT interactive.
|
||
pub fn hit_test_wires(&self) -> Arc<Vec<WireModel>> {
|
||
if self.current_layout == "Model" {
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Tessellate all non-invisible entities owned by `block_handle`.
|
||
fn wires_for_block(&self, block_handle: Handle) -> Vec<WireModel> {
|
||
use acadrust::objects::ObjectType;
|
||
|
||
// ── Ensure sort-order index is current ────────────────────────────
|
||
// Replaces the old O(objects) find_map with one rebuild per epoch,
|
||
// after which every wires_for_block call is an O(1) HashMap lookup.
|
||
{
|
||
let needs_rebuild = self
|
||
.sort_cache
|
||
.borrow()
|
||
.as_ref()
|
||
.map(|(e, _)| *e != self.geometry_epoch)
|
||
.unwrap_or(true);
|
||
|
||
if needs_rebuild {
|
||
let mut idx: HashMap<Handle, HashMap<u64, u64>> = HashMap::new();
|
||
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));
|
||
}
|
||
}
|
||
|
||
// Collect visible entities sequentially (filter needs &self).
|
||
let visible: Vec<&EntityType> = self
|
||
.document
|
||
.entities()
|
||
.filter(|e| {
|
||
let c = e.common();
|
||
if c.invisible {
|
||
return false;
|
||
}
|
||
// Block/BlockEnd are block-defn sentinels, not drawable geometry.
|
||
// Without this skip they fall through to legacy_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;
|
||
}
|
||
if self
|
||
.document
|
||
.layers
|
||
.get(&c.layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
{
|
||
return false;
|
||
}
|
||
self.belongs_to_visible_block(e.common().handle, c.owner_handle, block_handle)
|
||
})
|
||
.collect();
|
||
|
||
// Tessellate in parallel across all available CPU cores.
|
||
use rayon::prelude::*;
|
||
let doc = &self.document;
|
||
let sel = &self.selected;
|
||
let avp = self.active_viewport;
|
||
// Paper-space entities live in sheet coordinates (mm), not model-world
|
||
// coordinates, so world_offset must not be subtracted from them.
|
||
let woff = if self.current_layout == "Model" {
|
||
self.world_offset
|
||
} else {
|
||
[0.0; 3]
|
||
};
|
||
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();
|
||
let blk_ref: &block_cache::BlockCache = &blk_cache;
|
||
let view_aabb = self.view_world_aabb();
|
||
let wpp = self.world_per_pixel();
|
||
// 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::truck_tess::set_curve_tol_override(None);
|
||
}
|
||
}
|
||
let _tol_guard = wpp.map(|w| {
|
||
crate::scene::truck_tess::set_curve_tol_override(Some((w * 0.5) as f64));
|
||
CurveTolGuard
|
||
});
|
||
let mut wires: Vec<WireModel> = visible
|
||
.into_par_iter()
|
||
.flat_map(|e| {
|
||
tessellate_entity(
|
||
doc,
|
||
sel,
|
||
avp,
|
||
woff,
|
||
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);
|
||
sort_map.get(&key).copied().unwrap_or(u64::MAX / 2)
|
||
});
|
||
}
|
||
}
|
||
}
|
||
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::new();
|
||
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
|
||
})
|
||
}
|
||
|
||
/// 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,
|
||
self.world_offset,
|
||
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, _] = self.world_offset;
|
||
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;
|
||
}
|
||
}
|
||
return if any { Some((min, max)) } else { None };
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback: tessellate (first call or paper-space context).
|
||
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, y, z));
|
||
max = max.max(glam::Vec3::new(x, y, z));
|
||
any = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if any {
|
||
Some((min, max))
|
||
} else {
|
||
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(vp)
|
||
&& vp.common.owner_handle == 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 & viewport parameters ───────────────────────────────
|
||
// view_height is always correct; custom_scale is unreliable in DWG files
|
||
// (acadrust DWG reader never populates it, so it stays at 1.0).
|
||
let scale = if vp.view_height.abs() > 1e-9 {
|
||
(vp.height / vp.view_height) as f32
|
||
} else if vp.custom_scale.abs() > 1e-9 {
|
||
vp.custom_scale as f32
|
||
} else {
|
||
1.0
|
||
};
|
||
|
||
// view_target is in raw model coords; wire points have world_offset
|
||
// subtracted, so bring target into the same wire-space.
|
||
let target = glam::Vec3::new(
|
||
(vp.view_target.x - self.world_offset[0]) as f32,
|
||
(vp.view_target.y - self.world_offset[1]) as f32,
|
||
(vp.view_target.z - self.world_offset[2]) as f32,
|
||
);
|
||
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.
|
||
let model_wires = self.model_wires_for_viewport_arc(vp_handle);
|
||
|
||
// ── 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();
|
||
|
||
for wire in model_wires.iter() {
|
||
// Project 3-D model points onto view plane → paper space.
|
||
let projected_pts: Vec<[f32; 3]> = wire
|
||
.points
|
||
.iter()
|
||
.map(|&[mx, my, mz]| {
|
||
if mx.is_nan() || my.is_nan() || mz.is_nan() {
|
||
return [f32::NAN; 3];
|
||
}
|
||
let mp = glam::Vec3::new(mx, my, mz) - target;
|
||
// view_center is the 2-D DCS offset of the display centre from
|
||
// view_target; subtract it so model origin maps to viewport centre.
|
||
let u = mp.dot(view_right) - vp.view_center.x as f32;
|
||
let v = mp.dot(view_up) - vp.view_center.y as f32;
|
||
if use_perspective {
|
||
let d_vd = mp.dot(cam_frame.rotation * glam::Vec3::Z);
|
||
let fwd = camera_dist - d_vd;
|
||
if fwd <= 0.001 {
|
||
return [f32::NAN; 3];
|
||
}
|
||
let factor = camera_dist / fwd;
|
||
[pcx + u * factor * scale, pcy + v * factor * scale, pcz]
|
||
} else {
|
||
[pcx + u * scale, pcy + v * scale, pcz]
|
||
}
|
||
})
|
||
.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;
|
||
}
|
||
|
||
let adapted = render::adapt_to_bg(wire.color, self.paper_bg_color);
|
||
let [r, g, b, a] = adapted;
|
||
let mut out = wire.clone();
|
||
out.points = clipped;
|
||
out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
|
||
out.line_weight_px = wire.line_weight_px;
|
||
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.
|
||
pub fn paper_to_model(&self, paper_pt: glam::Vec3) -> glam::Vec3 {
|
||
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,
|
||
};
|
||
let scale = if vp.custom_scale.abs() > 1e-9 {
|
||
vp.custom_scale as f32
|
||
} else if vp.view_height.abs() > 1e-9 {
|
||
(vp.height / vp.view_height) as f32
|
||
} else {
|
||
1.0
|
||
};
|
||
if scale.abs() < 1e-9 {
|
||
return paper_pt;
|
||
}
|
||
let tx = vp.view_target.x as f32;
|
||
let ty = vp.view_target.y as f32;
|
||
let pcx = vp.center.x as f32;
|
||
let pcy = vp.center.y as f32;
|
||
glam::Vec3::new(
|
||
(paper_pt.x - pcx) / scale + tx,
|
||
(paper_pt.y - pcy) / scale + ty,
|
||
paper_pt.z,
|
||
)
|
||
}
|
||
|
||
/// 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 = if vp.custom_scale.abs() > 1e-9 {
|
||
vp.custom_scale as f32
|
||
} else if vp.view_height.abs() > 1e-9 {
|
||
(vp.height / vp.view_height) as f32
|
||
} else {
|
||
1.0
|
||
};
|
||
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))
|
||
// but view_direction convention (matching snap_active_viewport_to_angles) is
|
||
// (cos(p)*sin(y), +cos(p)*cos(y), sin(p)) ← Y has opposite sign.
|
||
// Negate Y when writing back so camera_for_viewport round-trips correctly.
|
||
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 a canonical yaw/pitch.
|
||
/// No-op when there is no active viewport or it is locked.
|
||
pub fn snap_active_viewport_to_angles(&mut self, yaw: f32, pitch: f32) {
|
||
let vp_handle = match self.active_viewport {
|
||
Some(h) => h,
|
||
None => return,
|
||
};
|
||
let cos_p = pitch.cos();
|
||
let eye = glam::Vec3::new(cos_p * yaw.sin(), cos_p * yaw.cos(), pitch.sin());
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// Return (yaw, pitch) of the active viewport's camera, or None if PSPACE.
|
||
pub fn active_viewport_yaw_pitch(&self) -> Option<(f32, f32)> {
|
||
let h = self.active_viewport?;
|
||
let cam = self.camera_for_viewport(h)?;
|
||
Some((cam.yaw, cam.pitch))
|
||
}
|
||
|
||
/// 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(vp)
|
||
|| vp.common.owner_handle != 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(vp)
|
||
&& vp.common.owner_handle == 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.document.remove_entity(*h);
|
||
}
|
||
|
||
// Remove the Layout object itself.
|
||
self.document.objects.remove(&layout_handle);
|
||
|
||
// 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 {
|
||
let hatch_offset = if self.current_layout == "Model" {
|
||
self.world_offset
|
||
} else {
|
||
[0.0; 3]
|
||
};
|
||
let hatch_seed = if let EntityType::Hatch(dxf) = &entity {
|
||
let color = self.render_style(&entity).0;
|
||
Self::hatch_model_from_dxf(dxf, color, hatch_offset)
|
||
} else if let EntityType::Solid(solid) = &entity {
|
||
let color = self.render_style(&entity).0;
|
||
Some(Self::solid_hatch_model(solid, color, hatch_offset))
|
||
} else {
|
||
None
|
||
};
|
||
let image_seed = if let EntityType::RasterImage(img) = &entity {
|
||
ImageModel::from_raster_image(img)
|
||
} else {
|
||
None
|
||
};
|
||
let mesh_seed = match &entity {
|
||
EntityType::Solid3D(s3d) => {
|
||
let color = self.render_style(&entity).0;
|
||
solid3d_tess::tessellate_solid3d(s3d, color)
|
||
}
|
||
EntityType::Region(r) => {
|
||
let color = self.render_style(&entity).0;
|
||
solid3d_tess::tessellate_region(r, color)
|
||
}
|
||
EntityType::Body(b) => {
|
||
let color = self.render_style(&entity).0;
|
||
solid3d_tess::tessellate_body(b, color)
|
||
}
|
||
_ => 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);
|
||
}
|
||
self.bump_geometry();
|
||
}
|
||
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::tessellate::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);
|
||
for (old_handle, mut entity) in source_entities {
|
||
dispatch::apply_transform(&mut entity, &local);
|
||
entity = crate::modules::home::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)))
|
||
}
|
||
|
||
fn synced_hatch_models(&self) -> Vec<HatchModel> {
|
||
let layout_block = self.current_layout_block_handle();
|
||
let hatch_offset = if self.current_layout == "Model" {
|
||
self.world_offset
|
||
} else {
|
||
[0.0; 3]
|
||
};
|
||
|
||
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> = self
|
||
.hatches
|
||
.iter()
|
||
.filter(|(&handle, _)| {
|
||
let Some(entity) = self.document.get_entity(handle) else {
|
||
return true;
|
||
};
|
||
let c = entity.common();
|
||
!c.invisible
|
||
&& !layer_hidden(&c.layer)
|
||
&& self.belongs_to_visible_block(handle, c.owner_handle, layout_block)
|
||
})
|
||
.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 {
|
||
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;
|
||
}
|
||
hatch_model::HatchPattern::Gradient { angle_deg, .. } => {
|
||
*angle_deg = dxf.pattern_angle.to_degrees() as f32;
|
||
}
|
||
hatch_model::HatchPattern::Solid => {}
|
||
}
|
||
}
|
||
}
|
||
if self.selected.contains(&handle) {
|
||
m.color = [0.15, 0.55, 1.00, m.color[3]];
|
||
}
|
||
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);
|
||
for sub in ins
|
||
.explode_from_document(&self.document)
|
||
.into_iter()
|
||
.map(crate::modules::home::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(mut model) = Self::hatch_model_from_dxf(&dxf, color, hatch_offset) {
|
||
if selected {
|
||
model.color = [0.15, 0.55, 1.00, model.color[3]];
|
||
}
|
||
models.push(model);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Wide LWPolyline and Polyline2D fills
|
||
for entity in self.document.entities() {
|
||
let (common, fills) = match entity {
|
||
EntityType::LwPolyline(pl) => (&pl.common, wide_lwpolyline_fills(pl)),
|
||
EntityType::Polyline2D(pl) => (&pl.common, wide_polyline2d_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 boundary in fills {
|
||
models.push(HatchModel {
|
||
boundary: Arc::new(boundary),
|
||
pattern: hatch_model::HatchPattern::Solid,
|
||
name: "SOLID".into(),
|
||
color,
|
||
angle_offset: 0.0,
|
||
scale: 1.0,
|
||
vp_scissor: None,
|
||
});
|
||
}
|
||
}
|
||
|
||
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
|
||
};
|
||
// Paper-space entities are already in small coordinates — don't shift them.
|
||
let world_offset = if is_paper {
|
||
[0.0; 3]
|
||
} else {
|
||
self.world_offset
|
||
};
|
||
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;
|
||
}
|
||
let boundary = Self::wipeout_boundary_2d(wo, world_offset);
|
||
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: hatch_model::HatchPattern::Solid,
|
||
name: "WIPEOUT_FILL".into(),
|
||
color: fill_color,
|
||
angle_offset: 0.0,
|
||
scale: 1.0,
|
||
vp_scissor: None,
|
||
});
|
||
}
|
||
}
|
||
models
|
||
}
|
||
|
||
/// Compute the 2D (XY) boundary polygon for a Wipeout entity.
|
||
fn wipeout_boundary_2d(
|
||
wo: &acadrust::entities::Wipeout,
|
||
world_offset: [f64; 3],
|
||
) -> Vec<[f32; 2]> {
|
||
use acadrust::entities::WipeoutClipType;
|
||
|
||
let [wox, woy, _woz] = world_offset;
|
||
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;
|
||
wo.clip_boundary_vertices
|
||
.iter()
|
||
.map(|v| {
|
||
let wx =
|
||
(wo.u_vector.x * v.x * wo.size.x + wo.v_vector.x * v.y * wo.size.y) as f32;
|
||
let wy =
|
||
(wo.u_vector.y * v.x * wo.size.x + wo.v_vector.y * v.y * wo.size.y) as f32;
|
||
[ox + wx, oy + wy]
|
||
})
|
||
.collect()
|
||
} 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;
|
||
vec![
|
||
[ox, oy],
|
||
[ox + ux, oy + uy],
|
||
[ox + ux + vx, oy + uy + vy],
|
||
[ox + vx, oy + vy],
|
||
]
|
||
}
|
||
}
|
||
|
||
fn hatch_model_from_dxf(
|
||
dxf: &DxfHatch,
|
||
color: [f32; 4],
|
||
world_offset: [f64; 3],
|
||
) -> Option<HatchModel> {
|
||
let [ox, oy, _oz] = world_offset;
|
||
let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z);
|
||
let to_xy = |x: f64, y: f64| -> [f32; 2] {
|
||
let (wx, wy, _) =
|
||
crate::scene::transform::ocs_point_to_wcs((x, y, dxf.elevation), normal);
|
||
[(wx - ox) as f32, (wy - oy) as f32]
|
||
};
|
||
if dxf.paths.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut boundary: Vec<[f32; 2]> = Vec::new();
|
||
|
||
for path in &dxf.paths {
|
||
let before_path = boundary.len();
|
||
if !boundary.is_empty() {
|
||
boundary.push([f32::NAN, f32::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;
|
||
if bulge.abs() < 1e-9 {
|
||
boundary.push(to_xy(v0.x, v0.y));
|
||
} else {
|
||
let p0 = [v0.x as f32, v0.y as f32];
|
||
let p1 = [v1.x as f32, v1.y as f32];
|
||
let theta = 4.0 * (bulge as f32).atan();
|
||
let dx = p1[0] - p0[0];
|
||
let dy = p1[1] - p0[1];
|
||
let d = (dx * dx + dy * dy).sqrt();
|
||
if d < 1e-9 {
|
||
boundary.push(to_xy(v0.x, v0.y));
|
||
continue;
|
||
}
|
||
let r = (d * 0.5) / (theta * 0.5).sin().abs();
|
||
let mx = (p0[0] + p1[0]) * 0.5;
|
||
let my = (p0[1] + p1[1]) * 0.5;
|
||
let px = -dy / d;
|
||
let py = dx / d;
|
||
let sign = if bulge > 0.0 { 1.0_f32 } else { -1.0_f32 };
|
||
let center_offset = r * (theta * 0.5).cos();
|
||
let cx = mx + sign * px * center_offset;
|
||
let cy = my + sign * py * center_offset;
|
||
let a0 = (p0[1] - cy).atan2(p0[0] - cx);
|
||
let a1 = (p1[1] - cy).atan2(p1[0] - cx);
|
||
let mut sweep = a1 - a0;
|
||
if bulge > 0.0 {
|
||
if sweep <= 0.0 {
|
||
sweep += std::f32::consts::TAU;
|
||
}
|
||
} else if sweep >= 0.0 {
|
||
sweep -= std::f32::consts::TAU;
|
||
}
|
||
if sweep.abs() < 1e-7 {
|
||
sweep = if bulge > 0.0 {
|
||
std::f32::consts::TAU
|
||
} else {
|
||
-std::f32::consts::TAU
|
||
};
|
||
}
|
||
let segs = ((sweep.abs() / std::f32::consts::TAU) * 16.0)
|
||
.ceil()
|
||
.max(4.0) as u32;
|
||
for j in 0..segs {
|
||
let a = a0 + sweep * (j as f32 / segs as f32);
|
||
boundary.push(to_xy(
|
||
(cx + r * a.cos()) as f64,
|
||
(cy + r * a.sin()) as f64,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
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) => {
|
||
const TAU: f64 = std::f64::consts::TAU;
|
||
let cx = arc.center.x;
|
||
let cy = arc.center.y;
|
||
let r = arc.radius;
|
||
let sa = arc.start_angle;
|
||
let ea = arc.end_angle;
|
||
let ccw = arc.counter_clockwise;
|
||
let (sa, ea) = if ccw { (sa, ea) } else { (TAU - sa, TAU - ea) };
|
||
let span = ea - sa;
|
||
let segs = ((span.abs() / TAU) * 32.0).ceil().max(4.0) as u32;
|
||
for i in 0..=segs {
|
||
let t = sa + span * (i as f64 / segs as f64);
|
||
boundary.push(to_xy(cx + r * t.cos(), cy + r * t.sin()));
|
||
}
|
||
}
|
||
BoundaryEdge::EllipticArc(ell) => {
|
||
const TAU: f32 = std::f32::consts::TAU;
|
||
let cx = ell.center.x;
|
||
let cy = ell.center.y;
|
||
let maj_x = ell.major_axis_endpoint.x as f32;
|
||
let maj_y = ell.major_axis_endpoint.y as f32;
|
||
let r_maj = (maj_x * maj_x + maj_y * maj_y).sqrt();
|
||
let r_min = r_maj * ell.minor_axis_ratio as f32;
|
||
let rot = maj_y.atan2(maj_x);
|
||
let ccw = ell.counter_clockwise;
|
||
let sa = ell.start_angle as f32;
|
||
let ea = ell.end_angle as f32;
|
||
let (sa, ea) = if ccw { (sa, ea) } else { (TAU - sa, TAU - ea) };
|
||
let span = ea - sa;
|
||
let segs = ((span.abs() / TAU) * 32.0).ceil().max(4.0) as u32;
|
||
for i in 0..=segs {
|
||
let t = sa + span * (i as f32 / segs as f32);
|
||
let lx = r_maj * t.cos();
|
||
let ly = r_min * t.sin();
|
||
boundary.push(to_xy(
|
||
cx + (lx * rot.cos() - ly * rot.sin()) as f64,
|
||
cy + (lx * rot.sin() + ly * rot.cos()) as f64,
|
||
));
|
||
}
|
||
}
|
||
BoundaryEdge::Spline(spline) => {
|
||
// Evaluate the B-spline curve into smooth boundary points.
|
||
// Fall back to control-point polyline for degenerate inputs.
|
||
let degree = spline.degree as usize;
|
||
let cps: Vec<Point3> = spline
|
||
.control_points
|
||
.iter()
|
||
.map(|p| Point3::new(p.x, p.y, 0.0))
|
||
.collect();
|
||
let knot_vec = if !spline.knots.is_empty() {
|
||
KnotVec::from(spline.knots.clone())
|
||
} else if cps.len() >= 2 {
|
||
KnotVec::uniform_knot(degree, cps.len() - 1)
|
||
} else {
|
||
KnotVec::from(vec![])
|
||
};
|
||
let ok = cps.len() >= 2
|
||
&& degree >= 1
|
||
&& knot_vec.len() == cps.len() + degree + 1;
|
||
if ok {
|
||
let bspl = TruckBSpline::new(knot_vec, cps);
|
||
let (t0, t1) = bspl.range_tuple();
|
||
let segs = 16u32;
|
||
for i in 0..=segs {
|
||
let t = t0 + (t1 - t0) * (i as f64 / segs as f64);
|
||
let p = bspl.subs(t);
|
||
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(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;
|
||
hatch_model::HatchPattern::Gradient { angle_deg, color2 }
|
||
} else if dxf.is_solid {
|
||
hatch_model::HatchPattern::Solid
|
||
} else {
|
||
let pat_name = &dxf.pattern.name;
|
||
if let Some(entry) = crate::scene::hatch_patterns::find(pat_name) {
|
||
entry.gpu.clone()
|
||
} else {
|
||
hatch_model::HatchPattern::Pattern(vec![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()
|
||
};
|
||
Some(HatchModel {
|
||
boundary: std::sync::Arc::new(boundary),
|
||
pattern,
|
||
name,
|
||
color,
|
||
angle_offset: dxf.pattern_angle as f32,
|
||
scale: dxf.pattern_scale as f32,
|
||
vp_scissor: None,
|
||
})
|
||
}
|
||
|
||
/// 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 model_block = self.model_space_block_handle();
|
||
let world_offset = self.world_offset;
|
||
|
||
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 rayon::prelude::*;
|
||
self.hatches = entries
|
||
.into_par_iter()
|
||
.filter_map(|(handle, kind)| {
|
||
// Paper-space entities live in sheet coordinates — world_offset must not
|
||
// be applied to them. Only model-space entities need the shift.
|
||
let owner = kind.common().owner_handle;
|
||
let offset = if owner == model_block {
|
||
world_offset
|
||
} else {
|
||
[0.0; 3]
|
||
};
|
||
let model = match &kind {
|
||
EntityType::Hatch(dxf) => {
|
||
let color = tessellate::aci_to_rgba(&dxf.common.color);
|
||
Self::hatch_model_from_dxf(dxf, color, offset)
|
||
}
|
||
EntityType::Solid(solid) => {
|
||
let color = tessellate::aci_to_rgba(&solid.common.color);
|
||
Some(Self::solid_hatch_model(solid, color, offset))
|
||
}
|
||
_ => 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();
|
||
// Collect all ACIS-bearing entities with their color resolved now,
|
||
// so the parallel phase only sees owned data.
|
||
let entries: Vec<(Handle, EntityType, [f32; 4])> = self
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| match e {
|
||
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) => {
|
||
let color = tessellate::aci_to_rgba(&e.common().color);
|
||
Some((e.common().handle, e.clone(), color))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
|
||
use rayon::prelude::*;
|
||
self.meshes = entries
|
||
.into_par_iter()
|
||
.filter_map(|(handle, entity, color)| {
|
||
let model = match &entity {
|
||
EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color),
|
||
EntityType::Region(r) => solid3d_tess::tessellate_region(r, color),
|
||
EntityType::Body(b) => solid3d_tess::tessellate_body(b, color),
|
||
_ => None,
|
||
};
|
||
model.map(|m| (handle, m))
|
||
})
|
||
.collect();
|
||
|
||
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], world_offset: [f64; 3]) -> HatchModel {
|
||
let [ox, oy, _oz] = world_offset;
|
||
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: hatch_model::HatchPattern::Solid,
|
||
name: "SOLID".into(),
|
||
color,
|
||
angle_offset: 0.0,
|
||
scale: 1.0,
|
||
vp_scissor: None,
|
||
}
|
||
}
|
||
|
||
pub fn add_hatch(&mut self, model: HatchModel) -> Handle {
|
||
let mut dxf = DxfHatch::new();
|
||
dxf.is_solid = matches!(
|
||
model.pattern,
|
||
crate::scene::hatch_model::HatchPattern::Solid
|
||
);
|
||
let verts: Vec<Vector2> = model
|
||
.boundary
|
||
.iter()
|
||
.map(|&[x, y]| Vector2::new(x as f64, y as f64))
|
||
.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::hatch_patterns::find(&model.name) {
|
||
dxf.pattern = crate::scene::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::new();
|
||
self.preview_wires = vec![];
|
||
self.current_layout = "Model".to_string();
|
||
self.hatches = HashMap::new();
|
||
self.meshes = HashMap::new();
|
||
*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>) {
|
||
self.preview_wires = wires;
|
||
self.bump_geometry();
|
||
}
|
||
|
||
pub fn clear_preview_wire(&mut self) {
|
||
self.preview_wires = vec![];
|
||
self.interim_wire = None;
|
||
self.bump_geometry();
|
||
}
|
||
|
||
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) {
|
||
self.interim_wire = Some(w);
|
||
self.bump_geometry();
|
||
}
|
||
|
||
// ── Selection ─────────────────────────────────────────────────────────
|
||
|
||
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
|
||
if exclusive {
|
||
self.selected.clear();
|
||
}
|
||
self.selected.insert(handle);
|
||
self.bump_geometry();
|
||
}
|
||
|
||
pub fn deselect_all(&mut self) {
|
||
self.selected.clear();
|
||
self.bump_geometry();
|
||
}
|
||
|
||
pub fn selected_entities(&self) -> Vec<(Handle, &EntityType)> {
|
||
self.selected
|
||
.iter()
|
||
.filter_map(|&h| self.document.get_entity(h).map(|e| (h, e)))
|
||
.collect()
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
// 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);
|
||
}
|
||
self.bump_geometry();
|
||
}
|
||
|
||
// ── 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_geometry();
|
||
}
|
||
|
||
// ── 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) {
|
||
let hatch_offset = if self.current_layout == "Model" {
|
||
self.world_offset
|
||
} else {
|
||
[0.0; 3]
|
||
};
|
||
for &h in handles {
|
||
if let Some(entity) = self.document.get_entity_mut(h) {
|
||
dispatch::apply_transform(entity, t);
|
||
}
|
||
if self.hatches.contains_key(&h) {
|
||
let existing_color = self.hatches[&h].color;
|
||
let new_model = if let Some(EntityType::Hatch(dxf)) = self.document.get_entity(h) {
|
||
Self::hatch_model_from_dxf(dxf, existing_color, hatch_offset)
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(model) = new_model {
|
||
self.hatches.insert(h, model);
|
||
}
|
||
}
|
||
}
|
||
self.bump_geometry();
|
||
}
|
||
|
||
pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec<Handle> {
|
||
let hatch_offset = if self.current_layout == "Model" {
|
||
self.world_offset
|
||
} else {
|
||
[0.0; 3]
|
||
};
|
||
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 {
|
||
dispatch::apply_transform(&mut entity, t);
|
||
entity.common_mut().handle = Handle::NULL;
|
||
let h = self.document.add_entity(entity).unwrap_or(Handle::NULL);
|
||
if !h.is_null() {
|
||
let new_model = if let Some(EntityType::Hatch(dxf)) = self.document.get_entity(h) {
|
||
let color = tessellate::aci_to_rgba(&dxf.common.color);
|
||
Self::hatch_model_from_dxf(dxf, color, hatch_offset)
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(model) = new_model {
|
||
self.hatches.insert(h, model);
|
||
}
|
||
}
|
||
new_handles.push(h);
|
||
}
|
||
self.bump_geometry();
|
||
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]> = match self.document.get_entity(handle) {
|
||
Some(EntityType::Solid3D(s)) => Some([
|
||
s.point_of_reference.x,
|
||
s.point_of_reference.y,
|
||
s.point_of_reference.z,
|
||
]),
|
||
Some(EntityType::Region(r)) => Some([
|
||
r.point_of_reference.x,
|
||
r.point_of_reference.y,
|
||
r.point_of_reference.z,
|
||
]),
|
||
Some(EntityType::Body(b)) => Some([
|
||
b.point_of_reference.x,
|
||
b.point_of_reference.y,
|
||
b.point_of_reference.z,
|
||
]),
|
||
_ => None,
|
||
};
|
||
|
||
if let Some(entity) = self.document.get_entity_mut(handle) {
|
||
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]> = match self.document.get_entity(handle) {
|
||
Some(EntityType::Solid3D(s)) => Some([
|
||
s.point_of_reference.x,
|
||
s.point_of_reference.y,
|
||
s.point_of_reference.z,
|
||
]),
|
||
Some(EntityType::Region(r)) => Some([
|
||
r.point_of_reference.x,
|
||
r.point_of_reference.y,
|
||
r.point_of_reference.z,
|
||
]),
|
||
Some(EntityType::Body(b)) => Some([
|
||
b.point_of_reference.x,
|
||
b.point_of_reference.y,
|
||
b.point_of_reference.z,
|
||
]),
|
||
_ => None,
|
||
};
|
||
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.
|
||
let hatch_offset = if self.current_layout == "Model" {
|
||
self.world_offset
|
||
} else {
|
||
[0.0; 3]
|
||
};
|
||
match self.document.get_entity(handle) {
|
||
Some(EntityType::Hatch(dxf)) => {
|
||
let color = tessellate::aci_to_rgba(&dxf.common.color);
|
||
if let Some(model) = Self::hatch_model_from_dxf(dxf, color, hatch_offset) {
|
||
self.hatches.insert(handle, model);
|
||
} else {
|
||
self.hatches.remove(&handle);
|
||
}
|
||
}
|
||
Some(EntityType::Solid(solid)) => {
|
||
let color = tessellate::aci_to_rgba(&solid.common.color);
|
||
self.hatches
|
||
.insert(handle, Self::solid_hatch_model(solid, color, hatch_offset));
|
||
}
|
||
_ => {}
|
||
}
|
||
self.bump_geometry();
|
||
}
|
||
|
||
// ── 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 = Vec3::new(
|
||
view.target.x as f32,
|
||
view.target.y as f32,
|
||
view.target.z as f32,
|
||
);
|
||
// 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.
|
||
/// `model_space`: if true, subtracts world_offset from target (wire-space).
|
||
fn apply_camera_from_view_entry(
|
||
&mut self,
|
||
view: &acadrust::tables::View,
|
||
model_space: bool,
|
||
) -> bool {
|
||
if view.height.abs() < 1e-9 {
|
||
return false;
|
||
}
|
||
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();
|
||
let yaw = if vd.x.abs() < 1e-6 && vd.y.abs() < 1e-6 {
|
||
0.0_f32
|
||
} else {
|
||
vd.x.atan2(-vd.y)
|
||
};
|
||
let rotation = camera::yaw_pitch_to_quat(yaw, pitch);
|
||
let view_right = rotation * glam::Vec3::X;
|
||
let view_up = rotation * glam::Vec3::Y;
|
||
let base = if model_space {
|
||
glam::Vec3::new(
|
||
(view.target.x - self.world_offset[0]) as f32,
|
||
(view.target.y - self.world_offset[1]) as f32,
|
||
(view.target.z - self.world_offset[2]) as f32,
|
||
)
|
||
} else {
|
||
glam::Vec3::new(
|
||
view.target.x as f32,
|
||
view.target.y as f32,
|
||
view.target.z as f32,
|
||
)
|
||
};
|
||
let target = base + view_right * view.center.x as f32 + view_up * view.center.y as f32;
|
||
let fov_y = 45.0_f32.to_radians();
|
||
let distance = ((view.height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001);
|
||
let mut cam = self.camera.borrow_mut();
|
||
cam.target = target;
|
||
cam.rotation = rotation;
|
||
cam.distance = distance;
|
||
cam.yaw = yaw;
|
||
cam.pitch = pitch;
|
||
cam.fov_y = fov_y;
|
||
cam.projection = camera::Projection::Orthographic;
|
||
drop(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 {
|
||
// Prefer our named View entry — survives DWG save without being overridden.
|
||
let saved_view = self
|
||
.document
|
||
.views
|
||
.iter()
|
||
.find(|v| v.name == "H7CAD_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,
|
||
};
|
||
|
||
if vp.view_height.abs() < 1e-9 {
|
||
return false;
|
||
}
|
||
|
||
let vd = glam::Vec3::new(
|
||
vp.view_direction.x as f32,
|
||
vp.view_direction.y as f32,
|
||
vp.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)
|
||
};
|
||
let rotation = camera::yaw_pitch_to_quat(yaw, pitch);
|
||
let view_right = rotation * glam::Vec3::X;
|
||
let view_up = rotation * glam::Vec3::Y;
|
||
|
||
// view_target is WCS; wire-space subtracts world_offset.
|
||
let base = glam::Vec3::new(
|
||
(vp.view_target.x - self.world_offset[0]) as f32,
|
||
(vp.view_target.y - self.world_offset[1]) as f32,
|
||
(vp.view_target.z - self.world_offset[2]) as f32,
|
||
);
|
||
let target =
|
||
base + view_right * vp.view_center.x as f32 + view_up * vp.view_center.y as f32;
|
||
|
||
let fov_y = 45.0_f32.to_radians();
|
||
let distance = ((vp.view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001);
|
||
|
||
let mut cam = self.camera.borrow_mut();
|
||
cam.target = target;
|
||
cam.rotation = rotation;
|
||
cam.distance = distance;
|
||
cam.yaw = yaw;
|
||
cam.pitch = pitch;
|
||
cam.fov_y = fov_y;
|
||
cam.projection = camera::Projection::Orthographic;
|
||
drop(cam);
|
||
|
||
self.camera_generation += 1;
|
||
true
|
||
}
|
||
|
||
/// 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.
|
||
fn apply_sheet_viewport_camera(&mut self) -> bool {
|
||
// Prefer our named View entry — survives DWG save without being overridden.
|
||
let view_name = format!("H7CAD_Camera_{}", self.current_layout);
|
||
let saved_view = self
|
||
.document
|
||
.views
|
||
.iter()
|
||
.find(|v| v.name == view_name)
|
||
.cloned();
|
||
if let Some(view) = saved_view {
|
||
return self.apply_camera_from_view_entry(&view, false);
|
||
}
|
||
|
||
let layout_block = self.current_layout_block_handle();
|
||
if layout_block.is_null() {
|
||
return false;
|
||
}
|
||
|
||
let sheet_vp = 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(vp));
|
||
|
||
let vp = match sheet_vp {
|
||
Some(v) => v,
|
||
None => return false,
|
||
};
|
||
|
||
if vp.view_height.abs() < 1e-9 {
|
||
return false;
|
||
}
|
||
|
||
let vd = glam::Vec3::new(
|
||
vp.view_direction.x as f32,
|
||
vp.view_direction.y as f32,
|
||
vp.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)
|
||
};
|
||
let rotation = camera::yaw_pitch_to_quat(yaw, pitch);
|
||
let view_right = rotation * glam::Vec3::X;
|
||
let view_up = rotation * glam::Vec3::Y;
|
||
|
||
// Paper-space entities have no world_offset applied, so target is raw.
|
||
let base = glam::Vec3::new(
|
||
vp.view_target.x as f32,
|
||
vp.view_target.y as f32,
|
||
vp.view_target.z as f32,
|
||
);
|
||
let target =
|
||
base + view_right * vp.view_center.x as f32 + view_up * vp.view_center.y as f32;
|
||
|
||
let fov_y = 45.0_f32.to_radians();
|
||
let distance = ((vp.view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001);
|
||
|
||
let mut cam = self.camera.borrow_mut();
|
||
cam.target = target;
|
||
cam.rotation = rotation;
|
||
cam.distance = distance;
|
||
cam.yaw = yaw;
|
||
cam.pitch = pitch;
|
||
cam.fov_y = fov_y;
|
||
cam.projection = camera::Projection::Orthographic;
|
||
drop(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;
|
||
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) + self.world_offset[0],
|
||
y: (cam.target.y as f64) + self.world_offset[1],
|
||
z: (cam.target.z as f64) + self.world_offset[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;
|
||
}
|
||
|
||
// Also write to View table — survives DWG save without override.
|
||
self.write_camera_view_entry("H7CAD_Camera_Model", target_wcs, vd3, view_height);
|
||
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,
|
||
};
|
||
let view_name = format!("H7CAD_Camera_{}", self.current_layout);
|
||
|
||
// Write back to the sheet viewport entity.
|
||
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(vp)
|
||
})
|
||
.map(|vp| vp.common.handle);
|
||
|
||
if let Some(handle) = sheet_handle {
|
||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
|
||
vp.view_target = target_wcs;
|
||
vp.view_center = acadrust::types::Vector3::ZERO;
|
||
vp.view_direction = vd3;
|
||
vp.view_height = view_height as f64;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Also write to View table — survives DWG save without override.
|
||
self.write_camera_view_entry(&view_name, target_wcs, vd3, view_height);
|
||
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,
|
||
) {
|
||
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;
|
||
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" {
|
||
self.apply_active_vport_camera()
|
||
} else {
|
||
self.apply_sheet_viewport_camera()
|
||
};
|
||
if !restored {
|
||
self.fit_all();
|
||
}
|
||
}
|
||
|
||
pub fn fit_all(&mut self) {
|
||
let wires = self.entity_wires();
|
||
if wires.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;
|
||
}
|
||
if x.abs() > lim || y.abs() > lim {
|
||
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() {
|
||
return;
|
||
}
|
||
|
||
// IQR-based reject only kicks in with enough samples for the quartiles
|
||
// to be meaningful. Below that, the absolute `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 {
|
||
(-lim, lim, -lim, 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.abs() > lim || y.abs() > lim {
|
||
continue;
|
||
}
|
||
min = min.min(glam::Vec3::new(x, y, z));
|
||
max = max.max(glam::Vec3::new(x, y, z));
|
||
}
|
||
}
|
||
// 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 ───────────────────────────────────
|
||
|
||
/// Convert a paper-space Viewport entity's position/size into a pixel
|
||
/// `Rectangle` relative to the top-left of the paper canvas.
|
||
///
|
||
/// Uses the same camera-based ortho transform as `PaperCanvas::draw()` so
|
||
/// that 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;
|
||
let ty = cam.target.y;
|
||
drop(cam);
|
||
|
||
// Mirror the to_px closure in PaperCanvas::draw().
|
||
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,
|
||
})
|
||
}
|
||
|
||
// ── ViewportPane helpers ──────────────────────────────────────────────
|
||
|
||
/// Paper-space entity wires only (title blocks, frames, borders).
|
||
/// Does NOT include viewport content projection — that is handled by
|
||
/// individual ViewportPane::Paper widgets layered on top.
|
||
/// All wires needed to render the paper-space canvas (2D widget path).
|
||
/// Includes paper entities, paper boundary, inactive viewport projections
|
||
/// (excluding the active MSPACE viewport), plus interim/preview wires.
|
||
pub fn paper_canvas_wires(&self) -> Arc<Vec<WireModel>> {
|
||
{
|
||
let cache = self.paper_canvas_cache.borrow();
|
||
if let Some((cached_epoch, ref arc)) = *cache {
|
||
if cached_epoch == self.geometry_epoch {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
let layout_block = self.current_layout_block_handle();
|
||
let mut wires = self.paper_sheet_wires();
|
||
wires.extend(self.viewport_content_wires(layout_block, None, self.active_viewport));
|
||
if let Some(iw) = &self.interim_wire {
|
||
wires.push(iw.clone());
|
||
}
|
||
wires.extend(self.preview_wires.iter().cloned());
|
||
let arc = Arc::new(wires);
|
||
*self.paper_canvas_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||
arc
|
||
}
|
||
|
||
/// Hatch fills for the paper-space canvas.
|
||
pub fn paper_canvas_hatches(&self) -> Arc<Vec<HatchModel>> {
|
||
self.hatch_models_arc()
|
||
}
|
||
|
||
/// Wipeout (opaque background fill) models for the paper-space canvas.
|
||
pub fn paper_canvas_wipeouts(&self) -> Arc<Vec<HatchModel>> {
|
||
self.wipeout_models_arc()
|
||
}
|
||
|
||
pub(super) fn paper_sheet_wires(&self) -> Vec<WireModel> {
|
||
(*self.paper_sheet_wires_arc()).clone()
|
||
}
|
||
|
||
/// Build a Camera oriented and scaled to match a paper-space Viewport entity.
|
||
/// Used by `ViewportPane::Paper` to render model-space content through the
|
||
/// viewport's own view direction and scale.
|
||
fn camera_for_viewport(&self, vp_handle: Handle) -> Option<camera::Camera> {
|
||
let vp = match self.document.get_entity(vp_handle) {
|
||
Some(EntityType::Viewport(vp)) => vp,
|
||
_ => return None,
|
||
};
|
||
|
||
let vd = glam::Vec3::new(
|
||
vp.view_direction.x as f32,
|
||
vp.view_direction.y as f32,
|
||
vp.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)
|
||
};
|
||
|
||
let rotation = camera::yaw_pitch_to_quat(yaw, pitch);
|
||
let view_right = rotation * glam::Vec3::X;
|
||
let view_up = rotation * glam::Vec3::Y;
|
||
|
||
// view_target is in raw model/WCS coords; the GPU renderer works in
|
||
// wire-space (model - world_offset), so subtract world_offset here.
|
||
// view_center is a 2-D DCS offset of the display centre from view_target.
|
||
let base_target = glam::Vec3::new(
|
||
(vp.view_target.x - self.world_offset[0]) as f32,
|
||
(vp.view_target.y - self.world_offset[1]) as f32,
|
||
(vp.view_target.z - self.world_offset[2]) as f32,
|
||
);
|
||
let target =
|
||
base_target + view_right * vp.view_center.x as f32 + view_up * vp.view_center.y as f32;
|
||
|
||
let fov_y = 45.0_f32.to_radians();
|
||
let view_height = if vp.view_height.abs() > 1e-9 {
|
||
vp.view_height as f32
|
||
} else {
|
||
vp.height as f32
|
||
};
|
||
// ortho_size = distance * tan(fov_y/2) => distance = view_height/2 / tan(fov_y/2)
|
||
let distance = ((view_height / 2.0) / (fov_y * 0.5).tan()).max(0.001);
|
||
|
||
Some(camera::Camera {
|
||
target,
|
||
rotation,
|
||
distance,
|
||
fov_y,
|
||
projection: camera::Projection::Orthographic,
|
||
yaw,
|
||
pitch,
|
||
})
|
||
}
|
||
|
||
/// Collect model-space WireModels visible through `vp_handle`, respecting
|
||
/// global layer visibility and the viewport's per-viewport layer freeze list.
|
||
fn model_wires_for_viewport(&self, vp_handle: Handle) -> Vec<WireModel> {
|
||
use std::collections::HashSet as HSet;
|
||
|
||
let (frozen, vp_anno_scale) = match self.document.get_entity(vp_handle) {
|
||
Some(EntityType::Viewport(vp)) => {
|
||
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
|
||
let vp_scale = if vp.custom_scale.abs() > 1e-9 {
|
||
vp.custom_scale
|
||
} else if vp.view_height.abs() > 1e-9 {
|
||
vp.height / vp.view_height
|
||
} else {
|
||
1.0
|
||
};
|
||
let anno = if vp_scale > 1e-9 {
|
||
(1.0 / vp_scale) as f32
|
||
} else {
|
||
1.0_f32
|
||
};
|
||
(f, anno)
|
||
}
|
||
_ => (HSet::new(), 1.0_f32),
|
||
};
|
||
|
||
let model_block = self.model_space_block_handle();
|
||
let blk_cache = self.block_cache_arc();
|
||
|
||
self.document
|
||
.entities()
|
||
.filter(|e| {
|
||
let c = e.common();
|
||
if c.invisible || matches!(e, EntityType::Viewport(_)) {
|
||
return false;
|
||
}
|
||
if !self.belongs_to_visible_block(c.handle, c.owner_handle, model_block) {
|
||
return false;
|
||
}
|
||
if self
|
||
.document
|
||
.layers
|
||
.get(&c.layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
{
|
||
return false;
|
||
}
|
||
if !frozen.is_empty() {
|
||
if let Some(lh) = self.document.layers.get(&c.layer).map(|l| l.handle) {
|
||
if frozen.contains(&lh) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
true
|
||
})
|
||
.flat_map(|e| {
|
||
// Per-viewport tessellation uses the viewport's own camera —
|
||
// not the model-space camera — so we don't pass a view_aabb
|
||
// here. (Viewports are typically small enough that culling
|
||
// them isn't worth the added complexity.)
|
||
tessellate_entity(
|
||
&self.document,
|
||
&self.selected,
|
||
self.active_viewport,
|
||
self.world_offset,
|
||
self.bg_color,
|
||
vp_anno_scale,
|
||
e,
|
||
Some(&blk_cache),
|
||
None,
|
||
None,
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub(super) fn model_wires_for_viewport_arc(&self, vp_handle: Handle) -> Arc<Vec<WireModel>> {
|
||
{
|
||
let cache = self.viewport_wire_cache.borrow();
|
||
if let Some((cached_epoch, ref arc)) = cache.get(&vp_handle) {
|
||
if *cached_epoch == self.geometry_epoch {
|
||
return Arc::clone(arc);
|
||
}
|
||
}
|
||
}
|
||
let arc = Arc::new(self.model_wires_for_viewport(vp_handle));
|
||
self.viewport_wire_cache
|
||
.borrow_mut()
|
||
.insert(vp_handle, (self.geometry_epoch, 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.
|
||
|
||
fn tessellate_entity(
|
||
document: &acadrust::CadDocument,
|
||
selected: &HashSet<Handle>,
|
||
active_viewport: Option<Handle>,
|
||
world_offset: [f64; 3],
|
||
bg_color: [f32; 4],
|
||
anno_scale: f32,
|
||
e: &EntityType,
|
||
block_cache: Option<&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, world_offset);
|
||
if ab != WireModel::UNBOUNDED_AABB {
|
||
if let Some(view) = view_aabb {
|
||
if block_cache::aabb_disjoint_xy(ab, view) {
|
||
return vec![];
|
||
}
|
||
}
|
||
if let Some(wpp) = world_per_pixel {
|
||
let w = (ab[2] - ab[0]).abs();
|
||
let h = (ab[3] - ab[1]).abs();
|
||
// Keep in sync with `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(_));
|
||
if !is_text && w.max(h) / wpp < 5.0 {
|
||
return vec![];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 wire = tessellate::tessellate(
|
||
document,
|
||
h,
|
||
e,
|
||
sel,
|
||
color,
|
||
pattern_length,
|
||
pattern,
|
||
1.5,
|
||
world_offset,
|
||
1.0,
|
||
);
|
||
wire.aabb = entity_aabb(e, world_offset);
|
||
return vec![wire];
|
||
}
|
||
|
||
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
|
||
render::render_style_for(document, e);
|
||
let entity_color = 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 = 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);
|
||
|
||
if let EntityType::Dimension(dim) = e {
|
||
let aabb = entity_aabb(e, world_offset);
|
||
let mut wires = tessellate::tessellate_dimension(
|
||
document,
|
||
h,
|
||
dim,
|
||
sel,
|
||
entity_color,
|
||
line_weight_px,
|
||
world_offset,
|
||
anno_scale,
|
||
);
|
||
for w in &mut wires {
|
||
w.aci = aci;
|
||
w.aabb = aabb;
|
||
}
|
||
return wires;
|
||
}
|
||
|
||
if let EntityType::MultiLeader(ml) = e {
|
||
let aabb = entity_aabb(e, world_offset);
|
||
let mut wires = tessellate::tessellate_multileader(
|
||
document,
|
||
h,
|
||
ml,
|
||
sel,
|
||
entity_color,
|
||
line_weight_px,
|
||
world_offset,
|
||
anno_scale,
|
||
world_per_pixel,
|
||
);
|
||
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, _) = render::render_style_for(document, e);
|
||
let ins_color = render::adapt_to_bg(ins_color, bg_color);
|
||
let [ox, oy, oz] = world_offset;
|
||
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![],
|
||
color: entity_color,
|
||
selected: sel,
|
||
aci: 0,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![(ip, wire_model::SnapHint::Insertion)],
|
||
tangent_geoms: vec![],
|
||
key_vertices: vec![],
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
};
|
||
|
||
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) = block_cache::expand_insert(
|
||
cache,
|
||
ins,
|
||
h,
|
||
ins_color,
|
||
ins_pat_len,
|
||
ins_pat,
|
||
ins_lw_px,
|
||
sel,
|
||
world_offset,
|
||
pslt_factor,
|
||
view_aabb,
|
||
world_per_pixel,
|
||
is_xref,
|
||
bg_color,
|
||
) {
|
||
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::home::modify::explode::normalize_insert_entity)
|
||
.flat_map(|sub| {
|
||
let (sub_color, sub_pattern_length, sub_pattern, sub_line_weight_px, sub_aci) =
|
||
render::render_style_for_block_sub(
|
||
document,
|
||
&sub,
|
||
ins_color,
|
||
ins_pat_len,
|
||
ins_pat,
|
||
ins_lw_px,
|
||
);
|
||
let sub_color = render::adapt_to_bg(sub_color, bg_color);
|
||
let sub_color = if is_xref && !sel {
|
||
block_cache::fade_toward_bg(sub_color, bg_color)
|
||
} else {
|
||
sub_color
|
||
};
|
||
let sub_aabb = entity_aabb(&sub, world_offset);
|
||
let sub_pattern_length = sub_pattern_length * pslt_factor;
|
||
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
|
||
let mut wire = tessellate::tessellate(
|
||
document,
|
||
h,
|
||
&sub,
|
||
sel,
|
||
sub_color,
|
||
sub_pattern_length,
|
||
sub_pattern,
|
||
sub_line_weight_px,
|
||
world_offset,
|
||
anno_scale,
|
||
);
|
||
wire.name = h.value().to_string();
|
||
wire.aci = sub_aci;
|
||
wire.aabb = sub_aabb;
|
||
vec![wire]
|
||
})
|
||
.collect();
|
||
wires.push(marker);
|
||
return wires;
|
||
}
|
||
|
||
let aabb = entity_aabb(e, world_offset);
|
||
|
||
// 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
|
||
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),
|
||
_ => 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 = text_baseline_points(e, anno_scale, world_offset, 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 {
|
||
return vec![];
|
||
}
|
||
return vec![WireModel {
|
||
name: h.value().to_string(),
|
||
points: pts,
|
||
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![],
|
||
}];
|
||
}
|
||
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
|
||
let fill_tris = text_greek_obb_tris(e, anno_scale, world_offset, n_lines);
|
||
if fill_tris.is_empty() {
|
||
return vec![];
|
||
}
|
||
return vec![WireModel {
|
||
name: h.value().to_string(),
|
||
points: vec![],
|
||
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,
|
||
}];
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut base = tessellate::tessellate(
|
||
document,
|
||
h,
|
||
e,
|
||
sel,
|
||
entity_color,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
world_offset,
|
||
anno_scale,
|
||
);
|
||
base.aci = aci;
|
||
base.aabb = aabb;
|
||
|
||
if let Some(clt) = crate::linetypes::complex_lt(lt_name) {
|
||
let mut wires = 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;
|
||
}
|
||
}
|
||
|
||
vec![base]
|
||
}
|
||
|
||
/// 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.
|
||
pub(crate) fn text_obb_corners_native(
|
||
e: &EntityType,
|
||
anno_scale: f32,
|
||
mtext_lines_override: Option<usize>,
|
||
) -> Option<[[f64; 3]; 4]> {
|
||
use acadrust::entities::{
|
||
AttachmentPoint, TextHorizontalAlignment, TextVerticalAlignment,
|
||
};
|
||
|
||
let anno = anno_scale as f64;
|
||
|
||
let (ix, iy, iz, w, h, rot, h_anchor, v_anchor) = match e {
|
||
EntityType::Text(t) => {
|
||
let h_world = t.height * anno;
|
||
let w_factor = if t.width_factor > 0.0 { t.width_factor } else { 1.0 };
|
||
let n = t.value.chars().count().max(1) as f64;
|
||
// Approximate glyph width: AutoCAD's stroke fonts average ~0.6 em.
|
||
let w_world = n * h_world * w_factor * 0.6;
|
||
let h_anchor = match t.horizontal_alignment {
|
||
TextHorizontalAlignment::Left => 0.0,
|
||
TextHorizontalAlignment::Center
|
||
| TextHorizontalAlignment::Middle
|
||
| TextHorizontalAlignment::Aligned
|
||
| TextHorizontalAlignment::Fit => 0.5,
|
||
TextHorizontalAlignment::Right => 1.0,
|
||
};
|
||
let v_anchor = match t.vertical_alignment {
|
||
TextVerticalAlignment::Baseline | TextVerticalAlignment::Bottom => 0.0,
|
||
TextVerticalAlignment::Middle => 0.5,
|
||
TextVerticalAlignment::Top => 1.0,
|
||
};
|
||
// AutoCAD writes `alignment_point` (DXF 11) whenever the text
|
||
// isn't simple Left+Baseline; in that case it — not the
|
||
// insertion_point — is the anchor the alignment fractions map to.
|
||
let use_alignment_pt = !matches!(
|
||
(t.horizontal_alignment, t.vertical_alignment),
|
||
(
|
||
TextHorizontalAlignment::Left,
|
||
TextVerticalAlignment::Baseline,
|
||
)
|
||
);
|
||
let anchor = match (use_alignment_pt, t.alignment_point) {
|
||
(true, Some(p)) => p,
|
||
_ => t.insertion_point,
|
||
};
|
||
(
|
||
anchor.x,
|
||
anchor.y,
|
||
anchor.z,
|
||
w_world,
|
||
h_world,
|
||
t.rotation,
|
||
h_anchor,
|
||
v_anchor,
|
||
)
|
||
}
|
||
EntityType::MText(m) => {
|
||
let h_world = m.height * anno;
|
||
let raw_lines = (m.value.matches('\n').count() + 1) as f64;
|
||
let effective_lines = match mtext_lines_override {
|
||
Some(n) => n.max(1) as f64,
|
||
None => raw_lines,
|
||
};
|
||
let w_world = if m.rectangle_width > 0.0 {
|
||
m.rectangle_width
|
||
} else {
|
||
h_world * 8.0 * effective_lines.max(1.0)
|
||
};
|
||
// Wrap-aware override beats `rectangle_height` — the stored
|
||
// height can be stale on DWGs that were re-saved without
|
||
// updating the bounds.
|
||
let total_h = if mtext_lines_override.is_some() {
|
||
h_world * effective_lines.max(1.0) * m.line_spacing_factor.max(0.5)
|
||
} else {
|
||
m.rectangle_height.unwrap_or(
|
||
h_world * raw_lines.max(1.0) * m.line_spacing_factor.max(0.5),
|
||
)
|
||
};
|
||
// MText `attachment_point` puts `insertion_point` at one of the
|
||
// 9 corners/midpoints of the text bbox. h_anchor / v_anchor are
|
||
// fractions from (left, bottom) of the bbox.
|
||
let (h_anchor, v_anchor) = match m.attachment_point {
|
||
AttachmentPoint::TopLeft => (0.0, 1.0),
|
||
AttachmentPoint::TopCenter => (0.5, 1.0),
|
||
AttachmentPoint::TopRight => (1.0, 1.0),
|
||
AttachmentPoint::MiddleLeft => (0.0, 0.5),
|
||
AttachmentPoint::MiddleCenter => (0.5, 0.5),
|
||
AttachmentPoint::MiddleRight => (1.0, 0.5),
|
||
AttachmentPoint::BottomLeft => (0.0, 0.0),
|
||
AttachmentPoint::BottomCenter => (0.5, 0.0),
|
||
AttachmentPoint::BottomRight => (1.0, 0.0),
|
||
};
|
||
(
|
||
m.insertion_point.x,
|
||
m.insertion_point.y,
|
||
m.insertion_point.z,
|
||
w_world,
|
||
total_h,
|
||
m.rotation,
|
||
h_anchor,
|
||
v_anchor,
|
||
)
|
||
}
|
||
_ => return None,
|
||
};
|
||
|
||
let x0 = -h_anchor * w;
|
||
let x1 = (1.0 - h_anchor) * w;
|
||
let y0 = -v_anchor * h;
|
||
let y1 = (1.0 - v_anchor) * h;
|
||
|
||
let (s, c) = (rot.sin(), rot.cos());
|
||
let rot_pt = |lx: f64, ly: f64| -> [f64; 3] {
|
||
let rx = lx * c - ly * s;
|
||
let ry = lx * s + ly * c;
|
||
[ix + rx, iy + ry, iz]
|
||
};
|
||
|
||
Some([
|
||
rot_pt(x0, y0),
|
||
rot_pt(x1, y0),
|
||
rot_pt(x1, y1),
|
||
rot_pt(x0, y1),
|
||
])
|
||
}
|
||
|
||
/// NaN-separated baseline segments — one per visible wrap line — for a
|
||
/// sub-pixel Text / MText, in world-offset-subtracted f32 space. `n_lines`
|
||
/// must be the actual rendered line count (1 for Text, the wrap-expanded
|
||
/// count for MText). Lines are emitted top → bottom along the OBB's up
|
||
/// direction, each at the bottom edge of its slab.
|
||
fn text_baseline_points(
|
||
e: &EntityType,
|
||
anno_scale: f32,
|
||
world_offset: [f64; 3],
|
||
n_lines: usize,
|
||
) -> Vec<[f32; 3]> {
|
||
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
|
||
return vec![];
|
||
};
|
||
let line_h = match e {
|
||
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
|
||
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
|
||
_ => return vec![],
|
||
};
|
||
if line_h <= 0.0 {
|
||
return vec![];
|
||
}
|
||
let n_lines = n_lines.max(1);
|
||
let [ox, oy, oz] = world_offset;
|
||
let cast = |p: [f64; 3]| -> [f32; 3] {
|
||
[(p[0] - ox) as f32, (p[1] - oy) as f32, (p[2] - oz) as f32]
|
||
};
|
||
let bl = cast(corners[0]);
|
||
let br = cast(corners[1]);
|
||
let full_tl = cast(corners[3]);
|
||
|
||
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
|
||
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
|
||
if ulen < 1e-9 {
|
||
return vec![bl, br];
|
||
}
|
||
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
|
||
|
||
let mut pts = Vec::with_capacity(n_lines * 3);
|
||
for i in 0..n_lines {
|
||
// i = 0 is the topmost line — its bottom sits one line_h below
|
||
// the OBB top (≈ `ulen`). For n_lines > ulen/line_h the deepest
|
||
// baselines clamp to the OBB bottom.
|
||
let bot_off = ((i + 1) as f32) * line_h;
|
||
let along = (ulen - bot_off).max(0.0);
|
||
let p0 = [bl[0] + nx * along, bl[1] + ny * along, bl[2] + nz * along];
|
||
let p1 = [br[0] + nx * along, br[1] + ny * along, br[2] + nz * along];
|
||
if !pts.is_empty() {
|
||
pts.push([f32::NAN; 3]);
|
||
}
|
||
pts.extend_from_slice(&[p0, p1]);
|
||
}
|
||
pts
|
||
}
|
||
|
||
/// Filled tris for a greeked top-level Text / MText. One 2-triangle rect
|
||
/// per visible line — `n_lines` is the actual rendered line count from
|
||
/// `mtext_line_count` (1 for Text). Stacked top → bottom along the OBB's
|
||
/// up direction. The face3d pipeline skips its 0.45 dim for wires with
|
||
/// empty `points`, so these tris render at the literal text color.
|
||
fn text_greek_obb_tris(
|
||
e: &EntityType,
|
||
anno_scale: f32,
|
||
world_offset: [f64; 3],
|
||
n_lines: usize,
|
||
) -> Vec<[f32; 3]> {
|
||
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
|
||
return vec![];
|
||
};
|
||
let line_h = match e {
|
||
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
|
||
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
|
||
_ => return vec![],
|
||
};
|
||
if line_h <= 0.0 {
|
||
return vec![];
|
||
}
|
||
let n_lines = n_lines.max(1);
|
||
let [ox, oy, oz] = world_offset;
|
||
let cast = |p: [f64; 3]| -> [f32; 3] {
|
||
[(p[0] - ox) as f32, (p[1] - oy) as f32, (p[2] - oz) as f32]
|
||
};
|
||
let bl = cast(corners[0]);
|
||
let br = cast(corners[1]);
|
||
let full_tl = cast(corners[3]);
|
||
|
||
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
|
||
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
|
||
if ulen < 1e-9 {
|
||
return vec![];
|
||
}
|
||
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
|
||
|
||
let mut tris = Vec::with_capacity(n_lines * 6);
|
||
for i in 0..n_lines {
|
||
let top_along = (ulen - (i as f32) * line_h).max(0.0);
|
||
let bot_along = (ulen - ((i + 1) as f32) * line_h).max(0.0);
|
||
let tl = [
|
||
bl[0] + nx * top_along,
|
||
bl[1] + ny * top_along,
|
||
bl[2] + nz * top_along,
|
||
];
|
||
let tr = [
|
||
br[0] + nx * top_along,
|
||
br[1] + ny * top_along,
|
||
br[2] + nz * top_along,
|
||
];
|
||
let lbl = [
|
||
bl[0] + nx * bot_along,
|
||
bl[1] + ny * bot_along,
|
||
bl[2] + nz * bot_along,
|
||
];
|
||
let lbr = [
|
||
br[0] + nx * bot_along,
|
||
br[1] + ny * bot_along,
|
||
br[2] + nz * bot_along,
|
||
];
|
||
tris.extend_from_slice(&[lbl, lbr, tr, lbl, tr, tl]);
|
||
}
|
||
tris
|
||
}
|
||
|
||
fn entity_aabb(e: &acadrust::EntityType, world_offset: [f64; 3]) -> [f32; 4] {
|
||
let bbox = e.as_entity().bounding_box();
|
||
let [ox, oy, _] = world_offset;
|
||
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]
|
||
}
|
||
|
||
/// Generate solid-fill boundary polygons for each wide segment of an LWPolyline.
|
||
/// Returns one polygon per segment that has non-zero width; empty if the polyline
|
||
/// has zero `constant_width` and all vertex widths are zero.
|
||
fn wide_lwpolyline_fills(pl: &acadrust::entities::LwPolyline) -> Vec<Vec<[f32; 2]>> {
|
||
let hw_const = (pl.constant_width / 2.0) as f32;
|
||
let verts = &pl.vertices;
|
||
let n = verts.len();
|
||
if n < 2 {
|
||
return vec![];
|
||
}
|
||
let seg_count = if pl.is_closed { n } else { n - 1 };
|
||
let mut out = Vec::new();
|
||
for i in 0..seg_count {
|
||
let v0 = &verts[i];
|
||
let v1 = &verts[(i + 1) % n];
|
||
let hw0 = if v0.start_width > 1e-9 {
|
||
v0.start_width as f32 / 2.0
|
||
} else {
|
||
hw_const
|
||
};
|
||
let hw1 = if v0.end_width > 1e-9 {
|
||
v0.end_width as f32 / 2.0
|
||
} else {
|
||
hw_const
|
||
};
|
||
if hw0 < 1e-6 && hw1 < 1e-6 {
|
||
continue;
|
||
}
|
||
let p0 = [v0.location.x as f32, v0.location.y as f32];
|
||
let p1 = [v1.location.x as f32, v1.location.y as f32];
|
||
if let Some(poly) = polyline_segment_fill(p0, p1, hw0, hw1, v0.bulge as f32) {
|
||
out.push(poly);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Generate solid-fill boundary polygons for each wide segment of a Polyline2D.
|
||
fn wide_polyline2d_fills(pl: &acadrust::entities::Polyline2D) -> Vec<Vec<[f32; 2]>> {
|
||
let hw_default = (pl.start_width.max(pl.end_width) / 2.0) as f32;
|
||
let verts = &pl.vertices;
|
||
let n = verts.len();
|
||
if n < 2 {
|
||
return vec![];
|
||
}
|
||
let seg_count = if pl.is_closed() { n } else { n - 1 };
|
||
let mut out = Vec::new();
|
||
for i in 0..seg_count {
|
||
let v0 = &verts[i];
|
||
let v1 = &verts[(i + 1) % n];
|
||
let hw0 = if v0.start_width > 1e-9 {
|
||
v0.start_width as f32 / 2.0
|
||
} else {
|
||
hw_default
|
||
};
|
||
let hw1 = if v0.end_width > 1e-9 {
|
||
v0.end_width as f32 / 2.0
|
||
} else {
|
||
hw_default
|
||
};
|
||
if hw0 < 1e-6 && hw1 < 1e-6 {
|
||
continue;
|
||
}
|
||
let p0 = [v0.location.x as f32, v0.location.y as f32];
|
||
let p1 = [v1.location.x as f32, v1.location.y as f32];
|
||
if let Some(poly) = polyline_segment_fill(p0, p1, hw0, hw1, v0.bulge as f32) {
|
||
out.push(poly);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Compute the filled boundary polygon for one polyline segment.
|
||
/// For straight segments: a rectangle/trapezoid.
|
||
/// For arc segments: an arc band (outer arc + reversed inner arc).
|
||
/// Returns `None` if the segment is degenerate.
|
||
fn polyline_segment_fill(
|
||
p0: [f32; 2],
|
||
p1: [f32; 2],
|
||
hw0: f32,
|
||
hw1: f32,
|
||
bulge: f32,
|
||
) -> Option<Vec<[f32; 2]>> {
|
||
if bulge.abs() < 1e-9 {
|
||
// Straight segment — rectangle or trapezoid
|
||
let dx = p1[0] - p0[0];
|
||
let dy = p1[1] - p0[1];
|
||
let len = (dx * dx + dy * dy).sqrt();
|
||
if len < 1e-9 {
|
||
return None;
|
||
}
|
||
let nx = -dy / len;
|
||
let ny = dx / len;
|
||
Some(vec![
|
||
[p0[0] + hw0 * nx, p0[1] + hw0 * ny],
|
||
[p1[0] + hw1 * nx, p1[1] + hw1 * ny],
|
||
[p1[0] - hw1 * nx, p1[1] - hw1 * ny],
|
||
[p0[0] - hw0 * nx, p0[1] - hw0 * ny],
|
||
])
|
||
} else {
|
||
// Arc segment — arc band polygon
|
||
let angle = 4.0 * (bulge as f64).atan();
|
||
let dx = (p1[0] - p0[0]) as f64;
|
||
let dy = (p1[1] - p0[1]) as f64;
|
||
let d = (dx * dx + dy * dy).sqrt();
|
||
if d < 1e-9 {
|
||
return None;
|
||
}
|
||
let r = (d / 2.0) / (angle / 2.0).sin().abs();
|
||
let mx = ((p0[0] + p1[0]) * 0.5) as f64;
|
||
let my = ((p0[1] + p1[1]) * 0.5) as f64;
|
||
let px = -dy / d;
|
||
let py = dx / d;
|
||
let sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 };
|
||
let h = r - (r * r - d * d / 4.0).max(0.0).sqrt();
|
||
let cx = (mx - sign * px * (r - h)) as f32;
|
||
let cy = (my - sign * py * (r - h)) as f32;
|
||
let a0 = ((p0[1] - cy) as f32).atan2((p0[0] - cx) as f32);
|
||
let a1 = ((p1[1] - cy) as f32).atan2((p1[0] - cx) as f32);
|
||
let (sa, mut ea) = if bulge > 0.0 { (a0, a1) } else { (a1, a0) };
|
||
if ea < sa {
|
||
ea += std::f32::consts::TAU;
|
||
}
|
||
let span = ea - sa;
|
||
let segs = ((span.abs() / std::f32::consts::TAU) * 12.0)
|
||
.ceil()
|
||
.max(4.0) as u32;
|
||
let r = r as f32;
|
||
let hw = (hw0 + hw1) * 0.5;
|
||
let r_outer = r + hw;
|
||
let r_inner = (r - hw).max(0.0);
|
||
let mut boundary = Vec::with_capacity((segs as usize + 1) * 2);
|
||
for j in 0..=segs {
|
||
let t = sa + span * (j as f32 / segs as f32);
|
||
boundary.push([cx + r_outer * t.cos(), cy + r_outer * t.sin()]);
|
||
}
|
||
for j in (0..=segs).rev() {
|
||
let t = sa + span * (j as f32 / segs as f32);
|
||
boundary.push([cx + r_inner * t.cos(), cy + r_inner * t.sin()]);
|
||
}
|
||
boundary.truncate(64);
|
||
Some(boundary)
|
||
}
|
||
}
|