421 lines
18 KiB
Rust
421 lines
18 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 相机的名义标记,实际位置/缩放是被追踪的普通属性。
|
|
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<CameraView, VmError> {
|
|
// 先验证实际宿主 payload,避免只有 x/y/zoom 同名字段的普通对象冒充相机。
|
|
store.payload::<Camera2D>(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")?)?;
|
|
// 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 出现的有限内容范围调整其相机;显式共享相机会同步影响其他视口。
|
|
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",
|
|
));
|
|
}
|
|
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::<crate::mount::Mount>(root, "Mount")?
|
|
.clone();
|
|
// 只汇总场景直接子出现的有限边界,沿 sibling 链扫描;不把无限世界或整棵后代图作为布局盒。
|
|
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::<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;
|
|
}
|
|
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 变更追踪/绑定机制;共享该相机的其他视口也会观察到更新。
|
|
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)
|
|
}
|
|
/// 将视图点反算回逻辑坐标,供命中和手势锚点计算使用。
|
|
// 不在这里额外校验 zoom,调用方必须先保证 valid;零缩放无法反算。
|
|
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,
|
|
)
|
|
}
|
|
/// 将当前变换再放入外层变换,保持绘制与逆向命中的组合顺序一致。
|
|
// 组合为 outer(self(point)):内层平移也要乘外层缩放,不能只把两次平移相加。
|
|
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],平移有限且绝对值不超过一万亿。
|
|
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] {
|
|
// 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 映射,最后重建屏幕命中索引。
|
|
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<Arc<crate::scene3d::panel::Mapping>>,
|
|
);
|
|
let mut contexts = HashMap::<u64, Scope>::new();
|
|
// 保留 next_sibling 查找表,遍历可变 Mount 时仍可按稳定子出现顺序为面板根建立映射。
|
|
let next: HashMap<_, _> = frame
|
|
.mounts
|
|
.iter()
|
|
.map(|mount| (mount.id, mount.next_sibling))
|
|
.collect();
|
|
let mut panels =
|
|
HashMap::<u64, (Arc<crate::scene3d::panel::Mapping>, 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"
|
|
);
|
|
// 单位变换省略存储,其余保持为显式 Transform;每轮先清理旧 viewport_clip,避免残留上帧状态。
|
|
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.),
|
|
))
|
|
// 普通节点的 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
|
|
};
|
|
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)));
|
|
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;
|
|
// slots、面板尺寸和直接子出现按同序配对;缺少预期子出现时立即报错,不能把内容映射到错误面板。
|
|
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(),
|
|
});
|
|
// 记录待遍历面板根的新上下文;之后走到该孩子时切换纹理空间,不把外层 clip 误当纹理像素。
|
|
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();
|
|
// 按每条命令的 Mount 归属重建 view/clip,确保源绘制、默认绘制和输入都使用这一轮最终投影。
|
|
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() {
|
|
// 相机坐标可达正负一百万,缩放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)
|
|
);
|
|
}
|
|
}
|