forked from 1b-agent/opencadstudio-upstream
Per the upstream review of the XDATA-based storage: the host no longer writes its own state into the saved file. The marker record under the host-owned OCS APPID, the on-first-use APPID registration and the verbatim-EED rewrite handling are removed; the single source of truth is a scene-scoped in-memory set of entity handles (Scene::reference_only) that dies with the drawing tab, so a reopened file starts undesignated and the owning plugin re-marks on the document-opened notification. All behaviour sites are unchanged, driven by the set: the wire stamp moves from an XDATA read to a set lookup threaded through the tessellation pipelines (tessellate_entity and the dimension text path take the handle set as a plain parameter), and the pick filters, selection retains and update/erase refusals read Scene::is_reference_only. Undo keeps the entity-write contract: set mutations are recorded as explicit first-touch before/after values on each history delta (the parametric- constraints channel pattern). A designation inside a plugin push_undo scope joins it, a scope-less change is one step of its own, an idempotent re-mark records nothing, erasing a designated entity drops the set entry with the entity, and undo/redo restores entity and designation together. The round-trip test is inverted: reference_only_designation_is_not_written_ to_dwg_or_dxf now asserts the marker appears in neither format's bytes, that no OCS APPID or marker record survives a reload, and that the designation stays live in memory.
3493 lines
131 KiB
Rust
3493 lines
131 KiB
Rust
// Block-definition tessellation cache.
|
||
//
|
||
// Each block record is tessellated once into block-local coordinates and
|
||
// stored as a list of `LocalSub` (either a tessellated primitive wire OR
|
||
// an unexpanded reference to a nested INSERT). At Insert use-time we walk
|
||
// the defn, transform-copy primitives, and recurse into nested references —
|
||
// each nested defn is itself a cache hit, never re-tessellated.
|
||
//
|
||
// This shape (lazy nested expansion) is essential: a single block like
|
||
// `xref-PLANKOTE` can hold ~4700 nested INSERTs, so build-time inlining
|
||
// produces a combinatorial blowup. Storing references and expanding on
|
||
// demand keeps build work proportional to total entity count.
|
||
//
|
||
// Cycle detection: at expand-time we maintain a recursion-depth limit and
|
||
// a visited set so a self-referential block produces a marker rather than
|
||
// recursing forever.
|
||
|
||
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
||
use std::sync::Arc;
|
||
|
||
use codec::types::{Color as AcadColor, LineWeight, Transform, Vector3};
|
||
use codec::{CadDocument, EntityType, Handle};
|
||
use kernel::space::{Plane as KernelPlane, Vec3 as KernelVec3};
|
||
|
||
use crate::scene::convert::tessellate;
|
||
use crate::scene::model::wire_model::{
|
||
decode_pattern_station_map, encode_pattern_stations, pattern_station_values, PatternStationMap,
|
||
PatternStationPiece, PointMarker, SnapHint, TangentGeom, WireModel,
|
||
};
|
||
|
||
const MAX_NESTING_DEPTH: usize = 32;
|
||
/// Small line-only nested definitions cost more as separate instance records
|
||
/// than as part of their parent's existing style batches. Folding these tiny
|
||
/// subtrees at expansion time keeps exact geometry while avoiding one
|
||
/// heavyweight `WireModel` per leaf occurrence.
|
||
const INLINE_NESTED_POINT_BUDGET: usize = 128;
|
||
/// Skip wires whose world-AABB projects to fewer than this many pixels in
|
||
/// the active view. Picks up tiny detail at zoom-out so the tessellator
|
||
/// doesn't waste time on geometry that contributes a few sub-pixel marks
|
||
/// to the final image. Two pixels is a practical small-element floor:
|
||
/// — visibly the same image, dramatically fewer wires.
|
||
const MIN_PIXEL_SIZE: f32 = 2.0;
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct LocalWire {
|
||
pub points: Vec<[f32; 3]>,
|
||
/// Low-bit residual paired with `points` so block-instance wires keep
|
||
/// sub-f32 precision once the renderer translates them to world space.
|
||
pub points_low: Vec<[f32; 3]>,
|
||
pub is_point: bool,
|
||
pub point_marker: Option<PointMarker>,
|
||
/// SDF glyph quads for block-internal text, in block-local coordinates.
|
||
/// Non-empty only when SDF text is on and this sub is a TEXT/MTEXT. The
|
||
/// expand-time transform (`emit_wire`) maps each vertex to world exactly
|
||
/// like `points`, so block-instance text lands at the right place/scale.
|
||
pub text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
|
||
pub searchable_text: Vec<crate::scene::model::wire_model::SearchableTextRun>,
|
||
pub key_vertices: Vec<[f64; 3]>,
|
||
pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
|
||
pub tangent_geoms: Vec<TangentGeom>,
|
||
pub fill_tris: Vec<[f32; 3]>,
|
||
pub fill_tris_low: Vec<[f32; 3]>,
|
||
pub fill_is_3d: bool,
|
||
/// Preserves the planar SOLID classification through block expansion so
|
||
/// it never gets merged with unrelated annotation fills of the same style.
|
||
pub fill_is_2d_solid: bool,
|
||
/// Thickness-wall pick geometry, transformed by the insert like `points`
|
||
/// so a block child's extruded wall stays selectable at the instance's
|
||
/// place and scale. Never reaches the GPU.
|
||
pub pick_tris: Vec<[f32; 3]>,
|
||
pub pick_tris_low: Vec<[f32; 3]>,
|
||
/// Per-wire colour from the tessellator output. For most entities this
|
||
/// equals the sub-entity's resolved colour. For colour-split MTEXT
|
||
/// (`\C`/`\c` inline overrides) each wire carries its own override colour.
|
||
pub color: [f32; 4],
|
||
pub contrast_bg: Option<[f32; 4]>,
|
||
pub preserve_color: bool,
|
||
pub canvas_color: bool,
|
||
pub aci: u8,
|
||
pub pattern_length: f32,
|
||
pub pattern: [f32; 8],
|
||
pub line_weight_px: f32,
|
||
pub pattern_stations: Vec<f32>,
|
||
/// World-space band width for a wide polyline (see `WireModel.world_width`).
|
||
/// Block-local; the expand-time transform scales it by the insert so the
|
||
/// shader band grows with a scaled insert. `0.0` = a normal wire.
|
||
pub world_width: f32,
|
||
pub plinegen: bool,
|
||
pub plot_visible: bool,
|
||
pub plot_l0: bool,
|
||
pub hide_unselected: bool,
|
||
/// Set at construction; used to discriminate fill-only GPU batches from
|
||
/// stroke batches in [`StyleKey`]. Derived from
|
||
/// `points.is_empty() && !fill_tris.is_empty()`.
|
||
pub is_fill_only: bool,
|
||
pub color_is_byblock: bool,
|
||
pub transparency_is_byblock: bool,
|
||
pub lt_is_byblock: bool,
|
||
pub lw_is_byblock: bool,
|
||
/// Set when this child sits on layer "0" and the matching property is
|
||
/// ByLayer. At expand time the value is then taken from the INSERT's
|
||
/// *layer* (the layer-0 inheritance rule) instead of the cached layer-0
|
||
/// value baked here.
|
||
pub color_l0: bool,
|
||
pub transparency_l0: bool,
|
||
pub lt_l0: bool,
|
||
pub lw_l0: bool,
|
||
/// XY bounding box of this wire in block-local coordinates.
|
||
/// `[min_x, min_y, max_x, max_y]`. Used for view-frustum culling at
|
||
/// expand-time: transform corners by the Insert transform → world AABB
|
||
/// → test against the camera's world-space view rect.
|
||
pub aabb_local: [f32; 4],
|
||
/// This child's signed draw-order rank in (-1,1) within its owning block
|
||
/// (from the scene draw-depth map, so SortEntitiesTable overrides apply).
|
||
/// Composed at expand time into a `depth_override` for wide-polyline
|
||
/// bands so a band orders against its block siblings.
|
||
pub local_rank: f32,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct NestedRef {
|
||
pub block_name: String,
|
||
pub xform: Transform,
|
||
pub style: crate::scene::render_graph::InsertStyleSpec,
|
||
/// ATTRIB geometry is stored in the parent definition's frame, but resolves
|
||
/// ByBlock and layer-0 properties through this nested insert's style.
|
||
pub attachments: Vec<LocalWire>,
|
||
pub instance_offsets: Vec<[f64; 3]>,
|
||
pub plot_visible: bool,
|
||
pub plot_l0: bool,
|
||
/// XCLIP boundary for this nested insert, in the parent defn's local frame
|
||
/// (`None` = unclipped). Baked at build time because the clip's spatial
|
||
/// filter lives in `doc.objects`, which isn't reachable at expand time; on
|
||
/// expansion it is mapped to world by the accumulated transform and the
|
||
/// nested wires are clipped to it.
|
||
pub clip_poly: Option<Vec<[f64; 2]>>,
|
||
/// The nested INSERT's own signed draw-order rank in (-1,1) within the
|
||
/// parent block — narrows the depth sub-range its children may occupy.
|
||
pub local_rank: f32,
|
||
pub suppress_root_points: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub enum LocalSub {
|
||
Wire(LocalWire),
|
||
Nested(NestedRef),
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default)]
|
||
pub struct BlockMetrics {
|
||
pub aabb_local: [f32; 4],
|
||
pub has_point_marker: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct BlockDefn {
|
||
pub subs: Vec<LocalSub>,
|
||
pub metrics: BlockMetrics,
|
||
/// Point cost when this whole subtree is safe and cheap to fold into its
|
||
/// parent's batches. `None` keeps the regular instanced expansion path.
|
||
inline_point_cost: Option<usize>,
|
||
/// Raw entity count of the source block record (`entity_handles.len()`).
|
||
/// Divisor for nested depth composition: a nested insert's children get a
|
||
/// sub-range of `parent_scale / (child_count + 1)`, shared with the scene
|
||
/// graph so bands and fills stay on one depth scale.
|
||
pub child_count: usize,
|
||
}
|
||
|
||
#[derive(Default, Debug)]
|
||
pub struct BlockCache {
|
||
defns: HashMap<String, Arc<BlockDefn>>,
|
||
prototype_blocks: HashSet<String>,
|
||
/// Fully expanded prototype for repeated, non-array inserts. The key omits
|
||
/// translation but includes the linear transform and every inherited style
|
||
/// input. A matching insert therefore reuses all nested expansion/style
|
||
/// work and only applies its translation to the immutable prototype.
|
||
expansion_prototypes: std::sync::Mutex<
|
||
HashMap<ExpansionPrototypeKey, Arc<std::sync::Mutex<Option<Arc<CachedExpansion>>>>>,
|
||
>,
|
||
clip_prototypes: std::sync::Mutex<HashMap<(u64, Vec<u64>), u64>>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||
struct ExpansionPrototypeKey {
|
||
block_name: String,
|
||
linear: [u64; 9],
|
||
insert_style: Vec<u32>,
|
||
selected: bool,
|
||
is_xref: bool,
|
||
suppress_root_points: bool,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct CachedExpansion {
|
||
translation: [f64; 3],
|
||
wires: Arc<Vec<WireModel>>,
|
||
}
|
||
|
||
impl BlockCache {
|
||
pub fn clip_source_id(
|
||
&self,
|
||
source_id: u64,
|
||
polygon: &[[f64; 2]],
|
||
translation: [f64; 3],
|
||
) -> u64 {
|
||
let mut shape = Vec::with_capacity(polygon.len() * 2);
|
||
for point in polygon {
|
||
shape.push((point[0] - translation[0]).to_bits());
|
||
shape.push((point[1] - translation[1]).to_bits());
|
||
}
|
||
*self
|
||
.clip_prototypes
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||
.entry((source_id, shape))
|
||
.or_insert_with(crate::scene::model::instance_model::next_source_id)
|
||
}
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
pub fn defn(&self, block_name: &str) -> Option<&Arc<BlockDefn>> {
|
||
self.defns.get(block_name).or_else(|| {
|
||
self.defns
|
||
.iter()
|
||
.find(|(name, _)| name.eq_ignore_ascii_case(block_name))
|
||
.map(|(_, definition)| definition)
|
||
})
|
||
}
|
||
|
||
/// Build definitions referenced by block-backed entities. Space records stay
|
||
/// roots so their direct entities preserve individual selection handles.
|
||
pub fn build(
|
||
doc: &CadDocument,
|
||
anno_scale: f32,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
bg_color: [f32; 4],
|
||
viewport: Option<Handle>,
|
||
// Scene draw-depth map ([depth, half] per handle) — source of each
|
||
// block child's in-block rank, shared by cached wires and graph leaves.
|
||
depth_map: &HashMap<u64, [f32; 2]>,
|
||
) -> Self {
|
||
use crate::par::prelude::*;
|
||
let mut cache = Self::new();
|
||
let referenced = collect_referenced_blocks(doc, anno_scale);
|
||
let mut reference_counts: HashMap<String, usize> = HashMap::default();
|
||
for entity in doc.entities() {
|
||
if let EntityType::Insert(insert) = entity {
|
||
*reference_counts
|
||
.entry(insert.block_name.clone())
|
||
.or_default() += insert.instance_count();
|
||
}
|
||
}
|
||
cache.prototype_blocks = reference_counts
|
||
.into_iter()
|
||
.filter_map(|(name, count)| (count > 1).then_some(name))
|
||
.collect();
|
||
// Each defn is built independently: nested INSERTs are stored as
|
||
// by-name references (`LocalSub::Nested`), never expanded here, so a
|
||
// block's build never depends on another block's defn. That makes the
|
||
// builds embarrassingly parallel over the read-only `doc` — no
|
||
// topological ordering required. Metrics are resolved after all
|
||
// definitions exist.
|
||
cache.defns = referenced
|
||
.par_iter()
|
||
.map(|name| {
|
||
(
|
||
name.clone(),
|
||
Arc::new(build_defn(
|
||
doc,
|
||
name,
|
||
anno_scale,
|
||
annotation_scale_handle,
|
||
all_visible,
|
||
bg_color,
|
||
viewport,
|
||
depth_map,
|
||
)),
|
||
)
|
||
})
|
||
.collect();
|
||
cache.compute_block_metrics(&referenced);
|
||
cache
|
||
}
|
||
|
||
/// Build a small cache rooted at one block. Used only when a synthetic or
|
||
/// newly-added Insert is not present in the resident cache yet.
|
||
pub fn build_for_block(
|
||
doc: &CadDocument,
|
||
block_name: &str,
|
||
anno_scale: f32,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
bg_color: [f32; 4],
|
||
viewport: Option<Handle>,
|
||
depth_map: &HashMap<u64, [f32; 2]>,
|
||
) -> Self {
|
||
use crate::par::prelude::*;
|
||
let mut cache = Self::new();
|
||
let referenced = referenced_block_tree(doc, block_name, anno_scale);
|
||
cache.defns = referenced
|
||
.par_iter()
|
||
.map(|name| {
|
||
(
|
||
name.clone(),
|
||
Arc::new(build_defn(
|
||
doc,
|
||
name,
|
||
anno_scale,
|
||
annotation_scale_handle,
|
||
all_visible,
|
||
bg_color,
|
||
viewport,
|
||
depth_map,
|
||
)),
|
||
)
|
||
})
|
||
.collect();
|
||
cache.compute_block_metrics(&referenced);
|
||
cache
|
||
}
|
||
|
||
/// Resolve recursive bounds and point-marker presence.
|
||
fn compute_block_metrics(&mut self, names: &[String]) {
|
||
use crate::par::prelude::*;
|
||
// Definitions are complete, so each recursive read can run independently.
|
||
let this: &Self = self;
|
||
let resolved: Vec<(&String, BlockMetrics, Option<usize>)> = names
|
||
.par_iter()
|
||
.map(|name| {
|
||
let mut visited: Vec<String> = Vec::new();
|
||
let metrics = this.defn_metrics_recursive(name, &mut visited);
|
||
let inline_point_cost =
|
||
this.defn_inline_point_cost_recursive(name, &mut Vec::new());
|
||
(name, metrics, inline_point_cost)
|
||
})
|
||
.collect();
|
||
for (name, metrics, inline_point_cost) in resolved {
|
||
if let Some(defn_arc) = self.defns.get_mut(name) {
|
||
let mut defn = (**defn_arc).clone();
|
||
defn.metrics = metrics;
|
||
defn.inline_point_cost = inline_point_cost;
|
||
*defn_arc = Arc::new(defn);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn defn_inline_point_cost_recursive(
|
||
&self,
|
||
block_name: &str,
|
||
visited: &mut Vec<String>,
|
||
) -> Option<usize> {
|
||
// `visited` is the current path, so it bounds cycles but not depth:
|
||
// a legal acyclic chain B1 → … → Bn recursed n frames deep and
|
||
// overflowed rayon's 2 MiB worker stacks (hard abort). Expansion
|
||
// already truncates at `MAX_NESTING_DEPTH` (`depth > 32`); stopping
|
||
// at the same boundary keeps cost/bounds in sync with what renders.
|
||
if visited.len() > MAX_NESTING_DEPTH || visited.iter().any(|name| name == block_name) {
|
||
return None;
|
||
}
|
||
let defn = self.defns.get(block_name)?;
|
||
visited.push(block_name.to_string());
|
||
let result = (|| {
|
||
let mut cost = 0usize;
|
||
for sub in &defn.subs {
|
||
let sub_cost = match sub {
|
||
LocalSub::Wire(wire) => inline_wire_point_cost(wire)?,
|
||
LocalSub::Nested(nested)
|
||
if nested.clip_poly.is_none() && nested.attachments.is_empty() =>
|
||
{
|
||
self.defn_inline_point_cost_recursive(&nested.block_name, visited)?
|
||
.checked_mul(nested.instance_offsets.len())?
|
||
}
|
||
LocalSub::Nested(_) => return None,
|
||
};
|
||
cost = cost.checked_add(sub_cost)?;
|
||
if cost > INLINE_NESTED_POINT_BUDGET {
|
||
return None;
|
||
}
|
||
}
|
||
Some(cost)
|
||
})();
|
||
visited.pop();
|
||
result
|
||
}
|
||
|
||
fn defn_metrics_recursive(&self, block_name: &str, visited: &mut Vec<String>) -> BlockMetrics {
|
||
// Same depth guard as `defn_inline_point_cost_recursive`: cycle-only
|
||
// guarding let acyclic chains recurse until the stack overflowed.
|
||
if visited.len() > MAX_NESTING_DEPTH || visited.iter().any(|name| name == block_name) {
|
||
return BlockMetrics::default();
|
||
}
|
||
let Some(defn) = self.defns.get(block_name) else {
|
||
return BlockMetrics::default();
|
||
};
|
||
visited.push(block_name.to_string());
|
||
let mut metrics = BlockMetrics::default();
|
||
for sub in &defn.subs {
|
||
match sub {
|
||
LocalSub::Wire(wire) => {
|
||
metrics.aabb_local = aabb_union(metrics.aabb_local, wire.aabb_local);
|
||
metrics.has_point_marker |= wire.point_marker.is_some();
|
||
}
|
||
LocalSub::Nested(nref) => {
|
||
let nested = self.defn_metrics_recursive(&nref.block_name, visited);
|
||
metrics.has_point_marker |= nested.has_point_marker;
|
||
for offset in &nref.instance_offsets {
|
||
let transform = nested_instance_transform(nref, *offset);
|
||
metrics.aabb_local = aabb_union(
|
||
metrics.aabb_local,
|
||
transform_aabb_xy(nested.aabb_local, &transform),
|
||
);
|
||
let attachment_transform = nested_attachment_transform(nref, *offset);
|
||
for wire in &nref.attachments {
|
||
metrics.aabb_local = aabb_union(
|
||
metrics.aabb_local,
|
||
transform_aabb_xy(wire.aabb_local, &attachment_transform),
|
||
);
|
||
metrics.has_point_marker |= wire.point_marker.is_some();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
visited.pop();
|
||
metrics
|
||
}
|
||
}
|
||
|
||
fn block_object_names(doc: &CadDocument, entity: &EntityType, anno_scale: f32) -> Vec<String> {
|
||
crate::scene::render_graph::entity_render_block_uses(doc, entity, anno_scale)
|
||
.into_iter()
|
||
.filter(|block_use| !block_use.block.is_null())
|
||
.map(|block_use| block_use.insert.block_name)
|
||
.collect()
|
||
}
|
||
|
||
/// Collect block definitions needed by rendered representations.
|
||
fn collect_referenced_blocks(doc: &CadDocument, anno_scale: f32) -> Vec<String> {
|
||
let mut seen: HashSet<String> = HashSet::default();
|
||
let mut queue: Vec<String> = Vec::new();
|
||
|
||
for entity in doc.entities() {
|
||
for name in block_object_names(doc, entity, anno_scale) {
|
||
if seen.insert(name.clone()) {
|
||
queue.push(name);
|
||
}
|
||
}
|
||
}
|
||
while let Some(name) = queue.pop() {
|
||
let Some(br) = crate::scene::render_graph::block_record_by_name(doc, &name) else {
|
||
continue;
|
||
};
|
||
for &eh in &br.entity_handles {
|
||
let Some(entity) = doc.get_entity(eh) else {
|
||
continue;
|
||
};
|
||
for name in block_object_names(doc, entity, anno_scale) {
|
||
if seen.insert(name.clone()) {
|
||
queue.push(name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
seen.into_iter().collect()
|
||
}
|
||
|
||
fn referenced_block_tree(doc: &CadDocument, root: &str, anno_scale: f32) -> Vec<String> {
|
||
let mut seen: HashSet<String> = HashSet::default();
|
||
let mut queue = vec![root.to_string()];
|
||
seen.insert(root.to_string());
|
||
while let Some(name) = queue.pop() {
|
||
let Some(record) = crate::scene::render_graph::block_record_by_name(doc, &name) else {
|
||
continue;
|
||
};
|
||
for &handle in &record.entity_handles {
|
||
let Some(entity) = doc.get_entity(handle) else {
|
||
continue;
|
||
};
|
||
for name in block_object_names(doc, entity, anno_scale) {
|
||
if seen.insert(name.clone()) {
|
||
queue.push(name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
seen.into_iter().collect()
|
||
}
|
||
|
||
/// True when `layer` is turned off or frozen — entities on it never render.
|
||
fn layer_hidden(doc: &CadDocument, layer: &str) -> bool {
|
||
doc.layers
|
||
.get(layer)
|
||
.map(|l| l.flags.off || l.flags.frozen)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn nested_instance_transform(nref: &NestedRef, offset: [f64; 3]) -> Transform {
|
||
if offset == [0.0; 3] {
|
||
nref.xform.clone()
|
||
} else {
|
||
Transform::from_translation(Vector3::new(offset[0], offset[1], offset[2])).then(&nref.xform)
|
||
}
|
||
}
|
||
|
||
fn nested_attachment_transform(nref: &NestedRef, offset: [f64; 3]) -> Transform {
|
||
let base = nref.xform.apply(Vector3::ZERO);
|
||
let instance = nested_instance_transform(nref, offset).apply(Vector3::ZERO);
|
||
Transform::from_translation(instance - base)
|
||
}
|
||
|
||
fn build_defn(
|
||
doc: &CadDocument,
|
||
block_name: &str,
|
||
anno_scale: f32,
|
||
annotation_scale_handle: Option<Handle>,
|
||
all_visible: bool,
|
||
bg_color: [f32; 4],
|
||
viewport: Option<Handle>,
|
||
depth_map: &HashMap<u64, [f32; 2]>,
|
||
) -> BlockDefn {
|
||
let br = match crate::scene::render_graph::block_record_by_name(doc, block_name) {
|
||
Some(br) => br,
|
||
None => return BlockDefn::default(),
|
||
};
|
||
|
||
// ── Pass 2: tessellate each sub with the chosen offset so stored
|
||
// coordinates fit into f32 without precision loss.
|
||
let cap = br.entity_handles.len();
|
||
let mut subs: Vec<LocalSub> = Vec::with_capacity(cap);
|
||
for &eh in &br.entity_handles {
|
||
let Some(source_entity) = doc.get_entity(eh) else {
|
||
continue;
|
||
};
|
||
let contextual = crate::scene::annotative::entity_for_annotation_context(
|
||
doc,
|
||
source_entity,
|
||
annotation_scale_handle,
|
||
);
|
||
let entity = contextual.as_ref();
|
||
// Skip entities flagged invisible. Dynamic blocks (e.g. a visibility-
|
||
// state parametric block) keep the geometry for every state in one
|
||
// anonymous block and mark all but the active state's entities
|
||
// invisible — honouring the flag is what shows a single profile
|
||
// instead of every variant stacked on top of each other.
|
||
if entity.common().invisible {
|
||
continue;
|
||
}
|
||
// A sub-entity on a layer that is off or frozen must not render, same
|
||
// as a top-level entity on that layer. The defn cache is rebuilt on
|
||
// every layer off/freeze toggle (bump_geometry bumps block_epoch), so
|
||
// baking the visibility here stays in sync.
|
||
if layer_hidden(doc, &entity.common().layer) {
|
||
continue;
|
||
}
|
||
// Annotative scale representation: bake only the current scale's copy
|
||
// into the defn so off-scale representations don't stack (e.g. a 1×
|
||
// copy under a 10×). See `annotative::annotative_offscale`.
|
||
if crate::scene::annotative::annotative_offscale_for(
|
||
doc,
|
||
entity.common(),
|
||
annotation_scale_handle,
|
||
all_visible,
|
||
) {
|
||
continue;
|
||
}
|
||
let block_uses: Vec<_> =
|
||
crate::scene::render_graph::entity_render_block_uses(doc, entity, anno_scale)
|
||
.into_iter()
|
||
.filter(|block_use| block_use.active)
|
||
.collect();
|
||
let replaces_host = block_uses
|
||
.iter()
|
||
.any(|block_use| block_use.replaces_host_wire);
|
||
for block_use in &block_uses {
|
||
let mut nested = build_nested_ref(
|
||
&block_use.insert,
|
||
block_use.scale_policy,
|
||
doc,
|
||
anno_scale,
|
||
bg_color,
|
||
viewport,
|
||
depth_map,
|
||
);
|
||
nested.suppress_root_points = block_use.suppress_root_points;
|
||
subs.push(LocalSub::Nested(nested));
|
||
}
|
||
if replaces_host {
|
||
continue;
|
||
}
|
||
match entity {
|
||
EntityType::Block(_) | EntityType::BlockEnd(_) => continue,
|
||
// A non-constant ATTDEF is only a template — the insert supplies an
|
||
// ATTRIB with the real value (tessellated separately). A CONSTANT
|
||
// attribute has no ATTRIB; its value lives in the block itself, so
|
||
// it must render as part of the block content (unless flagged
|
||
// invisible). With an empty value there is nothing to draw: the
|
||
// ATTDEF tessellator would fall back to its tag-placeholder
|
||
// preview, which belongs to a standalone definition, not to
|
||
// placed block content.
|
||
EntityType::AttributeDefinition(ad)
|
||
if !ad.flags.constant || ad.flags.invisible || ad.default_value.is_empty() =>
|
||
{
|
||
continue
|
||
}
|
||
_ => {
|
||
// A standalone definition draws its tag; as block content a
|
||
// constant one draws its value.
|
||
let shown;
|
||
let entity = match entity {
|
||
EntityType::AttributeDefinition(ad) => {
|
||
let mut value = ad.clone();
|
||
value.tag = ad.default_value.clone();
|
||
shown = EntityType::AttributeDefinition(value);
|
||
&shown
|
||
}
|
||
other => other,
|
||
};
|
||
// A wide polyline inside a block carries its `world_width` on
|
||
// the LocalWire; `emit_wire` scales it by the insert transform
|
||
// so the shader band matches the scaled geometry (same band the
|
||
// top-level path draws — depth-tested + linetype-dashed).
|
||
for lw in tessellate_sub_local(
|
||
doc,
|
||
entity,
|
||
anno_scale,
|
||
annotation_scale_handle,
|
||
bg_color,
|
||
viewport,
|
||
depth_map,
|
||
) {
|
||
subs.push(LocalSub::Wire(lw));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
BlockDefn {
|
||
subs,
|
||
metrics: BlockMetrics::default(),
|
||
inline_point_cost: None,
|
||
child_count: br.entity_handles.len(),
|
||
}
|
||
}
|
||
|
||
fn inline_wire_point_cost(wire: &LocalWire) -> Option<usize> {
|
||
let line_tangents_only = wire
|
||
.tangent_geoms
|
||
.iter()
|
||
.all(|tangent| matches!(tangent, TangentGeom::Line { .. }));
|
||
(line_tangents_only
|
||
&& !wire.is_point
|
||
&& wire.point_marker.is_none()
|
||
&& wire.text_verts.is_empty()
|
||
&& wire.fill_tris.is_empty()
|
||
&& wire.pick_tris.is_empty()
|
||
&& wire.pattern_stations.is_empty()
|
||
&& wire.world_width == 0.0)
|
||
.then_some(wire.points.len())
|
||
}
|
||
|
||
fn build_nested_ref(
|
||
nested_ins: &codec::entities::Insert,
|
||
scale_policy: crate::scene::BlockScalePolicy,
|
||
doc: &CadDocument,
|
||
anno_scale: f32,
|
||
bg_color: [f32; 4],
|
||
viewport: Option<Handle>,
|
||
depth_map: &HashMap<u64, [f32; 2]>,
|
||
) -> NestedRef {
|
||
// Bake the XCLIP boundary (parent-defn-local) so the nested insert keeps
|
||
// its clip when the parent block is expanded — the spatial filter object
|
||
// isn't reachable at expand time.
|
||
let xform = crate::scene::render_graph::insert_transform_with_policy(
|
||
doc,
|
||
nested_ins,
|
||
anno_scale,
|
||
scale_policy,
|
||
);
|
||
let clip_poly = crate::scene::pick::xclip::insert_spatial_filter(doc, nested_ins)
|
||
.map(|filter| crate::scene::pick::xclip::world_clip_polygon_for_transform(filter, &xform));
|
||
let plot_l0 = crate::scene::view::render::is_effective_layer_zero(&nested_ins.common.layer);
|
||
let plot_visible = doc
|
||
.layers
|
||
.get(&nested_ins.common.layer)
|
||
.map(|layer| layer.is_plottable)
|
||
.unwrap_or(true);
|
||
let attachments = crate::entities::insert::insert_attribute_entities(
|
||
doc,
|
||
nested_ins,
|
||
anno_scale,
|
||
scale_policy,
|
||
)
|
||
.into_iter()
|
||
.flat_map(|attribute| {
|
||
tessellate_sub_local(doc, &attribute, 1.0, None, bg_color, viewport, depth_map)
|
||
})
|
||
.collect();
|
||
|
||
NestedRef {
|
||
block_name: nested_ins.block_name.clone(),
|
||
xform,
|
||
style: crate::scene::render_graph::InsertStyleSpec::new(doc, nested_ins, viewport),
|
||
attachments,
|
||
instance_offsets: crate::scene::render_graph::array_offsets(nested_ins),
|
||
plot_visible,
|
||
plot_l0,
|
||
clip_poly,
|
||
local_rank: depth_map
|
||
.get(&nested_ins.common.handle.value())
|
||
.map_or(0.0, |d| d[0]),
|
||
suppress_root_points: false,
|
||
}
|
||
}
|
||
|
||
fn tessellate_sub_local(
|
||
doc: &CadDocument,
|
||
sub: &EntityType,
|
||
anno_scale: f32,
|
||
annotation_scale_handle: Option<Handle>,
|
||
bg_color: [f32; 4],
|
||
viewport: Option<Handle>,
|
||
depth_map: &HashMap<u64, [f32; 2]>,
|
||
) -> Vec<LocalWire> {
|
||
let h = sub.common().handle;
|
||
|
||
// Sanity guard: skip sub-entities whose primary dimension is so large
|
||
// that adaptive tessellation will explode into hundreds of millions
|
||
// of points. These are typically corrupt-radius primitives that slipped
|
||
// past purge_corrupt_entities (finite but absurd values).
|
||
if is_unreasonable_extent(sub) {
|
||
return vec![];
|
||
}
|
||
|
||
// Store the RAW colour. `Batches::finalize` applies `adapt_to_bg`
|
||
// with the per-render bg, so the cache no longer has to rebuild on
|
||
// BACKGROUND / layout-switch — the dynamic adaptation tracks the
|
||
// live bg at render time.
|
||
let (sub_color, pat_len, pat, lw_px, aci) =
|
||
crate::scene::view::render::render_style_for_viewport(doc, sub, viewport);
|
||
let _ = bg_color;
|
||
|
||
let has_book_color = crate::scene::view::render::has_resolved_book_color(doc, sub);
|
||
let color_is_byblock = !has_book_color && sub.common().color == AcadColor::ByBlock;
|
||
let lt_is_byblock = sub.common().linetype.eq_ignore_ascii_case("byblock");
|
||
let lw_is_byblock = matches!(sub.common().line_weight, LineWeight::ByBlock);
|
||
|
||
// Layer-0 rule: a child on layer "0" with ByLayer properties inherits the
|
||
// INSERT's layer at expand time. Flag each ByLayer property so emit_wire
|
||
// can override the cached (layer-0-resolved) value with the insert layer's.
|
||
let on_l0 = crate::scene::view::render::is_effective_layer_zero(&sub.common().layer);
|
||
let layer_plottable = doc
|
||
.layers
|
||
.get(&sub.common().layer)
|
||
.map(|layer| layer.is_plottable)
|
||
.unwrap_or(true);
|
||
let color_l0 = !has_book_color && on_l0 && sub.common().color == AcadColor::ByLayer;
|
||
let transparency_is_byblock = sub.common().transparency.is_by_block();
|
||
let transparency_l0 = on_l0 && sub.common().transparency.is_by_layer();
|
||
let lt_l0 = on_l0 && {
|
||
let lt = &sub.common().linetype;
|
||
lt.is_empty() || lt.eq_ignore_ascii_case("bylayer")
|
||
};
|
||
let lw_l0 = on_l0
|
||
&& matches!(
|
||
sub.common().line_weight,
|
||
LineWeight::ByLayer | LineWeight::Default
|
||
);
|
||
|
||
// Pass `local_offset` as the f64 world-offset so tessellate subtracts it
|
||
// before casting to f32 — same precision-preservation trick used for
|
||
// top-level entities, applied per-defn.
|
||
let wires_out = if let EntityType::Dimension(dimension) = sub {
|
||
use crate::entities::dimension::DimensionTess;
|
||
// Block-definition members never carry the reference-only
|
||
// designation (it targets top-level geometry — see the API v9
|
||
// known-limit note), so the block cache tessellates with an empty set.
|
||
dimension.tessellate(
|
||
doc,
|
||
h,
|
||
false,
|
||
sub_color,
|
||
lw_px,
|
||
anno_scale,
|
||
&HashSet::default(),
|
||
None,
|
||
bg_color,
|
||
None,
|
||
None,
|
||
&HashSet::default(),
|
||
)
|
||
} else {
|
||
tessellate::tessellate(
|
||
doc,
|
||
h,
|
||
sub,
|
||
false,
|
||
sub_color,
|
||
pat_len,
|
||
pat,
|
||
lw_px,
|
||
anno_scale,
|
||
annotation_scale_handle,
|
||
None,
|
||
bg_color,
|
||
false,
|
||
)
|
||
};
|
||
if wires_out.is_empty() {
|
||
return vec![];
|
||
}
|
||
|
||
let frame_mode =
|
||
crate::scene::frame::entity_kind(sub).map(|kind| crate::scene::frame::mode(doc, kind));
|
||
let mut result = Vec::with_capacity(wires_out.len());
|
||
for wire in wires_out {
|
||
// Per-wire point-count cap: a single wire that exceeds this is skipped
|
||
// rather than aborting the whole sub-entity — with per-wire separation,
|
||
// other colour segments from the same entity still render.
|
||
if wire.points.len() > 100_000 {
|
||
continue;
|
||
}
|
||
|
||
// Geometry is stored absolute; the double-single (high/low) render path
|
||
// keeps it precise at UTM scale, so no per-defn offset is subtracted.
|
||
// SDF text wires have no points/fills — fold in the glyph-quad positions
|
||
// so the view-frustum cull uses the text's real box, not a degenerate
|
||
// point at the block origin (which would drop the text entirely).
|
||
// `pick_tris` is in here for the same reason `fill_tris` is: hit-testing
|
||
// rejects on this box before it looks at the triangles, so a box drawn
|
||
// only around `points` would make a block child's thickness wall or wide
|
||
// polyline band unpickable — `points` merely bounds those.
|
||
let aabb_local = aabb_from_points_iter(
|
||
wire.points
|
||
.iter()
|
||
.copied()
|
||
.chain(wire.fill_tris.iter().copied())
|
||
.chain(wire.pick_tris.iter().copied())
|
||
.chain(wire.text_verts.iter().map(|v| v.pos)),
|
||
);
|
||
let is_fill_only = wire.points.is_empty() && !wire.fill_tris.is_empty();
|
||
let mtext_has_background = matches!(
|
||
sub,
|
||
EntityType::MText(text) if text.background_fill_flags & 0x03 != 0
|
||
);
|
||
let preserve_color = mtext_has_background && is_fill_only;
|
||
let canvas_color = is_fill_only
|
||
&& matches!(
|
||
sub,
|
||
EntityType::MText(text) if text.background_fill_flags & 0x02 != 0
|
||
);
|
||
let contrast_bg =
|
||
if !preserve_color && (!wire.text_verts.is_empty() || !wire.points.is_empty()) {
|
||
tessellate::explicit_mtext_background(sub)
|
||
} else {
|
||
None
|
||
};
|
||
// A wire whose colour differs from the entity's resolved base colour
|
||
// carries an explicit per-segment override (e.g. an MTEXT `\C1;` inline
|
||
// colour). ByBlock / layer-0 inheritance applies only to wires still on
|
||
// the base colour — folding an explicit segment into the inherited
|
||
// colour would collapse colour-split geometry to one colour. (PR #301,
|
||
// Kevin Griffin — extended to SDF text per-vertex in emit_wire.)
|
||
let wire_on_base_color = wire.color == sub_color;
|
||
|
||
result.push(LocalWire {
|
||
points: wire.points,
|
||
points_low: wire.points_low,
|
||
is_point: matches!(sub, EntityType::Point(_)),
|
||
point_marker: wire.point_marker,
|
||
text_verts: wire.text_verts,
|
||
searchable_text: wire.searchable_text,
|
||
key_vertices: wire.key_vertices,
|
||
snap_pts: wire.snap_pts,
|
||
tangent_geoms: wire.tangent_geoms,
|
||
fill_tris: wire.fill_tris,
|
||
fill_tris_low: wire.fill_tris_low,
|
||
fill_is_3d: wire.fill_is_3d,
|
||
fill_is_2d_solid: wire.fill_is_2d_solid,
|
||
pick_tris: wire.pick_tris,
|
||
pick_tris_low: wire.pick_tris_low,
|
||
color: wire.color,
|
||
contrast_bg,
|
||
preserve_color,
|
||
canvas_color,
|
||
aci,
|
||
pattern_length: pat_len,
|
||
pattern: pat,
|
||
line_weight_px: lw_px,
|
||
pattern_stations: wire.pattern_stations,
|
||
world_width: wire.world_width,
|
||
plinegen: wire.plinegen,
|
||
plot_visible: frame_mode.is_none_or(|mode| mode == 1) && (on_l0 || layer_plottable),
|
||
plot_l0: on_l0,
|
||
hide_unselected: frame_mode == Some(0),
|
||
is_fill_only,
|
||
color_is_byblock: color_is_byblock && wire_on_base_color,
|
||
transparency_is_byblock,
|
||
lt_is_byblock,
|
||
lw_is_byblock,
|
||
color_l0: color_l0 && wire_on_base_color,
|
||
transparency_l0,
|
||
lt_l0,
|
||
lw_l0,
|
||
aabb_local,
|
||
local_rank: depth_map.get(&h.value()).map_or(0.0, |d| d[0]),
|
||
});
|
||
}
|
||
result
|
||
}
|
||
|
||
fn aabb_from_points_iter<I: IntoIterator<Item = [f32; 3]>>(pts: I) -> [f32; 4] {
|
||
let mut min_x = f32::INFINITY;
|
||
let mut min_y = f32::INFINITY;
|
||
let mut max_x = f32::NEG_INFINITY;
|
||
let mut max_y = f32::NEG_INFINITY;
|
||
for p in pts {
|
||
if !p[0].is_finite() {
|
||
continue;
|
||
}
|
||
if p[0] < min_x {
|
||
min_x = p[0];
|
||
}
|
||
if p[1] < min_y {
|
||
min_y = p[1];
|
||
}
|
||
if p[0] > max_x {
|
||
max_x = p[0];
|
||
}
|
||
if p[1] > max_y {
|
||
max_y = p[1];
|
||
}
|
||
}
|
||
if min_x.is_infinite() {
|
||
[0.0, 0.0, 0.0, 0.0]
|
||
} else {
|
||
[min_x, min_y, max_x, max_y]
|
||
}
|
||
}
|
||
|
||
/// Transform an absolute XY AABB by `t` and return the world-space XY AABB of
|
||
/// the transformed corners (computed in f64 so it stays accurate for distant
|
||
/// content).
|
||
fn transform_aabb_xy(local: [f32; 4], t: &Transform) -> [f32; 4] {
|
||
let [x0, y0, x1, y1] = local;
|
||
let corners = [
|
||
Vector3::new(x0 as f64, y0 as f64, 0.0),
|
||
Vector3::new(x1 as f64, y0 as f64, 0.0),
|
||
Vector3::new(x1 as f64, y1 as f64, 0.0),
|
||
Vector3::new(x0 as f64, y1 as f64, 0.0),
|
||
];
|
||
let mut min_x = f64::INFINITY;
|
||
let mut min_y = f64::INFINITY;
|
||
let mut max_x = f64::NEG_INFINITY;
|
||
let mut max_y = f64::NEG_INFINITY;
|
||
for c in corners {
|
||
let v = t.apply(c);
|
||
if v.x < min_x {
|
||
min_x = v.x;
|
||
}
|
||
if v.y < min_y {
|
||
min_y = v.y;
|
||
}
|
||
if v.x > max_x {
|
||
max_x = v.x;
|
||
}
|
||
if v.y > max_y {
|
||
max_y = v.y;
|
||
}
|
||
}
|
||
[min_x as f32, min_y as f32, max_x as f32, max_y as f32]
|
||
}
|
||
|
||
fn aabb_union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
|
||
// [0,0,0,0] is the "empty AABB" sentinel produced by aabb_from_points_iter
|
||
// when a wire has no finite points — treat it as if the other side wins.
|
||
if a == [0.0, 0.0, 0.0, 0.0] {
|
||
return b;
|
||
}
|
||
if b == [0.0, 0.0, 0.0, 0.0] {
|
||
return a;
|
||
}
|
||
[
|
||
a[0].min(b[0]),
|
||
a[1].min(b[1]),
|
||
a[2].max(b[2]),
|
||
a[3].max(b[3]),
|
||
]
|
||
}
|
||
|
||
pub fn aabb_disjoint_xy(a: [f32; 4], b: [f32; 4]) -> bool {
|
||
a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]
|
||
}
|
||
|
||
// ── Use-time expansion ───────────────────────────────────────────────────────
|
||
|
||
/// Expand one top-level INSERT into world-space WireModels via the cache.
|
||
///
|
||
/// Returns `None` if no defn is cached for `ins.block_name`. Returns
|
||
/// `Some(empty)` if the defn exists but is empty.
|
||
pub fn expand_insert(
|
||
doc: &CadDocument,
|
||
cache: &BlockCache,
|
||
ins: &codec::entities::Insert,
|
||
ins_handle: Handle,
|
||
ins_resolved_color: [f32; 4],
|
||
ins_aci: u8,
|
||
ins_pat_len: f32,
|
||
ins_pat: [f32; 8],
|
||
ins_lw_px: f32,
|
||
// The INSERT's own layer style — layer-0 inheritance target for children.
|
||
ins_layer: crate::scene::view::render::InheritStyle,
|
||
ins_layer_aci: u8,
|
||
ins_layer_plottable: bool,
|
||
selected: bool,
|
||
pslt_factor: f32,
|
||
// World-space XY view AABB (with world_offset already subtracted, so the
|
||
// comparison is in the same f32 space as emitted wires). `None` disables
|
||
// frustum culling — every cached sub is emitted.
|
||
view_aabb: Option<[f32; 4]>,
|
||
// World units per screen pixel. When `Some`, wires whose AABB projects
|
||
// smaller than `MIN_PIXEL_SIZE` get skipped entirely (LOD).
|
||
world_per_pixel: Option<f32>,
|
||
// True when `ins.block_name` resolves to an xref BlockRecord. All emitted
|
||
// colors are faded toward `bg_color` so xrefs are visually distinguishable
|
||
// from native content.
|
||
is_xref: bool,
|
||
bg_color: [f32; 4],
|
||
// Current annotation scale. An annotative block scales as one uniform unit
|
||
// about its insertion point; a non-annotative block is unaffected.
|
||
anno_scale: f32,
|
||
scale_policy: crate::scene::BlockScalePolicy,
|
||
// Dimension picture blocks store definition POINTs at their root. They are
|
||
// metadata, not visible geometry; nested POINTs remain regular block data.
|
||
suppress_root_points: bool,
|
||
) -> Option<Vec<WireModel>> {
|
||
let defn = cache.defn(&ins.block_name)?;
|
||
let xform = crate::scene::render_graph::insert_transform_with_policy(
|
||
doc,
|
||
ins,
|
||
anno_scale,
|
||
scale_policy,
|
||
);
|
||
let name = ins_handle.value().to_string();
|
||
// Mask-bearing blocks must not reuse translated GPU prototypes: ordinary
|
||
// strokes and wipeout fills have different upload paths. Their shared
|
||
// source_id plus per-copy translated geometry can make a moved INSERT
|
||
// display stale wipeout geometry or disappear as the view changes.
|
||
// Expand these from their definition until both GPU paths share a
|
||
// verified identical instancing convention.
|
||
let has_masks = defn.subs.iter().any(|sub| match sub {
|
||
LocalSub::Wire(wire) => wire.canvas_color,
|
||
LocalSub::Nested(_) => false,
|
||
});
|
||
let prototype_key = if !ins.is_array()
|
||
&& !has_masks
|
||
&& cache.prototype_blocks.contains(&ins.block_name)
|
||
{
|
||
Some(expansion_prototype_key(
|
||
ins,
|
||
&xform,
|
||
ins_resolved_color,
|
||
ins_pat_len,
|
||
ins_pat,
|
||
ins_lw_px,
|
||
ins_layer,
|
||
ins_layer_plottable,
|
||
selected,
|
||
pslt_factor,
|
||
is_xref,
|
||
bg_color,
|
||
anno_scale,
|
||
suppress_root_points,
|
||
))
|
||
} else {
|
||
None
|
||
};
|
||
let prototype_slot = prototype_key.as_ref().map(|key| {
|
||
let mut prototypes = cache
|
||
.expansion_prototypes
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
Arc::clone(
|
||
prototypes
|
||
.entry(key.clone())
|
||
.or_insert_with(|| Arc::new(std::sync::Mutex::new(None))),
|
||
)
|
||
});
|
||
let mut prototype_guard = prototype_slot
|
||
.as_ref()
|
||
.map(|slot| slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()));
|
||
if let Some(cached) = prototype_guard
|
||
.as_ref()
|
||
.and_then(|guard| guard.as_ref())
|
||
.cloned()
|
||
{
|
||
let translation = transform_translation(&xform);
|
||
let delta = [
|
||
translation[0] - cached.translation[0],
|
||
translation[1] - cached.translation[1],
|
||
translation[2] - cached.translation[2],
|
||
];
|
||
return Some(
|
||
cached
|
||
.wires
|
||
.iter()
|
||
.map(|wire| translated_prototype_wire(wire, &name, delta))
|
||
.collect(),
|
||
);
|
||
}
|
||
let mut batches = Batches::default();
|
||
let mut visited: Vec<String> = Vec::with_capacity(8);
|
||
|
||
let offsets = crate::scene::render_graph::array_offsets(ins);
|
||
let cell_xform = |offset: &[f64; 3]| {
|
||
crate::scene::render_graph::insert_instance_transform(
|
||
doc,
|
||
ins,
|
||
*offset,
|
||
anno_scale,
|
||
scale_policy,
|
||
)
|
||
};
|
||
let insert_world = offsets.iter().fold([0.0_f32; 4], |aabb, offset| {
|
||
aabb_union(
|
||
aabb,
|
||
transform_aabb_xy(defn.metrics.aabb_local, &cell_xform(offset)),
|
||
)
|
||
});
|
||
let insert_local = [
|
||
insert_world[0] as f32,
|
||
insert_world[1] as f32,
|
||
insert_world[2] as f32,
|
||
insert_world[3] as f32,
|
||
];
|
||
|
||
// Whole-Insert frustum cull.
|
||
if let Some(view) = view_aabb {
|
||
if aabb_disjoint_xy(insert_local, view) {
|
||
return Some(vec![]);
|
||
}
|
||
}
|
||
// Whole-Insert pixel-size LOD: if the entire Insert footprint projects
|
||
// to sub-pixel size, skip it entirely.
|
||
if let Some(wpp) = world_per_pixel {
|
||
if !defn.metrics.has_point_marker && aabb_pixel_size(insert_local, wpp) < MIN_PIXEL_SIZE {
|
||
return Some(vec![]);
|
||
}
|
||
}
|
||
|
||
let ctx = ExpandCtx {
|
||
cache,
|
||
ins_color: ins_resolved_color,
|
||
ins_aci,
|
||
ins_pat_len,
|
||
ins_pat,
|
||
ins_lw_px,
|
||
l0: ins_layer,
|
||
l0_aci: ins_layer_aci,
|
||
l0_plottable: ins_layer_plottable,
|
||
plot_visible: ins_layer_plottable,
|
||
selected,
|
||
pslt_factor,
|
||
view_aabb: None,
|
||
world_per_pixel: None,
|
||
is_xref,
|
||
bg_color,
|
||
};
|
||
if offsets.len() > 1 {
|
||
let first_xform = cell_xform(&offsets[0]);
|
||
let first_translation = transform_translation(&first_xform);
|
||
let mut first_batches = Batches::default();
|
||
expand_defn(
|
||
defn,
|
||
&first_xform,
|
||
&ctx,
|
||
&mut first_batches,
|
||
&mut visited,
|
||
0,
|
||
suppress_root_points,
|
||
(0.0, 1.0),
|
||
);
|
||
let mut first = first_batches.finalize(&name, selected, bg_color);
|
||
for wire in &mut first {
|
||
if !is_standalone_analytical_curve(wire) && wire.render_instance.is_none() {
|
||
wire.render_instance = Some(crate::scene::model::instance_model::RenderInstance {
|
||
source_id: crate::scene::model::instance_model::next_source_id(),
|
||
translation: first_translation,
|
||
});
|
||
}
|
||
}
|
||
let mut result = first.clone();
|
||
for offset in offsets.iter().skip(1) {
|
||
let translation = transform_translation(&cell_xform(offset));
|
||
let delta = [
|
||
translation[0] - first_translation[0],
|
||
translation[1] - first_translation[1],
|
||
translation[2] - first_translation[2],
|
||
];
|
||
result.extend(
|
||
first
|
||
.iter()
|
||
.map(|wire| translated_prototype_wire(wire, &name, delta)),
|
||
);
|
||
}
|
||
return Some(result);
|
||
}
|
||
|
||
for offset in &offsets {
|
||
let base_xform = cell_xform(offset);
|
||
expand_defn(
|
||
defn,
|
||
&base_xform,
|
||
&ctx,
|
||
&mut batches,
|
||
&mut visited,
|
||
0,
|
||
suppress_root_points,
|
||
(0.0, 1.0),
|
||
);
|
||
}
|
||
let mut wires = batches.finalize(&name, selected, bg_color);
|
||
if let Some(guard) = prototype_guard.as_mut() {
|
||
let translation = transform_translation(&xform);
|
||
for wire in &mut wires {
|
||
if !is_standalone_analytical_curve(wire) && wire.render_instance.is_none() {
|
||
wire.render_instance = Some(crate::scene::model::instance_model::RenderInstance {
|
||
source_id: crate::scene::model::instance_model::next_source_id(),
|
||
translation,
|
||
});
|
||
}
|
||
}
|
||
let cached = Arc::new(CachedExpansion {
|
||
translation,
|
||
wires: Arc::new(wires.clone()),
|
||
});
|
||
**guard = Some(cached);
|
||
}
|
||
Some(wires)
|
||
}
|
||
|
||
fn transform_translation(transform: &Transform) -> [f64; 3] {
|
||
[
|
||
transform.matrix.m[0][3],
|
||
transform.matrix.m[1][3],
|
||
transform.matrix.m[2][3],
|
||
]
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn expansion_prototype_key(
|
||
ins: &codec::entities::Insert,
|
||
transform: &Transform,
|
||
ins_color: [f32; 4],
|
||
ins_pat_len: f32,
|
||
ins_pat: [f32; 8],
|
||
ins_lw_px: f32,
|
||
ins_layer: crate::scene::view::render::InheritStyle,
|
||
ins_layer_plottable: bool,
|
||
selected: bool,
|
||
pslt_factor: f32,
|
||
is_xref: bool,
|
||
bg_color: [f32; 4],
|
||
anno_scale: f32,
|
||
suppress_root_points: bool,
|
||
) -> ExpansionPrototypeKey {
|
||
let matrix = &transform.matrix.m;
|
||
let linear = [
|
||
matrix[0][0].to_bits(),
|
||
matrix[0][1].to_bits(),
|
||
matrix[0][2].to_bits(),
|
||
matrix[1][0].to_bits(),
|
||
matrix[1][1].to_bits(),
|
||
matrix[1][2].to_bits(),
|
||
matrix[2][0].to_bits(),
|
||
matrix[2][1].to_bits(),
|
||
matrix[2][2].to_bits(),
|
||
];
|
||
let mut insert_style = Vec::with_capacity(32);
|
||
insert_style.extend(ins_color.map(f32::to_bits));
|
||
insert_style.push(ins_pat_len.to_bits());
|
||
insert_style.extend(ins_pat.map(f32::to_bits));
|
||
insert_style.push(ins_lw_px.to_bits());
|
||
insert_style.extend(ins_layer.color.map(f32::to_bits));
|
||
insert_style.push(ins_layer.pat_len.to_bits());
|
||
insert_style.extend(ins_layer.pat.map(f32::to_bits));
|
||
insert_style.push(ins_layer.lw_px.to_bits());
|
||
insert_style.push(ins_layer_plottable as u32);
|
||
insert_style.push(pslt_factor.to_bits());
|
||
insert_style.extend(bg_color.map(f32::to_bits));
|
||
insert_style.push(anno_scale.to_bits());
|
||
ExpansionPrototypeKey {
|
||
block_name: ins.block_name.clone(),
|
||
linear,
|
||
insert_style,
|
||
selected,
|
||
is_xref,
|
||
suppress_root_points,
|
||
}
|
||
}
|
||
|
||
fn translated_prototype_wire(source: &WireModel, name: &str, delta: [f64; 3]) -> WireModel {
|
||
let mut wire = source.clone();
|
||
wire.name = name.to_string();
|
||
if let Some(instance) = wire.render_instance.as_mut() {
|
||
for axis in 0..3 {
|
||
instance.translation[axis] += delta[axis];
|
||
}
|
||
}
|
||
translate_double_single(&mut wire.points, &mut wire.points_low, delta);
|
||
translate_double_single(&mut wire.fill_tris, &mut wire.fill_tris_low, delta);
|
||
translate_double_single(&mut wire.pick_tris, &mut wire.pick_tris_low, delta);
|
||
if let Some(marker) = &mut wire.point_marker {
|
||
marker.origin += glam::DVec3::from_array(delta);
|
||
}
|
||
for (point, _) in &mut wire.snap_pts {
|
||
point.x += delta[0];
|
||
point.y += delta[1];
|
||
point.z += delta[2];
|
||
}
|
||
for point in &mut wire.key_vertices {
|
||
point[0] += delta[0];
|
||
point[1] += delta[1];
|
||
point[2] += delta[2];
|
||
}
|
||
let delta_f32 = [delta[0] as f32, delta[1] as f32, delta[2] as f32];
|
||
for tangent in &mut wire.tangent_geoms {
|
||
match tangent {
|
||
TangentGeom::Line { p1, p2 } => {
|
||
for axis in 0..3 {
|
||
p1[axis] += delta_f32[axis];
|
||
p2[axis] += delta_f32[axis];
|
||
}
|
||
}
|
||
TangentGeom::Circle { center, .. } => {
|
||
for axis in 0..3 {
|
||
center[axis] += delta_f32[axis];
|
||
}
|
||
}
|
||
TangentGeom::PlanarCircle { center, .. }
|
||
| TangentGeom::Arc { center, .. }
|
||
| TangentGeom::PlanarEllipse { center, .. } => {
|
||
for axis in 0..3 {
|
||
center[axis] += delta[axis];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
wire.map_text_layout(
|
||
&|p| [p[0] + delta[0], p[1] + delta[1], p[2] + delta[2]],
|
||
1.0,
|
||
0.0,
|
||
);
|
||
if wire.aabb != WireModel::UNBOUNDED_AABB {
|
||
wire.aabb[0] += delta_f32[0];
|
||
wire.aabb[1] += delta_f32[1];
|
||
wire.aabb[2] += delta_f32[0];
|
||
wire.aabb[3] += delta_f32[1];
|
||
}
|
||
wire
|
||
}
|
||
|
||
fn translate_double_single(points: &mut [[f32; 3]], lows: &mut Vec<[f32; 3]>, delta: [f64; 3]) {
|
||
if points.is_empty() {
|
||
return;
|
||
}
|
||
if lows.len() != points.len() {
|
||
lows.resize(points.len(), [0.0; 3]);
|
||
}
|
||
for (point, low) in points.iter_mut().zip(lows.iter_mut()) {
|
||
for axis in 0..3 {
|
||
let value = point[axis] as f64 + low[axis] as f64 + delta[axis];
|
||
let high = value as f32;
|
||
point[axis] = high;
|
||
low[axis] = (value - high as f64) as f32;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn aabb_pixel_size(local_aabb: [f32; 4], world_per_pixel: f32) -> f32 {
|
||
let w = (local_aabb[2] - local_aabb[0]).abs();
|
||
let h = (local_aabb[3] - local_aabb[1]).abs();
|
||
w.max(h) / world_per_pixel
|
||
}
|
||
|
||
fn is_standalone_analytical_curve(wire: &WireModel) -> bool {
|
||
wire.tangent_geoms.len() == 1
|
||
&& wire.fill_tris.is_empty()
|
||
&& wire.pick_tris.is_empty()
|
||
&& wire.text_verts.is_empty()
|
||
&& matches!(
|
||
wire.tangent_geoms[0],
|
||
TangentGeom::Circle { .. }
|
||
| TangentGeom::PlanarCircle { .. }
|
||
| TangentGeom::Arc { .. }
|
||
| TangentGeom::PlanarEllipse { .. }
|
||
)
|
||
}
|
||
|
||
struct ExpandCtx<'a> {
|
||
cache: &'a BlockCache,
|
||
ins_color: [f32; 4],
|
||
ins_aci: u8,
|
||
ins_pat_len: f32,
|
||
ins_pat: [f32; 8],
|
||
ins_lw_px: f32,
|
||
/// Layer-0 inheritance target — the current INSERT's *layer* style, used
|
||
/// for child wires on layer "0" whose properties are ByLayer.
|
||
l0: crate::scene::view::render::InheritStyle,
|
||
l0_aci: u8,
|
||
l0_plottable: bool,
|
||
plot_visible: bool,
|
||
selected: bool,
|
||
pslt_factor: f32,
|
||
// World-space XY view AABB (post world_offset). `None` = no culling.
|
||
view_aabb: Option<[f32; 4]>,
|
||
// World units per screen pixel. `None` = no pixel-size LOD.
|
||
world_per_pixel: Option<f32>,
|
||
// True when this expansion descends from an xref INSERT. Causes emitted
|
||
// colors to be faded toward `bg_color` so the user can tell at a glance
|
||
// which geometry comes from an external reference.
|
||
is_xref: bool,
|
||
bg_color: [f32; 4],
|
||
}
|
||
|
||
fn nested_prototype_key(
|
||
block_name: &str,
|
||
transform: &Transform,
|
||
ctx: &ExpandCtx<'_>,
|
||
depth_scale: f32,
|
||
suppress_root_points: bool,
|
||
) -> NestedPrototypeKey {
|
||
let matrix = &transform.matrix.m;
|
||
let linear = [
|
||
matrix[0][0].to_bits(),
|
||
matrix[0][1].to_bits(),
|
||
matrix[0][2].to_bits(),
|
||
matrix[1][0].to_bits(),
|
||
matrix[1][1].to_bits(),
|
||
matrix[1][2].to_bits(),
|
||
matrix[2][0].to_bits(),
|
||
matrix[2][1].to_bits(),
|
||
matrix[2][2].to_bits(),
|
||
];
|
||
let mut style = Vec::with_capacity(32);
|
||
style.extend(ctx.ins_color.map(f32::to_bits));
|
||
style.push(ctx.ins_pat_len.to_bits());
|
||
style.extend(ctx.ins_pat.map(f32::to_bits));
|
||
style.push(ctx.ins_lw_px.to_bits());
|
||
style.extend(ctx.l0.color.map(f32::to_bits));
|
||
style.push(ctx.l0.pat_len.to_bits());
|
||
style.extend(ctx.l0.pat.map(f32::to_bits));
|
||
style.push(ctx.l0.lw_px.to_bits());
|
||
style.push(ctx.l0_plottable as u32);
|
||
style.push(ctx.plot_visible as u32);
|
||
style.push(ctx.pslt_factor.to_bits());
|
||
style.extend(ctx.bg_color.map(f32::to_bits));
|
||
NestedPrototypeKey {
|
||
block_name: block_name.to_string(),
|
||
linear,
|
||
style,
|
||
selected: ctx.selected,
|
||
is_xref: ctx.is_xref,
|
||
depth_scale: depth_scale.to_bits(),
|
||
suppress_root_points,
|
||
}
|
||
}
|
||
|
||
/// Fade `color` toward `bg` by 50%, preserving alpha. Used to mark xref
|
||
/// geometry — the hue stays recognizable but the contrast against the
|
||
/// background drops, reading as "washed out".
|
||
pub(crate) fn fade_toward_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
|
||
const T: f32 = 0.5;
|
||
[
|
||
color[0] * (1.0 - T) + bg[0] * T,
|
||
color[1] * (1.0 - T) + bg[1] * T,
|
||
color[2] * (1.0 - T) + bg[2] * T,
|
||
color[3],
|
||
]
|
||
}
|
||
|
||
/// XDWGFADECTL: how far referenced drawings fade toward the background, in
|
||
/// percent (0–90); zero or negative shows them unfaded.
|
||
static XREF_FADE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(50);
|
||
|
||
pub fn xref_fade_ctl() -> i32 {
|
||
XREF_FADE.load(std::sync::atomic::Ordering::Relaxed)
|
||
}
|
||
|
||
pub fn set_xref_fade_ctl(value: i32) {
|
||
XREF_FADE.store(value.clamp(-90, 90), std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
|
||
/// A referenced drawing's colour faded by XDWGFADECTL.
|
||
pub(crate) fn xref_fade(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
|
||
let t = xref_fade_ctl().clamp(0, 90) as f32 / 100.0;
|
||
[
|
||
color[0] * (1.0 - t) + bg[0] * t,
|
||
color[1] * (1.0 - t) + bg[1] * t,
|
||
color[2] * (1.0 - t) + bg[2] * t,
|
||
color[3],
|
||
]
|
||
}
|
||
|
||
/// Style fingerprint used to group local wires into a single GPU buffer.
|
||
/// f32 fields are bit-cast to u32 to make the key Hash + Eq.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||
struct StyleKey {
|
||
color: [u32; 4],
|
||
contrast_bg: Option<[u32; 4]>,
|
||
preserve_color: bool,
|
||
canvas_color: bool,
|
||
pattern_length: u32,
|
||
pattern: [u32; 8],
|
||
line_weight_px: u32,
|
||
source_length: u32,
|
||
/// Wide-polyline band width (bit-cast). Keeps bands of different widths — and
|
||
/// bands vs thin wires of the same colour/style — in separate batches so the
|
||
/// finalized WireModel carries one correct `world_width`.
|
||
world_width: u32,
|
||
point_marker: Option<PointMarkerKey>,
|
||
aci: u8,
|
||
plinegen: bool,
|
||
/// Marks batches that emit only `fill_tris` with no wire `points`. The
|
||
/// face3d pipeline uses `wire.points.is_empty()` as the "skip dim"
|
||
/// discriminator, so greek fills must stay in their own batches even
|
||
/// when their color/style would otherwise collide with regular wires.
|
||
is_fill_only: bool,
|
||
/// Part of the batch key so planar SOLID fills remain independently
|
||
/// switchable after block geometry is merged by style.
|
||
fill_is_2d_solid: bool,
|
||
fill_is_3d: bool,
|
||
plot_visible: bool,
|
||
hide_unselected: bool,
|
||
/// Bit-cast composed block-local depth for band wires (`0` = no override).
|
||
/// Keeps bands of different in-block draw ranks in separate batches so
|
||
/// each finalized WireModel carries one correct `depth_override`.
|
||
depth_bits: u32,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
struct PointMarkerKey {
|
||
origin: [u64; 3],
|
||
normal: [u64; 3],
|
||
axis_x: [u64; 3],
|
||
axis_y: [u64; 3],
|
||
viewport_percent: u32,
|
||
}
|
||
|
||
#[derive(Default, Debug)]
|
||
struct BatchEntry {
|
||
color: [f32; 4],
|
||
contrast_bg: Option<[f32; 4]>,
|
||
preserve_color: bool,
|
||
canvas_color: bool,
|
||
pattern_length: f32,
|
||
pattern: [f32; 8],
|
||
line_weight_px: f32,
|
||
pattern_stations: Vec<f32>,
|
||
pattern_station_segments: Vec<i32>,
|
||
pattern_station_pieces: Vec<PatternStationPiece>,
|
||
source_length: f32,
|
||
world_width: f32,
|
||
point_marker: Option<PointMarker>,
|
||
aci: u8,
|
||
plinegen: bool,
|
||
/// Composed block-local draw-order offset for a band batch (see
|
||
/// `WireModel::depth_override`). `None` for every other batch.
|
||
local_depth: Option<f32>,
|
||
points: Vec<[f32; 3]>,
|
||
points_low: Vec<[f32; 3]>,
|
||
snap_pts: Vec<(glam::DVec3, SnapHint)>,
|
||
key_vertices: Vec<[f64; 3]>,
|
||
tangent_geoms: Vec<TangentGeom>,
|
||
fill_tris: Vec<[f32; 3]>,
|
||
/// Double-single low residual paired with `fill_tris`, so block fills stay
|
||
/// precise at UTM-scale coordinates (the renderer's relative-to-eye path
|
||
/// reconstructs `high + low`). Without it absolute f32 fills quantize to
|
||
/// ~0.5 m and the greek-text rectangles shear.
|
||
fill_tris_low: Vec<[f32; 3]>,
|
||
fill_is_3d: bool,
|
||
fill_is_2d_solid: bool,
|
||
plot_visible: bool,
|
||
hide_unselected: bool,
|
||
/// Accumulated thickness-wall pick geometry, paired high/low like
|
||
/// `fill_tris`. Pick-only — no GPU batch reads this.
|
||
pick_tris: Vec<[f32; 3]>,
|
||
pick_tris_low: Vec<[f32; 3]>,
|
||
/// Accumulated SDF glyph quads (world space) for block-instance text.
|
||
text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
|
||
searchable_text: Vec<crate::scene::model::wire_model::SearchableTextRun>,
|
||
min_x: f32,
|
||
min_y: f32,
|
||
max_x: f32,
|
||
max_y: f32,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||
struct NestedPrototypeKey {
|
||
block_name: String,
|
||
linear: [u64; 9],
|
||
style: Vec<u32>,
|
||
selected: bool,
|
||
is_xref: bool,
|
||
depth_scale: u32,
|
||
suppress_root_points: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct NestedPrototype {
|
||
translation: [f64; 3],
|
||
depth_base: f32,
|
||
wires: Arc<Vec<WireModel>>,
|
||
}
|
||
|
||
/// Hard cap on point count for a single batched WireModel. Above this the
|
||
/// current batch is finalized (pushed into `closed`) and a fresh one is
|
||
/// started under the same style. Each WireModel point becomes ~6 GPU
|
||
/// vertices of 96 bytes — 200k points fits well under wgpu's 256 MB
|
||
/// per-buffer ceiling.
|
||
const MAX_POINTS_PER_BATCH: usize = 200_000;
|
||
|
||
#[derive(Default, Debug)]
|
||
struct Batches {
|
||
by_style: HashMap<StyleKey, BatchEntry>,
|
||
/// Batches that overflowed `MAX_POINTS_PER_BATCH` and have been closed.
|
||
closed: Vec<BatchEntry>,
|
||
/// Already-finalized wires that bypass the point batcher — currently the
|
||
/// clipped output of an XCLIP'd nested insert, which is produced as whole
|
||
/// WireModels by `clip_wires`. Appended verbatim at `finalize` (only their
|
||
/// name/selected flag are stamped to match the host insert).
|
||
extra_wires: Vec<WireModel>,
|
||
nested_prototypes: HashMap<NestedPrototypeKey, NestedPrototype>,
|
||
}
|
||
|
||
impl BatchEntry {
|
||
fn new(
|
||
color: [f32; 4],
|
||
contrast_bg: Option<[f32; 4]>,
|
||
preserve_color: bool,
|
||
canvas_color: bool,
|
||
pat_len: f32,
|
||
pat: [f32; 8],
|
||
lw_px: f32,
|
||
source_length: f32,
|
||
world_width: f32,
|
||
point_marker: Option<PointMarker>,
|
||
aci: u8,
|
||
plinegen: bool,
|
||
_is_fill_only: bool,
|
||
fill_is_2d_solid: bool,
|
||
fill_is_3d: bool,
|
||
plot_visible: bool,
|
||
hide_unselected: bool,
|
||
) -> Self {
|
||
// `is_fill_only` is part of the StyleKey hash so greek fills never
|
||
// share a batch with regular wires (otherwise the finalized
|
||
// WireModel would have both `points` and `fill_tris`, defeating
|
||
// the face3d-dim discriminator). It isn't stored on the entry
|
||
// itself — the empty `points` field is enough at finalize time.
|
||
Self {
|
||
color,
|
||
contrast_bg,
|
||
preserve_color,
|
||
canvas_color,
|
||
pattern_length: pat_len,
|
||
pattern: pat,
|
||
line_weight_px: lw_px,
|
||
source_length,
|
||
world_width,
|
||
point_marker,
|
||
aci,
|
||
plinegen,
|
||
fill_is_2d_solid,
|
||
fill_is_3d,
|
||
plot_visible,
|
||
hide_unselected,
|
||
min_x: f32::INFINITY,
|
||
min_y: f32::INFINITY,
|
||
max_x: f32::NEG_INFINITY,
|
||
max_y: f32::NEG_INFINITY,
|
||
..Default::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Batches {
|
||
fn finalize(self, name: &str, selected: bool, bg_color: [f32; 4]) -> Vec<WireModel> {
|
||
let extra = self.extra_wires;
|
||
let mut out: Vec<WireModel> = self
|
||
.closed
|
||
.into_iter()
|
||
.chain(self.by_style.into_values())
|
||
.map(|mut b| {
|
||
if b.hide_unselected && !selected {
|
||
b.points.clear();
|
||
b.points_low.clear();
|
||
b.pattern_stations.clear();
|
||
b.pattern_station_segments.clear();
|
||
b.pattern_station_pieces.clear();
|
||
}
|
||
let aabb = if b.min_x.is_infinite() {
|
||
WireModel::UNBOUNDED_AABB
|
||
} else {
|
||
[b.min_x, b.min_y, b.max_x, b.max_y]
|
||
};
|
||
let contrast_bg = b.contrast_bg.unwrap_or(bg_color);
|
||
// Record what this resolution consumed, so switching layout can
|
||
// redo it for another background instead of re-running the
|
||
// tessellation that produced identical geometry. Skipped when
|
||
// the resolution is a no-op — a preserved colour with no canvas
|
||
// fill looks the same under every background.
|
||
let bg_adapt: crate::scene::model::wire_model::BgAdapt =
|
||
(b.canvas_color || !b.preserve_color).then(|| {
|
||
Box::new(crate::scene::model::wire_model::BgAdaptInputs {
|
||
raw_color: b.color,
|
||
text_raw_colors: if b.preserve_color {
|
||
Vec::new()
|
||
} else {
|
||
b.text_verts.iter().map(|v| v.color).collect()
|
||
},
|
||
contrast_bg: b.contrast_bg,
|
||
canvas_color: b.canvas_color,
|
||
preserve_color: b.preserve_color,
|
||
})
|
||
});
|
||
let color = if b.canvas_color {
|
||
bg_color
|
||
} else if b.preserve_color {
|
||
b.color
|
||
} else {
|
||
crate::scene::view::render::adapt_to_bg(b.color, contrast_bg)
|
||
};
|
||
if !b.preserve_color {
|
||
for vertex in &mut b.text_verts {
|
||
vertex.color =
|
||
crate::scene::view::render::adapt_to_bg(vertex.color, contrast_bg);
|
||
}
|
||
}
|
||
// Glyph quads stay at neutral depth on purpose: the per-wire
|
||
// depth_override (b.local_depth) is composed with the insert's
|
||
// scene-graph level at upload time (wire_draw_depth:
|
||
// depths[insert] + override * half), the same place the
|
||
// wipeout/hatch fills get theirs. Baking the raw child rank
|
||
// into the vertices here would double-count it in per-block
|
||
// label units — for a negative rank that sinks the text below
|
||
// its own block's wipes instead of ahead of them.
|
||
if !b.pattern_stations.is_empty() {
|
||
b.pattern_stations = encode_pattern_stations(
|
||
std::mem::take(&mut b.pattern_stations),
|
||
b.source_length,
|
||
&b.pattern_station_segments,
|
||
&b.pattern_station_pieces,
|
||
);
|
||
}
|
||
WireModel {
|
||
bg_adapt,
|
||
point_marker: b.point_marker,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: b.pattern_stations,
|
||
world_width: b.world_width,
|
||
depth_override: b.local_depth,
|
||
display_visible: !b.hide_unselected || selected,
|
||
snap_only: false,
|
||
reference_only: false,
|
||
plot_visible: b.plot_visible,
|
||
fill_is_3d: b.fill_is_3d,
|
||
fill_is_2d_solid: b.fill_is_2d_solid,
|
||
render_instance: None,
|
||
pick_tris: b.pick_tris,
|
||
pick_tris_low: b.pick_tris_low,
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: b.text_verts,
|
||
searchable_text: b.searchable_text,
|
||
name: name.to_string(),
|
||
points: b.points,
|
||
points_low: b.points_low,
|
||
color,
|
||
selected,
|
||
pattern_length: b.pattern_length,
|
||
pattern: b.pattern,
|
||
line_weight_px: b.line_weight_px,
|
||
aci: b.aci,
|
||
snap_pts: b.snap_pts,
|
||
tangent_geoms: b.tangent_geoms,
|
||
key_vertices: b.key_vertices,
|
||
aabb,
|
||
plinegen: b.plinegen,
|
||
fill_tris: b.fill_tris,
|
||
fill_tris_low: b.fill_tris_low,
|
||
}
|
||
})
|
||
.collect();
|
||
// Clipped nested-insert wires are already whole WireModels; stamp the
|
||
// host insert's name/selected so picking maps them back correctly.
|
||
for mut w in extra {
|
||
w.name = name.to_string();
|
||
w.selected = selected;
|
||
out.push(w);
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
fn style_key(
|
||
color: [f32; 4],
|
||
contrast_bg: Option<[f32; 4]>,
|
||
preserve_color: bool,
|
||
canvas_color: bool,
|
||
pat_len: f32,
|
||
pat: [f32; 8],
|
||
lw_px: f32,
|
||
source_length: f32,
|
||
world_width: f32,
|
||
point_marker: Option<PointMarker>,
|
||
aci: u8,
|
||
plinegen: bool,
|
||
is_fill_only: bool,
|
||
fill_is_2d_solid: bool,
|
||
fill_is_3d: bool,
|
||
plot_visible: bool,
|
||
hide_unselected: bool,
|
||
local_depth: Option<f32>,
|
||
) -> StyleKey {
|
||
StyleKey {
|
||
color: [
|
||
color[0].to_bits(),
|
||
color[1].to_bits(),
|
||
color[2].to_bits(),
|
||
color[3].to_bits(),
|
||
],
|
||
contrast_bg: contrast_bg.map(|color| color.map(f32::to_bits)),
|
||
preserve_color,
|
||
canvas_color,
|
||
pattern_length: pat_len.to_bits(),
|
||
pattern: [
|
||
pat[0].to_bits(),
|
||
pat[1].to_bits(),
|
||
pat[2].to_bits(),
|
||
pat[3].to_bits(),
|
||
pat[4].to_bits(),
|
||
pat[5].to_bits(),
|
||
pat[6].to_bits(),
|
||
pat[7].to_bits(),
|
||
],
|
||
line_weight_px: lw_px.to_bits(),
|
||
source_length: source_length.to_bits(),
|
||
world_width: world_width.to_bits(),
|
||
point_marker: point_marker.map(|marker| PointMarkerKey {
|
||
origin: marker.origin.to_array().map(f64::to_bits),
|
||
normal: marker.normal.to_array().map(f64::to_bits),
|
||
axis_x: marker.axis_x.to_array().map(f64::to_bits),
|
||
axis_y: marker.axis_y.to_array().map(f64::to_bits),
|
||
viewport_percent: marker.viewport_percent.to_bits(),
|
||
}),
|
||
aci,
|
||
plinegen,
|
||
is_fill_only,
|
||
fill_is_2d_solid,
|
||
fill_is_3d,
|
||
plot_visible,
|
||
hide_unselected,
|
||
depth_bits: local_depth.map_or(0, f32::to_bits),
|
||
}
|
||
}
|
||
|
||
fn expand_defn(
|
||
defn: &BlockDefn,
|
||
accum_xform: &Transform,
|
||
ctx: &ExpandCtx,
|
||
out: &mut Batches,
|
||
visited: &mut Vec<String>,
|
||
depth: usize,
|
||
suppress_root_points: bool,
|
||
// Block-local depth sub-range `(base, scale)` accumulated through nested
|
||
// inserts: a child at rank r lands at `base + r * scale`, all within
|
||
// (-1,1) of the top-level insert. Seeded `(0.0, 1.0)` by `expand_insert`.
|
||
d_range: (f32, f32),
|
||
) {
|
||
if depth > MAX_NESTING_DEPTH {
|
||
eprintln!("block_cache: nested-block depth > {MAX_NESTING_DEPTH}, truncating");
|
||
return;
|
||
}
|
||
for sub in &defn.subs {
|
||
match sub {
|
||
LocalSub::Wire(lw) => {
|
||
if suppress_root_points && lw.is_point {
|
||
continue;
|
||
}
|
||
// `lw.aabb_local` is in the defn's offset frame; re-add
|
||
// `defn_lo` (in f64) before composing with `accum_xform`
|
||
// so culling uses correct world-space corners.
|
||
let world = transform_aabb_xy(lw.aabb_local, accum_xform);
|
||
let local = [
|
||
world[0] as f32,
|
||
world[1] as f32,
|
||
world[2] as f32,
|
||
world[3] as f32,
|
||
];
|
||
if let Some(view) = ctx.view_aabb {
|
||
if aabb_disjoint_xy(local, view) {
|
||
continue;
|
||
}
|
||
}
|
||
if let Some(wpp) = ctx.world_per_pixel {
|
||
// SDF text wires carry glyph quads but no points; they
|
||
// render at every zoom (no text LOD), so exempt them from
|
||
// the sub-pixel cull that drops tiny stroke / fill geometry.
|
||
let is_text = !lw.text_verts.is_empty();
|
||
if !is_text && aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
|
||
continue;
|
||
}
|
||
}
|
||
emit_wire(lw, accum_xform, ctx, out, d_range);
|
||
}
|
||
LocalSub::Nested(nref) => {
|
||
let parent_style = crate::scene::render_graph::BlockStyle {
|
||
insert: (
|
||
ctx.ins_color,
|
||
ctx.ins_pat_len,
|
||
ctx.ins_pat,
|
||
ctx.ins_lw_px,
|
||
ctx.ins_aci,
|
||
),
|
||
layer0: ctx.l0,
|
||
layer0_aci: ctx.l0_aci,
|
||
};
|
||
let nested_style = nref.style.resolve(parent_style);
|
||
let nested_layer_plottable = if nref.plot_l0 {
|
||
ctx.l0_plottable
|
||
} else {
|
||
nref.plot_visible
|
||
};
|
||
let inner_ctx = ExpandCtx {
|
||
cache: ctx.cache,
|
||
ins_color: nested_style.insert.0,
|
||
ins_aci: nested_style.insert.4,
|
||
ins_pat_len: nested_style.insert.1,
|
||
ins_pat: nested_style.insert.2,
|
||
ins_lw_px: nested_style.insert.3,
|
||
l0: nested_style.layer0,
|
||
l0_aci: nested_style.layer0_aci,
|
||
l0_plottable: nested_layer_plottable,
|
||
plot_visible: ctx.plot_visible && nested_layer_plottable,
|
||
selected: ctx.selected,
|
||
pslt_factor: ctx.pslt_factor,
|
||
view_aabb: ctx.view_aabb,
|
||
world_per_pixel: ctx.world_per_pixel,
|
||
is_xref: ctx.is_xref,
|
||
bg_color: ctx.bg_color,
|
||
};
|
||
for offset in &nref.instance_offsets {
|
||
let attachment_transform =
|
||
nested_attachment_transform(nref, *offset).then(accum_xform);
|
||
for attachment in &nref.attachments {
|
||
emit_wire(attachment, &attachment_transform, &inner_ctx, out, d_range);
|
||
}
|
||
}
|
||
if visited.iter().any(|n| n == &nref.block_name) {
|
||
continue;
|
||
}
|
||
let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else {
|
||
continue;
|
||
};
|
||
let world = nref
|
||
.instance_offsets
|
||
.iter()
|
||
.fold([0.0_f32; 4], |aabb, offset| {
|
||
let composed = nested_instance_transform(nref, *offset).then(accum_xform);
|
||
aabb_union(
|
||
aabb,
|
||
transform_aabb_xy(nested_defn.metrics.aabb_local, &composed),
|
||
)
|
||
});
|
||
let local = [
|
||
world[0] as f32,
|
||
world[1] as f32,
|
||
world[2] as f32,
|
||
world[3] as f32,
|
||
];
|
||
if let Some(view) = ctx.view_aabb {
|
||
if aabb_disjoint_xy(local, view) {
|
||
continue;
|
||
}
|
||
}
|
||
if let Some(wpp) = ctx.world_per_pixel {
|
||
if aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
|
||
continue;
|
||
}
|
||
}
|
||
visited.push(nref.block_name.clone());
|
||
// Children of this nested insert stack inside the slot its own
|
||
// rank owns — same composition the scene graph applies.
|
||
let nested_range = (
|
||
d_range.0 + nref.local_rank * d_range.1,
|
||
d_range.1 / (nested_defn.child_count.max(1) as f32 + 1.0),
|
||
);
|
||
let composed_for =
|
||
|offset: &[f64; 3]| nested_instance_transform(nref, *offset).then(accum_xform);
|
||
if nested_defn.inline_point_cost.is_some() && nref.clip_poly.is_none() {
|
||
for offset in &nref.instance_offsets {
|
||
expand_defn(
|
||
nested_defn,
|
||
&composed_for(offset),
|
||
&inner_ctx,
|
||
out,
|
||
visited,
|
||
depth + 1,
|
||
nref.suppress_root_points,
|
||
nested_range,
|
||
);
|
||
}
|
||
} else if let Some(cp) = &nref.clip_poly {
|
||
let base_composed = nref.xform.then(accum_xform);
|
||
let base_translation = transform_translation(&base_composed);
|
||
let base_poly: Vec<[f64; 2]> = cp
|
||
.iter()
|
||
.map(|&[x, y]| {
|
||
let w = accum_xform.apply(Vector3::new(x, y, 0.0));
|
||
[w.x, w.y]
|
||
})
|
||
.collect();
|
||
let mut first: Option<(Vec<WireModel>, [f64; 3])> = None;
|
||
for offset in &nref.instance_offsets {
|
||
let composed = composed_for(offset);
|
||
let translation = transform_translation(&composed);
|
||
if let Some((source, source_translation)) = &first {
|
||
let delta = [
|
||
translation[0] - source_translation[0],
|
||
translation[1] - source_translation[1],
|
||
translation[2] - source_translation[2],
|
||
];
|
||
out.extra_wires.extend(
|
||
source
|
||
.iter()
|
||
.map(|wire| translated_prototype_wire(wire, "", delta)),
|
||
);
|
||
continue;
|
||
}
|
||
let mut sub = Batches::default();
|
||
expand_defn(
|
||
nested_defn,
|
||
&composed,
|
||
&inner_ctx,
|
||
&mut sub,
|
||
visited,
|
||
depth + 1,
|
||
nref.suppress_root_points,
|
||
nested_range,
|
||
);
|
||
let mut wires = sub.finalize("", ctx.selected, ctx.bg_color);
|
||
let poly_delta = [
|
||
translation[0] - base_translation[0],
|
||
translation[1] - base_translation[1],
|
||
];
|
||
let world_poly: Vec<[f64; 2]> = base_poly
|
||
.iter()
|
||
.map(|point| [point[0] + poly_delta[0], point[1] + poly_delta[1]])
|
||
.collect();
|
||
crate::scene::pick::xclip::clip_wires(&mut wires, &world_poly);
|
||
for wire in &mut wires {
|
||
if !is_standalone_analytical_curve(wire) {
|
||
wire.render_instance =
|
||
Some(crate::scene::model::instance_model::RenderInstance {
|
||
source_id:
|
||
crate::scene::model::instance_model::next_source_id(),
|
||
translation,
|
||
});
|
||
}
|
||
}
|
||
out.extra_wires.extend(wires.iter().cloned());
|
||
first = Some((wires, translation));
|
||
}
|
||
} else {
|
||
for offset in &nref.instance_offsets {
|
||
let composed = composed_for(offset);
|
||
let translation = transform_translation(&composed);
|
||
let key = nested_prototype_key(
|
||
&nref.block_name,
|
||
&composed,
|
||
&inner_ctx,
|
||
nested_range.1,
|
||
nref.suppress_root_points,
|
||
);
|
||
if let Some(cached) = out.nested_prototypes.get(&key).cloned() {
|
||
let delta = [
|
||
translation[0] - cached.translation[0],
|
||
translation[1] - cached.translation[1],
|
||
translation[2] - cached.translation[2],
|
||
];
|
||
let depth_delta = nested_range.0 - cached.depth_base;
|
||
out.extra_wires.extend(cached.wires.iter().map(|wire| {
|
||
let mut wire = translated_prototype_wire(wire, "", delta);
|
||
if let Some(depth) = wire.depth_override.as_mut() {
|
||
*depth += depth_delta;
|
||
}
|
||
wire
|
||
}));
|
||
} else {
|
||
let mut sub = Batches::default();
|
||
expand_defn(
|
||
nested_defn,
|
||
&composed,
|
||
&inner_ctx,
|
||
&mut sub,
|
||
visited,
|
||
depth + 1,
|
||
nref.suppress_root_points,
|
||
nested_range,
|
||
);
|
||
let mut wires = sub.finalize("", ctx.selected, ctx.bg_color);
|
||
for wire in &mut wires {
|
||
if !is_standalone_analytical_curve(wire)
|
||
&& wire.render_instance.is_none()
|
||
{
|
||
wire.render_instance =
|
||
Some(crate::scene::model::instance_model::RenderInstance {
|
||
source_id:
|
||
crate::scene::model::instance_model::next_source_id(
|
||
),
|
||
translation,
|
||
});
|
||
}
|
||
}
|
||
out.extra_wires.extend(wires.iter().cloned());
|
||
out.nested_prototypes.insert(
|
||
key,
|
||
NestedPrototype {
|
||
translation,
|
||
depth_base: nested_range.0,
|
||
wires: Arc::new(wires),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
visited.pop();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resolve a cached LocalWire's final colour against the current expansion
|
||
/// context: selection override first, then ByBlock → insert colour, then the
|
||
/// layer-0 rule → insert-layer colour, else the cached colour; finally xref
|
||
/// fade. Shared by the stroke, fill, and greeked-text emit paths.
|
||
fn resolve_wire_color(lw: &LocalWire, ctx: &ExpandCtx) -> [f32; 4] {
|
||
let mut color = if ctx.selected {
|
||
WireModel::SELECTED
|
||
} else if lw.color_is_byblock {
|
||
ctx.ins_color
|
||
} else if lw.color_l0 {
|
||
ctx.l0.color
|
||
} else {
|
||
lw.color
|
||
};
|
||
if !ctx.selected {
|
||
color[3] = if lw.transparency_is_byblock {
|
||
ctx.ins_color[3]
|
||
} else if lw.transparency_l0 {
|
||
ctx.l0.color[3]
|
||
} else {
|
||
lw.color[3]
|
||
};
|
||
}
|
||
if ctx.is_xref && !ctx.selected {
|
||
xref_fade(color, ctx.bg_color)
|
||
} else {
|
||
color
|
||
}
|
||
}
|
||
|
||
/// Effective linetype scale along this wire after an INSERT transform.
|
||
///
|
||
/// A non-uniform INSERT has no single global scale. Weight each segment by its
|
||
/// local length, producing the exact factor for a line and a stable
|
||
/// path-weighted approximation for a polyline or tessellated curve.
|
||
fn transformed_wire_length_scale(lw: &LocalWire, xform: &Transform) -> f32 {
|
||
let mut local_length = 0.0_f64;
|
||
let mut transformed_length = 0.0_f64;
|
||
let mut previous: Option<Vector3> = None;
|
||
|
||
for (index, point) in lw.points.iter().enumerate() {
|
||
if !point.iter().all(|v| v.is_finite()) {
|
||
previous = None;
|
||
continue;
|
||
}
|
||
let low = lw.points_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
let current = Vector3::new(
|
||
point[0] as f64 + low[0] as f64,
|
||
point[1] as f64 + low[1] as f64,
|
||
point[2] as f64 + low[2] as f64,
|
||
);
|
||
if let Some(prev) = previous {
|
||
let delta = current - prev;
|
||
let segment_length = (delta.x * delta.x + delta.y * delta.y + delta.z * delta.z).sqrt();
|
||
if segment_length > 1e-12 {
|
||
let transformed = xform.matrix.transform_direction(delta);
|
||
local_length += segment_length;
|
||
transformed_length += (transformed.x * transformed.x
|
||
+ transformed.y * transformed.y
|
||
+ transformed.z * transformed.z)
|
||
.sqrt();
|
||
}
|
||
}
|
||
previous = Some(current);
|
||
}
|
||
|
||
if local_length > 1e-12 && transformed_length.is_finite() {
|
||
(transformed_length / local_length) as f32
|
||
} else {
|
||
1.0
|
||
}
|
||
}
|
||
|
||
struct TransformedStationData {
|
||
values: Vec<f32>,
|
||
source_length: f32,
|
||
point_segments: Vec<i32>,
|
||
pieces: Vec<PatternStationPiece>,
|
||
}
|
||
|
||
fn transformed_station_data(
|
||
values: &[f32],
|
||
map: &PatternStationMap,
|
||
plinegen: bool,
|
||
xform: &Transform,
|
||
) -> Option<TransformedStationData> {
|
||
if values.len() != map.point_segments.len() || map.pieces.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut pieces = Vec::with_capacity(map.pieces.len());
|
||
let mut global = 0.0_f32;
|
||
let mut local = 0.0_f32;
|
||
let mut current_segment = None;
|
||
for piece in &map.pieces {
|
||
if current_segment != Some(piece.source_segment) {
|
||
current_segment = Some(piece.source_segment);
|
||
local = 0.0;
|
||
}
|
||
let vector = Vector3::new(
|
||
piece.vector[0] as f64,
|
||
piece.vector[1] as f64,
|
||
piece.vector[2] as f64,
|
||
);
|
||
let mapped = xform.matrix.transform_direction(vector);
|
||
let chord = vector.length();
|
||
let mapped_chord = mapped.length();
|
||
let original_length = (piece.source_distances[1] - piece.source_distances[0]).abs();
|
||
let length = if chord > 1e-12 && mapped_chord.is_finite() {
|
||
original_length * (mapped_chord / chord) as f32
|
||
} else {
|
||
original_length
|
||
};
|
||
pieces.push(PatternStationPiece {
|
||
source_segment: piece.source_segment,
|
||
source_distances: [global, global + length],
|
||
segment_distances: [local, local + length],
|
||
vector: [mapped.x as f32, mapped.y as f32, mapped.z as f32],
|
||
});
|
||
global += length;
|
||
local += length;
|
||
}
|
||
|
||
let fallback_scale = map
|
||
.pieces
|
||
.last()
|
||
.map(|piece| piece.source_distances[1])
|
||
.filter(|length| length.abs() > 1e-12)
|
||
.map_or(1.0, |length| global / length);
|
||
let mapped_values = values
|
||
.iter()
|
||
.zip(&map.point_segments)
|
||
.map(|(&value, &segment)| {
|
||
if segment < 0 {
|
||
return 0.0;
|
||
}
|
||
let segment = segment as u32;
|
||
let range = |piece: &PatternStationPiece| {
|
||
if plinegen {
|
||
piece.source_distances
|
||
} else {
|
||
piece.segment_distances
|
||
}
|
||
};
|
||
let distance_to = |piece: &PatternStationPiece| {
|
||
let [start, end] = range(piece);
|
||
if value < start {
|
||
start - value
|
||
} else if value > end {
|
||
value - end
|
||
} else {
|
||
0.0
|
||
}
|
||
};
|
||
let Some((old_piece, new_piece)) = map
|
||
.pieces
|
||
.iter()
|
||
.zip(&pieces)
|
||
.filter(|(piece, _)| piece.source_segment == segment)
|
||
.min_by(|(a, _), (b, _)| distance_to(a).total_cmp(&distance_to(b)))
|
||
else {
|
||
return value * fallback_scale;
|
||
};
|
||
let old = range(old_piece);
|
||
let new = range(new_piece);
|
||
let span = old[1] - old[0];
|
||
let t = if span.abs() > 1e-12 {
|
||
((value - old[0]) / span).clamp(0.0, 1.0)
|
||
} else {
|
||
0.0
|
||
};
|
||
new[0] + (new[1] - new[0]) * t
|
||
})
|
||
.collect();
|
||
|
||
Some(TransformedStationData {
|
||
values: mapped_values,
|
||
source_length: global,
|
||
point_segments: map.point_segments.clone(),
|
||
pieces,
|
||
})
|
||
}
|
||
|
||
fn transformed_point_marker(marker: PointMarker, xform: &Transform) -> (PointMarker, f64) {
|
||
let origin = xform.apply(Vector3::new(
|
||
marker.origin.x,
|
||
marker.origin.y,
|
||
marker.origin.z,
|
||
));
|
||
let map_direction = |direction: glam::DVec3| {
|
||
let mapped =
|
||
xform
|
||
.matrix
|
||
.transform_direction(Vector3::new(direction.x, direction.y, direction.z));
|
||
KernelVec3::new(mapped.x, mapped.y, mapped.z)
|
||
};
|
||
let mapped_normal = map_direction(marker.normal);
|
||
let normal_scale = mapped_normal.length().max(f64::MIN_POSITIVE);
|
||
let normal = mapped_normal.normalize().unwrap_or(KernelVec3::Z);
|
||
let mapped_x = map_direction(marker.axis_x);
|
||
let axis_x = (mapped_x - normal * mapped_x.dot(normal))
|
||
.normalize()
|
||
.unwrap_or(KernelVec3::X);
|
||
let mapped_y = map_direction(marker.axis_y);
|
||
let axis_y = (mapped_y - normal * mapped_y.dot(normal) - axis_x * mapped_y.dot(axis_x))
|
||
.normalize()
|
||
.or_else(|| normal.cross(axis_x).normalize())
|
||
.unwrap_or(KernelVec3::Y);
|
||
(
|
||
PointMarker {
|
||
origin: glam::DVec3::new(origin.x, origin.y, origin.z),
|
||
normal: glam::DVec3::from_array(normal.to_array()),
|
||
axis_x: glam::DVec3::from_array(axis_x.to_array()),
|
||
axis_y: glam::DVec3::from_array(axis_y.to_array()),
|
||
viewport_percent: marker.viewport_percent,
|
||
},
|
||
normal_scale,
|
||
)
|
||
}
|
||
|
||
fn transformed_marker_point(
|
||
point: KernelVec3,
|
||
source: PointMarker,
|
||
target: PointMarker,
|
||
axial_scale: f64,
|
||
) -> KernelVec3 {
|
||
let source_origin = KernelVec3::from(source.origin.to_array());
|
||
let source_normal = KernelVec3::from(source.normal.to_array())
|
||
.normalize()
|
||
.unwrap_or(KernelVec3::Z);
|
||
let delta = point - source_origin;
|
||
let axial = delta.dot(source_normal);
|
||
let source_plane = KernelPlane::from_axes(
|
||
source.origin.to_array(),
|
||
source.axis_x.to_array(),
|
||
source.axis_y.to_array(),
|
||
);
|
||
let uv = source_plane
|
||
.project_vector((delta - source_normal * axial).to_array())
|
||
.unwrap_or([0.0; 2]);
|
||
let target_plane = KernelPlane::from_axes(
|
||
target.origin.to_array(),
|
||
target.axis_x.to_array(),
|
||
target.axis_y.to_array(),
|
||
);
|
||
KernelVec3::from(target_plane.point_at(uv))
|
||
+ KernelVec3::from(target.normal.to_array()) * axial * axial_scale
|
||
}
|
||
|
||
fn emit_wire(
|
||
lw: &LocalWire,
|
||
accum_xform: &Transform,
|
||
ctx: &ExpandCtx,
|
||
out: &mut Batches,
|
||
d_range: (f32, f32),
|
||
) {
|
||
if lw.points.is_empty()
|
||
&& lw.fill_tris.is_empty()
|
||
&& lw.text_verts.is_empty()
|
||
&& lw.pick_tris.is_empty()
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Resolve final style for this LocalWire against the outer Insert ctx
|
||
// before we hash it into a batch.
|
||
let final_color = resolve_wire_color(lw, ctx);
|
||
let final_aci = if lw.color_is_byblock {
|
||
ctx.ins_aci
|
||
} else if lw.color_l0 {
|
||
ctx.l0_aci
|
||
} else {
|
||
lw.aci
|
||
};
|
||
let (final_pat_len, final_pat) = if lw.lt_is_byblock {
|
||
(ctx.ins_pat_len, ctx.ins_pat)
|
||
} else if lw.lt_l0 {
|
||
(ctx.l0.pat_len, ctx.l0.pat)
|
||
} else {
|
||
(lw.pattern_length, lw.pattern)
|
||
};
|
||
let final_lw_px = if lw.lw_is_byblock {
|
||
ctx.ins_lw_px
|
||
} else if lw.lw_l0 {
|
||
ctx.l0.lw_px
|
||
} else {
|
||
lw.line_weight_px
|
||
};
|
||
|
||
// Analytical GPU circle / arc / ellipse fast path:
|
||
// If this LocalWire is a pure analytical curve (single tangent geometry, no fills/text/pick),
|
||
// and transforms cleanly with accum_xform into an analytical curve, emit it as a standalone
|
||
// wire in extra_wires. This lets partition_wires extract it into CircleGpu / EllipseGpu for
|
||
// pixel-perfect analytical rendering on the GPU instead of segmented lines.
|
||
if !lw.tangent_geoms.is_empty()
|
||
&& lw.fill_tris.is_empty()
|
||
&& lw.pick_tris.is_empty()
|
||
&& lw.text_verts.is_empty()
|
||
{
|
||
let transformed_tangents: Option<Vec<TangentGeom>> = lw
|
||
.tangent_geoms
|
||
.iter()
|
||
.map(|tg| transform_tangent(tg, accum_xform))
|
||
.collect();
|
||
if let Some(tangents) = transformed_tangents {
|
||
if tangents.iter().all(|tangent| {
|
||
matches!(
|
||
tangent,
|
||
TangentGeom::Circle { .. }
|
||
| TangentGeom::PlanarCircle { .. }
|
||
| TangentGeom::Arc { .. }
|
||
| TangentGeom::PlanarEllipse { .. }
|
||
)
|
||
}) {
|
||
let contrast_bg = lw.contrast_bg.unwrap_or(ctx.bg_color);
|
||
let color = if lw.canvas_color {
|
||
ctx.bg_color
|
||
} else if lw.preserve_color {
|
||
final_color
|
||
} else {
|
||
crate::scene::view::render::adapt_to_bg(final_color, contrast_bg)
|
||
};
|
||
let bg_adapt: crate::scene::model::wire_model::BgAdapt =
|
||
(lw.canvas_color || !lw.preserve_color).then(|| {
|
||
Box::new(crate::scene::model::wire_model::BgAdaptInputs {
|
||
raw_color: final_color,
|
||
text_raw_colors: Vec::new(),
|
||
contrast_bg: lw.contrast_bg,
|
||
canvas_color: lw.canvas_color,
|
||
preserve_color: lw.preserve_color,
|
||
})
|
||
});
|
||
|
||
let mut points = Vec::with_capacity(lw.points.len());
|
||
let mut points_low = Vec::with_capacity(lw.points.len());
|
||
let mut min_x = f32::INFINITY;
|
||
let mut min_y = f32::INFINITY;
|
||
let mut max_x = f32::NEG_INFINITY;
|
||
let mut max_y = f32::NEG_INFINITY;
|
||
for (idx, p) in lw.points.iter().enumerate() {
|
||
let pl = lw.points_low.get(idx).copied().unwrap_or([0.0; 3]);
|
||
let point = accum_xform.apply(Vector3::new(
|
||
p[0] as f64 + pl[0] as f64,
|
||
p[1] as f64 + pl[1] as f64,
|
||
p[2] as f64 + pl[2] as f64,
|
||
));
|
||
let (hx, lx) = WireModel::split_ds(point.x);
|
||
let (hy, ly) = WireModel::split_ds(point.y);
|
||
let (hz, lz) = WireModel::split_ds(point.z);
|
||
if hx < min_x {
|
||
min_x = hx;
|
||
}
|
||
if hy < min_y {
|
||
min_y = hy;
|
||
}
|
||
if hx > max_x {
|
||
max_x = hx;
|
||
}
|
||
if hy > max_y {
|
||
max_y = hy;
|
||
}
|
||
points.push([hx, hy, hz]);
|
||
points_low.push([lx, ly, lz]);
|
||
}
|
||
let aabb = if min_x.is_infinite() {
|
||
WireModel::UNBOUNDED_AABB
|
||
} else {
|
||
[min_x, min_y, max_x, max_y]
|
||
};
|
||
let mut snap_pts = Vec::with_capacity(lw.snap_pts.len());
|
||
for (p, hint) in &lw.snap_pts {
|
||
let v = accum_xform.apply(Vector3::new(p.x, p.y, p.z));
|
||
snap_pts.push((glam::DVec3::new(v.x, v.y, v.z), *hint));
|
||
}
|
||
let mut key_vertices = Vec::with_capacity(lw.key_vertices.len());
|
||
for p in &lw.key_vertices {
|
||
let v = accum_xform.apply(Vector3::new(p[0], p[1], p[2]));
|
||
key_vertices.push([v.x, v.y, v.z]);
|
||
}
|
||
let local_depth = if d_range != (0.0, 1.0) {
|
||
Some(d_range.0)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let wire = WireModel {
|
||
bg_adapt,
|
||
point_marker: lw.point_marker,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: local_depth,
|
||
display_visible: !lw.hide_unselected || ctx.selected,
|
||
snap_only: false,
|
||
plot_visible: lw.plot_visible,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: String::new(),
|
||
points,
|
||
points_low,
|
||
color,
|
||
selected: ctx.selected,
|
||
pattern_length: final_pat_len,
|
||
pattern: final_pat,
|
||
line_weight_px: final_lw_px,
|
||
aci: final_aci,
|
||
snap_pts,
|
||
tangent_geoms: tangents,
|
||
key_vertices,
|
||
aabb,
|
||
plinegen: lw.plinegen,
|
||
fill_tris: Vec::new(),
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
};
|
||
out.extra_wires.push(wire);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
let station_values = pattern_station_values(&lw.pattern_stations, lw.points.len());
|
||
let station_map = decode_pattern_station_map(&lw.pattern_stations, lw.points.len());
|
||
let transformed_stations = station_values.and_then(|(values, _)| {
|
||
station_map
|
||
.as_ref()
|
||
.and_then(|map| transformed_station_data(values, map, lw.plinegen, accum_xform))
|
||
});
|
||
let has_stations = station_values.is_some();
|
||
let pattern_scale = transformed_stations.as_ref().map_or_else(
|
||
|| {
|
||
if final_pat_len > 0.0 || has_stations {
|
||
transformed_wire_length_scale(lw, accum_xform)
|
||
} else {
|
||
1.0
|
||
}
|
||
},
|
||
|data| {
|
||
station_values.map_or(1.0, |(_, source_length)| {
|
||
if source_length.abs() > 1e-12 {
|
||
data.source_length / source_length
|
||
} else {
|
||
1.0
|
||
}
|
||
})
|
||
},
|
||
);
|
||
let final_pat_len = final_pat_len * ctx.pslt_factor * pattern_scale;
|
||
let final_pat = final_pat.map(|v| v * ctx.pslt_factor * pattern_scale);
|
||
let source_length = transformed_stations.as_ref().map_or_else(
|
||
|| station_values.map_or(0.0, |(_, length)| length * pattern_scale),
|
||
|data| data.source_length,
|
||
);
|
||
|
||
// A wide polyline's band width is baked in block-local units; scale it by
|
||
// the insert transform so the shader band matches the scaled geometry.
|
||
// Average the X and Y axis image lengths — exact for a uniform insert, a
|
||
// sensible mean for a non-uniform one (the band carries one width).
|
||
let final_world_width = if lw.world_width > 0.0 {
|
||
let o = accum_xform.apply(Vector3::new(0.0, 0.0, 0.0));
|
||
let ax = accum_xform.apply(Vector3::new(1.0, 0.0, 0.0));
|
||
let ay = accum_xform.apply(Vector3::new(0.0, 1.0, 0.0));
|
||
let sx = ((ax.x - o.x).powi(2) + (ax.y - o.y).powi(2) + (ax.z - o.z).powi(2)).sqrt();
|
||
let sy = ((ay.x - o.x).powi(2) + (ay.y - o.y).powi(2) + (ay.z - o.z).powi(2)).sqrt();
|
||
lw.world_width * ((sx + sy) * 0.5) as f32
|
||
} else if lw.world_width < 0.0 {
|
||
lw.world_width
|
||
} else {
|
||
0.0
|
||
};
|
||
let marker_transform = lw
|
||
.point_marker
|
||
.map(|marker| transformed_point_marker(marker, accum_xform));
|
||
let point_marker = marker_transform.map(|(marker, _)| marker);
|
||
|
||
// Band wires take a per-child composed depth: their solid area is what
|
||
// covers siblings, and their width already splits them into their own
|
||
// batches. Text-bearing wires need it too — but as `depth_override`, not
|
||
// baked into vertices: the upload resolves it against the insert's
|
||
// scene-graph entry (depths[insert] + override * half), which is what
|
||
// keeps text interleaved with its sibling wipeout/hatch fills. Without a
|
||
// composed rank the text sits at the bare insert level, an exact tie with
|
||
// every sibling fill, and the later wipeout pass erases it (unselected
|
||
// block text vanished under its wipeout; selecting won only because the
|
||
// xray pass ignores depth). Fill-only wires take the rank as well: an
|
||
// MTEXT background (a dimension's DIMTFILL box) left at the bare insert
|
||
// level sat in front of its own text whenever that text's rank was
|
||
// below zero.
|
||
// Every block member must retain its block-local draw rank, including
|
||
// ordinary thin LINE/LWPOLYLINE wires. Previously these wires had no
|
||
// depth_override, so they were drawn at the INSERT's bare depth while
|
||
// wipeout/hatch fills were positioned at the member rank. COPY and grip
|
||
// moves rebuild instance batches and exposed this mismatch in model,
|
||
// paper-space and PDF output.
|
||
let local_depth = Some(d_range.0 + lw.local_rank * d_range.1);
|
||
let plot_visible = ctx.plot_visible && lw.plot_visible && (!lw.plot_l0 || ctx.l0_plottable);
|
||
|
||
// Batched wires share one dash distance, which runs on across the NaN
|
||
// joins only for plinegen wires. PLINEGEN is about a wire's own breaks;
|
||
// a member without any (every LINE) must not carry its pattern into the
|
||
// next member, or each line in a block starts mid-dash where the last
|
||
// one stopped (#898). A member with stations keeps its own flag.
|
||
let plinegen = lw.plinegen
|
||
&& (has_stations
|
||
|| transformed_stations.is_some()
|
||
|| lw.points.iter().any(|point| point[0].is_nan()));
|
||
let key = style_key(
|
||
final_color,
|
||
lw.contrast_bg,
|
||
lw.preserve_color,
|
||
lw.canvas_color,
|
||
final_pat_len,
|
||
final_pat,
|
||
final_lw_px,
|
||
source_length,
|
||
final_world_width,
|
||
point_marker,
|
||
final_aci,
|
||
plinegen,
|
||
lw.is_fill_only,
|
||
lw.fill_is_2d_solid,
|
||
lw.fill_is_3d,
|
||
plot_visible,
|
||
lw.hide_unselected,
|
||
local_depth,
|
||
);
|
||
|
||
if transformed_stations.is_some() {
|
||
if let Some(closed) = out.by_style.remove(&key) {
|
||
out.closed.push(closed);
|
||
}
|
||
}
|
||
|
||
// If the open batch for this style would exceed wgpu's per-buffer limit
|
||
// after appending this wire, finalize it now and start a fresh batch.
|
||
if let Some(existing) = out.by_style.get(&key) {
|
||
if existing.points.len() + lw.points.len() + 1 > MAX_POINTS_PER_BATCH {
|
||
if let Some(closed) = out.by_style.remove(&key) {
|
||
out.closed.push(closed);
|
||
}
|
||
}
|
||
}
|
||
let entry = out.by_style.entry(key).or_insert_with(|| {
|
||
BatchEntry::new(
|
||
final_color,
|
||
lw.contrast_bg,
|
||
lw.preserve_color,
|
||
lw.canvas_color,
|
||
final_pat_len,
|
||
final_pat,
|
||
final_lw_px,
|
||
source_length,
|
||
final_world_width,
|
||
point_marker,
|
||
final_aci,
|
||
plinegen,
|
||
lw.is_fill_only,
|
||
lw.fill_is_2d_solid,
|
||
lw.fill_is_3d,
|
||
plot_visible,
|
||
lw.hide_unselected,
|
||
)
|
||
});
|
||
entry.local_depth = local_depth;
|
||
if let Some(data) = &transformed_stations {
|
||
entry.pattern_station_pieces.clone_from(&data.pieces);
|
||
}
|
||
|
||
// NaN separator between previously-appended geometry and this wire so the
|
||
// GPU shader treats them as disconnected polylines within one buffer.
|
||
let needs_sep =
|
||
!entry.points.is_empty() && !entry.points.last().map(|p| p[0].is_nan()).unwrap_or(false);
|
||
|
||
if !lw.points.is_empty() {
|
||
if needs_sep {
|
||
entry.points.push([f32::NAN; 3]);
|
||
entry.points_low.push([0.0; 3]);
|
||
if has_stations {
|
||
entry.pattern_stations.push(0.0);
|
||
}
|
||
if transformed_stations.is_some() {
|
||
entry.pattern_station_segments.push(-1);
|
||
}
|
||
}
|
||
// Iterate paired with the matching low residual so the GPU keeps
|
||
// sub-f32 precision once the INSERT transform lands the wire in
|
||
// world space at UTM-scale coordinates.
|
||
for (idx, p) in lw.points.iter().enumerate() {
|
||
let station = transformed_stations
|
||
.as_ref()
|
||
.map(|data| data.values[idx])
|
||
.or_else(|| has_stations.then(|| lw.pattern_stations[idx] * pattern_scale));
|
||
let station_segment = transformed_stations
|
||
.as_ref()
|
||
.map(|data| data.point_segments[idx]);
|
||
if p[0].is_nan() {
|
||
entry.points.push([f32::NAN; 3]);
|
||
entry.points_low.push([0.0; 3]);
|
||
if let Some(station) = station {
|
||
entry.pattern_stations.push(station);
|
||
}
|
||
if let Some(segment) = station_segment {
|
||
entry.pattern_station_segments.push(segment);
|
||
}
|
||
continue;
|
||
}
|
||
let pl = lw.points_low.get(idx).copied().unwrap_or([0.0; 3]);
|
||
// Reconstruct the f64 source from (high, low) before applying the
|
||
// insert transform — otherwise the low half is silently dropped.
|
||
let source = KernelVec3::new(
|
||
p[0] as f64 + pl[0] as f64,
|
||
p[1] as f64 + pl[1] as f64,
|
||
p[2] as f64 + pl[2] as f64,
|
||
);
|
||
let mapped = if let (Some(source_marker), Some((target_marker, axial_scale))) =
|
||
(lw.point_marker, marker_transform)
|
||
{
|
||
transformed_marker_point(source, source_marker, target_marker, axial_scale)
|
||
} else {
|
||
let point = accum_xform.apply(Vector3::new(source.x, source.y, source.z));
|
||
KernelVec3::new(point.x, point.y, point.z)
|
||
};
|
||
let v = Vector3::new(mapped.x, mapped.y, mapped.z);
|
||
let qx = (v.x) as f32;
|
||
let qy = (v.y) as f32;
|
||
let qz = (v.z) as f32;
|
||
let qx_l = ((v.x) - qx as f64) as f32;
|
||
let qy_l = ((v.y) - qy as f64) as f32;
|
||
let qz_l = ((v.z) - qz as f64) as f32;
|
||
let q = [qx, qy, qz];
|
||
if qx < entry.min_x {
|
||
entry.min_x = qx;
|
||
}
|
||
if qy < entry.min_y {
|
||
entry.min_y = qy;
|
||
}
|
||
if qx > entry.max_x {
|
||
entry.max_x = qx;
|
||
}
|
||
if qy > entry.max_y {
|
||
entry.max_y = qy;
|
||
}
|
||
entry.points.push(q);
|
||
entry.points_low.push([qx_l, qy_l, qz_l]);
|
||
if let Some(station) = station {
|
||
entry.pattern_stations.push(station);
|
||
}
|
||
if let Some(segment) = station_segment {
|
||
entry.pattern_station_segments.push(segment);
|
||
}
|
||
}
|
||
}
|
||
|
||
for p in &lw.key_vertices {
|
||
let v = accum_xform.apply(Vector3::new(p[0] as f64, p[1] as f64, p[2] as f64));
|
||
entry.key_vertices.push([v.x, v.y, v.z]);
|
||
}
|
||
for (p, hint) in &lw.snap_pts {
|
||
let v = accum_xform.apply(Vector3::new(p.x as f64, p.y as f64, p.z as f64));
|
||
entry
|
||
.snap_pts
|
||
.push((glam::DVec3::new(v.x, v.y, v.z), *hint));
|
||
}
|
||
for tg in &lw.tangent_geoms {
|
||
if let Some(tangent) = transform_tangent(tg, accum_xform) {
|
||
entry.tangent_geoms.push(tangent);
|
||
}
|
||
}
|
||
// Per the WireModel contract an empty `fill_tris_low` means "all-zero low
|
||
// half" (e.g. a Leader / dimension arrowhead fill, which the tessellator
|
||
// emits without a low half). Only a *partially* populated low half is a
|
||
// real bug — keep the tripwire for that, but permit the empty case so debug
|
||
// builds don't panic on legitimate geometry.
|
||
debug_assert!(
|
||
lw.fill_tris_low.is_empty() || lw.fill_tris.len() == lw.fill_tris_low.len(),
|
||
"fill_tris_low must be empty or the same length as fill_tris (got {} vs {})",
|
||
lw.fill_tris.len(),
|
||
lw.fill_tris_low.len(),
|
||
);
|
||
for (idx, p) in lw.fill_tris.iter().enumerate() {
|
||
// Empty/short fill_tris_low means "no low half" (all-zero), per the
|
||
// WireModel contract — same panic-safe access the other fill consumers
|
||
// use (face3d_gpu, xclip). A Leader with a filled arrowhead nested in a
|
||
// block reaches here with populated fill_tris but empty fill_tris_low;
|
||
// a raw `[idx]` would panic in release (bounds checks are not gated by
|
||
// debug-assertions). The debug_assert above stays as a tripwire.
|
||
let pl = lw.fill_tris_low.get(idx).copied().unwrap_or([0.0; 3]);
|
||
let v = accum_xform.apply(Vector3::new(
|
||
p[0] as f64 + pl[0] as f64,
|
||
p[1] as f64 + pl[1] as f64,
|
||
p[2] as f64 + pl[2] as f64,
|
||
));
|
||
let (hx, lx) = WireModel::split_ds(v.x);
|
||
let (hy, ly) = WireModel::split_ds(v.y);
|
||
let (hz, lz) = WireModel::split_ds(v.z);
|
||
entry.fill_tris.push([hx, hy, hz]);
|
||
entry.fill_tris_low.push([lx, ly, lz]);
|
||
}
|
||
// Thickness walls: same reconstruct → transform → re-split as the fills
|
||
// above, so a block child's wall tracks the insert's placement and scale.
|
||
debug_assert!(
|
||
lw.pick_tris_low.is_empty() || lw.pick_tris.len() == lw.pick_tris_low.len(),
|
||
"pick_tris_low must be empty or the same length as pick_tris (got {} vs {})",
|
||
lw.pick_tris.len(),
|
||
lw.pick_tris_low.len(),
|
||
);
|
||
for (idx, p) in lw.pick_tris.iter().enumerate() {
|
||
let pl = lw.pick_tris_low.get(idx).copied().unwrap_or([0.0; 3]);
|
||
let v = accum_xform.apply(Vector3::new(
|
||
p[0] as f64 + pl[0] as f64,
|
||
p[1] as f64 + pl[1] as f64,
|
||
p[2] as f64 + pl[2] as f64,
|
||
));
|
||
let (hx, lx) = WireModel::split_ds(v.x);
|
||
let (hy, ly) = WireModel::split_ds(v.y);
|
||
let (hz, lz) = WireModel::split_ds(v.z);
|
||
entry.pick_tris.push([hx, hy, hz]);
|
||
entry.pick_tris_low.push([lx, ly, lz]);
|
||
}
|
||
// SDF glyph quads: reconstruct each block-local f64 position, apply the
|
||
// insert transform, re-split — same path as points/fills so block-instance
|
||
// text lands at the right world place and scale. Colour resolves to the
|
||
// batch's final colour (ByBlock / layer-0 block text follows the insert).
|
||
for tv in &lw.text_verts {
|
||
let wx = tv.pos[0] as f64 + tv.pos_low[0] as f64;
|
||
let wy = tv.pos[1] as f64 + tv.pos_low[1] as f64;
|
||
let wz = tv.pos[2] as f64 + tv.pos_low[2] as f64;
|
||
let v = accum_xform.apply(Vector3::new(wx, wy, wz));
|
||
let (hx, lx) = WireModel::split_ds(v.x);
|
||
let (hy, ly) = WireModel::split_ds(v.y);
|
||
let (hz, lz) = WireModel::split_ds(v.z);
|
||
// Grow the batch AABB by the glyph extent so a text-only block wire
|
||
// (no points) still finalizes to a bounded pick box.
|
||
if hx < entry.min_x {
|
||
entry.min_x = hx;
|
||
}
|
||
if hy < entry.min_y {
|
||
entry.min_y = hy;
|
||
}
|
||
if hx > entry.max_x {
|
||
entry.max_x = hx;
|
||
}
|
||
if hy > entry.max_y {
|
||
entry.max_y = hy;
|
||
}
|
||
// Base glyphs inherit the resolved (ByBlock / layer-0) colour; a glyph
|
||
// carrying an inline `\C` / `\c` override — colour differs from the
|
||
// wire's base — keeps it, so block-nested colour-split MTEXT stays
|
||
// multi-colour. Per-vertex analogue of PR #301's wire-level gate.
|
||
let rgb =
|
||
if [tv.color[0], tv.color[1], tv.color[2]] == [lw.color[0], lw.color[1], lw.color[2]] {
|
||
[final_color[0], final_color[1], final_color[2]]
|
||
} else {
|
||
[tv.color[0], tv.color[1], tv.color[2]]
|
||
};
|
||
entry
|
||
.text_verts
|
||
.push(crate::scene::pipeline::text_gpu::TextVertex {
|
||
pos: [hx, hy, hz],
|
||
pos_low: [lx, ly, lz],
|
||
uv: tv.uv,
|
||
color: [rgb[0], rgb[1], rgb[2], final_color[3]],
|
||
draw_depth: tv.draw_depth,
|
||
});
|
||
}
|
||
// Searchable runs follow the same insert transform: origin via the full
|
||
// matrix, cap height by the mean XY scale, rotation by the transformed
|
||
// X-axis angle — so block-instance text plots searchable at its placed
|
||
// size and angle, not its block-local one.
|
||
//
|
||
// Notes (review V2): `sx`/`sy` are axis *lengths* (always ≥ 0), so a
|
||
// mirrored insert (`sx = -1`) yields length 1, not a zero mean — there is
|
||
// no 0.1 pt collapse. Mirroring folds into `rotation += π` (readable
|
||
// invisible text) rather than a reflected `Tm`: the visible outlines
|
||
// carry the true mirror. Non-uniform scale, width factor, oblique and
|
||
// tracking are approximated by the mean scale here; exact reproduction
|
||
// needs `Tz`/`Tc`/skewed `Tm` (follow-up — selection-only mismatch on the
|
||
// invisible layer, never a visual defect).
|
||
if !lw.searchable_text.is_empty() {
|
||
let x_axis = accum_xform.apply_rotation(Vector3::new(1.0, 0.0, 0.0));
|
||
let y_axis = accum_xform.apply_rotation(Vector3::new(0.0, 1.0, 0.0));
|
||
let sx = (x_axis.x * x_axis.x + x_axis.y * x_axis.y + x_axis.z * x_axis.z).sqrt();
|
||
let sy = (y_axis.x * y_axis.x + y_axis.y * y_axis.y + y_axis.z * y_axis.z).sqrt();
|
||
let mean_scale = ((sx + sy) * 0.5).max(1e-6);
|
||
let rot_delta = (x_axis.y).atan2(x_axis.x);
|
||
let base_len = entry.searchable_text.len();
|
||
entry
|
||
.searchable_text
|
||
.extend(lw.searchable_text.iter().cloned());
|
||
crate::scene::model::wire_model::map_searchable_runs(
|
||
&mut entry.searchable_text[base_len..],
|
||
&|p| {
|
||
let v = accum_xform.apply(Vector3::new(p[0], p[1], p[2]));
|
||
[v.x, v.y, v.z]
|
||
},
|
||
mean_scale,
|
||
rot_delta as f32,
|
||
);
|
||
for run in &mut entry.searchable_text[base_len..] {
|
||
run.color = final_color;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn transform_tangent(tg: &TangentGeom, t: &Transform) -> Option<TangentGeom> {
|
||
match tg {
|
||
TangentGeom::Line { p1, p2 } => {
|
||
let q1 = t.apply(Vector3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64));
|
||
let q2 = t.apply(Vector3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64));
|
||
Some(TangentGeom::Line {
|
||
p1: [(q1.x) as f32, (q1.y) as f32, (q1.z) as f32],
|
||
p2: [(q2.x) as f32, (q2.y) as f32, (q2.z) as f32],
|
||
})
|
||
}
|
||
TangentGeom::Circle { center, radius } => {
|
||
let c = t.apply(Vector3::new(
|
||
center[0] as f64,
|
||
center[1] as f64,
|
||
center[2] as f64,
|
||
));
|
||
let m = &t.matrix.m;
|
||
let sx = ((m[0][0] * m[0][0] + m[0][1] * m[0][1] + m[0][2] * m[0][2]) as f64).sqrt();
|
||
let sy = ((m[1][0] * m[1][0] + m[1][1] * m[1][1] + m[1][2] * m[1][2]) as f64).sqrt();
|
||
let s = ((sx + sy) * 0.5) as f32;
|
||
Some(TangentGeom::Circle {
|
||
center: [(c.x) as f32, (c.y) as f32, (c.z) as f32],
|
||
radius: radius * s,
|
||
})
|
||
}
|
||
TangentGeom::Arc {
|
||
center,
|
||
axis_x,
|
||
axis_y,
|
||
radius,
|
||
start_angle,
|
||
end_angle,
|
||
} => {
|
||
let c = t.apply(Vector3::new(center[0], center[1], center[2]));
|
||
let x = t.apply_rotation(Vector3::new(axis_x[0], axis_x[1], axis_x[2]));
|
||
let y = t.apply_rotation(Vector3::new(axis_y[0], axis_y[1], axis_y[2]));
|
||
let sx = x.length();
|
||
let sy = y.length();
|
||
let scale = sx.max(sy);
|
||
if !scale.is_finite() || scale <= 1.0e-12 || (sx - sy).abs() > scale * 1.0e-9 {
|
||
return None;
|
||
}
|
||
let x = x / sx;
|
||
let y = y / sy;
|
||
if x.dot(&y).abs() > 1.0e-9 {
|
||
return None;
|
||
}
|
||
Some(TangentGeom::Arc {
|
||
center: [c.x, c.y, c.z],
|
||
axis_x: [x.x, x.y, x.z],
|
||
axis_y: [y.x, y.y, y.z],
|
||
radius: radius * ((sx + sy) * 0.5),
|
||
start_angle: *start_angle,
|
||
end_angle: *end_angle,
|
||
})
|
||
}
|
||
TangentGeom::PlanarCircle {
|
||
center,
|
||
axis_x,
|
||
axis_y,
|
||
radius,
|
||
} => {
|
||
let c = t.apply(Vector3::new(center[0], center[1], center[2]));
|
||
let x = t.apply_rotation(Vector3::new(axis_x[0], axis_x[1], axis_x[2]));
|
||
let y = t.apply_rotation(Vector3::new(axis_y[0], axis_y[1], axis_y[2]));
|
||
let sx = x.length();
|
||
let sy = y.length();
|
||
let scale = sx.max(sy);
|
||
if !scale.is_finite() || scale <= 1.0e-12 || (sx - sy).abs() > scale * 1.0e-9 {
|
||
return None;
|
||
}
|
||
let x = x / sx;
|
||
let y = y / sy;
|
||
if x.dot(&y).abs() > 1.0e-9 {
|
||
return None;
|
||
}
|
||
Some(TangentGeom::PlanarCircle {
|
||
center: [c.x, c.y, c.z],
|
||
axis_x: [x.x, x.y, x.z],
|
||
axis_y: [y.x, y.y, y.z],
|
||
radius: radius * ((sx + sy) * 0.5),
|
||
})
|
||
}
|
||
TangentGeom::PlanarEllipse {
|
||
center,
|
||
major_axis,
|
||
normal,
|
||
minor_axis_ratio,
|
||
start_param,
|
||
end_param,
|
||
} => {
|
||
let (c, m, n, ratio, s, e) = crate::scene::view::transform::transform_ellipse_geometry(
|
||
Vector3::new(center[0], center[1], center[2]),
|
||
Vector3::new(major_axis[0], major_axis[1], major_axis[2]),
|
||
Vector3::new(normal[0], normal[1], normal[2]),
|
||
*minor_axis_ratio,
|
||
*start_param,
|
||
*end_param,
|
||
t,
|
||
)?;
|
||
Some(TangentGeom::PlanarEllipse {
|
||
center: [c.x, c.y, c.z],
|
||
major_axis: [m.x, m.y, m.z],
|
||
normal: [n.x, n.y, n.z],
|
||
minor_axis_ratio: ratio,
|
||
start_param: s,
|
||
end_param: e,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Coordinate cap for invalid or impractical extents.
|
||
const SANE_EXTENT: f64 = 1.0e8;
|
||
|
||
fn is_unreasonable_extent(e: &EntityType) -> bool {
|
||
// Drop degenerate primitives and impractical coordinate ranges.
|
||
match e {
|
||
EntityType::Circle(c) => c.radius.abs() < 1.0e-9 || c.radius.abs() > SANE_EXTENT,
|
||
EntityType::Arc(a) => a.radius.abs() < 1.0e-9 || a.radius.abs() > SANE_EXTENT,
|
||
EntityType::Ellipse(el) => {
|
||
let mx = el.major_axis.x.abs() + el.major_axis.y.abs() + el.major_axis.z.abs();
|
||
mx < 1.0e-9
|
||
|| el.major_axis.x.abs() > SANE_EXTENT
|
||
|| el.major_axis.y.abs() > SANE_EXTENT
|
||
|| el.major_axis.z.abs() > SANE_EXTENT
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod bg_resolution_tests {
|
||
use super::*;
|
||
use crate::scene::pipeline::text_gpu::TextVertex;
|
||
use crate::scene::view::render::{adapt_to_bg, resolve_colors_for_bg};
|
||
|
||
const DARK: [f32; 4] = [0.1, 0.1, 0.1, 1.0];
|
||
const LIGHT: [f32; 4] = [0.95, 0.95, 0.95, 1.0];
|
||
/// Near-white, not pure white: the case that makes `adapt_to_bg`
|
||
/// non-re-appliable, so it must survive a round trip through the recorded
|
||
/// raw colour.
|
||
const NEAR_WHITE: [f32; 4] = [0.97, 0.99, 0.96, 1.0];
|
||
|
||
fn entry(
|
||
color: [f32; 4],
|
||
contrast_bg: Option<[f32; 4]>,
|
||
preserve_color: bool,
|
||
canvas_color: bool,
|
||
) -> BatchEntry {
|
||
let mut e = BatchEntry::new(
|
||
color,
|
||
contrast_bg,
|
||
preserve_color,
|
||
canvas_color,
|
||
0.0,
|
||
[0.0; 8],
|
||
1.0,
|
||
0.0,
|
||
0.0,
|
||
None,
|
||
1,
|
||
false,
|
||
false,
|
||
false,
|
||
false,
|
||
true,
|
||
false,
|
||
);
|
||
e.points = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
|
||
e.text_verts = vec![TextVertex {
|
||
pos: [0.0; 3],
|
||
pos_low: [0.0; 3],
|
||
uv: [0.0; 2],
|
||
color,
|
||
draw_depth: 0.0,
|
||
}];
|
||
e
|
||
}
|
||
|
||
/// Every combination `finalize` distinguishes, in one set.
|
||
fn batches() -> Batches {
|
||
Batches {
|
||
by_style: HashMap::default(),
|
||
closed: vec![
|
||
entry(NEAR_WHITE, None, false, false),
|
||
entry([0.0, 0.0, 0.0, 1.0], None, false, false),
|
||
entry(NEAR_WHITE, None, true, false),
|
||
entry(NEAR_WHITE, Some(LIGHT), false, false),
|
||
entry(NEAR_WHITE, None, false, true),
|
||
],
|
||
extra_wires: Vec::new(),
|
||
nested_prototypes: HashMap::default(),
|
||
}
|
||
}
|
||
|
||
fn colors(wires: &[WireModel]) -> Vec<([f32; 4], Vec<[f32; 4]>)> {
|
||
wires
|
||
.iter()
|
||
.map(|w| (w.color, w.text_verts.iter().map(|v| v.color).collect()))
|
||
.collect()
|
||
}
|
||
|
||
/// The contract the whole background-key removal rests on: resolving a set
|
||
/// built under one background for another must land exactly where building
|
||
/// it under that background would have.
|
||
#[test]
|
||
fn resolving_for_a_background_matches_building_under_it() {
|
||
for (from, to) in [(DARK, LIGHT), (LIGHT, DARK), (DARK, DARK), (LIGHT, LIGHT)] {
|
||
let mut resolved = batches().finalize("b", false, from);
|
||
resolve_colors_for_bg(&mut resolved, to);
|
||
let direct = batches().finalize("b", false, to);
|
||
assert_eq!(
|
||
colors(&resolved),
|
||
colors(&direct),
|
||
"built under {from:?}, resolved for {to:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Safe to apply twice, and over a set mixing already-resolved wires with
|
||
/// freshly built ones — which is exactly what a memo hit plus a miss is.
|
||
#[test]
|
||
fn resolving_twice_changes_nothing() {
|
||
let mut once = batches().finalize("b", false, DARK);
|
||
resolve_colors_for_bg(&mut once, LIGHT);
|
||
let mut twice = once.clone();
|
||
resolve_colors_for_bg(&mut twice, LIGHT);
|
||
assert_eq!(colors(&once), colors(&twice));
|
||
}
|
||
|
||
/// Only colour 7's exact white / black swap with the background; a colour
|
||
/// drawn as authored — near-white, or a true-colour white mask — keeps its
|
||
/// value on any background. (#1500)
|
||
#[test]
|
||
fn only_colour_seven_swaps_with_the_background() {
|
||
assert_eq!(
|
||
adapt_to_bg([1.0, 1.0, 1.0, 1.0], LIGHT),
|
||
[0.0, 0.0, 0.0, 1.0]
|
||
);
|
||
assert_eq!(
|
||
adapt_to_bg([0.0, 0.0, 0.0, 1.0], DARK),
|
||
[1.0, 1.0, 1.0, 1.0]
|
||
);
|
||
assert_eq!(adapt_to_bg(NEAR_WHITE, LIGHT), NEAR_WHITE);
|
||
let mask = crate::scene::convert::tess_util::aci_to_rgba(&codec::types::Color::Rgb {
|
||
r: 255,
|
||
g: 255,
|
||
b: 255,
|
||
});
|
||
assert_eq!(adapt_to_bg(mask, LIGHT), mask);
|
||
let seven = crate::scene::convert::tess_util::aci_to_rgba(&codec::types::Color::Index(7));
|
||
assert_eq!(adapt_to_bg(seven, LIGHT), [0.0, 0.0, 0.0, 1.0]);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod compact_nested_tests {
|
||
use super::*;
|
||
use crate::scene::view::render::InheritStyle;
|
||
use codec::entities::{Insert, Line};
|
||
use codec::tables::BlockRecord;
|
||
|
||
fn add_block(document: &mut CadDocument, name: &str) -> Handle {
|
||
let mut block = BlockRecord::new(name);
|
||
block.handle = document.allocate_handle();
|
||
let handle = block.handle;
|
||
document.block_records.add(block).unwrap();
|
||
handle
|
||
}
|
||
|
||
fn add_owned(document: &mut CadDocument, owner: Handle, mut entity: EntityType) {
|
||
entity.common_mut().owner_handle = owner;
|
||
document.add_entity(entity).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn repeated_small_nested_lines_fold_into_parent_batches() {
|
||
let mut document = CadDocument::new();
|
||
let leaf = add_block(&mut document, "LEAF");
|
||
for y in 0..6 {
|
||
add_owned(
|
||
&mut document,
|
||
leaf,
|
||
EntityType::Line(Line::from_points(
|
||
Vector3::new(0.0, y as f64, 0.0),
|
||
Vector3::new(1.0, y as f64, 0.0),
|
||
)),
|
||
);
|
||
}
|
||
|
||
let twig = add_block(&mut document, "TWIG");
|
||
for x in 0..5 {
|
||
add_owned(
|
||
&mut document,
|
||
twig,
|
||
EntityType::Insert(Insert::new("LEAF", Vector3::new(x as f64 * 2.0, 0.0, 0.0))),
|
||
);
|
||
}
|
||
|
||
let root = add_block(&mut document, "ROOT");
|
||
for x in 0..100 {
|
||
add_owned(
|
||
&mut document,
|
||
root,
|
||
EntityType::Insert(Insert::new("TWIG", Vector3::new(x as f64 * 20.0, 0.0, 0.0))),
|
||
);
|
||
}
|
||
|
||
let root_insert = Insert::new("ROOT", Vector3::ZERO);
|
||
let root_handle = document
|
||
.add_entity(EntityType::Insert(root_insert.clone()))
|
||
.unwrap();
|
||
let cache = BlockCache::build(
|
||
&document,
|
||
1.0,
|
||
None,
|
||
true,
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
None,
|
||
&HashMap::default(),
|
||
);
|
||
let wires = expand_insert(
|
||
&document,
|
||
&cache,
|
||
&root_insert,
|
||
root_handle,
|
||
[1.0; 4],
|
||
0,
|
||
0.0,
|
||
[0.0; 8],
|
||
1.0,
|
||
InheritStyle {
|
||
color: [1.0; 4],
|
||
pat_len: 0.0,
|
||
pat: [0.0; 8],
|
||
lw_px: 1.0,
|
||
},
|
||
0,
|
||
true,
|
||
false,
|
||
1.0,
|
||
None,
|
||
None,
|
||
false,
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
1.0,
|
||
crate::scene::BlockScalePolicy::FromInsert,
|
||
false,
|
||
)
|
||
.unwrap();
|
||
|
||
let finite_points = wires
|
||
.iter()
|
||
.flat_map(|wire| &wire.points)
|
||
.filter(|point| point[0].is_finite())
|
||
.count();
|
||
assert_eq!(finite_points, 6_000);
|
||
assert_eq!(wires.len(), 1);
|
||
assert!(wires[0].render_instance.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_tangent_ellipse_arc_negative_scale_and_rotation() {
|
||
let ellipse_geom = TangentGeom::PlanarEllipse {
|
||
center: [10.0, 20.0, 0.0],
|
||
major_axis: [100.0, 0.0, 0.0],
|
||
normal: [0.0, 0.0, 1.0],
|
||
minor_axis_ratio: 0.5,
|
||
start_param: std::f64::consts::FRAC_PI_4,
|
||
end_param: 3.0 * std::f64::consts::FRAC_PI_4,
|
||
};
|
||
|
||
// Negative scale (-1, -1, 1), negative rotation -45 deg, distant translation (7000, 5000, 0)
|
||
let t = Transform::from_scaling(Vector3::new(-1.0, -1.0, 1.0))
|
||
.then(&Transform::from_rotation(
|
||
Vector3::UNIT_Z,
|
||
-std::f64::consts::FRAC_PI_4,
|
||
))
|
||
.then(&Transform::from_translation(Vector3::new(
|
||
7000.0, 5000.0, 0.0,
|
||
)));
|
||
|
||
let transformed =
|
||
transform_tangent(&ellipse_geom, &t).expect("transform_tangent should succeed");
|
||
let TangentGeom::PlanarEllipse {
|
||
center: c,
|
||
major_axis: m,
|
||
normal: n,
|
||
minor_axis_ratio: ratio,
|
||
start_param: s,
|
||
end_param: _e,
|
||
} = transformed
|
||
else {
|
||
panic!("Expected PlanarEllipse");
|
||
};
|
||
|
||
// Center must be transformed
|
||
let expected_c = t.apply(Vector3::new(10.0, 20.0, 0.0));
|
||
assert!((c[0] - expected_c.x).abs() < 1e-9);
|
||
assert!((c[1] - expected_c.y).abs() < 1e-9);
|
||
|
||
// Major axis length must be 100, ratio 0.5
|
||
let m_vec = Vector3::new(m[0], m[1], m[2]);
|
||
assert!((m_vec.length() - 100.0).abs() < 1e-9);
|
||
assert!((ratio - 0.5).abs() < 1e-9);
|
||
assert!((n[2] - 1.0).abs() < 1e-9);
|
||
|
||
// Start and end points on the curve must match mathematical transformation of original start/end
|
||
let orig_start_pt = Vector3::new(10.0, 20.0, 0.0)
|
||
+ Vector3::new(100.0, 0.0, 0.0) * (std::f64::consts::FRAC_PI_4).cos()
|
||
+ Vector3::new(0.0, 50.0, 0.0) * (std::f64::consts::FRAC_PI_4).sin();
|
||
let expected_start_pt = t.apply(orig_start_pt);
|
||
|
||
let new_v = Vector3::new(n[0], n[1], n[2]).cross(&m_vec) * ratio;
|
||
let actual_start_pt = Vector3::new(c[0], c[1], c[2]) + m_vec * s.cos() + new_v * s.sin();
|
||
|
||
assert!(
|
||
(actual_start_pt - expected_start_pt).length() < 1e-9,
|
||
"Start point mismatch: expected {expected_start_pt:?}, got {actual_start_pt:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod metrics_depth_tests {
|
||
use super::*;
|
||
use codec::entities::{Insert, Line};
|
||
use codec::tables::BlockRecord;
|
||
|
||
fn add_block(document: &mut CadDocument, name: &str) -> Handle {
|
||
let mut block = BlockRecord::new(name);
|
||
block.handle = document.allocate_handle();
|
||
let handle = block.handle;
|
||
document.block_records.add(block).unwrap();
|
||
handle
|
||
}
|
||
|
||
fn add_owned(document: &mut CadDocument, owner: Handle, mut entity: EntityType) {
|
||
entity.common_mut().owner_handle = owner;
|
||
document.add_entity(entity).unwrap();
|
||
}
|
||
|
||
/// Chain B0 → B1 → … → Bn (each block holds one INSERT of the next, the
|
||
/// last a line). Both metrics recursions guard *cycles* only, so depth n
|
||
/// reaches rayon's default 2 MiB stacks: `thread has overflowed its
|
||
/// stack` — a hard abort, not a panic. Expansion already stops at
|
||
/// `MAX_NESTING_DEPTH`; metrics must too.
|
||
#[test]
|
||
fn a_deep_insert_chain_computes_metrics_without_overflowing_the_stack() {
|
||
const DEPTH: usize = 20_000;
|
||
let mut document = CadDocument::new();
|
||
let mut prev = add_block(&mut document, "B0");
|
||
for i in 1..=DEPTH {
|
||
let cur = add_block(&mut document, &format!("B{i}"));
|
||
add_owned(
|
||
&mut document,
|
||
prev,
|
||
EntityType::Insert(Insert::new(&format!("B{i}"), Vector3::ZERO)),
|
||
);
|
||
prev = cur;
|
||
}
|
||
add_owned(
|
||
&mut document,
|
||
prev,
|
||
EntityType::Line(Line::from_points(
|
||
Vector3::ZERO,
|
||
Vector3::new(1.0, 0.0, 0.0),
|
||
)),
|
||
);
|
||
document
|
||
.add_entity(EntityType::Insert(Insert::new("B0", Vector3::ZERO)))
|
||
.unwrap();
|
||
|
||
let cache = BlockCache::build(
|
||
&document,
|
||
1.0,
|
||
None,
|
||
true,
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
None,
|
||
&HashMap::default(),
|
||
);
|
||
assert_eq!(cache.defns.len(), DEPTH + 1);
|
||
// The tail still carries the line's bounds through the depth cap.
|
||
let tail = cache
|
||
.defns
|
||
.get(&format!("B{}", DEPTH - 1))
|
||
.expect("tail block built");
|
||
assert_ne!(tail.metrics.aabb_local, [0.0; 4]);
|
||
}
|
||
|
||
/// A chain well below the depth cap is aggregated end to end: the root
|
||
/// block's bounds include the deepest line, identical to the tail's.
|
||
#[test]
|
||
fn metrics_below_the_depth_cap_are_still_fully_aggregated() {
|
||
const DEPTH: usize = 10;
|
||
let mut document = CadDocument::new();
|
||
let mut prev = add_block(&mut document, "B0");
|
||
for i in 1..=DEPTH {
|
||
let cur = add_block(&mut document, &format!("B{i}"));
|
||
add_owned(
|
||
&mut document,
|
||
prev,
|
||
EntityType::Insert(Insert::new(&format!("B{i}"), Vector3::ZERO)),
|
||
);
|
||
prev = cur;
|
||
}
|
||
add_owned(
|
||
&mut document,
|
||
prev,
|
||
EntityType::Line(Line::from_points(
|
||
Vector3::ZERO,
|
||
Vector3::new(1.0, 0.0, 0.0),
|
||
)),
|
||
);
|
||
document
|
||
.add_entity(EntityType::Insert(Insert::new("B0", Vector3::ZERO)))
|
||
.unwrap();
|
||
|
||
let cache = BlockCache::build(
|
||
&document,
|
||
1.0,
|
||
None,
|
||
true,
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
None,
|
||
&HashMap::default(),
|
||
);
|
||
let root = cache.defns.get("B0").expect("root block built");
|
||
let tail = cache
|
||
.defns
|
||
.get(&format!("B{DEPTH}"))
|
||
.expect("tail block built");
|
||
assert_ne!(root.metrics.aabb_local, [0.0; 4]);
|
||
assert_eq!(root.metrics.aabb_local, tail.metrics.aabb_local);
|
||
}
|
||
}
|