Files
OneCAD-Draft/src/scene/cache/block_cache.rs
T
Sebastian 50db12b169 refactor(scene): reference-only designation becomes a scene-scoped set (API v9)
Per the upstream review of the XDATA-based storage: the host no longer writes
its own state into the saved file. The marker record under the host-owned OCS
APPID, the on-first-use APPID registration and the verbatim-EED rewrite
handling are removed; the single source of truth is a scene-scoped in-memory
set of entity handles (Scene::reference_only) that dies with the drawing tab,
so a reopened file starts undesignated and the owning plugin re-marks on the
document-opened notification.

All behaviour sites are unchanged, driven by the set: the wire stamp moves
from an XDATA read to a set lookup threaded through the tessellation
pipelines (tessellate_entity and the dimension text path take the handle set
as a plain parameter), and the pick filters, selection retains and
update/erase refusals read Scene::is_reference_only.

Undo keeps the entity-write contract: set mutations are recorded as explicit
first-touch before/after values on each history delta (the parametric-
constraints channel pattern). A designation inside a plugin push_undo scope
joins it, a scope-less change is one step of its own, an idempotent re-mark
records nothing, erasing a designated entity drops the set entry with the
entity, and undo/redo restores entity and designation together.

The round-trip test is inverted: reference_only_designation_is_not_written_
to_dwg_or_dxf now asserts the marker appears in neither format's bytes, that
no OCS APPID or marker record survives a reload, and that the designation
stays live in memory.
2026-10-14 21:12:17 +02:00

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