//! 独立 2D 相机与可逆投影。视口尺寸属于布局;平移、缩放属于视图,不改变世界对象的逻辑位置。 //! project 为每次出现附加组合变换、裁剪和嵌套面板映射;绘制与命中共用这些结果。fit 根据有限内容边界调整指定相机。 //! //! Reversible projections keep camera movement independent of layout. pub mod spatial; use crate::geometry::{Point, Rect}; use endor_core::{ObjectPayload, ObjectRef, ObjectStore, VmError}; /// Marker payload gives cameras a native nominal identity while their fields /// remain ordinary traced, bindable VM attributes. #[derive(Debug)] /// 托管 2D 相机的名义标记,实际位置/缩放是被追踪的普通属性。 // ui_bridge 创建相机时分配此 payload;camera 读取入口据此验证名义身份,属性值仍保存在 VM 属性存储。 pub struct Camera2D; impl ObjectPayload for Camera2D { /// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。 fn as_any(&self) -> &dyn std::any::Any { self } /// 返回运行时诊断/类型检查使用的稳定 payload 名称。 fn kind_name(&self) -> &'static str { "camera-2d" } /// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。 fn allows_attributes(&self) -> bool { true } /// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。 // 标记自身没有托管引用;x/y/zoom 等普通属性的追踪由对象存储负责,不在此重复枚举。 fn trace(&self, _: &mut dyn FnMut(ObjectRef)) {} } #[derive(Debug, Clone, PartialEq)] /// 从 VM 提取的独立相机数值描述,渲染与布局投影不直接读取托管堆。 pub struct CameraView { // 相机托管身份的文本形式,供快照比较和输入重新关联;原生描述本身不拥有 VM 根。 pub identity: String, // 相机在世界空间的左上位置,投影时先从内容坐标中扣除,再乘 zoom。 pub x: f32, // 世界纵向左上偏移,和 x 一样在投影前扣除,不是视口盒子的布局原点。 pub y: f32, // 已校验 0.01..100 的相机倍率;嵌套后的累计倍率另受 Transform.valid 预算约束。 pub zoom: f32, } /// 读取并校验托管 Camera2D 的位置和缩放,生成原生投影描述。 // block/snapshot 提取 CanvasViewport 视觉、相机创建校验及 page_input 交互读取都共用此入口。 pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result { // 先验证实际宿主 payload,避免只有 x/y/zoom 同名字段的普通对象冒充相机。 store.payload::(object, "Camera2D")?; // 三个属性都经有限 Number 转换,位置允许负值;zoom 随后再用专门正范围收紧。 let x = crate::scroll::coordinate(&store.get_attr(object, "x")?)?; let y = crate::scroll::coordinate(&store.get_attr(object, "y")?)?; let zoom = crate::scroll::coordinate(&store.get_attr(object, "zoom")?)?; // coordinate 已检查有限性与绝对范围;zoom 还要严格为正并位于专门相机范围,保证可逆变换。 if !(0.01..=100.0).contains(&zoom) { return Err(VmError::InvalidArgument( "Camera2D.zoom expects 0.01 through 100", )); } Ok(CameraView { identity: object.to_string(), x, y, zoom, }) } /// Fit finite scene bounds using this occurrence's allocation. Sharing a camera /// is explicit, so fitting one view then intentionally affects all its users. /// 根据这个 CanvasViewport 出现的有限内容范围调整其相机;显式共享相机会同步影响其他视口。 // model/mount 的 fitView 协议调用,按指定出现的内容分配更新其共享相机,不重新排列业务节点。 pub fn fit( cx: &mut endor_core::NativeContext<'_>, mount: &crate::mount::Mount, ) -> Result<(), VmError> { // 只接受已提交的 CanvasViewport,布局期间的临时几何不能作为 fitView 分配尺寸依据。 if !mount.geometry.canvas_viewport || mount.layout.is_some() { return Err(VmError::InvalidArgument( "fitView requires a committed CanvasViewport Mount", )); } // 从当前 Block 解析有效视觉属性,取得真实相机引用,不能只信旧视觉快照里的相机数值。 let (_, data) = crate::block::resolve(cx.store(), mount.block)?; let data = crate::block::visual_data(cx.store(), data)?; let camera = data .camera .ok_or(VmError::InvalidArgument("missing viewport camera"))?; // 首子出现是场景根,弱身份在 NativeContext 内升级保活;没有根不能推导场景边界。 let root = mount .first_child .map(|weak| cx.upgrade(weak)) .transpose()? .flatten() .ok_or(VmError::InvalidArgument("missing scene occurrence"))?; // 复制场景根 Mount 的原生描述,后面遍历其直接孩子并以它的原点作坐标基准。 let scene = cx .store() .payload::(root, "Mount")? .clone(); // 只汇总场景直接子出现的有限边界,沿 sibling 链扫描;不把无限世界或整棵后代图作为布局盒。 let mut next = scene.first_child; let mut bounds: Option<[f32; 4]> = None; let mut count = 0; // 沿稳定 sibling 链逐项汇总,每步递增预算,异常长链不能让 fitView 无限扫描。 while let Some(weak) = next { count += 1; // 最多处理一万项,超过时在写相机属性之前报执行限制错误。 if count > 10_000 { return Err(VmError::ExecutionLimit("fitView scene bounds")); } // 每个直接孩子必须仍然存活;失效不能静默跳过,否则拟合范围会遗漏内容。 let object = cx .upgrade(weak)? .ok_or(VmError::InvalidArgument("expired scene child"))?; let child = cx.store().payload::(object, "Mount")?; // 先扣 scene 原点,把孩子绝对布局坐标转为场景相对坐标;此数组存左上/右下,而非宽高。 let rect = [ child.geometry.x - scene.geometry.x, child.geometry.y - scene.geometry.y, child.geometry.x - scene.geometry.x + child.geometry.width, child.geometry.y - scene.geometry.y + child.geometry.height, ]; // 逐个扩展共同包围框,空场景保持 None 并在后面回到单位相机。 bounds = Some(bounds.map_or(rect, |b| { [ b[0].min(rect[0]), b[1].min(rect[1]), b[2].max(rect[2]), b[3].max(rect[3]), ] })); // 在读取当前孩子后推进到同层下一出现,不递归进入其内部世界或后代布局。 next = child.next_sibling; } // 读取视口当前盒模型,以扣除 padding/border 后的内容尺寸决定相机倍率。 let node = crate::block::snapshot(cx.store(), mount.block)?; let inset = crate::layout::insets(&node); // 扣除布局 inset 后才是相机可用的内容区域;负值归零,避免用边框占用部分计算缩放。 let width = (mount.geometry.width - inset.horizontal()).max(0.0); let height = (mount.geometry.height - inset.vertical()).max(0.0); // 有内容时居中拟合,空场景则恢复原点与单位缩放;不会自动改变视口分配尺寸。 let (x, y, zoom) = if let Some(bounds) = bounds { // 两个轴分别计算可容纳倍率再取较小者;内容跨度增加 48 世界单位形成留白,最终钳到相机范围。 let zoom = (width / (bounds[2] - bounds[0] + 48.0)) .min(height / (bounds[3] - bounds[1] + 48.0)) .clamp(0.01, 100.0); ( // 让内容中心落在视口中心:相机位置 = 内容中心 - 半个视口的世界尺寸。 (bounds[0] + bounds[2]) / 2.0 - width / (2.0 * zoom), (bounds[1] + bounds[3]) / 2.0 - height / (2.0 * zoom), zoom, ) } else { (0.0, 0.0, 1.0) }; // 最终通过普通属性写入走 VM 变更追踪/绑定机制;共享该相机的其他视口也会观察到更新。 // 逐个普通属性写入,任一写入失败向上传播;这里没有把三次 set_attr 包成原子事务。 for (name, value) in [("x", x), ("y", y), ("zoom", zoom)] { cx.set_attr( camera, name, crate::native_visual::value(value.clamp(-1_000_000.0, 1_000_000.0)), )?; } Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] /// 可组合的均匀缩放与平移;正向绘制和逆向命中必须使用同一结果。 pub struct Transform { // 均匀缩放,point/rect 正向乘此值,inverse 撤销时除以此值;累计变换须经 valid。 pub zoom: f32, // 缩放之后叠加的目标空间横向平移;then 组合时此值还须乘外层 zoom。 pub x: f32, // 同一目标空间的纵向平移,不等同于 CameraView.y 的世界偏移。 pub y: f32, } impl Default for Transform { /// 返回不改变坐标的单位缩放与零平移,供根视图和面板纹理空间使用。 // 单位变换供无相机或独立面板纹理空间使用;None 的 mount.view 也表示这一效果。 fn default() -> Self { Self { zoom: 1.0, x: 0.0, y: 0.0, } } } impl Transform { /// 将逻辑点按当前缩放和平移变换到外层视图。 // document 的坐标查询、绘制命令投影与 panel 映射共用,公式先缩放后平移。 pub fn point(self, point: Point) -> Point { Point::new(point.x * self.zoom + self.x, point.y * self.zoom + self.y) } /// 将视图点反算回逻辑坐标,供命中和手势锚点计算使用。 // 不在这里额外校验 zoom,调用方必须先保证 valid;零缩放无法反算。 // MountGeometry 和 page_input 的手势计算使用,先撤平移再除缩放,不修改原布局几何。 pub fn inverse(self, point: Point) -> Point { Point::new( (point.x - self.x) / self.zoom, (point.y - self.y) / self.zoom, ) } /// 使用同一缩放和平移转换矩形的位置与尺寸。 // project 用于把视口边界投到父空间;缩放为正的约束保证宽高无需翻转。 pub fn rect(self, rect: Rect) -> Rect { // 只变换左上点,宽高乘同一 zoom;此类型不支持旋转或非均匀缩放。 let point = self.point(Point::new(rect.x, rect.y)); Rect::new( point.x, point.y, rect.width * self.zoom, rect.height * self.zoom, ) } /// 将当前变换再放入外层变换,保持绘制与逆向命中的组合顺序一致。 // 组合为 outer(self(point)):内层平移也要乘外层缩放,不能只把两次平移相加。 // project 在嵌套 CanvasViewport 时组合局部相机与祖先变换,返回 outer ∘ self。 pub fn then(self, outer: Self) -> Self { Self { zoom: self.zoom * outer.zoom, x: self.x * outer.zoom + outer.x, y: self.y * outer.zoom + outer.y, } } /// 验证累计二维变换:zoom 有限且在 (0,1000000],平移有限且绝对值不超过一万亿。 // 嵌套投影和命令校验在使用前检查累计预算,单个 Camera2D 合法不意味着任意多层组合都合法。 pub fn valid(self) -> bool { // 允许累计平移远大于单个世界坐标范围,因为相机偏移还会乘缩放;同时限制嵌套累计放大。 self.zoom.is_finite() && self.zoom > 0.0 && self.zoom <= 1_000_000.0 && self.x.is_finite() && self.y.is_finite() && self.x.abs() <= 1_000_000_000_000.0 && self.y.abs() <= 1_000_000_000_000.0 } } /// 计算视口与祖先裁剪的共同可见矩形。 // project 将当前视口边界与已累计祖先 clip 求交,不能扩大祖先允许绘制的范围。 fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { // clip 用 [x,y,width,height],分离的矩形返回零面积,不产生负宽高。 let x = a[0].max(b[0]); let y = a[1].max(b[1]); [ x, y, ((a[0] + a[2]).min(b[0] + b[2]) - x).max(0.0), ((a[1] + a[3]).min(b[1] + b[3]) - y).max(0.0), ] } /// 遍历出现树,组合相机与父视图变换,建立裁剪和世界 UI 映射,最后重建屏幕命中索引。 // ui_bridge::finish_layout_frame 在 scroll::project 之后调用,完成后 Frame 同时具备命令投影和输入宽阶段索引。 pub fn project( root: &crate::ui_bridge::UiNode, frame: &mut crate::document::Frame, ) -> anyhow::Result<()> { use crate::ui_bridge::UiKind; use std::collections::HashMap; use std::sync::Arc; // 按 Block 身份建立语义视觉索引;多个 Mount 可查到同一节点,但每个出现单独组合视图。 // scroll::project 已把祖先滚动量计入 Mount.x/y;此处只附相机/面板映射,不能再从相机平移中重复扣滚动偏移。 let mut nodes = HashMap::new(); let mut work = vec![root]; // 显式栈只展开视觉节点树,不以此遍历顺序决定绘制或输入优先级。 while let Some(node) = work.pop() { nodes.insert(node.block_id.as_str(), node); work.extend(node.children().iter().map(|node| node.as_ref())); } // 每个出现向孩子传递累计二维变换、当前空间的裁剪和嵌套面板映射;业务布局坐标不被改写。 type Scope = ( Transform, Option<[f32; 4]>, Option>, ); // 上下文按父出现 ID 传给孩子;遍历要求 frame.mounts 父先子后,避免读取尚未建立的父投影。 let mut contexts = HashMap::::new(); // 保留 next_sibling 查找表,遍历可变 Mount 时仍可按稳定子出现顺序为面板根建立映射。 let next: HashMap<_, _> = frame .mounts .iter() .map(|mount| (mount.id, mount.next_sibling)) .collect(); // 3D 视口先为后续面板根登记映射和纹理尺寸,轮到该根时再切换独立纹理坐标空间。 let mut panels = HashMap::, crate::layout::Size)>::new(); // 逐出现处理,读取当前业务视觉但只改 Frame 的派生投影,不写 VM 相机或业务节点属性。 for mount in &mut frame.mounts { let node = nodes[mount.block_id.as_str()]; // 面板根进入独立纹理像素空间,二维变换重置为单位,裁剪重置为面板尺寸;普通节点沿用父上下文。 let (view, clip, panel) = if let Some((panel, size)) = panels.get(&mount.id) { ( Transform::default(), Some([0., 0., size.width, size.height]), Some(panel.clone()), ) } else { mount .parent .and_then(|parent| contexts.get(&parent).cloned()) .unwrap_or_default() }; // 检查继承/重置后的累计二维变换,超预算时结束草稿处理,不把非法缩放传给后续命令。 anyhow::ensure!( view.valid(), "nested viewport transform exceeds numeric budget" ); // 单位变换省略存储,其余保持为显式 Transform;每轮先清理旧 viewport_clip,避免残留上帧状态。 mount.view = (view != Transform::default()).then_some(view); mount.panel = panel.clone(); mount.clip = clip; mount.viewport_clip = None; // 当前出现使用继承的 view/clip;仅其子树才叠加本层相机或本层子裁剪。 // 因此相机移动场景内容,但视口本身的外框和内容裁剪仍固定在父布局分配的位置。 let mut child_view = view; let mut child_clip = clip; let inset = crate::layout::insets(node); // 独立二维/三维视口裁内容盒,普通 clip_children 裁边框内,Scroll 最后补默认容器裁剪。 let viewport = if let UiKind::CanvasViewport { camera, .. } = &node.kind { // 内容原点含左/上 inset,局部相机围绕该原点锚定,不围绕全页原点缩放。 let x = mount.x + inset.left; let y = mount.y + inset.top; // 围绕视口内容原点建立相机变换:原点保持锚定,世界内容先减相机位置再缩放,最后套父变换。 child_view = Transform { zoom: camera.zoom, x: x * (1.0 - camera.zoom) - camera.x * camera.zoom, y: y * (1.0 - camera.zoom) - camera.y * camera.zoom, } .then(view); Some(Rect::new( x, y, (mount.width - inset.horizontal()).max(0.0), (mount.height - inset.vertical()).max(0.0), )) // 3D 摄像机由 View 负责,这里只建立二维内容矩形,不给世界网格再套一个 Camera2D。 } else if matches!(node.kind, UiKind::Viewport3D { .. }) { Some(Rect::new( mount.x + inset.left, mount.y + inset.top, (mount.width - inset.horizontal()).max(0.), (mount.height - inset.vertical()).max(0.), )) // 普通节点的 clip_children 裁到边框内侧,独立视口裁内容盒,Scroll 默认裁整个滚动盒。 } else if node.clip_children { let border = node.border; Some(Rect::new( mount.x + border.left.min(mount.width), mount.y + border.top.min(mount.height), (mount.width - border.horizontal()).max(0.0), (mount.height - border.vertical()).max(0.0), )) } else if matches!(node.kind, UiKind::Scroll { .. }) { Some(Rect::new(mount.x, mount.y, mount.width, mount.height)) } else { None }; // 只有产生了本层可见区域才收窄 child_clip;当前 Mount 的外层 clip 保持为继承值。 if let Some(rect) = viewport { // 本视口边界按父变换投影,不能套当前相机的 child_view,否则相机会连自己的视口框一起移动。 let rect = view.rect(rect); let rect = [rect.x, rect.y, rect.width, rect.height]; child_clip = Some(clip.map_or(rect, |clip| intersect(clip, rect))); // 独立视口另存 viewport_clip,后续命中和面板 Mapping 使用内容区裁剪而非整个外框。 if mount.canvas_viewport || mount.scene3d_viewport { mount.viewport_clip = child_clip; } } if let UiKind::Viewport3D { view: scene, panels: sizes, .. } = &node.kind { // 沿面板映射链限制嵌套深度,防止递归纹理装配和输入逆投影无限展开。 // 沿已有父面板映射链计数,不把普通二维嵌套计作 3D 纹理深度。 let mut depth = 0; let mut ancestor = panel.clone(); // 只读遍历不可变 Arc 链,每前进一步计一层,限制下面新建映射后的总嵌套规模。 while let Some(mapping) = ancestor { depth += 1; ancestor = mapping.parent.clone(); } anyhow::ensure!(depth < 8, "3D UI viewport nesting exceeds 8 surfaces"); let mut child = mount.first_child; // slots、面板尺寸和直接子出现按同序配对;缺少预期子出现时立即报错,不能把内容映射到错误面板。 for (key, size) in node.slots.iter().zip(sizes) { // slots 与 sizes 由场景快照提供,当前直接子出现必须存在,不能因缺项错位关联后续面板。 let id = child.ok_or_else(|| anyhow::anyhow!("missing 3D UI root occurrence"))?; // Mapping 保存所属视口的相机、布局矩形、外层二维变换和父面板链,供正逆坐标转换共用。 let mapping = Arc::new(crate::scene3d::panel::Mapping { owner: mount.id, key: *key, view: scene.clone(), rect: [ mount.x + inset.left, mount.y + inset.top, (mount.width - inset.horizontal()).max(0.), (mount.height - inset.vertical()).max(0.), ], outer: view, clip: mount.viewport_clip, parent: panel.clone(), }); // 记录待遍历面板根的新上下文;之后走到该孩子时切换纹理空间,不把外层 clip 误当纹理像素。 panels.insert(id, (mapping, *size)); // 依照预存 sibling 表推进,不在可变遍历 frame.mounts 时再次借用整个出现数组。 child = next[&id]; } } // 保存孩子最终上下文;3D 面板根随后通过 panels 表覆盖成纹理空间入口。 contexts.insert(mount.id, (child_view, child_clip, panel)); } // 投影完成后按命令 owner 查回对应出现,重建平行元数据;此时不再修改 Mount 几何。 // 这里只准备当前默认命令的投影;后续 source_paint 替换命令时也必须按出现归属维护对应元数据,不能沿用旧命令下标。 let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect(); // 按每条命令的 Mount 归属重建 view/clip,确保源绘制、默认绘制和输入都使用这一轮最终投影。 frame.command_views.clear(); frame.command_clips.clear(); anyhow::ensure!( frame.command_mounts.len() == frame.commands.len(), "missing command occurrence table" ); // 每条命令复制所属出现的 view/clip,不能按命令自身位置猜测归属。 for owner in &frame.command_mounts { let mount = mounts .get(owner) .ok_or_else(|| anyhow::anyhow!("paint owner is unavailable"))?; frame.command_views.push(mount.view); frame.command_clips.push(mount.clip); } // 几何、裁剪和视图信息全部就绪后才建立屏幕宽阶段索引,避免查询旧投影候选。 frame.hit_index = std::sync::Arc::new(spatial::Index::build(&frame.mounts)); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] /// 以实际两次点变换核对嵌套组合顺序、逆变换和矩形尺寸,并允许大相机投影平移而非套用较小世界坐标限额。 fn nested_views_compose_and_invert_without_changing_layout_coordinates() { // 相机坐标可达正负一百万,缩放100后投影平移可达一亿;不能继续使用较小的世界坐标限制。 assert!( Transform { zoom: 100., x: -100_000_000., y: 100_000_000. } .valid() ); // 构造非单位内外层变换,包含不同缩放和平移,防止只测试平移而漏掉组合次序。 let inner = Transform { zoom: 2.0, x: 12.0, y: -40.0, }; // 外层缩放也会缩放内层平移,后面的逐次点变换是独立于组合公式的参照。 let outer = Transform { zoom: 0.5, x: 80.0, y: 20.0, }; // 用非零且带负分量的输入点核对正向与逆向恢复,不改变该布局点本身。 let point = Point::new(-5.0, 100.0); // 以两次实际点变换验证组合顺序,再检查逆变换和矩形尺寸,避免只验证某一种公式写法。 let composed = inner.then(outer); assert_eq!(composed.point(point), outer.point(inner.point(point))); assert_eq!(composed.inverse(composed.point(point)), point); assert_eq!( composed.rect(Rect::new(-5.0, 100.0, 20.0, 30.0)), Rect::new(81.0, 100.0, 20.0, 30.0) ); } }