394 lines
14 KiB
Rust
394 lines
14 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> {
|
|
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")?)?;
|
|
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::<crate::mount::Mount>(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::<crate::mount::Mount>(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<Arc<crate::scene3d::panel::Mapping>>,
|
|
);
|
|
let mut contexts = HashMap::<u64, Scope>::new();
|
|
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"
|
|
);
|
|
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)
|
|
);
|
|
}
|
|
}
|