Files
endorphin-tessera-gui/src/render/viewport.rs
T

478 lines
24 KiB
Rust

//! 独立 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<CameraView, VmError> {
// 先验证实际宿主 payload,避免只有 x/y/zoom 同名字段的普通对象冒充相机。
store.payload::<Camera2D>(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::<crate::mount::Mount>(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::<crate::mount::Mount>(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<Arc<crate::scene3d::panel::Mapping>>,
);
// 上下文按父出现 ID 传给孩子;遍历要求 frame.mounts 父先子后,避免读取尚未建立的父投影。
let mut contexts = HashMap::<u64, Scope>::new();
// 保留 next_sibling 查找表,遍历可变 Mount 时仍可按稳定子出现顺序为面板根建立映射。
let next: HashMap<_, _> = frame
.mounts
.iter()
.map(|mount| (mount.id, mount.next_sibling))
.collect();
// 3D 视口先为后续面板根登记映射和纹理尺寸,轮到该根时再切换独立纹理坐标空间。
let mut panels =
HashMap::<u64, (Arc<crate::scene3d::panel::Mapping>, 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)
);
}
}