//! 有界屏幕网格,用来减少普通二维命中和相机目标查询的候选。 //! 巨大对象和 3D 面板进入后备集合,再由精确命中判断;索引预算不足不能导致遗漏目标。反序列化或索引长度不匹配时可退回完整候选。 //! //! Bounded screen-space broad phase. Projective world UI uses the precise //! ray-based narrow phase rather than an unsafe screen rectangle approximation. use crate::{document::MountGeometry, geometry::Point}; use std::collections::HashMap; #[derive(Debug, Default)] /// 有限屏幕格子和后备集合。候选筛选允许保守多取,但不能因索引预算漏掉有效命中。 pub struct Index { // 建立索引时的 Mount 总数,用来发现明显过期的索引;数量相同不代表几何未改变。 count: usize, // 格子坐标映射到原 mounts 切片下标,保留原始绘制顺序信息,不持有托管对象。 cells: HashMap<(i32, i32), Vec>, // 不能安全装入网格的候选,每次查询都参与;由后续精确 contains 排除误命中。 fallback: Vec, } impl Index { /// 从帧几何构建 128 单位屏幕网格;大对象、投影面板和超预算项进入保守后备集合。 // viewport::project 完成所有出现的变换/裁剪后调用,Frame 保存此纯原生索引供命中和相机目标查询。 pub fn build(mounts: &[MountGeometry]) -> Self { // 保存原 mounts 数量用于查询时的基本一致性检查,格子与 fallback 初始为空。 let mut result = Self { count: mounts.len(), ..Self::default() }; // 累计格子内的下标引用数,不是不同格子数,也不是有效 Mount 数。 let mut entries = 0; // index 始终指原帧数组位置,跳过不可参与输入的节点也不压缩/重排其他下标。 for (index, mount) in mounts.iter().enumerate() { // 只有可交互项、场景视口或滚动容器需要参与输入目标宽阶段,其余外观节点跳过。 if !mount.interactive && !mount.canvas_viewport && !mount.scene3d_viewport && mount.scroll.is_none() { continue; } // 3D 面板使用射线投影,不能把局部矩形当成屏幕 AABB 安全近似,统一交给精确阶段。 if mount.panel.is_some() { result.fallback.push(index); continue; } // 普通二维节点取两对角点并应用视图变换,再排序上下界,兼容坐标方向变化。 let a = Point::new(mount.x, mount.y); let b = Point::new(mount.x + mount.width, mount.y + mount.height); let a = mount.view.map_or(a, |view| view.point(a)); let b = mount.view.map_or(b, |view| view.point(b)); let (mut x0, mut y0, mut x1, mut y1) = (a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); // 先与有效裁剪求交,缩小入网范围;完全不可见的矩形不需要成为候选。 if let Some([x, y, w, h]) = mount.clip { x0 = x0.max(x); y0 = y0.max(y); x1 = x1.min(x + w); y1 = y1.min(y + h); } if x1 < x0 || y1 < y0 { continue; } // 极端或非有限坐标不做网格展开,转后备集合;不能因转换溢出而漏掉待精确检查的目标。 if ![x0, y0, x1, y1] .iter() .all(|value| value.is_finite() && value.abs() < 1e12) { result.fallback.push(index); continue; } // 将裁剪后的屏幕界转换为格子边界,右下边界也包含以保守覆盖几何边缘。 let (left, top, right, bottom) = (cell(x0), cell(y0), cell(x1), cell(y1)); // 用 i64 计算格子跨度和饱和乘积,避免极大矩形在预算检查前发生算术溢出。 let cells = (i64::from(right) - i64::from(left) + 1) .saturating_mul(i64::from(bottom) - i64::from(top) + 1); // 单项最多展开 256 格,全索引最多 65536 项格子引用;超预算只降级,不丢弃目标。 if cells > 256 || entries + cells > 65_536 { result.fallback.push(index); continue; } // 只有真正入网的项计入引用预算,fallback 不展开格子,因此不会被巨大矩形撑大索引。 entries += cells; // 二维范围最多展开 256 格,同一出现可进入多格,每格内仍按原帧顺序追加。 for x in left..=right { // 把当前原帧下标登记到每个覆盖格,查询点随后只需读取一格加 fallback。 for y in top..=bottom { result.cells.entry((x, y)).or_default().push(index); } } } result } /// 返回当前网格格子与后备集合中的候选,去重并恢复逆绘制顺序。 // Frame 的 hit_test/camera_at 查询调用;返回的是保守下标集,调用方仍需检查实际几何、裁剪和资格。 pub fn candidates(&self, point: Point, count: usize) -> Vec { // 索引总数与当前帧不一致时,下标集合不再可靠;直接返回整帧逆序候选。 if self.count != count { return (0..count).rev().collect(); } // 每次先复制全部后备下标,3D 面板必须保留给射线精确阶段,不能用二维格子筛掉。 let mut output = self.fallback.clone(); // 常规候选只来自指针当前格,未命中格子不影响后备集合继续参与。 if let Some(items) = self.cells.get(&(cell(point.x), cell(point.y))) { output.extend(items); } // 按下标降序还原从前景到背景的绘制顺序;去重避免同一出现被调用方重复精确检查。 output.sort_unstable_by(|a, b| b.cmp(a)); output.dedup(); output } } /// 把屏幕坐标映射到有界整数网格坐标,处理负方向与极端数值。 // build 转换矩形边界与 candidates 转换查询点共用,保证负坐标和格子边界使用相同规则。 fn cell(value: f32) -> i32 { // 每格 128 个屏幕单位;floor 让 -1 属于负格而不是零格,再限制到整数表示范围。 (value / 128.) .floor() .clamp(i32::MIN as f32, i32::MAX as f32) as i32 } #[cfg(test)] mod tests { use super::*; #[test] /// 200个分散节点只返回局部网格候选;覆盖海量格的巨大前景节点进入后备集合后仍按前景顺序参与精确命中。 fn screen_grid_limits_candidates_and_keeps_huge_rectangles_hittable() { // 借真实 ReplSession 生成交互 Mount 模板,确保测试几何包含生产路径所需字段,而非手写近似结构。 let (_, document) = crate::endor_host::ReplSession::load( "page=Page()\npage.render(Button(\"hit\"))", "app.main", 1, 930, ) .unwrap(); // 从真实文档挑一个可交互出现,随后只替换矩形、变换和裁剪以隔离网格筛选行为。 let template = document .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); // 保留原帧下标 0..199,每项横向间隔 400,大于 128 格宽,让原点只查到局部候选。 let mut mounts = Vec::new(); // 所有模板都清除视图与 clip,避免旧页面投影影响这组人工屏幕坐标。 for index in 0..200 { let mut mount = template.clone(); mount.x = index as f32 * 400.; mount.y = 0.; mount.width = 32.; mount.height = 32.; mount.view = None; mount.clip = None; mounts.push(mount); } // 首次构建检查普通项的局部筛选,随后扩大最后一个出现并重建,验证超预算降级不漏目标。 let index = Index::build(&mounts); assert_eq!(index.candidates(Point::new(5., 5.), mounts.len()), vec![0]); // 把最前景节点扩大到覆盖海量格子,验证其进入后备集合后仍排在普通候选之前且可精确命中。 mounts[199].x = -1_000_000.; mounts[199].width = 2_000_000.; let index = Index::build(&mounts); assert_eq!( index.candidates(Point::new(5., 5.), mounts.len()), vec![199, 0] ); assert!(mounts[199].contains(Point::new(5., 5.))); } }