// 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; use std::sync::Arc; use acadrust::types::{Color as AcadColor, LineWeight, Transform, Vector3}; use acadrust::{CadDocument, EntityType, Handle}; use crate::scene::convert::tessellate; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; const MAX_NESTING_DEPTH: usize = 32; /// 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. 2 px is the AutoCAD-default "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]>, /// 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, pub key_vertices: Vec<[f64; 3]>, pub snap_pts: Vec<(glam::DVec3, SnapHint)>, pub tangent_geoms: Vec, pub fill_tris: Vec<[f32; 3]>, pub fill_tris_low: Vec<[f32; 3]>, /// 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 aci: u8, pub pattern_length: f32, pub pattern: [f32; 8], pub line_weight_px: 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, /// 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 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 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, /// Nested INSERT's own resolved style (used when child wires need /// to inherit something via ByBlock). pub ins_color: [f32; 4], pub ins_pat_len: f32, pub ins_pat: [f32; 8], pub ins_lw_px: f32, pub color_is_byblock: bool, pub lt_is_byblock: bool, pub lw_is_byblock: bool, /// The nested INSERT's own properties are ByLayer. Combined with /// `layer_is_zero` these drive the layer-0 rule for the nested insert /// *itself* (so its ByBlock leaves inherit the outer layer, not layer 0). pub color_is_bylayer: bool, pub lt_is_bylayer: bool, pub lw_is_bylayer: bool, /// The nested INSERT itself sits on layer "0": its own layer-0 children /// chain up to the outer insert's layer rather than resolving to `l0`. pub layer_is_zero: bool, /// The nested INSERT's own layer style — the layer-0 inheritance target /// for its children when the nested insert is not itself on layer 0. pub l0: crate::scene::view::render::InheritStyle, pub instance_offsets: Vec<[f64; 3]>, /// 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>, /// 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, } #[derive(Clone, Debug)] pub enum LocalSub { Wire(LocalWire), Nested(NestedRef), } #[derive(Clone, Debug, Default)] pub struct BlockDefn { pub subs: Vec, /// Union of every sub's local AABB (including nested-INSERT contributions /// resolved at expand time via their own defn's `aabb_local`). XY only — /// the wire renderer is 2D-dominant. Expressed in this defn's *offset* /// Absolute world-space XY (the double-single render path keeps it precise). pub aabb_local: [f32; 4], /// 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)` — the same formula the /// exploded-fill path uses, so bands and fills stay on one depth scale. pub child_count: usize, } #[derive(Default, Debug)] pub struct BlockCache { defns: HashMap>, } impl BlockCache { pub fn new() -> Self { Self::default() } pub fn defn(&self, block_name: &str) -> Option<&Arc> { self.defns.get(block_name) } /// Build (flat) defns only for block records actually referenced by /// Inserts in the document — transitively, so nested-insert targets are /// included too. The Model_Space / Paper_Space block_records are skipped /// because their entities are emitted as top-level wires, not via the /// cache. pub fn build( doc: &CadDocument, anno_scale: f32, bg_color: [f32; 4], // Scene draw-depth map ([depth, half] per handle) — source of each // block child's in-block rank, so band depth composition agrees with // the SortEntitiesTable-aware ranking the fill explosion uses. depth_map: &HashMap, ) -> Self { use crate::par::prelude::*; let mut cache = Self::new(); let referenced = collect_referenced_blocks(doc); // 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. `compute_block_aabbs` stays a serial // post-pass (it resolves nested references and is comparatively cheap). cache.defns = referenced .par_iter() .map(|name| { ( name.clone(), Arc::new(build_defn(doc, name, anno_scale, bg_color, depth_map)), ) }) .collect(); cache.compute_block_aabbs(&referenced); cache } /// Compute and store the `aabb_local` for every cached defn. Direct wires /// contribute their own aabb_local; nested INSERT references look up the /// nested defn (already cached) and transform its aabb_local by the /// nested Insert's transform before unioning. /// /// Run as a post-pass so it doesn't matter which order build_defn was /// called in. Cycle guard: a self-referential block keeps an empty AABB /// (will fail every frustum test → not emitted, which is correct). fn compute_block_aabbs(&mut self, names: &[String]) { use crate::par::prelude::*; // Phase 1 (parallel, read-only): each defn's union AABB is resolved // by `defn_aabb_recursive`, which only *reads* `self.defns` (the map // is fully built by now). There's no memoization, so a defn shared by // many parents is re-walked per parent — real work on block-heavy // drawings — and the per-name walks are independent, so they fan out. let this: &Self = self; let resolved: Vec<(&String, [f32; 4])> = names .par_iter() .map(|name| { let mut visited: Vec = Vec::new(); (name, this.defn_aabb_recursive(name, &mut visited)) }) .collect(); // Phase 2 (serial): store the AABB back into each defn. for (name, aabb) in resolved { if let Some(defn_arc) = self.defns.get_mut(name) { let mut defn = (**defn_arc).clone(); defn.aabb_local = aabb; *defn_arc = Arc::new(defn); } } } /// Returns the union AABB for `block_name`'s defn, expressed in **that /// defn's offset frame** (so its caller can store it in /// `BlockDefn.aabb_local` without a coordinate-frame mismatch). /// /// LocalWire contributions are already in the parent defn's offset /// frame. Nested-INSERT contributions live in the *child* defn's offset /// frame, so we re-add `child.local_offset` (f64), apply the nested /// Insert's transform to get parent-native coordinates, then subtract /// `parent.local_offset` to land back in the parent's offset frame. fn defn_aabb_recursive(&self, block_name: &str, visited: &mut Vec) -> [f32; 4] { if visited.iter().any(|n| n == block_name) { return [0.0, 0.0, 0.0, 0.0]; } let Some(defn) = self.defns.get(block_name) else { return [0.0, 0.0, 0.0, 0.0]; }; visited.push(block_name.to_string()); let mut acc = [0.0_f32, 0.0, 0.0, 0.0]; for sub in &defn.subs { let aabb = match sub { LocalSub::Wire(lw) => lw.aabb_local, LocalSub::Nested(nref) => { let nested = self.defn_aabb_recursive(&nref.block_name, visited); transform_aabb_xy(nested, &nref.xform) } }; acc = aabb_union(acc, aabb); } visited.pop(); acc } } /// Walk all entities + all block_record contents collecting every distinct /// `block_name` that appears in an Insert (transitively). fn collect_referenced_blocks(doc: &CadDocument) -> Vec { use rustc_hash::FxHashSet as HashSet; let mut seen: HashSet = HashSet::default(); let mut queue: Vec = Vec::new(); for entity in doc.entities() { if let EntityType::Insert(ins) = entity { if seen.insert(ins.block_name.clone()) { queue.push(ins.block_name.clone()); } } } while let Some(name) = queue.pop() { let Some(br) = doc.block_records.get(&name) else { continue; }; for &eh in &br.entity_handles { let Some(entity) = doc.get_entity(eh) else { continue; }; if let EntityType::Insert(ins) = entity { if seen.insert(ins.block_name.clone()) { queue.push(ins.block_name.clone()); } } } } 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 build_defn( doc: &CadDocument, block_name: &str, anno_scale: f32, bg_color: [f32; 4], depth_map: &HashMap, ) -> BlockDefn { let br = match doc.block_records.get(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 = Vec::with_capacity(cap); for &eh in &br.entity_handles { let Some(entity) = doc.get_entity(eh) else { continue; }; // 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(doc, entity.common()) { 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 } EntityType::Insert(nested_ins) => { subs.push(LocalSub::Nested(build_nested_ref( nested_ins, doc, bg_color, depth_map, ))); } // A dimension nested in a block bakes its geometry (extension / // dim lines, arrows, text) into a per-instance `*D` block, exactly // like a top-level dimension. The top-level path expands that block // in `tessellate_entity`; the block-expand path calls the plain // `tessellate::tessellate`, which does NOT, so nested dimensions // drew nothing. Expand the `*D` block's entities here as block-local // subs so they transform with the parent insert. (Empty block_name // — a non-baked dimension — falls through to the default arm.) EntityType::Dimension(dim) if !dim.base().block_name.trim().is_empty() => { let dblk = doc .block_records .iter() .find(|br| br.name.eq_ignore_ascii_case(&dim.base().block_name)); if let Some(dblk) = dblk { // The `*D` block content is baked in the coordinate space it // occupied when the dimension was created — for a dimension // inside a block that is often the ORIGINAL WCS, not the // block-local space. The dimension's insertion_point (DXF // 12) is the offset that maps the baked content into the // dimension's own (block-local) space; it is zero for // dimensions baked in place, so this is a no-op there. let ins = dim.base().insertion_point; for &deh in &dblk.entity_handles { let Some(dsub) = doc.get_entity(deh) else { continue; }; // Definition points are baked as POINTs on Defpoints — // grip markers, never drawn (matches the top-level path). if matches!(dsub, EntityType::Point(_)) { continue; } if dsub.common().invisible || layer_hidden(doc, &dsub.common().layer) { continue; } let mut placed = dsub.clone(); placed.as_entity_mut().translate(ins); // An arrowhead is baked as a nested INSERT of an arrow // block — route it through the nested-ref machinery so // the arrow block expands (tessellate_sub_local doesn't // expand inserts). if let EntityType::Insert(arrow) = &placed { subs.push(LocalSub::Nested(build_nested_ref( arrow, doc, bg_color, depth_map, ))); } else { for lw in tessellate_sub_local( doc, &placed, anno_scale, bg_color, depth_map, ) { subs.push(LocalSub::Wire(lw)); } } } } } // A table nested in a block bakes its geometry (gridlines, cell // text, fills) into a `*T` block, laid out in block-LOCAL space with // the table carrying the world placement in insertion_point + // horizontal_direction (like an INSERT). The top-level path expands // and places it in `tessellate_entity`; the block-expand path skips // that, so a nested table drew nothing. Place each `*T` sub by the // table's rotation+insertion here (defn-local), then tessellate — the // parent insert transform composes on top at expand time. EntityType::Table(tab) => { let tblk = tab .block_record_handle .and_then(|h| doc.block_records.iter().find(|br| br.handle == h)); if let Some(tblk) = tblk { let ins = tab.insertion_point; let angle = tab.horizontal_direction.y.atan2(tab.horizontal_direction.x); for &teh in &tblk.entity_handles { let Some(tsub) = doc.get_entity(teh) else { continue; }; if tsub.common().invisible || layer_hidden(doc, &tsub.common().layer) { continue; } let mut placed = tsub.clone(); { let ent = placed.as_entity_mut(); if angle.abs() > 1e-9 { ent.apply_rotation( acadrust::types::Vector3::new(0.0, 0.0, 1.0), angle, ); } ent.translate(ins); } // A baked table can nest inserts too (block cells); // route those through the nested-ref path like the // dimension arrows above. if let EntityType::Insert(cell_ins) = &placed { subs.push(LocalSub::Nested(build_nested_ref( cell_ins, doc, bg_color, depth_map, ))); } else { for lw in tessellate_sub_local(doc, &placed, anno_scale, bg_color, depth_map) { subs.push(LocalSub::Wire(lw)); } } } } } _ => { // 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, bg_color, depth_map) { subs.push(LocalSub::Wire(lw)); } } } } BlockDefn { subs, aabb_local: [0.0; 4], child_count: br.entity_handles.len(), } } fn build_nested_ref( nested_ins: &acadrust::entities::Insert, doc: &CadDocument, bg_color: [f32; 4], depth_map: &HashMap, ) -> NestedRef { // Store the RAW colour — `adapt_to_bg` runs at emit time // (`Batches::finalize`) so the same cached defn can serve renders // against different backgrounds without rebuilding. let (ins_color, ins_pat_len, ins_pat, ins_lw_px, _) = crate::scene::view::render::render_style_for(doc, &EntityType::Insert(nested_ins.clone())); // The nested insert's own layer style — the layer-0 target for its // children (raw colour; adapted at emit like `ins_color`). let l0 = crate::scene::view::render::layer_render_style(doc, &nested_ins.common.layer); let _ = bg_color; // 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 clip_poly = crate::scene::pick::xclip::insert_spatial_filter(doc, nested_ins) .map(|sf| crate::scene::pick::xclip::world_clip_polygon_f64(sf, nested_ins)); NestedRef { block_name: nested_ins.block_name.clone(), xform: nested_ins.get_transform(), ins_color, ins_pat_len, ins_pat, ins_lw_px, color_is_byblock: nested_ins.common.color == AcadColor::ByBlock, lt_is_byblock: nested_ins.common.linetype.eq_ignore_ascii_case("byblock"), lw_is_byblock: matches!(nested_ins.common.line_weight, LineWeight::ByBlock), color_is_bylayer: nested_ins.common.color == AcadColor::ByLayer, lt_is_bylayer: { let lt = &nested_ins.common.linetype; lt.is_empty() || lt.eq_ignore_ascii_case("bylayer") }, lw_is_bylayer: matches!( nested_ins.common.line_weight, LineWeight::ByLayer | LineWeight::Default ), layer_is_zero: crate::scene::view::render::is_effective_layer_zero( &nested_ins.common.layer, ), l0, instance_offsets: array_offsets(nested_ins), clip_poly, local_rank: depth_map .get(&nested_ins.common.handle.value()) .map_or(0.0, |d| d[0]), } } fn tessellate_sub_local( doc: &CadDocument, sub: &EntityType, anno_scale: f32, bg_color: [f32; 4], depth_map: &HashMap, ) -> Vec { 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(doc, sub); let _ = bg_color; let color_is_byblock = 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 color_l0 = on_l0 && sub.common().color == AcadColor::ByLayer; 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 = tessellate::tessellate( doc, h, sub, false, sub_color, pat_len, pat, lw_px, anno_scale, None, bg_color, false, ); if wires_out.is_empty() { return vec![]; } 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(); // 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, text_verts: wire.text_verts, 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, pick_tris: wire.pick_tris, pick_tris_low: wire.pick_tris_low, color: wire.color, aci, pattern_length: pat_len, pattern: pat, line_weight_px: lw_px, world_width: wire.world_width, plinegen: wire.plinegen, is_fill_only, color_is_byblock: color_is_byblock && wire_on_base_color, lt_is_byblock, lw_is_byblock, color_l0: color_l0 && wire_on_base_color, 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>(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( cache: &BlockCache, ins: &acadrust::entities::Insert, ins_handle: Handle, ins_resolved_color: [f32; 4], 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, 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, // 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, ) -> Option> { let defn = cache.defn(&ins.block_name)?; let mut xform = ins.get_transform(); // Annotative blocks (the flag lives on the block definition; the instance is // marked with the AcAnnotativeData XDATA) scale as ONE uniform unit about // their insertion point — internal geometry/text/attributes are carried by // this transform, never scaled individually (which would double-scale). if (anno_scale - 1.0).abs() > 1e-6 && ins .common .extended_data .get_record("AcAnnotativeData") .is_some() { let p = ins.insert_point; let scale_about_p = Transform::from_translation(Vector3::new(-p.x, -p.y, -p.z)) .then(&Transform::from_scale(anno_scale as f64)) .then(&Transform::from_translation(Vector3::new(p.x, p.y, p.z))); xform = xform.then(&scale_about_p); } let name = ins_handle.value().to_string(); let mut batches = Batches::default(); let mut visited: Vec = Vec::with_capacity(8); // `defn.aabb_local` is in the defn's offset frame — re-add // `defn.local_offset` (f64) before transforming so the world AABB is // accurate for distant content. let insert_world = transform_aabb_xy(defn.aabb_local, &xform); 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 aabb_pixel_size(insert_local, wpp) < MIN_PIXEL_SIZE { return Some(vec![]); } } for offset in &array_offsets(ins) { let base_xform = if offset == &[0.0; 3] { xform.clone() } else { let translation = Transform::from_translation(Vector3::new( offset[0], offset[1], offset[2], )); translation.then(&xform) }; let ctx = ExpandCtx { cache, ins_color: ins_resolved_color, ins_pat_len, ins_pat, ins_lw_px, l0: ins_layer, selected, pslt_factor, view_aabb, world_per_pixel, is_xref, bg_color, }; expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0, (0.0, 1.0)); } Some(batches.finalize(&name, selected, bg_color)) } 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 } struct ExpandCtx<'a> { cache: &'a BlockCache, ins_color: [f32; 4], 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, 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, // 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], } /// 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], ] } /// 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], pattern_length: u32, pattern: [u32; 8], line_weight_px: 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, 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, /// 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(Default, Debug)] struct BatchEntry { color: [f32; 4], pattern_length: f32, pattern: [f32; 8], line_weight_px: f32, world_width: f32, 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, points: Vec<[f32; 3]>, points_low: Vec<[f32; 3]>, snap_pts: Vec<(glam::DVec3, SnapHint)>, key_vertices: Vec<[f64; 3]>, tangent_geoms: Vec, 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]>, /// 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, min_x: f32, min_y: f32, max_x: f32, max_y: f32, } /// 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, /// Batches that overflowed `MAX_POINTS_PER_BATCH` and have been closed. closed: Vec, /// 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, } impl BatchEntry { fn new( color: [f32; 4], pat_len: f32, pat: [f32; 8], lw_px: f32, world_width: f32, aci: u8, plinegen: bool, _is_fill_only: 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, pattern_length: pat_len, pattern: pat, line_weight_px: lw_px, world_width, aci, plinegen, 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 { let extra = self.extra_wires; let mut out: Vec = self .closed .into_iter() .chain(self.by_style.into_values()) .map(|b| { let aabb = if b.min_x.is_infinite() { WireModel::UNBOUNDED_AABB } else { [b.min_x, b.min_y, b.max_x, b.max_y] }; // RAW colour came from `tessellate_sub_local` (and from // `expand_defn`'s ByBlock fallbacks); apply `adapt_to_bg` // now so each render against a different bg gets the // right pure-black ↔ pure-white flip without rebuilding // the cached defn. let color = crate::scene::view::render::adapt_to_bg(b.color, bg_color); WireModel { taper_widths: Vec::new(), world_width: b.world_width, depth_override: b.local_depth, fill_is_3d: false, 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, 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], pat_len: f32, pat: [f32; 8], lw_px: f32, world_width: f32, aci: u8, plinegen: bool, is_fill_only: bool, local_depth: Option, ) -> StyleKey { StyleKey { color: [ color[0].to_bits(), color[1].to_bits(), color[2].to_bits(), color[3].to_bits(), ], 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(), world_width: world_width.to_bits(), aci, plinegen, is_fill_only, 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, depth: usize, // 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) => { // `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) => { if visited.iter().any(|n| n == &nref.block_name) { // Cycle — skip. continue; } let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else { continue; }; // Nested-INSERT cull: union AABB of the nested defn, // transformed by composed xform, vs view rect + pixel size. // `nested_defn.aabb_local` lives in the nested defn's offset // frame — re-add `nested_defn.local_offset` in f64 before // composing with the parent transforms. let composed = nref.xform.then(accum_xform); let world = transform_aabb_xy(nested_defn.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; } } // Resolve the nested insert's own style against the outer ctx: // ByBlock inherits the outer insert; a nested insert that is // itself on layer "0" with ByLayer props inherits the outer // layer-0 target (so its ByBlock leaves resolve to that layer, // not layer 0). Mirrors the leaf resolution in emit_wire. let nested_color = if nref.color_is_byblock { ctx.ins_color } else if nref.layer_is_zero && nref.color_is_bylayer { ctx.l0.color } else { nref.ins_color }; let (nested_pat_len, nested_pat) = if nref.lt_is_byblock { (ctx.ins_pat_len, ctx.ins_pat) } else if nref.layer_is_zero && nref.lt_is_bylayer { (ctx.l0.pat_len, ctx.l0.pat) } else { (nref.ins_pat_len, nref.ins_pat) }; let nested_lw_px = if nref.lw_is_byblock { ctx.ins_lw_px } else if nref.layer_is_zero && nref.lw_is_bylayer { ctx.l0.lw_px } else { nref.ins_lw_px }; // Layer-0 target for the nested expansion: a nested insert that // is itself on layer 0 chains up to the outer target; otherwise // its layer-0 children resolve to the nested insert's own layer. let nested_l0 = if nref.layer_is_zero { ctx.l0 } else { nref.l0 }; let inner_ctx = ExpandCtx { cache: ctx.cache, ins_color: nested_color, ins_pat_len: nested_pat_len, ins_pat: nested_pat, ins_lw_px: nested_lw_px, l0: nested_l0, 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, }; visited.push(nref.block_name.clone()); // Children of this nested insert stack inside the slot its own // rank owns — same composition the exploded-fill path 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), ); if let Some(cp) = &nref.clip_poly { // XCLIP'd nested insert: expand into an isolated batch set, // finalize to whole wires, clip them to the boundary mapped // into world by the accumulated transform, then carry the // clipped wires out via `extra_wires`. Single instance only — // an array of clipped nested inserts is vanishingly rare, so // the array case is not handled (boundary placement per // instance would differ). let composed = nref.xform.then(accum_xform); let mut sub = Batches::default(); expand_defn( nested_defn, &composed, &inner_ctx, &mut sub, visited, depth + 1, nested_range, ); let mut wires = sub.finalize("", ctx.selected, ctx.bg_color); let world_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(); crate::scene::pick::xclip::clip_wires(&mut wires, &world_poly); out.extra_wires.append(&mut wires); } else { for offset in &nref.instance_offsets { let composed = if offset == &[0.0; 3] { nref.xform.then(accum_xform) } else { let translation = Transform::from_translation(Vector3::new( offset[0], offset[1], offset[2], )); translation.then(&nref.xform).then(accum_xform) }; expand_defn( nested_defn, &composed, &inner_ctx, out, visited, depth + 1, nested_range, ); } } 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 c = if ctx.selected { WireModel::SELECTED } else if lw.color_is_byblock { ctx.ins_color } else if lw.color_l0 { // Inherit the insert layer's RGB but keep the child's own transparency. [ctx.l0.color[0], ctx.l0.color[1], ctx.l0.color[2], lw.color[3]] } else { lw.color }; if ctx.is_xref && !ctx.selected { fade_toward_bg(c, ctx.bg_color) } else { c } } /// 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 = 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 } } 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_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 }; // Pattern distances are stored in block-local units. Scale them by the // wire's actual path-length ratio so uniform and non-uniform INSERTs both // stay dimensionally consistent with their transformed geometry. let pattern_scale = if final_pat_len > 0.0 { transformed_wire_length_scale(lw, accum_xform) } 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); // 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 { 0.0 }; // Only 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 — thin wires keep the shared whole-insert depth so same-style // batches stay merged. let local_depth = (lw.world_width > 0.0) .then(|| d_range.0 + lw.local_rank * d_range.1); let key = style_key( final_color, final_pat_len, final_pat, final_lw_px, final_world_width, lw.aci, lw.plinegen, lw.is_fill_only, local_depth, ); // 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, final_pat_len, final_pat, final_lw_px, final_world_width, lw.aci, lw.plinegen, lw.is_fill_only, ) }); entry.local_depth = local_depth; // 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]); } // 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() { if p[0].is_nan() { entry.points.push([f32::NAN; 3]); entry.points_low.push([0.0; 3]); 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 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 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]); } } 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 { entry .tangent_geoms .push(transform_tangent(tg, accum_xform)); } // 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], tv.color[3]], draw_depth: tv.draw_depth, }); } } fn transform_tangent( tg: &TangentGeom, t: &Transform, ) -> 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, )); 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; TangentGeom::Circle { center: [(c.x) as f32, (c.y) as f32, (c.z) as f32], radius: radius * s, } } } } /// Radius / coordinate cap above which adaptive curve tessellation will /// allocate hundreds of millions of points. `parameter_division` samples /// to a fixed chord tolerance, so a Circle of radius 1e10 already produces /// tens of millions of points. const SANE_EXTENT: f64 = 1.0e8; fn is_unreasonable_extent(e: &EntityType) -> bool { // Adaptive curve tessellation also explodes on degenerate primitives // (radius = 0, axes of length 0): `parameter_division` allocates // proportional to range/tolerance, which underflows when the curve // collapses to a point. Drop both ends of the spectrum. 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, } } fn array_offsets(ins: &acadrust::entities::Insert) -> Vec<[f64; 3]> { if !ins.is_minsert() { return vec![[0.0; 3]]; } let mut offsets = Vec::with_capacity(ins.instance_count()); for row in 0..ins.row_count { for col in 0..ins.column_count { offsets.push([ col as f64 * ins.column_spacing, row as f64 * ins.row_spacing, 0.0, ]); } } offsets }