Files
OneCAD-Draft/src/scene/block_cache.rs
T
HakanandClaude Opus 4.7 0584e3c862 perf(scene): text LOD ladder 1/5 px with baseline-line tier
Replaces the 2/4 drop/greek/full ladder with 1/5 baseline/greek/full so
zoomed-out text still leaves a visual hint instead of disappearing:

  h_px < 1   → OBB baseline line in the text color
  1 ≤ h < 5  → greeked OBB rect
  h_px ≥ 5   → full per-glyph stroke

Extra guard: in the baseline tier, the line itself is dropped when its
projected length is under 2 px (single-char text seen edge-on would
otherwise emit a sub-pixel segment).

Text/MText is exempted from the generic 5 px AABB cull (top-level and
block-defn paths) so it can reach this ladder even when its bounding
box projects below the threshold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 13:19:43 +03:00

1353 lines
47 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 std::collections::HashMap;
use std::sync::Arc;
use acadrust::types::{Color as AcadColor, LineWeight, Transform, Vector3};
use acadrust::{CadDocument, EntityType, Handle};
use crate::scene::tessellate;
use crate::scene::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]>,
pub key_vertices: Vec<[f32; 3]>,
pub snap_pts: Vec<(glam::Vec3, SnapHint)>,
pub tangent_geoms: Vec<TangentGeom>,
pub fill_tris: Vec<[f32; 3]>,
pub color: [f32; 4],
pub aci: u8,
pub pattern_length: f32,
pub pattern: [f32; 8],
pub line_weight_px: f32,
pub plinegen: bool,
pub color_is_byblock: bool,
pub lt_is_byblock: bool,
pub lw_is_byblock: 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],
/// For Text / MText subs: the entity's anno-scaled glyph height in
/// local units. Lets `emit_wire` apply the same LOD ladder used for
/// top-level text (cull / greek / full) to text that's been baked into
/// a block defn. `None` for non-text entities.
pub text_height_local: Option<f32>,
/// For Text / MText subs: the 4 OBB corners in block-local coords
/// (rotation, anchor offsets and width-approximation already applied).
/// Emitted at greek time so the rect matches the text's orientation
/// instead of falling back to the axis-aligned bbox.
pub text_obb_local: Option<[[f32; 3]; 4]>,
}
#[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,
pub instance_offsets: Vec<[f64; 3]>,
}
#[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*
/// frame (i.e. `local_offset` already subtracted).
pub aabb_local: [f32; 4],
/// f64 constant subtracted from every coordinate stored inside this
/// defn (points, AABBs, text OBB corners, fill_tris, snap_pts, …).
/// Picks an origin near the defn's geometric centre so f32 storage
/// retains millimetre precision even when the source content lives at
/// large coordinates (xref data authored at UTM, MGRS, etc.). When the
/// defn is expanded the offset is re-added in f64 BEFORE the Insert
/// transform is applied — that's where precision matters.
pub local_offset: [f64; 3],
}
#[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]) -> Self {
let mut cache = Self::new();
let referenced = collect_referenced_blocks(doc);
for name in &referenced {
let defn = build_defn(doc, name, anno_scale, bg_color);
cache.defns.insert(name.clone(), Arc::new(defn));
}
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]) {
// Snapshot defn pointers up front — we mutate the map below.
let names: Vec<String> = names.to_vec();
for name in &names {
let mut visited: Vec<String> = Vec::new();
let aabb = self.defn_aabb_recursive(name, &mut visited);
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];
};
let parent_lo = defn.local_offset;
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_local = self.defn_aabb_recursive(&nref.block_name, visited);
let child_lo = self
.defns
.get(&nref.block_name)
.map(|d| d.local_offset)
.unwrap_or([0.0; 3]);
let parent_native =
transform_offset_aabb_xy(nested_local, child_lo, &nref.xform);
[
parent_native[0] - parent_lo[0] as f32,
parent_native[1] - parent_lo[1] as f32,
parent_native[2] - parent_lo[0] as f32,
parent_native[3] - parent_lo[1] as f32,
]
}
};
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 std::collections::HashSet;
let mut seen: HashSet<String> = HashSet::new();
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()
}
fn build_defn(
doc: &CadDocument,
block_name: &str,
anno_scale: f32,
bg_color: [f32; 4],
) -> BlockDefn {
let br = match doc.block_records.get(block_name) {
Some(br) => br,
None => return BlockDefn::default(),
};
// ── Pass 1: compute a defn-level offset near the geometric centre.
// Falling back to [0,0,0] for empty / nested-only defns is fine —
// precision is only at risk when stored points have large magnitudes,
// and those come from direct wire entities. Walks Insert sub-entities
// too: nested Inserts' insertion_point contribute to the centroid so a
// composite block whose only direct children are Inserts still gets a
// useful offset.
//
// Bbox values that aren't finite (corrupt entities) or that exceed
// `SANE_EXTENT` are dropped — otherwise a single bad entity would
// poison the centroid for the entire block. Tracked per-axis so a
// missing-z entity doesn't taint the x/y choice (would have produced
// `INFINITY + NEG_INFINITY = NaN` otherwise).
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
let mut have = [false; 3];
for &eh in &br.entity_handles {
let Some(entity) = doc.get_entity(eh) else {
continue;
};
if matches!(
entity,
EntityType::Block(_)
| EntityType::BlockEnd(_)
| EntityType::AttributeDefinition(_)
) {
continue;
}
// For Inserts, take the insertion_point itself (acadrust's
// `Insert::bounding_box` returns just that). One level deep is
// enough for an offset-picker — nested Inserts inside that block
// contribute via their own defns.
let (bmin, bmax) = match entity {
EntityType::Insert(ins) => (ins.insert_point, ins.insert_point),
_ => {
let bb = entity.as_entity().bounding_box();
(bb.min, bb.max)
}
};
// Skip the "placeholder ZERO bbox" that empty Polyline / Hatch /
// Spline / Mesh / etc. return when they have no vertices —
// including these would pull the centroid toward origin and ruin
// the precision-preservation for UTM-authored content.
let is_zero_placeholder = bmin.x == 0.0
&& bmin.y == 0.0
&& bmin.z == 0.0
&& bmax.x == 0.0
&& bmax.y == 0.0
&& bmax.z == 0.0;
if is_zero_placeholder {
continue;
}
let lo_arr = [bmin.x, bmin.y, bmin.z];
let hi_arr = [bmax.x, bmax.y, bmax.z];
for i in 0..3 {
let lo = lo_arr[i];
let hi = hi_arr[i];
if !lo.is_finite() || !hi.is_finite() {
continue;
}
if lo.abs() > SANE_EXTENT || hi.abs() > SANE_EXTENT {
continue;
}
if lo < min[i] { min[i] = lo }
if hi > max[i] { max[i] = hi }
have[i] = true;
}
}
let local_offset: [f64; 3] = [
if have[0] { (min[0] + max[0]) * 0.5 } else { 0.0 },
if have[1] { (min[1] + max[1]) * 0.5 } else { 0.0 },
if have[2] { (min[2] + max[2]) * 0.5 } else { 0.0 },
];
// ── 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;
};
match entity {
EntityType::Block(_)
| EntityType::BlockEnd(_)
| EntityType::AttributeDefinition(_) => continue,
EntityType::Insert(nested_ins) => {
subs.push(LocalSub::Nested(build_nested_ref(nested_ins, doc, bg_color)));
}
_ => {
if let Some(lw) =
tessellate_sub_local(doc, entity, anno_scale, bg_color, local_offset)
{
subs.push(LocalSub::Wire(lw));
}
}
}
}
BlockDefn {
subs,
aabb_local: [0.0; 4],
local_offset,
}
}
fn build_nested_ref(
nested_ins: &acadrust::entities::Insert,
doc: &CadDocument,
bg_color: [f32; 4],
) -> NestedRef {
let (mut ins_color, ins_pat_len, ins_pat, ins_lw_px, _) =
crate::scene::render::render_style_for(doc, &EntityType::Insert(nested_ins.clone()));
ins_color = crate::scene::render::adapt_to_bg(ins_color, bg_color);
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),
instance_offsets: array_offsets(nested_ins),
}
}
fn tessellate_sub_local(
doc: &CadDocument,
sub: &EntityType,
anno_scale: f32,
bg_color: [f32; 4],
local_offset: [f64; 3],
) -> Option<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 None;
}
let (sub_color, pat_len, pat, lw_px, aci) = crate::scene::render::render_style_for(doc, sub);
let sub_color = crate::scene::render::adapt_to_bg(sub_color, 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);
// 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 wire = tessellate::tessellate(
doc, h, sub, false, sub_color, pat_len, pat, lw_px, local_offset, anno_scale,
);
if wire.points.len() > 100_000 {
return None;
}
let aabb_local = aabb_from_points_iter(
wire.points.iter().copied().chain(wire.fill_tris.iter().copied()),
);
let text_height_local: Option<f32> = match sub {
EntityType::Text(t) => Some((t.height * anno_scale as f64) as f32),
EntityType::MText(m) => Some((m.height * anno_scale as f64) as f32),
_ => None,
};
// Pre-compute the OBB corners (block-offset-frame f32) for Text / MText
// so the greek path can emit a rect that matches the entity's rotation
// instead of falling back to the axis-aligned `aabb_local`. Subtract the
// defn's `local_offset` in f64 first so distant text retains precision.
let [lo_x, lo_y, lo_z] = local_offset;
let text_obb_local: Option<[[f32; 3]; 4]> =
crate::scene::text_obb_corners_native(sub, anno_scale).map(|c| {
[
[(c[0][0] - lo_x) as f32, (c[0][1] - lo_y) as f32, (c[0][2] - lo_z) as f32],
[(c[1][0] - lo_x) as f32, (c[1][1] - lo_y) as f32, (c[1][2] - lo_z) as f32],
[(c[2][0] - lo_x) as f32, (c[2][1] - lo_y) as f32, (c[2][2] - lo_z) as f32],
[(c[3][0] - lo_x) as f32, (c[3][1] - lo_y) as f32, (c[3][2] - lo_z) as f32],
]
});
Some(LocalWire {
points: wire.points,
key_vertices: wire.key_vertices,
snap_pts: wire.snap_pts,
tangent_geoms: wire.tangent_geoms,
fill_tris: wire.fill_tris,
color: sub_color,
aci,
pattern_length: pat_len,
pattern: pat,
line_weight_px: lw_px,
plinegen: wire.plinegen,
color_is_byblock,
lt_is_byblock,
lw_is_byblock,
aabb_local,
text_height_local,
text_obb_local,
})
}
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 a local-space XY AABB by `t` and return the world-space XY AABB
/// of the transformed corners. Re-adds an f64 `defn_lo` to each corner
/// before transforming — when the input AABB is stored in a defn's *offset
/// frame* (precision-preservation trick) the offset has to be applied in
/// f64 to keep the world result accurate even for distant content. Pass
/// `[0.0; 3]` for callers that don't use an offset frame.
fn transform_offset_aabb_xy(local: [f32; 4], defn_lo: [f64; 3], t: &Transform) -> [f32; 4] {
let [x0, y0, x1, y1] = local;
let [lo_x, lo_y, lo_z] = defn_lo;
let corners = [
Vector3::new(x0 as f64 + lo_x, y0 as f64 + lo_y, lo_z),
Vector3::new(x1 as f64 + lo_x, y0 as f64 + lo_y, lo_z),
Vector3::new(x1 as f64 + lo_x, y1 as f64 + lo_y, lo_z),
Vector3::new(x0 as f64 + lo_x, y1 as f64 + lo_y, lo_z),
];
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,
selected: bool,
world_offset: [f64; 3],
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],
) -> Option<Vec<WireModel>> {
let defn = cache.defn(&ins.block_name)?;
let xform = ins.get_transform();
let name = ins_handle.value().to_string();
let mut batches = Batches::default();
let mut visited: Vec<String> = Vec::with_capacity(8);
let [ox, oy, _] = world_offset;
// `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_offset_aabb_xy(defn.aabb_local, defn.local_offset, &xform);
let insert_local = [
insert_world[0] - ox as f32,
insert_world[1] - oy as f32,
insert_world[2] - ox as f32,
insert_world[3] - oy 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,
selected,
world_offset,
pslt_factor,
view_aabb,
world_per_pixel,
is_xref,
bg_color,
};
expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0);
}
Some(batches.finalize(&name, selected))
}
/// Emit a greeked rectangle for a text LocalWire — same color, OBB-sized
/// fill (rotated to match the entity), no per-glyph stroke geometry.
/// Mirrors the top-level text greek in scene/mod.rs (including the 0.45
/// dim pre-boost the face3d pipeline applies to fill_tris). Falls back to
/// the axis-aligned `local_aabb` when no OBB was precomputed.
fn emit_greeked_text(
lw: &LocalWire,
local_aabb: [f32; 4],
accum_xform: &Transform,
defn_lo: [f64; 3],
ctx: &ExpandCtx,
out: &mut Batches,
) {
let [ox, oy, oz] = ctx.world_offset;
let [lo_x, lo_y, lo_z] = defn_lo;
let tris: [[f32; 3]; 6] = if let Some(obb) = lw.text_obb_local {
// Re-add the defn's `local_offset` in f64 before composing with
// `accum_xform` (the OBB corners are stored in offset frame f32 so
// they don't lose precision for distant text). Then subtract host
// `world_offset` so the fill_tris share the batch's f32 space.
let xf = |p: [f32; 3]| -> [f32; 3] {
let w = accum_xform.apply(Vector3::new(
p[0] as f64 + lo_x,
p[1] as f64 + lo_y,
p[2] as f64 + lo_z,
));
[(w.x - ox) as f32, (w.y - oy) as f32, (w.z - oz) as f32]
};
let p00 = xf(obb[0]);
let p10 = xf(obb[1]);
let p11 = xf(obb[2]);
let p01 = xf(obb[3]);
[p00, p10, p11, p00, p11, p01]
} else {
let [x0, y0, x1, y1] = local_aabb;
let z = 0.0_f32;
[
[x0, y0, z],
[x1, y0, z],
[x1, y1, z],
[x0, y0, z],
[x1, y1, z],
[x0, y1, z],
]
};
let final_color = if ctx.selected {
WireModel::SELECTED
} else if lw.color_is_byblock {
ctx.ins_color
} else {
lw.color
};
// Apply the same xref fade as `emit_wire` BEFORE the face3d 0.45 boost,
// so the final on-screen color (boost × 0.45 dim) lands at the faded value.
let final_color = if ctx.is_xref && !ctx.selected {
fade_toward_bg(final_color, ctx.bg_color)
} else {
final_color
};
let boost = 1.0 / 0.45_f32;
let [r, g, b, a] = final_color;
let greek_color = [
(r * boost).min(1.0),
(g * boost).min(1.0),
(b * boost).min(1.0),
a,
];
let key = style_key(greek_color, 0.0, [0.0; 8], 1.0, lw.aci, true);
let entry = out
.by_style
.entry(key)
.or_insert_with(|| BatchEntry::new(greek_color, 0.0, [0.0; 8], 1.0, lw.aci, true));
for p in tris {
entry.fill_tris.push(p);
if p[0] < entry.min_x {
entry.min_x = p[0];
}
if p[1] < entry.min_y {
entry.min_y = p[1];
}
if p[0] > entry.max_x {
entry.max_x = p[0];
}
if p[1] > entry.max_y {
entry.max_y = p[1];
}
}
}
/// Emit a 2-point baseline line for a sub-pixel text LocalWire — same color
/// resolution as `emit_wire`, but the wire's stored glyph points are
/// replaced with the OBB's baseline edge (`obb[0] → obb[1]`). Falls back to
/// a no-op when no OBB is cached (rotation/attachment unknown).
fn emit_text_baseline(
lw: &LocalWire,
accum_xform: &Transform,
defn_lo: [f64; 3],
ctx: &ExpandCtx,
out: &mut Batches,
wpp: f32,
) {
let Some(obb) = lw.text_obb_local else {
return;
};
let [ox, oy, oz] = ctx.world_offset;
let [lo_x, lo_y, lo_z] = defn_lo;
let xf = |p: [f32; 3]| -> [f32; 3] {
let w = accum_xform.apply(Vector3::new(
p[0] as f64 + lo_x,
p[1] as f64 + lo_y,
p[2] as f64 + lo_z,
));
[(w.x - ox) as f32, (w.y - oy) as f32, (w.z - oz) as f32]
};
let p0 = xf(obb[0]);
let p1 = xf(obb[1]);
// Skip the baseline too if the line itself projects under 2 px on screen.
let dx = p1[0] - p0[0];
let dy = p1[1] - p0[1];
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
if len_px < 2.0 {
return;
}
let final_color = if ctx.selected {
WireModel::SELECTED
} else if lw.color_is_byblock {
ctx.ins_color
} else {
lw.color
};
let final_color = if ctx.is_xref && !ctx.selected {
fade_toward_bg(final_color, ctx.bg_color)
} else {
final_color
};
let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true);
let entry = out
.by_style
.entry(key)
.or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true));
let needs_sep = !entry.points.is_empty()
&& !entry.points.last().map(|p| p[0].is_nan()).unwrap_or(false);
if needs_sep {
entry.points.push([f32::NAN; 3]);
}
for q in [p0, p1] {
if q[0] < entry.min_x {
entry.min_x = q[0];
}
if q[1] < entry.min_y {
entry.min_y = q[1];
}
if q[0] > entry.max_x {
entry.max_x = q[0];
}
if q[1] > entry.max_y {
entry.max_y = q[1];
}
entry.points.push(q);
}
}
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,
selected: bool,
world_offset: [f64; 3],
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(super) 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,
aci: u8,
plinegen: bool,
}
#[derive(Default, Debug)]
struct BatchEntry {
color: [f32; 4],
pattern_length: f32,
pattern: [f32; 8],
line_weight_px: f32,
aci: u8,
plinegen: bool,
points: Vec<[f32; 3]>,
snap_pts: Vec<(glam::Vec3, SnapHint)>,
key_vertices: Vec<[f32; 3]>,
tangent_geoms: Vec<TangentGeom>,
fill_tris: Vec<[f32; 3]>,
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>,
}
impl BatchEntry {
fn new(color: [f32; 4], pat_len: f32, pat: [f32; 8], lw_px: f32, aci: u8, plinegen: bool) -> Self {
Self {
color,
pattern_length: pat_len,
pattern: pat,
line_weight_px: lw_px,
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) -> 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]
};
WireModel {
name: name.to_string(),
points: b.points,
color: b.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,
vp_scissor: None,
fill_tris: b.fill_tris,
}
})
.collect()
}
}
fn style_key(
color: [f32; 4],
pat_len: f32,
pat: [f32; 8],
lw_px: f32,
aci: u8,
plinegen: bool,
) -> 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(),
aci,
plinegen,
}
}
fn expand_defn(
defn: &BlockDefn,
accum_xform: &Transform,
ctx: &ExpandCtx,
out: &mut Batches,
visited: &mut Vec<String>,
depth: usize,
) {
if depth > MAX_NESTING_DEPTH {
eprintln!("block_cache: nested-block depth > {MAX_NESTING_DEPTH}, truncating");
return;
}
let defn_lo = defn.local_offset;
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_offset_aabb_xy(lw.aabb_local, defn_lo, accum_xform);
let [ox, oy, _] = ctx.world_offset;
let local = [
world[0] - ox as f32,
world[1] - oy as f32,
world[2] - ox as f32,
world[3] - oy as f32,
];
if let Some(view) = ctx.view_aabb {
if aabb_disjoint_xy(local, view) {
continue;
}
}
if let Some(wpp) = ctx.world_per_pixel {
// Text/MText follows its own ladder below (baseline-line
// / greek / full) — must reach it even when the wire's
// AABB falls under MIN_PIXEL_SIZE.
let is_text = lw.text_height_local.is_some();
if !is_text && aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
continue;
}
// Text LOD ladder: text inside a block follows the same
// 1 / 5 px baseline/greek/full rules as top-level text.
// We apply the Insert's transform scale to the stored
// local glyph height to get the screen height.
if let Some(h_local) = lw.text_height_local {
let m = &accum_xform.matrix.m;
let sy = ((m[1][0] * m[1][0]
+ m[1][1] * m[1][1]
+ m[1][2] * m[1][2]) as f64)
.sqrt() as f32;
let h_world = h_local * sy;
let h_px = h_world / wpp;
if h_px < 1.0 {
emit_text_baseline(lw, accum_xform, defn_lo, ctx, out, wpp);
continue;
}
if h_px < 5.0 {
emit_greeked_text(lw, local, accum_xform, defn_lo, ctx, out);
continue;
}
}
}
emit_wire(lw, accum_xform, defn_lo, ctx, out);
}
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_offset_aabb_xy(
nested_defn.aabb_local,
nested_defn.local_offset,
&composed,
);
let [ox, oy, _] = ctx.world_offset;
let local = [
world[0] - ox as f32,
world[1] - oy as f32,
world[2] - ox as f32,
world[3] - oy 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 ByBlock for this nested ref against the outer ctx.
let nested_color = if nref.color_is_byblock {
ctx.ins_color
} else {
nref.ins_color
};
let (nested_pat_len, nested_pat) = if nref.lt_is_byblock {
(ctx.ins_pat_len, ctx.ins_pat)
} else {
(nref.ins_pat_len, nref.ins_pat)
};
let nested_lw_px = if nref.lw_is_byblock {
ctx.ins_lw_px
} else {
nref.ins_lw_px
};
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,
selected: ctx.selected,
world_offset: ctx.world_offset,
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());
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,
);
}
visited.pop();
}
}
}
}
fn emit_wire(
lw: &LocalWire,
accum_xform: &Transform,
defn_lo: [f64; 3],
ctx: &ExpandCtx,
out: &mut Batches,
) {
if lw.points.is_empty() && lw.fill_tris.is_empty() {
return;
}
let [ox, oy, oz] = ctx.world_offset;
let [lo_x, lo_y, lo_z] = defn_lo;
// Resolve final style for this LocalWire against the outer Insert ctx
// before we hash it into a batch.
let final_color = if ctx.selected {
WireModel::SELECTED
} else if lw.color_is_byblock {
ctx.ins_color
} else {
lw.color
};
let final_color = if ctx.is_xref && !ctx.selected {
fade_toward_bg(final_color, ctx.bg_color)
} else {
final_color
};
let (final_pat_len, final_pat) = if lw.lt_is_byblock {
(ctx.ins_pat_len, ctx.ins_pat)
} else {
(lw.pattern_length, lw.pattern)
};
let final_lw_px = if lw.lw_is_byblock {
ctx.ins_lw_px
} else {
lw.line_weight_px
};
let final_pat_len = final_pat_len * ctx.pslt_factor;
let final_pat = final_pat.map(|v| v * ctx.pslt_factor);
let key = style_key(
final_color,
final_pat_len,
final_pat,
final_lw_px,
lw.aci,
lw.plinegen,
);
// 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,
lw.aci,
lw.plinegen,
)
});
// 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]);
}
for p in &lw.points {
if p[0].is_nan() {
entry.points.push([f32::NAN; 3]);
continue;
}
let v = accum_xform.apply(Vector3::new(
p[0] as f64 + lo_x,
p[1] as f64 + lo_y,
p[2] as f64 + lo_z,
));
let q = [(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32];
if q[0] < entry.min_x {
entry.min_x = q[0];
}
if q[1] < entry.min_y {
entry.min_y = q[1];
}
if q[0] > entry.max_x {
entry.max_x = q[0];
}
if q[1] > entry.max_y {
entry.max_y = q[1];
}
entry.points.push(q);
}
}
for p in &lw.key_vertices {
let v = accum_xform.apply(Vector3::new(
p[0] as f64 + lo_x,
p[1] as f64 + lo_y,
p[2] as f64 + lo_z,
));
entry
.key_vertices
.push([(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32]);
}
for (p, hint) in &lw.snap_pts {
let v = accum_xform.apply(Vector3::new(
p.x as f64 + lo_x,
p.y as f64 + lo_y,
p.z as f64 + lo_z,
));
entry.snap_pts.push((
glam::Vec3::new(
(v.x - ox) as f32,
(v.y - oy) as f32,
(v.z - oz) as f32,
),
*hint,
));
}
for tg in &lw.tangent_geoms {
entry
.tangent_geoms
.push(transform_tangent(tg, accum_xform, defn_lo, [ox, oy, oz]));
}
for p in &lw.fill_tris {
let v = accum_xform.apply(Vector3::new(
p[0] as f64 + lo_x,
p[1] as f64 + lo_y,
p[2] as f64 + lo_z,
));
entry
.fill_tris
.push([(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32]);
}
}
fn transform_tangent(
tg: &TangentGeom,
t: &Transform,
defn_lo: [f64; 3],
woff: [f64; 3],
) -> TangentGeom {
let [ox, oy, oz] = woff;
let [lo_x, lo_y, lo_z] = defn_lo;
match tg {
TangentGeom::Line { p1, p2 } => {
let q1 = t.apply(Vector3::new(
p1[0] as f64 + lo_x,
p1[1] as f64 + lo_y,
p1[2] as f64 + lo_z,
));
let q2 = t.apply(Vector3::new(
p2[0] as f64 + lo_x,
p2[1] as f64 + lo_y,
p2[2] as f64 + lo_z,
));
TangentGeom::Line {
p1: [(q1.x - ox) as f32, (q1.y - oy) as f32, (q1.z - oz) as f32],
p2: [(q2.x - ox) as f32, (q2.y - oy) as f32, (q2.z - oz) as f32],
}
}
TangentGeom::Circle { center, radius } => {
let c = t.apply(Vector3::new(
center[0] as f64 + lo_x,
center[1] as f64 + lo_y,
center[2] as f64 + lo_z,
));
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 - ox) as f32, (c.y - oy) as f32, (c.z - oz) 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
}