//! 独立 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 相机的名义标记,实际位置/缩放是被追踪的普通属性。 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 引用追踪和循环回收使用。 fn trace(&self, _: &mut dyn FnMut(ObjectRef)) {} } #[derive(Debug, Clone, PartialEq)] /// 从 VM 提取的独立相机数值描述,渲染与布局投影不直接读取托管堆。 pub struct CameraView { pub identity: String, pub x: f32, pub y: f32, pub zoom: f32, } /// 读取并校验托管 Camera2D 的位置和缩放,生成原生投影描述。 pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result { store.payload::(object, "Camera2D")?; 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")?)?; 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 出现的有限内容范围调整其相机;显式共享相机会同步影响其他视口。 pub fn fit( cx: &mut endor_core::NativeContext<'_>, mount: &crate::mount::Mount, ) -> Result<(), VmError> { if !mount.geometry.canvas_viewport || mount.layout.is_some() { return Err(VmError::InvalidArgument( "fitView requires a committed CanvasViewport Mount", )); } 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"))?; let root = mount .first_child .map(|weak| cx.upgrade(weak)) .transpose()? .flatten() .ok_or(VmError::InvalidArgument("missing scene occurrence"))?; let scene = cx .store() .payload::(root, "Mount")? .clone(); let mut next = scene.first_child; let mut bounds: Option<[f32; 4]> = None; let mut count = 0; 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")?; 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, ]; 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; } let node = crate::block::snapshot(cx.store(), mount.block)?; let inset = crate::layout::insets(&node); 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 { 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) }; 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 { pub zoom: f32, pub x: f32, pub y: f32, } impl Default for Transform { /// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。 fn default() -> Self { Self { zoom: 1.0, x: 0.0, y: 0.0, } } } impl Transform { /// 将逻辑点按当前缩放和平移变换到外层视图。 pub fn point(self, point: Point) -> Point { Point::new(point.x * self.zoom + self.x, point.y * self.zoom + self.y) } /// 将视图点反算回逻辑坐标,供命中和手势锚点计算使用。 pub fn inverse(self, point: Point) -> Point { Point::new( (point.x - self.x) / self.zoom, (point.y - self.y) / self.zoom, ) } /// 使用同一缩放和平移转换矩形的位置与尺寸。 pub fn rect(self, rect: Rect) -> Rect { let point = self.point(Point::new(rect.x, rect.y)); Rect::new( point.x, point.y, rect.width * self.zoom, rect.height * self.zoom, ) } /// 将当前变换再放入外层变换,保持绘制与逆向命中的组合顺序一致。 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, } } /// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。 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 } } /// 计算视口与祖先裁剪的共同可见矩形。 fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { 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 映射,最后重建屏幕命中索引。 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; 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>, ); let mut contexts = HashMap::::new(); let next: HashMap<_, _> = frame .mounts .iter() .map(|mount| (mount.id, mount.next_sibling)) .collect(); let mut panels = HashMap::, crate::layout::Size)>::new(); 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" ); mount.view = (view != Transform::default()).then_some(view); mount.panel = panel.clone(); mount.clip = clip; mount.viewport_clip = None; let mut child_view = view; let mut child_clip = clip; let inset = crate::layout::insets(node); let viewport = if let UiKind::CanvasViewport { camera, .. } = &node.kind { 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), )) } 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.), )) } 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 }; if let Some(rect) = viewport { 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))); if mount.canvas_viewport || mount.scene3d_viewport { mount.viewport_clip = child_clip; } } if let UiKind::Viewport3D { view: scene, panels: sizes, .. } = &node.kind { let mut depth = 0; let mut ancestor = panel.clone(); 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; for (key, size) in node.slots.iter().zip(sizes) { let id = child.ok_or_else(|| anyhow::anyhow!("missing 3D UI root occurrence"))?; 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(), }); panels.insert(id, (mapping, *size)); child = next[&id]; } } contexts.insert(mount.id, (child_view, child_clip, panel)); } let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect(); frame.command_views.clear(); frame.command_clips.clear(); anyhow::ensure!( frame.command_mounts.len() == frame.commands.len(), "missing command occurrence table" ); 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() { // Camera coordinates may be +/-1e6 at zoom 100. Their projected // translation must not inherit the smaller world-coordinate limit. 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) ); } }