Files
OneCAD-Draft/src/scene/cache/block_cache.rs
T
Hakan Seven ceddb287f5 fix(renderer): match leader and dash styles
Read canonical ACAD/DSTYLE overrides for leader color and arrow size. Scale block-local linetype patterns through INSERT transforms.

Closes #436
2026-07-24 19:04:57 +03:00

1706 lines
69 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<crate::scene::pipeline::text_gpu::TextVertex>,
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]>,
/// 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<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,
}
#[derive(Clone, Debug)]
pub enum LocalSub {
Wire(LocalWire),
Nested(NestedRef),
}
#[derive(Clone, Debug, Default)]
pub struct BlockDefn {
pub subs: Vec<LocalSub>,
/// 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<String, Arc<BlockDefn>>,
}
impl BlockCache {
pub fn new() -> Self {
Self::default()
}
pub fn defn(&self, block_name: &str) -> Option<&Arc<BlockDefn>> {
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<u64, [f32; 2]>,
) -> 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<String> = 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<String>) -> [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<String> {
use rustc_hash::FxHashSet as HashSet;
let mut seen: HashSet<String> = HashSet::default();
let mut queue: Vec<String> = 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<u64, [f32; 2]>,
) -> 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<LocalSub> = 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<u64, [f32; 2]>,
) -> 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<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(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<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(
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<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,
) -> Option<Vec<WireModel>> {
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<String> = 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<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],
}
/// 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<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]>,
/// 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>,
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<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>,
}
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<WireModel> {
let extra = self.extra_wires;
let mut out: Vec<WireModel> = 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<f32>,
) -> 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<String>,
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<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
}
}
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
}