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

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)
);
}
}