fix(viewport): per-tile wire cache for Model layout

Tiled Model panes were all reading from the shared `entity_wires_arc`,
whose tessellation runs `view_world_aabb` / `world_per_pixel` against
the live `Scene::camera`. Zooming the active tile re-built that arc
with the active camera's LOD / frustum cull, then every other tile
rendered from the same arc — content in the inactive panes flickered
between full and LOD-stub tessellations as you zoomed.

- `wires_for_block` now delegates to `wires_for_block_culled`, which
  takes `view_aabb` + `wpp` explicitly. The original caller passes
  the live-camera values; tile rendering passes its own.
- New `model_tile_wires_arc` builds a per-tile view AABB + wpp from
  the tile's camera + pixel rect and caches the result. Cache is
  keyed by tile index; the value holds `(geometry_epoch,
  camera_state_hash)`, so a stale hit (camera moved, doc changed)
  still misses and rebuilds.
- `ViewportInstance` gains `tile_idx` so `viewport_data_for` can route
  Model tiles into the per-tile cache without disturbing the paper-
  layout content-viewport path (it still uses `model_wires_for_viewport_arc`).
This commit is contained in:
Hakan Seven 2026-05-28 20:13:23 +03:00
commit 0eaac008dd
2 changed files with 119 additions and 4 deletions

View file

@ -421,6 +421,11 @@ pub struct ViewportInstance {
/// Source viewport entity handle, or `Handle::NULL` for the implicit
/// full-canvas Model view that has no backing entity yet.
pub handle: Handle,
/// Source Model-space tile index, or `None` for paper-layout viewports
/// (they're identified by `handle` instead). Used as the cache key for
/// `Scene::model_tile_wires_arc` so each pane reuses its own entry on
/// camera moves instead of accumulating one per camera hash.
pub tile_idx: Option<usize>,
/// Screen rectangle (pixels, canvas-relative) this viewport fills.
pub screen_rect: iced::Rectangle,
pub camera: Camera,
@ -472,6 +477,25 @@ fn overlap_len(a: (f32, f32), b: (f32, f32)) -> f32 {
(a.1.min(b.1) - a.0.max(b.0)).max(0.0)
}
/// Stable hash of a `Camera`'s pose for use as a per-tile cache key.
/// Two cameras with bit-identical target / rotation / distance hash the
/// same; any orbit / pan / zoom on a tile bumps it.
fn camera_state_hash(c: &Camera) -> u64 {
fn h(state: u64, x: f32) -> u64 {
state.rotate_left(13) ^ x.to_bits() as u64
}
let mut s: u64 = 0xcbf2_9ce4_8422_2325;
s = h(s, c.target.x);
s = h(s, c.target.y);
s = h(s, c.target.z);
s = h(s, c.rotation.x);
s = h(s, c.rotation.y);
s = h(s, c.rotation.z);
s = h(s, c.rotation.w);
s = h(s, c.distance);
s
}
pub struct Scene {
pub camera: Rc<RefCell<Camera>>,
/// Model-space tiled viewport layout. One full-window tile by default;
@ -498,6 +522,16 @@ pub struct Scene {
/// invalidates the cull-dependent wire list as well as a geometry change.
/// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation.
wire_cache: RefCell<Option<((u64, u64), Arc<Vec<WireModel>>)>>,
/// Per-Model-tile cached tessellation. Each tile has its own camera
/// (live for the active tile, stored snapshot for the others), so
/// LOD / frustum culling has to run independently — the shared
/// `wire_cache` would cull every tile against whichever camera was
/// current when it was last built. Keyed by tile index; the value
/// carries `(geometry_epoch, camera_state_hash)` so a cache hit is
/// also rejected when the same tile's camera moved or the document
/// geometry changed.
model_tile_wire_cache:
RefCell<HashMap<usize, ((u64, u64), Arc<Vec<WireModel>>)>>,
/// Index built from every SortEntitiesTable in the document.
/// Maps block_handle → (entity_handle.value() → sort_handle.value()).
/// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup.
@ -613,6 +647,7 @@ impl Scene {
camera_generation: 0,
geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
wire_cache: RefCell::new(None),
model_tile_wire_cache: RefCell::new(HashMap::new()),
sort_cache: RefCell::new(None),
hatch_cache: RefCell::new(None),
wipeout_cache: RefCell::new(None),
@ -1126,6 +1161,59 @@ impl Scene {
.collect()
}
/// Per-tile cached tessellation for the Model layout. Each tile has
/// its own camera (live for the active tile, stored snapshot for the
/// others), so LOD / frustum culling has to run independently — the
/// shared `entity_wires_arc` cache would cull every tile against
/// whichever camera was current when it was last built.
///
/// `cam_aspect` is the tile's pixel `width / height`; together with the
/// camera's `ortho_size` it determines the world-XY rectangle culled
/// against. Returns a clone of the cached `Arc` on a key match.
pub(super) fn model_tile_wires_arc(
&self,
tile_idx: usize,
cam: &Camera,
cam_aspect: f32,
tile_pixel_height: f32,
) -> Arc<Vec<WireModel>> {
let cam_key = camera_state_hash(cam);
let key = (self.geometry_epoch, cam_key);
{
let cache = self.model_tile_wire_cache.borrow();
if let Some((cached_key, ref arc)) = cache.get(&tile_idx) {
if *cached_key == key {
return Arc::clone(arc);
}
}
}
// Compute the tile's own view AABB and world-per-pixel from its
// camera + pixel size (mirrors `view_world_aabb` /
// `world_per_pixel`, neither of which knows about anything beyond
// the live `self.camera`).
let view_aabb = if self.camera_generation == 0 {
None
} else {
let h = cam.ortho_size();
let w = h * cam_aspect.max(0.01);
let margin = 1.25_f32;
let cx = cam.target.x;
let cy = cam.target.y;
Some([cx - w * margin, cy - h * margin, cx + w * margin, cy + h * margin])
};
let wpp = if tile_pixel_height > 0.0 {
Some((2.0 * cam.ortho_size()) / tile_pixel_height)
} else {
None
};
let block = self.model_space_block_handle();
let arc = Arc::new(self.wires_for_block_culled(block, view_aabb, wpp));
self.model_tile_wire_cache
.borrow_mut()
.insert(tile_idx, (key, Arc::clone(&arc)));
arc
}
/// Cached tessellation of the current layout block's paper-space entities.
/// Shared by both `entity_wires_arc()` and `paper_canvas_wires()` so a single
/// cache miss triggers only one tessellation pass, not two.
@ -1330,6 +1418,22 @@ impl Scene {
/// Tessellate all non-invisible entities owned by `block_handle`.
fn wires_for_block(&self, block_handle: Handle) -> Vec<WireModel> {
// Default culling is driven by the live `Scene::camera`. Multi-tile
// Model layouts call `wires_for_block_culled` directly with each
// tile's own camera so the per-tile LOD / frustum cull is independent.
self.wires_for_block_culled(
block_handle,
self.view_world_aabb(),
self.world_per_pixel(),
)
}
fn wires_for_block_culled(
&self,
block_handle: Handle,
view_aabb: Option<[f32; 4]>,
wpp: Option<f32>,
) -> Vec<WireModel> {
use acadrust::objects::ObjectType;
// ── Ensure sort-order index is current ────────────────────────────
@ -1391,7 +1495,7 @@ impl Scene {
// entities (Insert/Viewport) are appended via a small linear scan.
// Paper space and the first-frame "settle" path fall back to the
// full doc scan — preserving prior behaviour.
let visible: Vec<&EntityType> = if let Some(local_view) = self.view_world_aabb() {
let visible: Vec<&EntityType> = if let Some(local_view) = view_aabb {
let [ox, oy, _] = self.world_offset;
let view_wcs: [f64; 4] = [
local_view[0] as f64 + ox,
@ -1460,8 +1564,6 @@ impl Scene {
};
let blk_cache = self.block_cache_arc();
let blk_ref: &block_cache::BlockCache = &blk_cache;
let view_aabb = self.view_world_aabb();
let wpp = self.world_per_pixel();
// Zoom-adaptive curve sampling for top-level Edge tessellation. Target
// ~0.5 px chord height — far-out arcs that used to emit hundreds of
// segments now collapse to a handful. The guard clears the override
@ -4976,6 +5078,7 @@ impl Scene {
};
ViewportInstance {
handle: Handle::NULL,
tile_idx: Some(i),
screen_rect: iced::Rectangle {
x: tile.rect.x * canvas_w,
y: tile.rect.y * canvas_h,
@ -5010,6 +5113,7 @@ impl Scene {
};
out.push(ViewportInstance {
handle: h,
tile_idx: None,
screen_rect,
camera,
render_mode: vp.render_mode,

View file

@ -580,7 +580,18 @@ impl Scene {
let us = (visible_w / full.width).clamp(0.0, 1.0);
let vs = (visible_h / full.height).clamp(0.0, 1.0);
let base_arc = if inst.handle == acadrust::Handle::NULL {
// Model tiles each get their own tessellation cache keyed off the
// tile's camera — culling/LOD depend on per-tile zoom, not the
// active tile's. Paper-space content viewports already had a
// per-viewport cache.
let base_arc = if let Some(tile_idx) = inst.tile_idx {
let aspect = if full.height > 0.0 {
full.width / full.height
} else {
1.0
};
self.model_tile_wires_arc(tile_idx, &inst.camera, aspect, full.height)
} else if inst.handle == acadrust::Handle::NULL {
self.entity_wires_arc()
} else {
self.model_wires_for_viewport_arc(inst.handle)