Extents: ignore what draws nothing (rays, missing or empty blocks, hidden block geometry), as ZOOM Extents does (#288)

* Extents: ignore rays and references to missing or empty blocks, as ZOOM Extents does

Opening real drawings in CADCraft often showed a tiny plan in a huge empty
canvas, because the extents included things that draw nothing:

- the base point of a RAY (Autodesk's own reference says infinite
  construction lines and rays do not affect ZOOM Extents; an office plan in
  our corpus carries two rays 1.98 million units from the building);
- the insertion point of an INSERT whose block is missing (a purged
  definition, a bound xref that never came along) or empty (an unloaded
  xref): a floor plan had such references 125 000 and 3.4 million units away.

XLINEs were already ignored; rays now are too, and an insert contributes only
what its block draws (attributes still count). Two regression tests use the
measured coordinates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Extents: skip invisible block entities, attdefs and blank texts

- Entities in a block definition count for an insert's (and a dimension
  block's) extents only if they are not AttDefs, have common.visible set,
  and sit on layer "0" or on a layer that is on and thawed. Dynamic blocks
  keep hidden constraint geometry far from the visible part.
- Text/MText that is empty, whitespace-only or only "\~" has empty bounds.
- Tests cover each rule separately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
STE1200 2026-10-10 22:11:41 +02:00 committed by GitHub
parent 7336a3f597
commit 253f0c8497
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 303 additions and 13 deletions

View File

@ -34,6 +34,22 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
Bounds2::from_points(local.map(|p| origin.xy() + p.rotate(t.rotation)))
}
/// Whether an entity inside a block definition shows: its own invisible flag and its layer count,
/// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the
/// entities a visibility state hides are invisible this way, and they sit far from the block's
/// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked.
fn in_block_visible(d: &Drawing, e: &Entity) -> bool {
// Attribute definitions are not drawn in an insert (its attribs are, see below).
!matches!(e.kind, EntityKind::AttDef(_))
&& e.common.visible
&& (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible))
}
/// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room.
fn blank(s: &str) -> bool {
s.replace("\\~", " ").chars().all(char::is_whitespace)
}
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
bounds_in(d, e, depth, &mut HashMap::new())
}
@ -44,6 +60,8 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
/// per reference (which grows exponentially with the nesting depth).
fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String, Bounds2>) -> Bounds2 {
match &e.kind {
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY,
EntityKind::Text(t) => text_box(t, t.value.chars().count()),
EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()),
EntityKind::MText(t) => {
@ -70,21 +88,23 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String,
if depth >= MAX_BLOCK_DEPTH {
return Bounds2::EMPTY;
}
let Some(blk) = d.block(&ins.block) else { return Bounds2::from_points([ins.insert.xy()]) };
// A reference to a missing block (an unloaded xref, a purged definition) or to an empty
// one draws nothing, so it contributes nothing: ZOOM Extents must not fly off to its
// insertion point. Attributes below still count.
let Some(blk) = d.block(&ins.block) else { return Bounds2::EMPTY };
let m = ins.transform(blk.base.xy());
let inner = match blocks.get(&blk.name) {
Some(b) => *b,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter() {
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
}
blocks.insert(blk.name.clone(), inner);
inner
}
};
let mut b =
if inner.is_empty() { Bounds2::from_points([ins.insert.xy()]) } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
let mut b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
if ins.cols > 1 || ins.rows > 1 {
let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
.rotate(ins.rotation);
@ -122,7 +142,7 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String,
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
&& depth < MAX_BLOCK_DEPTH
{
for be in blk.entities.iter() {
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
b = b.union(&bounds_in(d, be, depth + 1, blocks));
}
}
@ -156,14 +176,9 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String,
Prim::Ellipse(e) => e.bounds(),
Prim::Spline(s) => s.bounds(),
Prim::Point(p) => Bounds2::from_points([p]),
// Infinite lines don't contribute to extents (as in ZOOM Extents).
Prim::Infinite { base, ray, .. } => {
if ray {
Bounds2::from_points([base])
} else {
Bounds2::EMPTY
}
}
// Infinite lines don't contribute to extents, rays included (as in ZOOM Extents;
// real drawings carry stray rays millions of units away).
Prim::Infinite { .. } => Bounds2::EMPTY,
Prim::Fill(pts) => Bounds2::from_points(pts),
};
b = b.union(&pb);

View File

@ -36,6 +36,49 @@ fn hidden_layers_excluded_from_extents() {
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
}
#[test]
fn rays_and_xlines_do_not_stretch_extents() {
// ZOOM Extents ignores infinite lines; real drawings carry stray rays millions of units away.
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
let far = Vec3::new(-1_977_989.0, -1_952_638.0, 0.0);
d.add(&Space::Model, Common::default(), EntityKind::Ray(RayLine { base: far, dir: Vec3::new(-0.7, -0.7, 0.0) })).unwrap();
d.add(&Space::Model, Common::default(), EntityKind::XLine(RayLine { base: far, dir: Vec3::new(1.0, 0.0, 0.0) })).unwrap();
let b = d.extents(&Space::Model);
assert_eq!((b.min, b.max), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
}
#[test]
fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() {
// An unloaded xref or a purged definition draws nothing, so its insertion point is not an extent.
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
let ins = |block: &str, x: f64, y: f64| {
EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::new(x, y, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
})
};
d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty")));
d.add(&Space::Model, Common::default(), ins("Missing", -125_319.0, -79_990.0)).unwrap();
d.add(&Space::Model, Common::default(), ins("Empty", 55_652.0, -3_448_576.0)).unwrap();
let b = d.extents(&Space::Model);
assert_eq!((b.min, b.max), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
// A block with geometry still counts.
let mut full = Block::new("Full");
full.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (2.0, 2.0))));
d.blocks.insert("Full".into(), std::sync::Arc::new(full));
d.add(&Space::Model, Common::default(), ins("Full", 10.0, 10.0)).unwrap();
assert_eq!(d.extents(&Space::Model).max, Vec2::new(12.0, 12.0));
}
#[test]
fn transform_arc_mirror_keeps_shape() {
let mut k = EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 });
@ -187,3 +230,235 @@ fn wide_arc_segment_extents_reach_the_outer_edge() {
assert!(over > -1e-6 && over < 1e-3, "got {b:?}, exact {exact:?}");
}
}
fn plain_insert(block: &str) -> EntityKind {
EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
})
}
fn on_layer(layer: &str, kind: EntityKind, h: u64) -> Entity {
Entity { handle: Handle(h), common: Common { layer: layer.into(), ..Default::default() }, kind }
}
/// Model space with one insert of a block holding a near line (0,0)-(1,1) plus `extra`.
fn drawing_with_block(extra: Entity) -> Drawing {
let mut d = Drawing::new_imperial();
let mut blk = Block::new("B");
blk.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (1.0, 1.0))));
blk.entities.push(extra);
d.blocks.insert("B".into(), std::sync::Arc::new(blk));
d.add(&Space::Model, Common::default(), plain_insert("B")).unwrap();
d
}
fn far_line(layer: &str) -> Entity {
on_layer(layer, line((5000.0, 5000.0), (6000.0, 6000.0)), 2)
}
fn extents_pair(d: &Drawing) -> (Vec2, Vec2) {
let b = d.extents(&Space::Model);
(b.min, b.max)
}
#[test]
fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
let mut far = far_line("0");
far.common.visible = false;
let d = drawing_with_block(far);
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
// Control: the same entity visible does count.
let d = drawing_with_block(far_line("0"));
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}
#[test]
fn block_entity_on_off_or_frozen_layer_is_ignored() {
for frozen in [false, true] {
let mut d = drawing_with_block(far_line("Hidden"));
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
let mut layer = Layer::new("Hidden");
if frozen {
layer.frozen = true;
} else {
layer.on = false;
}
d.layers.push(layer);
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)), "frozen = {frozen}");
// Turned back on, it counts again.
let l = d.layer_mut("Hidden").unwrap();
l.on = true;
l.frozen = false;
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}
}
#[test]
fn block_entity_on_layer_zero_counts_even_if_layer_zero_is_off_or_missing() {
// Layer "0" takes the insert's layer, which the caller has already checked; so it counts here.
// The insert itself sits on layer "Vis".
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common { layer: "Vis".into(), ..Default::default() }, plain_insert("B")).unwrap();
let mut blk = Block::new("B");
blk.entities.push(far_line("0"));
d.blocks.insert("B".into(), std::sync::Arc::new(blk));
// Layer 0 switched off.
d.layer_mut("0").unwrap().on = false;
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
// Layer 0 frozen.
let l = d.layer_mut("0").unwrap();
l.on = true;
l.frozen = true;
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
// Layer 0 not defined at all.
d.layers.retain(|l| l.name != "0");
assert!(d.layer("0").is_none());
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}
#[test]
fn block_entity_on_undefined_layer_counts() {
// An unknown layer name is not hidden (is_none_or).
let d = drawing_with_block(far_line("Nowhere"));
assert!(d.layer("Nowhere").is_none());
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}
#[test]
fn attdef_in_block_is_ignored_in_insert_extents() {
let attdef = Entity::new(
Handle(2),
EntityKind::AttDef(Attrib {
tag: "TAG".into(),
text: Text {
insert: Vec3::new(5000.0, 5000.0, 0.0),
align_pt: None,
height: 1.0,
value: "x".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
},
invisible: false,
constant: false,
prompt: String::new(),
}),
);
let d = drawing_with_block(attdef);
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
}
fn text_kind(value: &str) -> EntityKind {
EntityKind::Text(Text {
insert: Vec3::new(10.0, 10.0, 0.0),
align_pt: None,
height: 2.0,
value: value.into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
})
}
fn mtext_kind(contents: &str) -> EntityKind {
EntityKind::MText(MText {
insert: Vec3::new(10.0, 10.0, 0.0),
height: 2.0,
width: 0.0,
attach: 1,
rotation: 0.0,
style: "Standard".into(),
contents: contents.into(),
line_spacing: 1.0,
})
}
#[test]
fn blank_text_and_mtext_have_no_bounds() {
let d = Drawing::new_imperial();
for s in ["", " ", " ", "\t \n", "\\~", " \\~ \\~", "\u{a0}"] {
for kind in [text_kind(s), mtext_kind(s)] {
let b = entity_bounds(&d, &Entity::new(Handle(1), kind), 0);
assert!(b.is_empty(), "{s:?} should have empty bounds");
}
}
// Blank texts do not stretch the drawing extents either.
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
d.add(&Space::Model, Common::default(), text_kind(" \\~")).unwrap();
d.add(&Space::Model, Common::default(), mtext_kind("")).unwrap();
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
}
#[test]
fn non_blank_text_and_mtext_have_bounds() {
let d = Drawing::new_imperial();
for kind in [text_kind("Hi"), text_kind(" a "), mtext_kind("Hi"), mtext_kind("a\\Pb"), mtext_kind("\\~x")] {
let b = entity_bounds(&d, &Entity::new(Handle(1), kind.clone()), 0);
assert!(!b.is_empty(), "{kind:?} should have bounds");
}
}
#[test]
fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
// Dynamic blocks keep dimensional constraints on layer *ADSK_CONSTRAINTS (group 60 = 1).
let dim = |d: &mut Drawing, far: Entity| {
let mut blk = Block::new("*D1");
blk.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (1.0, 0.0))));
blk.entities.push(far);
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
d.add(
&Space::Model,
Common::default(),
EntityKind::Dimension(Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(1.0, 1.0, 0.0),
text_mid: Vec3::new(0.5, 1.0, 0.0),
p13: Vec3::ZERO,
p14: Vec3::new(1.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 1.0,
text_rotation: 0.0,
user_text_pos: false,
block: Some("*D1".into()),
overrides: Default::default(),
assoc: vec![],
}),
)
.unwrap();
};
// Invisible flag.
let mut d = Drawing::new_imperial();
let mut hidden = on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2);
hidden.common.visible = false;
dim(&mut d, hidden);
assert!(d.extents(&Space::Model).max.x < 100.0);
// Hidden by its layer state.
let mut d = Drawing::new_imperial();
let mut l = Layer::new("*ADSK_CONSTRAINTS");
l.on = false;
d.layers.push(l);
dim(&mut d, on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2));
assert!(d.extents(&Space::Model).max.x < 100.0);
// Control: a visible far entity does count.
let mut d = Drawing::new_imperial();
dim(&mut d, far_line("0"));
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}