fix(scene): keep low-LOD objects visible and selectable
At deep zoom-out, entities that projected to under ~5 px were dropped from the scene entirely (and text below 2 px-baseline followed the same path). The objects disappeared visually AND fell out of window / crossing selection, and any prior selection highlight stopped rendering once the zoom transition crossed the LOD threshold. #19. Two changes: 1. `tessellate_entity`: replace the sub-5-px `return vec![]` cull, the sub-2-px text-baseline `return vec![]`, and the empty-greek fall-out with a new `lod_stub_wire` helper. The stub is a 2-point AABB diagonal carrying the same `selected` flag, ACI, and AABB as the entity — so the entity stays visible as a 1-pixel speck, tracks its highlight colour across LOD changes, and remains hit-test'able. 2. `box_hit` / `poly_hit`: when a wire has no `points` but a finite `aabb` (greek text emits only fill_tris), fall back to the AABB rectangle as the hit-test shape. Defense in depth so future fill- only wires don't silently drop out of selection. Closes #19 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
136e4581c0
commit
8355e5a278
2 changed files with 112 additions and 12 deletions
|
|
@ -94,15 +94,31 @@ pub fn box_hit<'a>(
|
||||||
wires
|
wires
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|wire| {
|
.filter_map(|wire| {
|
||||||
if wire.points.is_empty() {
|
// Fallback: when wire has no line geometry (e.g. greek text emits
|
||||||
|
// only fill_tris) treat the AABB rectangle as the hit-test shape
|
||||||
|
// so low-LOD text stays selectable. See #19.
|
||||||
|
let aabb_pts: Vec<[f32; 3]>;
|
||||||
|
let pts: &[[f32; 3]] = if !wire.points.is_empty() {
|
||||||
|
&wire.points
|
||||||
|
} else if wire.aabb != WireModel::UNBOUNDED_AABB {
|
||||||
|
let [ax, ay, bx, by] = wire.aabb;
|
||||||
|
aabb_pts = vec![
|
||||||
|
[ax, ay, 0.0],
|
||||||
|
[bx, ay, 0.0],
|
||||||
|
[bx, by, 0.0],
|
||||||
|
[ax, by, 0.0],
|
||||||
|
[ax, ay, 0.0],
|
||||||
|
];
|
||||||
|
&aabb_pts
|
||||||
|
} else {
|
||||||
return None;
|
return None;
|
||||||
}
|
};
|
||||||
|
|
||||||
let mut hit = false;
|
let mut hit = false;
|
||||||
let mut all_inside = true;
|
let mut all_inside = true;
|
||||||
let mut prev: Option<Point> = None;
|
let mut prev: Option<Point> = None;
|
||||||
|
|
||||||
for &[px, py, pz] in &wire.points {
|
for &[px, py, pz] in pts {
|
||||||
if px.is_nan() {
|
if px.is_nan() {
|
||||||
prev = None;
|
prev = None;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -164,15 +180,32 @@ pub fn poly_hit<'a>(
|
||||||
wires
|
wires
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|wire| {
|
.filter_map(|wire| {
|
||||||
if wire.points.is_empty() {
|
// Same AABB fallback as `box_hit`: when a wire has no line
|
||||||
|
// geometry (e.g. greek-LOD text emits only fill_tris) treat the
|
||||||
|
// AABB rectangle as the hit-test shape so low-LOD text stays
|
||||||
|
// selectable. See #19.
|
||||||
|
let aabb_pts: Vec<[f32; 3]>;
|
||||||
|
let pts: &[[f32; 3]] = if !wire.points.is_empty() {
|
||||||
|
&wire.points
|
||||||
|
} else if wire.aabb != WireModel::UNBOUNDED_AABB {
|
||||||
|
let [ax, ay, bx, by] = wire.aabb;
|
||||||
|
aabb_pts = vec![
|
||||||
|
[ax, ay, 0.0],
|
||||||
|
[bx, ay, 0.0],
|
||||||
|
[bx, by, 0.0],
|
||||||
|
[ax, by, 0.0],
|
||||||
|
[ax, ay, 0.0],
|
||||||
|
];
|
||||||
|
&aabb_pts
|
||||||
|
} else {
|
||||||
return None;
|
return None;
|
||||||
}
|
};
|
||||||
|
|
||||||
let mut hit = false;
|
let mut hit = false;
|
||||||
let mut all_inside = true;
|
let mut all_inside = true;
|
||||||
let mut prev: Option<Point> = None;
|
let mut prev: Option<Point> = None;
|
||||||
|
|
||||||
for &[px, py, pz] in &wire.points {
|
for &[px, py, pz] in pts {
|
||||||
if px.is_nan() {
|
if px.is_nan() {
|
||||||
prev = None;
|
prev = None;
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -4693,15 +4693,29 @@ fn tessellate_entity(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(wpp) = world_per_pixel {
|
if let Some(wpp) = world_per_pixel {
|
||||||
let w = (ab[2] - ab[0]).abs();
|
let w_px = (ab[2] - ab[0]).abs();
|
||||||
let h = (ab[3] - ab[1]).abs();
|
let h_px = (ab[3] - ab[1]).abs();
|
||||||
// Keep in sync with `block_cache::MIN_PIXEL_SIZE`.
|
// Keep in sync with `block_cache::MIN_PIXEL_SIZE`.
|
||||||
// Text/MText have their own LOD ladder below
|
// Text/MText have their own LOD ladder below
|
||||||
// (baseline-line / greek / full) and must reach it
|
// (baseline-line / greek / full) and must reach it
|
||||||
// even when projected size is sub-5 px.
|
// even when projected size is sub-5 px.
|
||||||
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
|
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
|
||||||
if !is_text && w.max(h) / wpp < 5.0 {
|
if !is_text && w_px.max(h_px) / wpp < 5.0 {
|
||||||
return vec![];
|
// Sub-pixel entity: emit a stub instead of
|
||||||
|
// nothing. Stays visible as a 1-pixel speck,
|
||||||
|
// tracks its `selected` highlight across zoom
|
||||||
|
// levels, and remains hit-test'able via the
|
||||||
|
// AABB. See #19.
|
||||||
|
let (entity_color, _, _, _, aci_idx) =
|
||||||
|
render::render_style_for(document, e);
|
||||||
|
let entity_color = render::adapt_to_bg(entity_color, bg_color);
|
||||||
|
return vec![lod_stub_wire(
|
||||||
|
h.value().to_string(),
|
||||||
|
entity_color,
|
||||||
|
sel,
|
||||||
|
aci_idx,
|
||||||
|
ab,
|
||||||
|
)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4982,7 +4996,16 @@ fn tessellate_entity(
|
||||||
let dy = pts[1][1] - pts[0][1];
|
let dy = pts[1][1] - pts[0][1];
|
||||||
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
|
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
|
||||||
if len_px < 2.0 {
|
if len_px < 2.0 {
|
||||||
return vec![];
|
// Text projects to under 2 px — fall back to the
|
||||||
|
// generic LOD stub so the entity stays visible /
|
||||||
|
// selectable. #19.
|
||||||
|
return vec![lod_stub_wire(
|
||||||
|
h.value().to_string(),
|
||||||
|
entity_color,
|
||||||
|
sel,
|
||||||
|
aci,
|
||||||
|
aabb,
|
||||||
|
)];
|
||||||
}
|
}
|
||||||
return vec![WireModel {
|
return vec![WireModel {
|
||||||
name: h.value().to_string(),
|
name: h.value().to_string(),
|
||||||
|
|
@ -5005,8 +5028,16 @@ fn tessellate_entity(
|
||||||
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
|
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
|
||||||
let fill_tris = text_greek_obb_tris(e, anno_scale, world_offset, n_lines);
|
let fill_tris = text_greek_obb_tris(e, anno_scale, world_offset, n_lines);
|
||||||
if fill_tris.is_empty() {
|
if fill_tris.is_empty() {
|
||||||
return vec![];
|
return vec![lod_stub_wire(
|
||||||
|
h.value().to_string(),
|
||||||
|
entity_color,
|
||||||
|
sel,
|
||||||
|
aci,
|
||||||
|
aabb,
|
||||||
|
)];
|
||||||
}
|
}
|
||||||
|
// Greek text renders as fill_tris only; hit_test's AABB
|
||||||
|
// fallback handles window / crossing selection. #19.
|
||||||
return vec![WireModel {
|
return vec![WireModel {
|
||||||
name: h.value().to_string(),
|
name: h.value().to_string(),
|
||||||
points: vec![],
|
points: vec![],
|
||||||
|
|
@ -5200,6 +5231,42 @@ pub(crate) fn text_obb_corners_native(
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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],
|
||||||
|
) -> WireModel {
|
||||||
|
let [ax, ay, bx, by] = aabb;
|
||||||
|
let cx = (ax + bx) * 0.5;
|
||||||
|
let cy = (ay + by) * 0.5;
|
||||||
|
WireModel {
|
||||||
|
name,
|
||||||
|
// Diagonal: projects to 1-5 px at the LOD threshold so the entity
|
||||||
|
// shows as a tiny mark.
|
||||||
|
points: vec![[ax, ay, 0.0], [bx, by, 0.0]],
|
||||||
|
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, cy, 0.0]],
|
||||||
|
aabb,
|
||||||
|
plinegen: true,
|
||||||
|
vp_scissor: None,
|
||||||
|
fill_tris: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tessellate each visible AttributeEntity attached to an Insert and append
|
/// Tessellate each visible AttributeEntity attached to an Insert and append
|
||||||
/// the resulting wires. AttributeEntity positions are already in WCS — the
|
/// the resulting wires. AttributeEntity positions are already in WCS — the
|
||||||
/// INSERT only stamps the geometry once, attribute text sits at the world
|
/// INSERT only stamps the geometry once, attribute text sits at the world
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue