perf: keep heavy work off UI thread

This commit is contained in:
Hakan Seven 2026-07-25 18:49:18 +03:00
commit deec48ad99
75 changed files with 11703 additions and 519 deletions

View file

@ -15,7 +15,7 @@
// a visited set so a self-referential block produces a marker rather than
// recursing forever.
use rustc_hash::FxHashMap as HashMap;
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::sync::Arc;
use acadrust::types::{Color as AcadColor, LineWeight, Transform, Vector3};
@ -153,6 +153,29 @@ pub struct BlockDefn {
#[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>>>>>,
>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct ExpansionPrototypeKey {
block_name: String,
linear: [u64; 9],
insert_style: Vec<u32>,
selected: bool,
is_xref: bool,
}
#[derive(Debug)]
struct CachedExpansion {
translation: [f64; 3],
wires: Arc<Vec<WireModel>>,
}
impl BlockCache {
@ -181,6 +204,18 @@ impl BlockCache {
use crate::par::prelude::*;
let mut cache = Self::new();
let referenced = collect_referenced_blocks(doc);
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
@ -269,7 +304,6 @@ impl BlockCache {
/// Walk all entities + all block_record contents collecting every distinct
/// `block_name` that appears in an Insert (transitively).
fn collect_referenced_blocks(doc: &CadDocument) -> Vec<String> {
use rustc_hash::FxHashSet as HashSet;
let mut seen: HashSet<String> = HashSet::default();
let mut queue: Vec<String> = Vec::new();
@ -795,6 +829,61 @@ pub fn expand_insert(
xform = xform.then(&scale_about_p);
}
let name = ins_handle.value().to_string();
let prototype_key = if view_aabb.is_none()
&& world_per_pixel.is_none()
&& !ins.is_array()
&& 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,
selected,
pslt_factor,
is_xref,
bg_color,
anno_scale,
))
} 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);
@ -848,7 +937,135 @@ pub fn expand_insert(
};
expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0, (0.0, 1.0));
}
Some(batches.finalize(&name, selected, bg_color))
let wires = batches.finalize(&name, selected, bg_color);
if let Some(guard) = prototype_guard.as_mut() {
let cached = Arc::new(CachedExpansion {
translation: transform_translation(&xform),
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: &acadrust::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,
selected: bool,
pslt_factor: f32,
is_xref: bool,
bg_color: [f32; 4],
anno_scale: f32,
) -> 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(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,
}
}
fn translated_prototype_wire(source: &WireModel, name: &str, delta: [f64; 3]) -> WireModel {
let mut wire = source.clone();
wire.name = name.to_string();
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);
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];
}
}
}
}
if !wire.text_verts.is_empty() {
wire.text_verts =
crate::scene::model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| {
(x + delta[0], y + delta[1], z + delta[2])
});
}
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 {

View file

@ -199,6 +199,7 @@ impl Scene {
};
if !handle.is_null() {
self.invalidate_dependency_index();
if let Some(model) = hatch_seed {
self.hatches.insert(handle, model);
}
@ -275,6 +276,7 @@ impl Scene {
}
}
}
self.invalidate_dependency_index();
self.bump_geometry();
true
}
@ -353,6 +355,7 @@ impl Scene {
return false;
};
*slot = entity;
self.invalidate_dependency_index();
// Drop stale derived caches for this handle, then reseed for the new
// entity's type (which may differ from the old one).
@ -2092,6 +2095,7 @@ impl Scene {
/// Rebuild hatch / image / mesh caches after the document is modified
/// outside the normal `add_entity` path (e.g. REFCLOSE SAVE).
pub fn rebuild_derived_caches(&mut self) {
self.invalidate_dependency_index();
self.populate_hatches_from_document_unbumped();
self.populate_images_from_document_unbumped();
self.populate_meshes_impl(false, false);

View file

@ -45,6 +45,22 @@ pub(super) struct EntityIndex {
pub unbounded_handles: Vec<Handle>,
}
#[derive(Default)]
struct DependencyTargets {
render_handles: HashSet<Handle>,
source_handles: HashSet<Handle>,
touches_block_definition: bool,
}
#[derive(Default)]
struct SceneDependencyIndex {
layers: HashMap<String, DependencyTargets>,
text_styles: HashMap<String, DependencyTargets>,
dim_styles: HashMap<String, DependencyTargets>,
object_styles: HashMap<Handle, DependencyTargets>,
blocks: HashMap<String, HashSet<Handle>>,
}
fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64; 4]> {
let mut aabb = [
f64::INFINITY,
@ -238,6 +254,21 @@ struct ResidentWireLayout {
marker_start: usize,
}
#[derive(Clone)]
pub struct PreparedOpenGeometry {
pub wires: Arc<Vec<WireModel>>,
pub interaction_index: Option<Arc<crate::scene::pick::interaction_index::InteractionIndex>>,
}
impl std::fmt::Debug for PreparedOpenGeometry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedOpenGeometry")
.field("wires", &self.wires.len())
.field("interaction_index", &self.interaction_index.is_some())
.finish()
}
}
#[derive(Debug)]
pub(crate) struct WireGpuPatch {
pub(crate) changes: Arc<Vec<(Handle, ChangeKind)>>,
@ -357,6 +388,15 @@ pub struct DerivedCaches {
/// Reported back to the UI so the user knows when a file had parser-junk
/// entities silently dropped.
pub corrupt_dropped: usize,
/// Corrupt entities dropped while resolving referenced drawings.
pub xref_dropped: usize,
/// XREF resolution results produced by the loader worker. Keeping these in
/// the open bundle prevents parsing and merging references on the UI thread.
pub xrefs: Vec<crate::io::xref::XrefInfo>,
/// Model wire set and its spatial interaction index, prepared on the loader
/// thread. Installing these prevents the first visible frame from paying a
/// whole-drawing tessellation/index build while the progress overlay freezes.
pub prepared_geometry: Option<PreparedOpenGeometry>,
/// Background-thread open-phase timings in milliseconds (parse, purge,
/// derived-cache build). Filled in by `open_path_with_phase`; surfaced in
/// the open-complete breakdown log so open-time regressions are visible.
@ -369,11 +409,30 @@ pub struct OpenTimings {
pub parse_ms: u32,
pub purge_ms: u32,
pub caches_ms: u32,
pub xref_ms: u32,
}
/// Build hatch / image / mesh caches from a document without needing `&mut Scene`.
/// Intended to run on a background thread during file load.
pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
build_derived_caches_impl(doc, None)
}
/// Build open-time caches while reporting monotonic progress in 0..=10000.
///
/// The callback is UI-agnostic and may run from Rayon workers. Callers should
/// keep it cheap, normally just updating atomics.
pub fn build_derived_caches_with_progress(
doc: &CadDocument,
progress: &(dyn Fn(u16) + Sync),
) -> DerivedCaches {
build_derived_caches_impl(doc, Some(progress))
}
fn build_derived_caches_impl(
doc: &CadDocument,
progress: Option<&(dyn Fn(u16) + Sync)>,
) -> DerivedCaches {
// A new drawing must not inherit the previous one's resolved images — drop
// the memoised set so each reference re-reads / re-fetches once here (and
// stays cached across this document's later cache rebuilds).
@ -428,7 +487,8 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
let mut image_handles: Vec<Handle> = Vec::new();
let mut mesh_handles: Vec<Handle> = Vec::new();
let mut centers: Vec<[f64; 3]> = Vec::new();
for e in doc.entities() {
let entity_total = doc.entity_count().max(1);
for (index, e) in doc.entities().enumerate() {
let h = e.common().handle;
match e {
EntityType::Hatch(_) | EntityType::Solid(_) => hatch_handles.push(h),
@ -444,6 +504,14 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
if let Some(c) = offset_centroid(e, model_block, &prep) {
centers.push(c);
}
if index & 0x1fff == 0 {
if let Some(progress) = progress {
progress(((index as u64 * 4000) / entity_total as u64) as u16);
}
}
}
if let Some(progress) = progress {
progress(4000);
}
let (local_center, local_extent_max) = cluster_extent_from_centers(centers, &doc.header);
@ -453,6 +521,19 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
// the per-layout adaptation kicks in later regardless).
const LOAD_BG: [f32; 4] = [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0];
let detail_total = hatch_handles
.len()
.saturating_add(image_handles.len())
.saturating_add(mesh_handles.len())
.max(1);
let detail_done = std::sync::atomic::AtomicUsize::new(0);
let report_detail = |done: usize| {
if let Some(progress) = progress {
let value = 4000u64 + done as u64 * 6000 / detail_total as u64;
progress(value.min(10000) as u16);
}
};
// hatches
let hatches: HashMap<Handle, HatchModel> = hatch_handles
.par_iter()
@ -465,15 +546,23 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
EntityType::Solid(solid) => Some(Scene::solid_hatch_model(solid, color)),
_ => None,
};
model.map(|m| (handle, m))
let result = model.map(|m| (handle, m));
let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1;
if done & 0xff == 0 || done == detail_total {
report_detail(done);
}
result
})
.collect();
// images
let images: HashMap<Handle, ImageModel> = image_handles
.par_iter()
.filter_map(|&handle| match doc.get_entity(handle)? {
EntityType::RasterImage(img) => ImageModel::from_raster_image(img).map(|m| (handle, m)),
.filter_map(|&handle| {
let result = match doc.get_entity(handle)? {
EntityType::RasterImage(img) => {
ImageModel::from_raster_image(img).map(|m| (handle, m))
}
EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)),
EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
@ -482,6 +571,12 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
_ => None,
},
_ => None,
};
let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1;
if done & 0xff == 0 || done == detail_total {
report_detail(done);
}
result
})
.collect();
@ -511,10 +606,16 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
let (raw, ..) = view::render::render_style_for(doc, e);
let color = view::render::adapt_to_bg(raw, LOAD_BG);
let top_level = layout_blocks.contains(&e.common().owner_handle);
crate::entities::solid3d::tessellate_volume(e, color, facet_res, isolines).map(|m| {
let result = crate::entities::solid3d::tessellate_volume(e, color, facet_res, isolines)
.map(|m| {
let m = if top_level { offset_mesh_lod_set(m) } else { m };
(handle, m, top_level)
})
});
let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1;
if done & 0xff == 0 || done == detail_total {
report_detail(done);
}
result
})
.collect();
let mut meshes: HashMap<Handle, MeshLodSet> = HashMap::default();
@ -527,6 +628,10 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
}
}
if let Some(progress) = progress {
progress(10000);
}
DerivedCaches {
local_extent_max,
local_center,
@ -535,10 +640,54 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
meshes,
block_meshes,
corrupt_dropped: 0,
xref_dropped: 0,
xrefs: Vec::new(),
prepared_geometry: None,
timings: OpenTimings::default(),
}
}
/// Prepare the expensive first Model wire set and spatial interaction index on
/// the loader thread. The temporary `Scene` never crosses threads (it contains
/// `Rc`/`RefCell` state); only its Send-safe document and immutable prepared
/// geometry are returned.
pub fn prepare_open_geometry(
doc: CadDocument,
caches: &DerivedCaches,
model_bg: [f32; 4],
) -> (CadDocument, PreparedOpenGeometry) {
let mut scene = Scene::new();
scene.document = doc;
scene.local_extent_max = caches.local_extent_max;
scene.local_center = caches.local_center;
scene.bg_color = model_bg;
let cannoscale_value = scene.document.header.annotation_scale_value;
scene.annotation_scale = if cannoscale_value > 1e-9 {
(1.0 / cannoscale_value) as f32
} else {
1.0
};
scene.current_layout = "Model".to_string();
let camera = scene.camera.borrow().clone();
let wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1.0);
let interaction_index = if scene.interaction_index_worthwhile(&wires) {
let index =
Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(&wires));
index.prepare_screen();
Some(index)
} else {
None
};
let doc = std::mem::replace(&mut scene.document, CadDocument::new());
(
doc,
PreparedOpenGeometry {
wires,
interaction_index,
},
)
}
/// Mirrors `cache::block_cache::SANE_EXTENT` — wire coords past this magnitude
/// are treated as corruption rather than precision-relevant geometry.
const CLUSTER_SANE_EXTENT: f64 = 1.0e8;
@ -1165,13 +1314,10 @@ pub struct Scene {
/// filtered variants. Viewports sharing a frozen set share one entry (like
/// the resident wire set). Empty for a viewport with no frozen layers (it
/// reuses the unfiltered `*_arc` sets directly).
frozen_hatch_cache:
RefCell<HashMap<(String, u64), (u64, u64, Arc<Vec<HatchModel>>)>>,
frozen_wipeout_cache:
RefCell<HashMap<(String, u64), (u64, Arc<Vec<HatchModel>>)>>,
frozen_hatch_cache: RefCell<HashMap<(String, u64), (u64, u64, Arc<Vec<HatchModel>>)>>,
frozen_wipeout_cache: RefCell<HashMap<(String, u64), (u64, Arc<Vec<HatchModel>>)>>,
frozen_image_cache: RefCell<HashMap<u64, (u64, Arc<Vec<ImageModel>>)>>,
frozen_mesh_cache:
RefCell<HashMap<(String, u64), (u64, Arc<Vec<MeshLodSet>>)>>,
frozen_mesh_cache: RefCell<HashMap<(String, u64), (u64, Arc<Vec<MeshLodSet>>)>>,
/// Cached block-INSERT hatches for hit-testing, keyed by geometry_epoch.
/// Building this explodes every model-space INSERT, so without the cache a
/// heavy block-instanced drawing re-explodes thousands of inserts on every
@ -1258,12 +1404,16 @@ pub struct Scene {
/// Reverse map: entity_handle → block_record_handle, built from entity_handles lists.
/// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block.
entity_block_map_cache: RefCell<Option<(u64, HashMap<Handle, Handle>)>>,
/// Reverse dependencies from layer/style/block definitions to the top-level
/// entities whose resident wire runs actually change. Kept independent from
/// `geometry_epoch`: a layer colour toggle can reuse the index, invalidate
/// only its dependants, and avoid a whole-document scan on every toggle.
dependency_index_cache: RefCell<Option<SceneDependencyIndex>>,
/// Tessellated block definitions in block-local coords, keyed by render
/// background and block epoch. Model and Paper adapt black/white colours
/// differently; retaining both variants prevents a full block rebuild on
/// every layout-tab switch.
block_defn_cache:
RefCell<HashMap<[u32; 4], (u64, Arc<cache::block_cache::BlockCache>)>>,
block_defn_cache: RefCell<HashMap<[u32; 4], (u64, Arc<cache::block_cache::BlockCache>)>>,
/// Spatial index + always-emit list for top-level entities
/// (Phase 2.1). Lazily rebuilt by `entity_index()` on
/// `geometry_epoch` change. See `EntityIndex` for what each side
@ -1455,6 +1605,7 @@ impl Scene {
annotation_affects_wires: std::cell::Cell::new(None),
model_extents_cache: RefCell::new(None),
entity_block_map_cache: RefCell::new(None),
dependency_index_cache: RefCell::new(None),
block_defn_cache: RefCell::new(HashMap::default()),
entity_index_cache: RefCell::new(None),
last_render_aspect: std::cell::Cell::new(16.0 / 9.0),
@ -1555,6 +1706,30 @@ impl Scene {
arc
}
/// Install loader-thread geometry into this scene's Model resident cache.
///
/// The target scene has a different epoch from the temporary loader scene,
/// so only immutable geometry is transferred and re-stamped here. The
/// optional interaction index is cached against the exact same `Arc`.
pub fn install_prepared_open_geometry(&self, prepared: PreparedOpenGeometry) {
let block = self.model_space_block_handle();
let key = Self::resident_wire_key(block, self.bg_color, None, None);
let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed);
self.last_model_wire_gen.set(gen);
self.resident_wire_sets.borrow_mut().insert(
key,
ResidentWireSet {
epoch: self.geometry_epoch,
gen,
wires: Arc::clone(&prepared.wires),
layout: None,
},
);
if let Some(index) = prepared.interaction_index {
self.cache_interaction_index(self.geometry_epoch, prepared.wires, index);
}
}
/// Push one delta onto the journal, evicting the oldest past the cap and
/// raising the floor so a consumer that fell behind falls back to a full
/// rebuild. Every `geometry_epoch` bump must call this exactly once so the
@ -1779,6 +1954,10 @@ impl Scene {
}
}
if !changes.is_empty() {
self.invalidate_dependency_index();
}
changes
}
@ -3245,30 +3424,7 @@ impl Scene {
} else {
self.paper_bg_color
};
let key = {
let mut k: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |x: u64| k = k.rotate_left(17) ^ x.wrapping_mul(0x9E37_79B9_7F4A_7C15);
mix(block.value());
for c in bg {
mix(c.to_bits() as u64);
}
mix(anno_scale_override
.map(|a| a.to_bits() as u64)
.unwrap_or(u64::MAX));
match frozen_layers {
Some(f) => {
// Order-independent fold of the frozen-layer set.
let mut acc: u64 = 0;
for h in f {
acc ^= h.value().wrapping_mul(0x9E37_79B9_7F4A_7C15);
}
mix(acc);
mix(f.len() as u64);
}
None => mix(u64::MAX - 1),
}
k
};
let key = Self::resident_wire_key(block, bg, anno_scale_override, frozen_layers);
{
let sets = self.resident_wire_sets.borrow();
if let Some(set) = sets.get(&key) {
@ -3331,6 +3487,36 @@ impl Scene {
arc
}
fn resident_wire_key(
block: Handle,
bg: [f32; 4],
anno_scale_override: Option<f32>,
frozen_layers: Option<&HashSet<Handle>>,
) -> u64 {
let mut key: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix =
|value: u64| key = key.rotate_left(17) ^ value.wrapping_mul(0x9E37_79B9_7F4A_7C15);
mix(block.value());
for component in bg {
mix(component.to_bits() as u64);
}
mix(anno_scale_override
.map(|scale| scale.to_bits() as u64)
.unwrap_or(u64::MAX));
match frozen_layers {
Some(frozen) => {
let mut signature = 0u64;
for handle in frozen {
signature ^= handle.value().wrapping_mul(0x9E37_79B9_7F4A_7C15);
}
mix(signature);
mix(frozen.len() as u64);
}
None => mix(u64::MAX - 1),
}
key
}
/// The GPU wire-arena handoff for a viewport whose content id is `gen`:
/// `(prev_gen, changed handles)` when the Model set reached `gen` via an
/// incremental resident patch, else `None`. Read (not consumed) so every
@ -6375,6 +6561,253 @@ impl Scene {
})
}
fn rebuild_dependency_index(&self) -> SceneDependencyIndex {
let layout_blocks: HashSet<Handle> = self
.document
.objects
.values()
.filter_map(|object| match object {
acadrust::objects::ObjectType::Layout(layout) if !layout.block_record.is_null() => {
Some(layout.block_record)
}
_ => None,
})
.collect();
let block_names: HashMap<Handle, String> = self
.document
.block_records
.iter()
.map(|record| (record.handle, record.name.to_ascii_uppercase()))
.collect();
let membership: HashMap<Handle, Handle> = self
.document
.block_records
.iter()
.flat_map(|record| {
record
.entity_handles
.iter()
.copied()
.map(move |handle| (handle, record.handle))
})
.collect();
let mut roots: HashMap<String, HashSet<Handle>> = HashMap::default();
let mut parents: HashMap<String, HashSet<String>> = HashMap::default();
for entity in self.document.entities() {
let EntityType::Insert(insert) = entity else {
continue;
};
let target = insert.block_name.to_ascii_uppercase();
let common = &insert.common;
let owner = if common.owner_handle.is_null() {
membership
.get(&common.handle)
.copied()
.unwrap_or(Handle::NULL)
} else {
common.owner_handle
};
if owner.is_null() || layout_blocks.contains(&owner) {
roots.entry(target).or_default().insert(common.handle);
} else if let Some(parent) = block_names.get(&owner) {
parents.entry(target).or_default().insert(parent.clone());
}
}
// Propagate top-level INSERT users through nested block references.
// Fixed-point form is cycle-safe and block graphs are normally shallow.
let mut changed = true;
while changed {
changed = false;
for (child, parent_names) in &parents {
let inherited: Vec<Handle> = parent_names
.iter()
.flat_map(|parent| roots.get(parent).into_iter().flatten().copied())
.collect();
let entry = roots.entry(child.clone()).or_default();
let before = entry.len();
entry.extend(inherited);
changed |= entry.len() != before;
}
}
let mut index = SceneDependencyIndex {
blocks: roots.clone(),
..SceneDependencyIndex::default()
};
for entity in self.document.entities() {
let common = entity.common();
let owner = if common.owner_handle.is_null() {
membership
.get(&common.handle)
.copied()
.unwrap_or(Handle::NULL)
} else {
common.owner_handle
};
let inside_block = !owner.is_null() && !layout_blocks.contains(&owner);
let render_handles: HashSet<Handle> = if inside_block {
block_names
.get(&owner)
.and_then(|name| roots.get(name))
.cloned()
.unwrap_or_default()
} else {
std::iter::once(common.handle).collect()
};
let add = |map: &mut HashMap<String, DependencyTargets>, name: &str| {
let target = map.entry(name.to_ascii_uppercase()).or_default();
target.render_handles.extend(render_handles.iter().copied());
target.source_handles.insert(common.handle);
target.touches_block_definition |= inside_block;
};
let add_handle = |map: &mut HashMap<Handle, DependencyTargets>,
handle: Option<Handle>| {
let Some(handle) = handle.filter(|handle| !handle.is_null()) else {
return;
};
let target = map.entry(handle).or_default();
target.render_handles.extend(render_handles.iter().copied());
target.source_handles.insert(common.handle);
target.touches_block_definition |= inside_block;
};
add(&mut index.layers, &common.layer);
match entity {
EntityType::Text(text) => add(&mut index.text_styles, &text.style),
EntityType::MText(text) => add(&mut index.text_styles, &text.style),
EntityType::AttributeDefinition(attribute) => {
add(&mut index.text_styles, &attribute.text_style)
}
EntityType::AttributeEntity(attribute) => {
add(&mut index.text_styles, &attribute.text_style)
}
EntityType::Insert(insert) => {
for attribute in &insert.attributes {
add(&mut index.text_styles, &attribute.text_style);
}
}
EntityType::Dimension(dimension) => {
add(&mut index.dim_styles, &dimension.base().style_name)
}
EntityType::Table(table) => {
add_handle(&mut index.object_styles, table.table_style_handle)
}
EntityType::MultiLeader(leader) => {
add_handle(&mut index.object_styles, leader.style_handle)
}
EntityType::MLine(line) => add_handle(&mut index.object_styles, line.style_handle),
_ => {}
}
}
index
}
fn dependency_targets(&self, kind: &str, names: &[String]) -> DependencyTargets {
if self.dependency_index_cache.borrow().is_none() {
*self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index());
}
let cache = self.dependency_index_cache.borrow();
let index = cache.as_ref().unwrap();
let map = match kind {
"layer" => &index.layers,
"text" => &index.text_styles,
"dim" => &index.dim_styles,
_ => unreachable!(),
};
let mut combined = DependencyTargets::default();
for name in names {
let Some(target) = map.get(&name.to_ascii_uppercase()) else {
continue;
};
combined
.render_handles
.extend(target.render_handles.iter().copied());
combined
.source_handles
.extend(target.source_handles.iter().copied());
combined.touches_block_definition |= target.touches_block_definition;
}
combined
}
fn invalidate_dependency_targets(&mut self, targets: DependencyTargets) {
if targets.render_handles.is_empty() {
return;
}
if targets.touches_block_definition {
self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
}
let sources: Vec<Handle> = targets.source_handles.iter().copied().collect();
self.recolor_meshes_for_handles(&sources);
let changes: Vec<(Handle, ChangeKind)> = targets
.render_handles
.into_iter()
.map(|handle| (handle, ChangeKind::Modified))
.collect();
self.bump_entities(&changes);
}
pub fn invalidate_layer_dependencies(&mut self, names: &[String]) {
let targets = self.dependency_targets("layer", names);
self.invalidate_dependency_targets(targets);
}
pub fn invalidate_text_style_dependencies(&mut self, name: &str) {
self.invalidate_text_style_dependencies_many(&[name.to_string()]);
}
pub fn invalidate_text_style_dependencies_many(&mut self, names: &[String]) {
let targets = self.dependency_targets("text", names);
self.invalidate_dependency_targets(targets);
}
pub fn invalidate_dim_style_dependencies(&mut self, name: &str) {
self.invalidate_dim_style_dependencies_many(&[name.to_string()]);
}
pub fn invalidate_dim_style_dependencies_many(&mut self, names: &[String]) {
let targets = self.dependency_targets("dim", names);
self.invalidate_dependency_targets(targets);
}
pub fn invalidate_object_style_dependencies(&mut self, handles: &[Handle]) {
if self.dependency_index_cache.borrow().is_none() {
*self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index());
}
let mut combined = DependencyTargets::default();
if let Some(index) = self.dependency_index_cache.borrow().as_ref() {
for handle in handles {
let Some(target) = index.object_styles.get(handle) else {
continue;
};
combined
.render_handles
.extend(target.render_handles.iter().copied());
combined
.source_handles
.extend(target.source_handles.iter().copied());
combined.touches_block_definition |= target.touches_block_definition;
}
}
self.invalidate_dependency_targets(combined);
}
pub fn block_dependency_handles(&self, name: &str) -> Vec<Handle> {
if self.dependency_index_cache.borrow().is_none() {
*self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index());
}
self.dependency_index_cache
.borrow()
.as_ref()
.and_then(|index| index.blocks.get(&name.to_ascii_uppercase()))
.map(|handles| handles.iter().copied().collect())
.unwrap_or_default()
}
pub(crate) fn invalidate_dependency_index(&self) {
self.dependency_index_cache.borrow_mut().take();
}
/// Spatial index + always-emit list for top-level entities. Lazily
/// rebuilt on `geometry_epoch` change.
///

View file

@ -47,7 +47,7 @@ pub struct Pipeline {
/// Used to draw ghost copies of selected wires through occluding geometry.
wire_xray_pipeline: wgpu::RenderPipeline,
/// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire /
/// xray pipelines). `Some` only on the fast native path; `None` in packed
/// xray pipelines). `Some` on native/WebGPU fast paths; `None` in packed
/// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`.
pub(crate) wire_const_bgl: Option<wgpu::BindGroupLayout>,
wipeout_pipeline: wgpu::RenderPipeline,
@ -338,8 +338,8 @@ impl Pipeline {
});
// ── Wire pipeline ──────────────────────────────────────────────────
// Select once per device. The fast native path hoists shared constants
// into storage; compatibility mode keeps them in 10 packed attributes.
// Select once per device. Native and WebGPU hoist shared constants into
// storage when limits allow; WebGL2/compat keeps packed attributes.
let wire_mode = wire_gpu::WirePipelineMode::select(device);
let renderer_mode_name =
if wire_mode.uses_storage() { "fast-storage" } else { "packed-compat" };
@ -357,12 +357,9 @@ impl Pipeline {
renderer_mode_name,
device.limits().max_storage_buffers_per_shader_stage
);
#[cfg(not(target_arch = "wasm32"))]
let wire_const_bgl = wire_mode
.uses_storage()
.then(|| wire_gpu::WireConst::bind_group_layout(device));
#[cfg(target_arch = "wasm32")]
let wire_const_bgl: Option<wgpu::BindGroupLayout> = None;
let mut wire_bgls: Vec<&wgpu::BindGroupLayout> = vec![&frame_bgl];
if let Some(bgl) = &wire_const_bgl {
wire_bgls.push(bgl);
@ -379,7 +376,6 @@ impl Pipeline {
let wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("wire.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(match wire_mode {
#[cfg(not(target_arch = "wasm32"))]
wire_gpu::WirePipelineMode::IndexedStorage => {
include_str!("../../shaders/wire_indexed.wgsl")
}
@ -3157,6 +3153,14 @@ fn create_msaa_texture(
pub struct MultiPipeline {
pub(crate) inners: Vec<Pipeline>,
format: wgpu::TextureFormat,
/// Stable viewport identity → pipeline slot map. Paper viewports used to
/// occupy slots by their current list position, so switching layouts or
/// scrolling a sheet could assign an existing viewport to a different
/// slot and throw away all of its GPU caches. Keep the association across
/// tab switches and only recycle genuinely cold slots.
pub(crate) slot_by_instance: rustc_hash::FxHashMap<u64, usize>,
slot_last_used: Vec<u64>,
slot_clock: u64,
/// The resident wire batches, keyed by `wire_content_id` and shared across
/// every slot (and every pane — one `MultiPipeline` backs all of them) that
/// renders the same content. `prepare` builds an entry once on a cache miss
@ -3189,8 +3193,70 @@ impl MultiPipeline {
let n = n.max(1);
while self.inners.len() < n {
self.inners.push(Pipeline::new(device, queue, self.format));
self.slot_last_used.push(0);
}
}
/// Resolve stable slots for the viewport identities in one primitive.
/// Thirty-two hot slots cover ordinary tiled/paper drawings. A cold slot
/// is recycled only after several other prepare calls, which prevents
/// sibling Model panes prepared in the same frame from evicting each
/// other. If every slot is still hot, growing is safer than a visible
/// rebuild hitch.
pub(crate) fn resolve_slots(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
instance_ids: &[u64],
) -> Vec<usize> {
const SOFT_LIMIT: usize = 32;
const HOT_WINDOW: u64 = 8;
self.slot_clock = self.slot_clock.wrapping_add(1).max(1);
let now = self.slot_clock;
let reserved: rustc_hash::FxHashSet<u64> = instance_ids.iter().copied().collect();
let mut slots = Vec::with_capacity(instance_ids.len());
for &instance_id in instance_ids {
let slot = if let Some(&slot) = self.slot_by_instance.get(&instance_id) {
slot
} else {
let vacant = self
.inners
.iter()
.position(|inner| inner.slot_id == u64::MAX);
let recyclable = vacant.or_else(|| {
(self.inners.len() >= SOFT_LIMIT)
.then(|| {
self.inners
.iter()
.enumerate()
.filter(|(_, inner)| !reserved.contains(&inner.slot_id))
.filter(|(slot, _)| {
now.saturating_sub(self.slot_last_used[*slot]) > HOT_WINDOW
})
.min_by_key(|(slot, _)| self.slot_last_used[*slot])
.map(|(slot, _)| slot)
})
.flatten()
});
let slot = recyclable.unwrap_or_else(|| {
let slot = self.inners.len();
self.ensure_len(device, queue, slot + 1);
slot
});
let old_id = self.inners[slot].slot_id;
if old_id != u64::MAX {
self.slot_by_instance.remove(&old_id);
}
self.slot_by_instance.insert(instance_id, slot);
slot
};
self.slot_last_used[slot] = now;
slots.push(slot);
}
slots
}
}
/// Send wgpu's uncaptured validation errors to stderr instead of the default
@ -3227,6 +3293,9 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
Self {
inners: vec![Pipeline::new(device, queue, format)],
format,
slot_by_instance: rustc_hash::FxHashMap::default(),
slot_last_used: vec![0],
slot_clock: 0,
wire_buffer_cache: rustc_hash::FxHashMap::default(),
}
}

View file

@ -58,18 +58,17 @@ fn instance_buffer_mapped<T: bytemuck::Pod>(
// ── Instance layout ───────────────────────────────────────────────────────
// ── Native: slim per-segment instance + shared per-wire constants ───────────
// ── WebGPU/native: slim per-segment instance + shared constants ─────────────
//
// Every segment of a wire used to carry the wire's color / line-weight / dash
// pattern / draw-depth (~44 B) on each instance — re-fetched once per segment
// even though it's constant along the wire. On native we hoist those into a
// even though it's constant along the wire. On storage-capable adapters we hoist those into a
// per-wire `WireConst` storage buffer indexed by `wire_id`, so the instance
// keeps only the per-segment data (endpoints + arc-length distances). Cuts the
// instance from 104 B to one 64-byte cache line and removes the redundant
// per-segment re-fetch of the shared constants. WebGL2 has no vertex-stage
// storage buffers, so the wasm build below keeps the original self-contained
// fat instance.
#[cfg(not(target_arch = "wasm32"))]
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct WireInstance {
@ -87,8 +86,6 @@ pub struct WireInstance {
/// exactly one 64-byte cache line.
pub taper_ratio: [u16; 2],
}
#[cfg(not(target_arch = "wasm32"))]
impl WireInstance {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
// Must match `InstanceIn` in wire_indexed.wgsl.
@ -110,11 +107,10 @@ impl WireInstance {
}
}
/// Per-wire constants shared by every segment of a wire (native only). std430
/// Per-wire constants shared by every segment of a wire. std430
/// layout: three vec4 then eight scalars = 80 B, matching `WireConst` in
/// wire_indexed.wgsl. `align_end` / `align_total` carry the "A"-type endpoint
/// alignment (see `wire_distances`); 0.0 total = no alignment.
#[cfg(not(target_arch = "wasm32"))]
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct WireConst {
@ -134,8 +130,6 @@ pub struct WireConst {
pub _pad1: f32,
pub _pad2: f32,
}
#[cfg(not(target_arch = "wasm32"))]
impl WireConst {
/// Bind-group layout for the per-wire storage buffer (group 1 of the wire /
/// xray pipelines). Read-only storage, visible to the vertex stage.
@ -158,8 +152,8 @@ impl WireConst {
// ── Packed compatibility instance (no vertex-stage storage) ────────────────
//
// Web always uses this layout. Native selects it at runtime for adapters whose
// storage-buffer limits are insufficient, or when --compat-renderer is set.
// WebGL2 and limited native adapters use this layout. WebGPU selects the
// indexed storage path when its reported limits satisfy the same requirement.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PackedWireInstance {
@ -224,21 +218,15 @@ impl PackedWireInstance {
}
}
#[cfg(target_arch = "wasm32")]
pub type WireInstance = PackedWireInstance;
/// Wire and hatch pipelines switch together: the fast path uses storage
/// buffers; the compatibility path carries wire constants in packed vertex
/// attributes and hatch data in a texture.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WirePipelineMode {
#[cfg(not(target_arch = "wasm32"))]
IndexedStorage,
Packed,
}
#[cfg(not(target_arch = "wasm32"))]
fn select_native_pipeline(max_storage_buffers_per_stage: u32, forced: bool) -> WirePipelineMode {
fn select_pipeline(max_storage_buffers_per_stage: u32, forced: bool) -> WirePipelineMode {
const REQUIRED_STORAGE_BUFFERS_PER_STAGE: u32 = 5;
if forced || max_storage_buffers_per_stage < REQUIRED_STORAGE_BUFFERS_PER_STAGE {
WirePipelineMode::Packed
@ -249,23 +237,15 @@ fn select_native_pipeline(max_storage_buffers_per_stage: u32, forced: bool) -> W
impl WirePipelineMode {
pub fn select(device: &wgpu::Device) -> Self {
#[cfg(target_arch = "wasm32")]
{
let _ = device;
Self::Packed
}
#[cfg(not(target_arch = "wasm32"))]
{
select_native_pipeline(
device.limits().max_storage_buffers_per_shader_stage,
crate::cli::gui_config().compat_renderer,
)
}
let forced = crate::cli::gui_config().compat_renderer;
#[cfg(target_arch = "wasm32")]
let forced = false;
select_pipeline(device.limits().max_storage_buffers_per_shader_stage, forced)
}
pub fn uses_storage(self) -> bool {
match self {
#[cfg(not(target_arch = "wasm32"))]
Self::IndexedStorage => true,
Self::Packed => false,
}
@ -273,7 +253,6 @@ impl WirePipelineMode {
pub fn layout<'a>(self) -> wgpu::VertexBufferLayout<'a> {
match self {
#[cfg(not(target_arch = "wasm32"))]
Self::IndexedStorage => WireInstance::layout(),
Self::Packed => PackedWireInstance::layout(),
}
@ -494,9 +473,8 @@ fn emit_wire_packed(
instances
}
/// Native: emit slim per-segment instances (positions + distances + `wire_id`)
/// Storage path: emit slim per-segment instances (positions + distances + `wire_id`)
/// plus the one `WireConst` record every segment of this wire shares.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn emit_wire_native(
wire: &WireModel,
wire_id: u32,
@ -579,11 +557,10 @@ pub(crate) fn wire_draw_depth(
}
}
/// Build the shared per-wire `WireConst` storage buffer and its bind group
/// (native only). All instance-buffer chunks from one build reference the same
/// Build the shared per-wire `WireConst` storage buffer and its bind group.
/// All instance-buffer chunks from one build reference the same
/// buffer via their global `wire_id`, so a single bind group is cloned into
/// each chunk.
#[cfg(not(target_arch = "wasm32"))]
fn build_const_bind_group(
device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout,
@ -677,7 +654,6 @@ impl WireGpu {
mesh_edge: bool,
const_bgl: Option<&wgpu::BindGroupLayout>,
) -> Vec<Self> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(const_bgl) = const_bgl {
const MAX_INSTANCES: usize =
268_435_456 / std::mem::size_of::<WireInstance>();
@ -769,7 +745,6 @@ impl WireGpu {
if total_segs == 0 {
return vec![];
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(const_bgl) = const_bgl {
// GPU max buffer size is 256 MB; chunk to stay within the limit.
const MAX_INSTANCES: usize =

View file

@ -391,7 +391,10 @@ impl Scene {
// Report the exact erased handles so derived caches drop just those and
// the resident set removes only their wires (bump_entities drops them
// from the tessellation memos too).
self.bump_entities(&erased);
if !erased.is_empty() {
self.invalidate_dependency_index();
self.bump_entities(&erased);
}
}
/// Restore erased Arc-backed entities without re-linking their still-present
@ -414,6 +417,7 @@ impl Scene {
}
}
if !changes.is_empty() {
self.invalidate_dependency_index();
self.bump_entities(&changes);
}
restored

View file

@ -152,13 +152,6 @@ pub struct Primitive {
pub(in crate::scene) viewports: Vec<ViewportData>,
/// Background color used to clear each viewport's MSAA buffer.
pub(in crate::scene) bg_color: [f32; 4],
/// First `MultiPipeline` inner slot this primitive owns. Paper space (one
/// shader widget, many viewports) uses 0. Per-pane Model widgets each own a
/// distinct slot (= their tile index) so several shader widgets can share
/// the type-keyed pipeline storage without clobbering one another — all
/// `prepare` calls run before all `render` calls, so disjoint slots are
/// safe.
pub(in crate::scene) base_slot: usize,
/// One input-to-render sample, carried only when PERF tracing is enabled.
pub(in crate::scene) nav_perf: Option<NavPerfSample>,
}
@ -250,10 +243,11 @@ impl shader::Primitive for Primitive {
let phys = viewport.physical_size();
let full_size = Size::new(phys.width, phys.height);
let scale = viewport.scale_factor() as f32;
pipeline.ensure_len(device, queue, self.base_slot + self.viewports.len());
let instance_ids: Vec<u64> = self.viewports.iter().map(|vp| vp.instance_id).collect();
let slots = pipeline.resolve_slots(device, queue, &instance_ids);
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[self.base_slot + i];
let inner = &mut pipeline.inners[slots[i]];
// Pipeline slots are addressed by list index, but off-canvas
// viewports are dropped from the list — so a slot can be reused by a
// DIFFERENT viewport across frames (e.g. the first viewport scrolls
@ -442,7 +436,10 @@ impl shader::Primitive for Primitive {
// the whole wire buffer. Only for the scissor-free, mesh-free
// (single-batch) Model set; scissored paper viewports and mixed
// 2D/3D sets fall through to the shared batched path below.
#[cfg(not(target_arch = "wasm32"))]
let mut arena_served = false;
#[cfg(target_arch = "wasm32")]
let arena_served = false;
#[cfg(not(target_arch = "wasm32"))]
let _perf = crate::perf::enabled();
#[cfg(not(target_arch = "wasm32"))]
@ -895,9 +892,12 @@ impl shader::Primitive for Primitive {
let ch = clip.height as f32;
let clip_right = clip.x + clip.width;
let clip_bottom = clip.y + clip.height;
for (i, vp) in self.viewports.iter().enumerate() {
let Some(inner) = pipeline.inners.get(self.base_slot + i) else {
break;
for vp in &self.viewports {
let Some(slot) = pipeline.slot_by_instance.get(&vp.instance_id) else {
continue;
};
let Some(inner) = pipeline.inners.get(*slot) else {
continue;
};
// Where the viewport would land on the surface in absolute
// pixels (i32 because either edge may stick off the canvas).
@ -1433,14 +1433,9 @@ impl Scene {
sample.build_ms = nav_build_started.elapsed().as_secs_f64() * 1000.0;
sample
});
// Model panes permanently own slots 0..N. Paper starts after them so
// its sheet/content viewports never evict the Model slot's wire arena,
// textures, mesh batches and render cache during a layout-tab switch.
let base_slot = self.model_tiles.borrow().len();
Primitive {
viewports,
bg_color,
base_slot,
nav_perf: perf_nav,
}
}
@ -1466,7 +1461,6 @@ impl Scene {
return Primitive {
viewports: vec![],
bg_color,
base_slot: tile_idx,
nav_perf: None,
};
};
@ -1514,7 +1508,6 @@ impl Scene {
Primitive {
viewports,
bg_color,
base_slot: tile_idx,
nav_perf: perf_nav,
}
}