cad-editor/src/scene/convert/tess.rs
Hakan Seven 56e1c03ac2 feat(layers): enforce layer lock (select/modify/fade + cursor badge)
Locked-layer objects were fully editable. Now they stay visible and
snappable but are otherwise protected:

- Not selectable: the five interactive pick paths (box, lasso, single
  click, cycling, box-release) plus SELECTALL and QSELECT skip locked
  entities.
- Not modifiable (defense-in-depth even if a handle slips through):
  transform_entities, erase_entities, apply_grip and the Properties
  commit all skip locked entities.
- Faded: entities on a locked layer render dimmed toward the background
  (render_style + wire tessellation); toggling lock re-tessellates.
- Snap still works: locked entities are intentionally kept in the wire
  set (visibility_ok untouched), so object snap keeps finding them.
- Feedback: a small padlock is drawn by the crosshair when hovering a
  locked object, and clicking one prints "Object is on locked layer …".

New Scene::is_layer_locked / locked_layer_name helpers centralise the
check (mirroring layer_hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:10:39 +03:00

927 lines
38 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.

// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
use super::super::*;
/// Dim an already-bg-adapted colour toward the background when the entity's
/// layer is locked, so locked objects read as non-editable (they stay visible
/// and snappable). No-op for unlocked layers.
fn fade_if_locked(
document: &acadrust::CadDocument,
e: &EntityType,
color: [f32; 4],
bg: [f32; 4],
) -> [f32; 4] {
if view::render::layer_locked(document, e) {
crate::scene::cache::block_cache::fade_toward_bg(color, bg)
} else {
color
}
}
// ── Parallel tessellation free function ──────────────────────────────────────
//
// Takes only the `Send + Sync` data needed for tessellation so that
// `wires_for_block` can dispatch work across rayon's thread pool without
// requiring `Scene` (which contains `Rc<RefCell<...>>` and is `!Send`) to
// cross thread boundaries.
/// Tessellate a synthesised dimension-text entity through `tessellate_entity`
/// so it picks up the standard text LOD ladder (baseline / greek / full),
/// then re-color the returned wires with the dimension's resolved text colour
/// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults).
pub(crate) fn tessellate_entity_dim_text(
document: &acadrust::CadDocument,
selected: &HashSet<Handle>,
active_viewport: Option<Handle>,
bg_color: [f32; 4],
anno_scale: f32,
e: &EntityType,
view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>,
text_color: [f32; 4],
) -> Vec<WireModel> {
let mut wires = tessellate_entity(
document, selected, active_viewport, bg_color,
anno_scale, e, None, view_aabb, world_per_pixel,
);
for w in &mut wires {
// Synth dim text carries no real entity colour — paint everything
// (including greek-LOD fill tris which read `wire.color`) with the
// dim's text colour. Selection highlight already baked in by
// tessellate_entity, so leave that alone.
if !w.selected {
w.color = text_color;
}
}
wires
}
pub(crate) fn tessellate_entity(
document: &acadrust::CadDocument,
selected: &HashSet<Handle>,
active_viewport: Option<Handle>,
bg_color: [f32; 4],
anno_scale: f32,
e: &EntityType,
block_cache: Option<&cache::block_cache::BlockCache>,
// World-space XY view AABB (post `world_offset` subtraction). When
// `Some`, entities whose AABB doesn't intersect this rect are skipped.
view_aabb: Option<[f32; 4]>,
// World units per screen pixel for LOD culling. `None` = no LOD.
world_per_pixel: Option<f32>,
) -> Vec<WireModel> {
let h = e.common().handle;
let sel = selected.contains(&h);
// Frustum + LOD cull for non-Insert, non-Viewport entities. Insert is
// handled separately (its WCS bbox depends on the block defn AABB ×
// Insert transform — done inside expand_insert). Viewports always emit
// so the viewport frame stays visible regardless of zoom.
let needs_cull = view_aabb.is_some() || world_per_pixel.is_some();
if needs_cull {
match e {
EntityType::Viewport(_) | EntityType::Insert(_) => {}
_ => {
let ab = entity_aabb(e);
if ab != WireModel::UNBOUNDED_AABB {
if let Some(view) = view_aabb {
if cache::block_cache::aabb_disjoint_xy(ab, view) {
return vec![];
}
}
if let Some(wpp) = world_per_pixel {
let w_px = (ab[2] - ab[0]).abs();
let h_px = (ab[3] - ab[1]).abs();
// Keep in sync with `cache::block_cache::MIN_PIXEL_SIZE`.
// Text/MText have their own LOD ladder below
// (baseline-line / greek / full) and must reach it
// even when projected size is sub-5 px.
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
// Face3D is exempt from the sub-pixel stub: it is trivially
// cheap to tessellate (4 corners → 2 tris), so there is no
// cost to draw it full at any zoom, and the cube-stub
// otherwise pops/coarsens flat faces across the threshold.
let is_face3d = matches!(e, EntityType::Face3D(_));
let is_3d_entity = matches!(
e,
EntityType::Solid3D(_)
| EntityType::Mesh(_)
| EntityType::PolyfaceMesh(_)
| EntityType::PolygonMesh(_)
| EntityType::Body(_)
| EntityType::Region(_)
| EntityType::Surface(_)
);
if !is_text && !is_face3d && w_px.max(h_px) / wpp < 5.0 {
// Sub-pixel entity: emit a stub instead of
// nothing so it stays visible / selectable /
// hit-test'able at any zoom. 2-D entities
// get the cheap diagonal segment; 3-D
// entities get an AABB cube so their
// footprint doesn't drift when the camera
// crosses the LOD threshold. See #19.
let (entity_color, _, _, _, aci_idx) =
view::render::render_style_for(document, e);
let entity_color = view::render::adapt_to_bg(entity_color, bg_color);
let entity_color = fade_if_locked(document, e, entity_color, bg_color);
if is_3d_entity {
// `ab` is already in the local frame
// (entity_aabb subtracted world_offset
// XY). The bbox z fields are still in
// WCS, so subtract `world_offset[2]` to
// match — otherwise the stub sits at a
// different z than the full tessellation
// and the geometry visibly shifts when
// the camera crosses the LOD threshold.
let bbox = e.as_entity().bounding_box();
let oz = 0.0_f64;
let z_min = (bbox.min.z - oz) as f32;
let z_max = (bbox.max.z - oz) as f32;
return vec![lod_stub_wire_3d(
h.value().to_string(),
entity_color,
sel,
aci_idx,
ab,
z_min,
z_max,
)];
}
return vec![lod_stub_wire(
h.value().to_string(),
entity_color,
sel,
aci_idx,
ab,
0.0,
0.0,
)];
}
}
}
}
}
}
if let EntityType::Viewport(vp) = e {
// The sheet viewport (overall/id=1) is never shown — it represents the
// paper boundary, not a user-defined content window.
if !Scene::is_content_viewport(vp) {
return vec![];
}
let is_active = active_viewport == Some(h);
let is_locked = vp.status.locked;
let color = if sel {
[1.0, 1.0, 1.0, 1.0]
} else if is_active {
[1.0, 0.90, 0.20, 1.0]
} else if is_locked {
[0.90, 0.55, 0.10, 1.0]
} else {
[0.0, 0.75, 0.75, 1.0]
};
let (pattern_length, pattern) = if is_active {
(1.5_f32, [0.8, -0.4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0_f32])
} else {
(0.0_f32, [0.0f32; 8])
};
let mut wires = convert::tessellate::tessellate(
document,
h,
e,
sel,
color,
pattern_length,
pattern,
1.5,
1.0,
world_per_pixel,
);
let ab = entity_aabb(e);
for w in &mut wires {
w.aabb = ab;
}
return wires;
}
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
view::render::render_style_for(document, e);
let entity_color = view::render::adapt_to_bg(entity_color, bg_color);
let entity_color = fade_if_locked(document, e, entity_color, bg_color);
let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32;
let lt_name = view::render::linetype_name_for(document, e);
// PSLTSCALE: scale linetype dashes by viewport anno_scale so they appear uniform in paper space.
let pslt_factor = if document.header.paper_space_linetype_scaling {
anno_scale
} else {
1.0
};
let pattern_length = pattern_length * pslt_factor;
let pattern = pattern.map(|v| v * pslt_factor);
// ── Dimension baked-block fast path ─────────────────────────────────────
//
// AutoCAD bakes each dimension's final geometry (extension lines, dim
// line, arrows, text MText) into a per-instance block — usually
// `*D<n>`, but custom names like `DIMBLOCK###-4NP` also occur. When the
// block exists we render its contents through `tessellate_entity` so
// sub-Text/MText get the standard baseline/greek/full LOD ladder, and
// DIMTXT × DIMSCALE isn't re-applied on already-baked geometry.
if let EntityType::Dimension(dim) = e {
let block_name = &dim.base().block_name;
if !block_name.trim().is_empty() {
if let Some(br) = document
.block_records
.iter()
.find(|br| br.name.eq_ignore_ascii_case(block_name))
{
if !br.entity_handles.is_empty() {
let mut wires: Vec<WireModel> =
Vec::with_capacity(br.entity_handles.len());
// The Dimension's own layer style — layer-0 inheritance
// target for baked sub-entities on layer "0" (#221).
let dim_l0_color = view::render::adapt_to_bg(
view::render::layer_render_style(document, &e.common().layer).color,
bg_color,
);
let dim_l0_aci = document
.layers
.get(&e.common().layer)
.map(|l| match &l.color {
acadrust::types::Color::Index(i) => *i,
_ => 0,
})
.unwrap_or(0);
for &eh in &br.entity_handles {
let Some(sub) = document.get_entity(eh) else { continue };
// Sub-entities inside *D### / DIMBLOCK## blocks
// typically use ByBlock color/linetype/lineweight —
// they should inherit from the Dimension entity.
let sub_color_is_byblock =
sub.common().color == acadrust::types::Color::ByBlock;
let sub_is_l0_bylayer = sub.common().layer == "0"
&& sub.common().color == acadrust::types::Color::ByLayer;
let sub_wires = tessellate_entity(
document, selected, active_viewport, bg_color,
// Block contents are baked at the final WCS size —
// don't let downstream paths re-apply anno_scale.
1.0, sub, block_cache, view_aabb, world_per_pixel,
);
for mut w in sub_wires {
w.name = h.value().to_string();
// Override ByBlock colour with the dim's resolved
// colour so text matches `DIMCLRT`-style behaviour
// (or layer colour) instead of the raw ByBlock
// fallback that render_style_for produces. A layer-0
// sub inherits the dim's layer colour instead.
if sub_color_is_byblock {
w.color = if sel { WireModel::SELECTED } else { entity_color };
w.aci = aci;
} else if sub_is_l0_bylayer && !sel {
w.color = dim_l0_color;
w.aci = dim_l0_aci;
}
wires.push(w);
}
}
if !wires.is_empty() {
let aabb = entity_aabb(e);
for w in &mut wires {
w.aabb = aabb;
}
return wires;
}
}
}
}
// Fall through to the synthesis path below when no block is attached.
}
if let EntityType::Dimension(dim) = e {
let aabb = entity_aabb(e);
use crate::entities::dimension::DimensionTess;
let mut wires = dim.tessellate(
document,
h,
sel,
entity_color,
line_weight_px,
anno_scale,
selected,
active_viewport,
bg_color,
view_aabb,
world_per_pixel,
);
for w in &mut wires {
w.aci = aci;
w.aabb = aabb;
}
return wires;
}
if let EntityType::MultiLeader(ml) = e {
let aabb = entity_aabb(e);
use crate::entities::multileader::MultiLeaderTess;
let mut wires = ml.tessellate(
document,
h,
sel,
entity_color,
line_weight_px,
anno_scale,
world_per_pixel,
);
for w in &mut wires {
w.aci = aci;
w.aabb = aabb;
}
return wires;
}
// ── Table baked-block fast path ─────────────────────────────────────────
//
// AutoCAD bakes a Table's final rendered geometry (cell text, gridlines,
// fill) into a per-instance block (usually `*T###`) referenced through
// `table.block_record_handle`. The block's text uses the *displayed*
// height; synthesising cells from `self.rows + TableStyle` instead would
// re-apply the table's scale factor on top of already-baked geometry.
// When the block exists we render it directly. Same pattern as
// Dimension's `block_name`.
if let EntityType::Table(tab) = e {
if let Some(br_h) = tab.block_record_handle {
if let Some(br) = document
.block_records
.iter()
.find(|br| br.handle == br_h)
{
if !br.entity_handles.is_empty() {
let mut wires: Vec<WireModel> =
Vec::with_capacity(br.entity_handles.len());
// The Table's own layer style — layer-0 inheritance target
// for baked sub-entities on layer "0" (#221).
let tab_l0_color = view::render::adapt_to_bg(
view::render::layer_render_style(document, &e.common().layer).color,
bg_color,
);
let tab_l0_aci = document
.layers
.get(&e.common().layer)
.map(|l| match &l.color {
acadrust::types::Color::Index(i) => *i,
_ => 0,
})
.unwrap_or(0);
for &eh in &br.entity_handles {
let Some(sub) = document.get_entity(eh) else { continue };
let sub_color_is_byblock =
sub.common().color == acadrust::types::Color::ByBlock;
let sub_is_l0_bylayer = sub.common().layer == "0"
&& sub.common().color == acadrust::types::Color::ByLayer;
let sub_wires = tessellate_entity(
document, selected, active_viewport, bg_color,
anno_scale, sub, block_cache, view_aabb, world_per_pixel,
);
for mut w in sub_wires {
w.name = h.value().to_string();
if sub_color_is_byblock {
w.color = if sel { WireModel::SELECTED } else { entity_color };
w.aci = aci;
} else if sub_is_l0_bylayer && !sel {
w.color = tab_l0_color;
w.aci = tab_l0_aci;
}
wires.push(w);
}
}
if !wires.is_empty() {
let aabb = entity_aabb(e);
for w in &mut wires {
w.aabb = aabb;
}
return wires;
}
}
}
}
// No baked block (e.g. a table created in-app) — synthesise coloured
// geometry from the rows + TableStyle so fills/colours/borders/margins
// are honoured instead of the monochrome fallback.
let mut wires = crate::entities::table::tessellate_table(
tab, document, sel, entity_color, line_weight_px,
);
if !wires.is_empty() {
let aabb = entity_aabb(e);
for w in &mut wires {
w.aci = aci;
w.aabb = aabb;
}
return wires;
}
}
if let EntityType::Insert(ins) = e {
// Resolve the INSERT's own style so ByBlock sub-entities can inherit it.
let (ins_color, ins_pat_len, ins_pat, ins_lw_px, _) = view::render::render_style_for(document, e);
let ins_color = view::render::adapt_to_bg(ins_color, bg_color);
// Resolve the INSERT's *layer* style — the layer-0 inheritance target
// for sub-entities on layer "0" with ByLayer properties (#221).
let ins_layer = {
let mut s = view::render::layer_render_style(document, &ins.common.layer);
s.color = view::render::adapt_to_bg(s.color, bg_color);
s
};
let ip = glam::Vec3::new(
(ins.insert_point.x) as f32,
(ins.insert_point.y) as f32,
(ins.insert_point.z) as f32,
);
let marker = WireModel {
name: h.value().to_string(),
points: vec![],
points_low: Vec::new(),
color: entity_color,
selected: sel,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![(ip.as_dvec3(), model::wire_model::SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
};
if let Some(cache) = block_cache {
// Xrefs render with the same hue but faded toward `bg_color` so
// the user can recognise external-reference geometry at a glance.
let is_xref = document
.block_records
.get(&ins.block_name)
.map(|br| br.flags.is_xref || br.flags.is_xref_overlay)
.unwrap_or(false);
if let Some(mut wires) = cache::block_cache::expand_insert(
cache,
ins,
h,
ins_color,
ins_pat_len,
ins_pat,
ins_lw_px,
ins_layer,
sel,
pslt_factor,
view_aabb,
world_per_pixel,
is_xref,
bg_color,
) {
// XCLIP: if this INSERT carries an enabled spatial filter,
// clip the expanded block geometry to the boundary polygon so
// only the portion inside the clip is drawn.
if let Some(sf) = pick::xclip::insert_spatial_filter(document, ins) {
let poly = pick::xclip::world_clip_polygon_f64(sf, ins);
pick::xclip::clip_wires(&mut wires, &poly);
}
// Per-INSERT attribute values. The block defn carries the
// AttributeDefinitions (templates) which expand_insert skips;
// the AttributeEntity instances live on the Insert itself in
// WCS and need their own tessellation so the user sees the
// values they actually filled in. See #20.
crate::entities::insert::append_insert_attribute_wires(
&mut wires,
document,
ins,
h,
sel,
ins_color,
ins_pat_len,
ins_pat,
ins_lw_px,
ins_layer,
bg_color,
is_xref,
pslt_factor,
anno_scale,
);
wires.push(marker);
return wires;
}
}
// Cache miss / unavailable: fall back to the original explode path.
// The block_cache primary path covers all typical Inserts; this
// branch only fires for pathological cache failures.
let br = document.block_records.get(&ins.block_name);
let is_xref = br
.map(|br| br.flags.is_xref || br.flags.is_xref_overlay)
.unwrap_or(false);
let mut wires: Vec<WireModel> = ins
.explode_from_document(document)
.iter()
.cloned()
.map(crate::modules::draw::modify::explode::normalize_insert_entity)
.flat_map(|sub| {
let (sub_color, sub_pattern_length, sub_pattern, sub_line_weight_px, sub_aci) =
view::render::render_style_for_block_sub(
document,
&sub,
ins_color,
ins_pat_len,
ins_pat,
ins_lw_px,
ins_layer,
);
let sub_color = view::render::adapt_to_bg(sub_color, bg_color);
let sub_color = if is_xref && !sel {
cache::block_cache::fade_toward_bg(sub_color, bg_color)
} else {
sub_color
};
let sub_aabb = entity_aabb(&sub);
let sub_pattern_length = sub_pattern_length * pslt_factor;
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
let mut wires = convert::tessellate::tessellate(
document,
h,
&sub,
sel,
sub_color,
sub_pattern_length,
sub_pattern,
sub_line_weight_px,
anno_scale,
world_per_pixel,
);
for w in &mut wires {
w.name = h.value().to_string();
w.aci = sub_aci;
w.aabb = sub_aabb;
}
wires
})
.collect();
crate::entities::insert::append_insert_attribute_wires(
&mut wires,
document,
ins,
h,
sel,
ins_color,
ins_pat_len,
ins_pat,
ins_lw_px,
ins_layer,
bg_color,
is_xref,
pslt_factor,
anno_scale,
);
wires.push(marker);
return wires;
}
let aabb = entity_aabb(e);
// Text-specific LOD ladder, keyed off the entity's glyph height in
// pixels (anno-scaled):
// < 1 px → baseline line in the text's color (text-here hint)
// 1–5 px → greeked OBB rect in the text's color
// ≥ 5 px → full per-glyph stroke tessellation
//
// Applies to every entity that is "primarily a piece of text" — Text,
// MText, ATTDEF, ATTRIB, Tolerance — so far-out drawings don't pay the
// full glyph-tessellation cost. Composite entities (Dimension, Table,
// MultiLeader) carry non-text geometry and have their own LOD paths.
if let Some(wpp) = world_per_pixel {
let text_height: Option<f64> = match e {
EntityType::Text(t) => Some(t.height * anno_scale as f64),
EntityType::MText(m) => Some(m.height * anno_scale as f64),
EntityType::AttributeDefinition(a) => Some(a.height * anno_scale as f64),
EntityType::AttributeEntity(a) => Some(a.height * anno_scale as f64),
EntityType::Tolerance(t) => {
// Tolerance text_height defaults to 0.18 from creation; treat
// 0 as missing and fall back to the AutoCAD default so the
// pixel check still kicks in for legitimately tiny dimensions.
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
Some(raw * anno_scale as f64)
}
_ => None,
};
if let Some(h_world) = text_height {
let h_px = (h_world as f32) / wpp;
// Wrap-expanded line count for MText (Text = 1).
let n_lines = match e {
EntityType::MText(m) => {
crate::entities::text_support::mtext_line_count(m, document, anno_scale)
}
_ => 1,
};
if h_px < 1.0 {
let pts = crate::entities::text_support::text_baseline_points(e, anno_scale, n_lines);
if pts.len() < 2 {
return vec![];
}
// Skip the baseline too if the line itself projects under
// 2 px (e.g. a 1-char text seen edge-on). All wrap lines
// share the same baseline length, so the first segment is
// a representative sample.
let dx = pts[1][0] - pts[0][0];
let dy = pts[1][1] - pts[0][1];
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
if len_px < 2.0 {
// Text projects to under 2 px — fall back to the
// generic LOD stub so the entity stays visible /
// selectable. #19. Text is 2-D in the XY plane so
// z_min = z_max = 0 keeps the historical behaviour.
return vec![lod_stub_wire(
h.value().to_string(),
entity_color,
sel,
aci,
aabb,
0.0,
0.0,
)];
}
return vec![WireModel {
name: h.value().to_string(),
points: pts,
points_low: Vec::new(),
color: entity_color,
selected: sel,
aci,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}];
}
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
let fill_tris = crate::entities::text_support::text_greek_obb_tris(e, anno_scale, n_lines);
if fill_tris.is_empty() {
// Text greek fallback: also 2-D, keep stub at z=0.
return vec![lod_stub_wire(
h.value().to_string(),
entity_color,
sel,
aci,
aabb,
0.0,
0.0,
)];
}
// Greek text renders via the face3d fill batch, which colours
// each tri with `wire.color`. Bake the selected colour in so
// a selected text stays highlighted across the LOD boundary.
// hit_test's AABB fallback handles window / crossing. #19.
let fill_color = if sel { WireModel::SELECTED } else { entity_color };
return vec![WireModel {
name: h.value().to_string(),
points: vec![],
points_low: Vec::new(),
color: fill_color,
selected: sel,
aci,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb,
plinegen: true,
vp_scissor: None,
fill_tris,
fill_tris_low: Vec::new(),
}];
}
}
}
let mut bases = convert::tessellate::tessellate(
document,
h,
e,
sel,
entity_color,
pattern_length,
pattern,
line_weight_px,
anno_scale,
world_per_pixel,
);
for b in &mut bases {
b.aci = aci;
b.aabb = aabb;
}
// Complex linetypes (with embedded shapes / text) expand the *base*
// polyline along its tangent. Text-type entities never have a complex
// linetype assigned, so we only consult the first wire here — multi-wire
// returns come exclusively from MTEXT colour splits which can't trigger
// this path.
if let Some(clt) = crate::io::linetypes::complex_lt(lt_name) {
if let Some(base) = bases.first() {
let mut wires = text::complex_lt::apply_along(
&base.name,
&base.points,
clt,
(lt_scale * pslt_factor).max(1e-4),
entity_color,
sel,
base.line_weight_px,
);
if !wires.is_empty() {
for w in &mut wires {
w.aabb = aabb;
}
return wires;
}
}
}
bases
}
/// Build the 4 OBB corners (CCW: bl, br, tr, tl) of a Text / MText entity
/// in its **native frame** — for top-level entities this is world coords,
/// for block-defn subs it's block-local. No offset/transform applied.
/// Width is approximated from glyph height × character count (TEXT) or
/// from `rectangle_width` (MTEXT). Returns `None` for non-text entities.
///
/// `mtext_lines_override` lets the caller plug in a wrap-aware line count
/// (from `text_support::mtext_line_count`). Without it, MText's OBB
/// height collapses to a single line when the file omits `rectangle_height`,
/// which makes downstream per-line LOD math degenerate.
/// Build a "low-LOD stub" wire for an entity that would otherwise be culled
/// to nothing — the entity's AABB diagonal as a 2-point segment, plus the
/// AABB itself so window / crossing selection picks the entity up. The
/// stored `selected` flag tracks across zoom levels so highlight visuals
/// don't disappear when the LOD level changes. See #19.
fn lod_stub_wire(
name: String,
color: [f32; 4],
selected: bool,
aci: u8,
aabb: [f32; 4],
z_min: f32,
z_max: f32,
) -> WireModel {
let [ax, ay, bx, by] = aabb;
let cx = (ax + bx) * 0.5;
let cy = (ay + by) * 0.5;
let cz = (z_min + z_max) * 0.5;
// Mirror what tessellate.rs does for the non-stub paths: bake the
// selection-highlight colour into the wire so a re-tessellate triggered
// by a zoom-induced LOD change keeps the entity highlighted. Without
// this swap the wire's `selected` flag is true but its colour stays at
// the entity's own hue, so the user sees the highlight vanish at the
// LOD boundary. #19.
let stored_color = if selected { WireModel::SELECTED } else { color };
WireModel {
name,
// Diagonal of the entity's 3D AABB so depth tests against
// shaded / hidden-line geometry are correct — the stub doesn't
// flatten to z=0 and pop in front of objects that sit at a
// different elevation. 2D entities (text fallbacks) pass
// z_min = z_max = 0 to keep the historical behaviour.
points: vec![[ax, ay, z_min], [bx, by, z_max]],
points_low: Vec::new(),
color: stored_color,
selected,
aci,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![[cx as f64, cy as f64, cz as f64]],
aabb,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
}
/// Sub-pixel LOD stub for 3D entities. Emits the entity's 3D AABB as a
/// 12-edge cube so the geometry occupies the same screen footprint and
/// depth range as the full tessellation, just with a tiny constant cost
/// (12 line segments). Without this, the diagonal stub used by
/// `lod_stub_wire` cuts off at two opposite bbox corners and drifts
/// visibly when the camera crosses the LOD threshold.
fn lod_stub_wire_3d(
name: String,
color: [f32; 4],
selected: bool,
aci: u8,
aabb: [f32; 4],
z_min: f32,
z_max: f32,
) -> WireModel {
let [x0, y0, x1, y1] = aabb;
let (z0, z1) = if z_min <= z_max { (z_min, z_max) } else { (z_max, z_min) };
let p = [
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
];
// 12 edges = 4 bottom-face + 4 top-face + 4 vertical connectors.
const EDGES: [(usize, usize); 12] = [
(0, 1), (1, 2), (2, 3), (3, 0),
(4, 5), (5, 6), (6, 7), (7, 4),
(0, 4), (1, 5), (2, 6), (3, 7),
];
let mut points: Vec<[f32; 3]> = Vec::with_capacity(EDGES.len() * 3);
for (a, b) in EDGES {
if !points.is_empty() {
points.push([f32::NAN; 3]);
}
points.push(p[a]);
points.push(p[b]);
}
let stored_color = if selected { WireModel::SELECTED } else { color };
WireModel {
name,
points,
points_low: Vec::new(),
color: stored_color,
selected,
aci,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
// No `key_vertices` — Face3DGpu requires 4 corners to emit a
// fill quad, and we don't want this stub painted as a solid
// face. The wire pass still draws its 12 edges.
key_vertices: vec![],
aabb,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
}
/// Tessellate each visible AttributeEntity attached to an Insert and append
/// the resulting wires. AttributeEntity positions are already in WCS — the
/// INSERT only stamps the geometry once, attribute text sits at the world
/// position recorded on each ATTRIB. See #20.
#[allow(clippy::too_many_arguments)]
pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] {
let bbox = e.as_entity().bounding_box();
let min_x = (bbox.min.x) as f32;
let min_y = (bbox.min.y) as f32;
let max_x = (bbox.max.x) as f32;
let max_y = (bbox.max.y) as f32;
// A degenerate box (min == max == 0) means bounding_box() returned Default —
// use UNBOUNDED so the wire is never wrongly pre-rejected.
if min_x == max_x && min_y == max_y {
return WireModel::UNBOUNDED_AABB;
}
[min_x, min_y, max_x, max_y]
}
/// AABB of `e` in WCS f64 (no world_offset subtraction). `None` for
/// entities whose `bounding_box()` returned the degenerate default
/// (which `entity_aabb` collapses to `UNBOUNDED_AABB`). Quadtree
/// indexing uses this so changing `world_offset` doesn't invalidate
/// the index.
pub(crate) fn entity_world_aabb_f64(e: &acadrust::EntityType) -> Option<[f64; 4]> {
let bbox = e.as_entity().bounding_box();
let (xmin, ymin, xmax, ymax) = (bbox.min.x, bbox.min.y, bbox.max.x, bbox.max.y);
if xmin == xmax && ymin == ymax {
return None;
}
if !xmin.is_finite() || !ymin.is_finite() || !xmax.is_finite() || !ymax.is_finite() {
return None;
}
Some([xmin, ymin, xmax, ymax])
}
/// True if `e` is a type the quadtree should skip. `Insert` and
/// `Viewport` are sized only after extra transformation; tessellation
/// already handles them via dedicated code paths. `Block`/`BlockEnd`
/// are block-defn sentinels with no geometry.
pub(crate) fn is_unindexable_entity(e: &acadrust::EntityType) -> bool {
use acadrust::EntityType as E;
matches!(
e,
E::Insert(_) | E::Viewport(_) | E::Block(_) | E::BlockEnd(_)
)
}