feat: bound scene visibility rendering and continuous native input

This commit is contained in:
2026-11-11 00:53:21 +08:00
parent 15fc01542c
commit 32d5da5528
15 changed files with 900 additions and 134 deletions
+4 -2
View File
@@ -259,7 +259,9 @@ Native creation and commands remain local until the input/candidate succeeds. Fa
The native window publishes its current logical content size to the existing VM worker on startup, resize and scale changes. The worker coalesces the latest size, updates the managed viewport input, and refreshes the same runtime without adding Console source/history entries. ui.viewport() returns a fresh managed width/height snapshot; reads during rendering depend on the private input object. User changes to a returned snapshot do not resize the window. Unchanged sizes cause no frame; a fresh or unopened Console runtime updates its inputs without replacing a preserved displayed page. Failed resize layouts stop the affected runtime and preserve the displayed frame.
Column/Button propagate available cross-axis width and Row propagates available height. Page constraints follow `page.mode`: `window` fills the available area, `vertical` grows downward, `horizontal` grows rightward and `canvas` arranges direct children using their signed `x` / `y` attributes. Native and source desired sizes still obey constraints and budgets. The host clips paint/input to the visible area; TextBlock wraps to its available width. See [page surfaces](docs/page-surfaces.md) and [layout-cache.endor](examples/layout-cache.endor).
Row, Column and Overlay can nest with main/cross alignment, per-child alignment, margin, asymmetric border/padding, fixed/auto/fill sizes and min/max constraints. Page owns the root; scrolling is a finite ScrollView around ordinary layout. Scene2D owns world positions and CanvasViewport provides a finite UI box with an explicit Camera2D. Multiple views can share a scene while retaining independent cameras; assigning the same camera explicitly shares navigation. The old `Page.mode` API remains for compatibility. See [scene architecture](docs/scene-architecture.md), [box layout](examples/box-layout.endor) and [scene viewports](examples/scene-viewports.endor).
Scene3D, Group3D, mesh geometry, lights and programmable perspective/orthographic cameras render through finite Viewport3D boxes. OrbitController handles rotation, pan and zoom and can be replaced by an Endor controller. Ray picking and world-space Panel3D UI use the same transforms and depth visibility. A panel can contain ordinary UI, a 2D canvas or another 3D viewport. Rendering currently uses the existing CPU compositor with bounded depth/texture surfaces; this is not a GPU renderer. See [3D scenes](examples/scene3d.endor) and [world UI](examples/world-ui.endor).
Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor).
@@ -275,7 +277,7 @@ A source subclass can override `paint(self, mount, geometry): DisplayList`. The
Renderer-dispatched paint runs in a read-only Aster call: existing heap objects cannot be modified; fresh result graphs may be allocated and filled. Existing globals, model fields, containers, Mounts and types remain protected. invalidate is rejected in this phase. All overrides share one VM budget. Read dependencies are currently recorded per object, with versions used to detect changes without depending on bounded change-notification delivery. Native frames keep their existing dependency/cache behavior. Derived allocations do not produce application change notifications, preventing temporary results from causing redraw feedback. This protects managed heap writes; trusted Rust host implementations must also respect read-only calls for external state and I/O. Paint errors stop the affected runtime before publication; failed PUT candidates preserve the previously running heap and frame.
The application starts with one Endor canvas page. Its four actions open window, vertical, horizontal or canvas Pages. The default page is implemented in [start_page.endor](src/bridge/ui_bridge/start_page.endor), compiled once per VM in a private namespace. `ui.newTab()` and the sidebar New page action use the same factory. Cards, copy, layout, actions and the shared-state example are Endor objects. The host owns the sidebar and native window controls.
The application starts with an Endor Page containing a Scene2D and CanvasViewport. Its four actions demonstrate a window-sized layout, vertically scrolling Column, horizontally scrolling Row and independent canvas. The default page is implemented in [start_page.endor](src/bridge/ui_bridge/start_page.endor), compiled once per VM in a private namespace. `ui.newTab()` and the sidebar New page action use the same factory. Cards, copy, layout, actions and the shared-state example are Endor objects. The host owns the sidebar and native window controls.
Console, Sources, Network and Settings are independent managed Pages with separate
view modules and window-local input/focus/scroll/selection state. They are not
+20 -4
View File
@@ -21,8 +21,8 @@
| 嵌套布局与盒模型 | 完成 | Row / Column / Overlay、双轴对齐、子项覆盖、外边距、非对称内边距和边框、尺寸策略、裁剪;见下面接口 |
| 独立 2D 场景与视口 | 完成 | 独立场景/摄像机、多视口及嵌套、覆盖 UI、投影/命中、原生输入路由、真实 HTTP 与像素回归;旧 Page.mode 仅保留兼容 |
| 3D 场景、视口和摄像机 | 完成 | 软件深度缓冲、网格/变换/灯光、透视/正交相机、可替换控制器、射线拾取、世界空间 UI 及 2D/3D 嵌套 |
| 可见性与资源防护 | 验证并补齐中 | 已有场景/绘制/像素/递归预算;继续补空间索引与输入队列回归 |
| 原生综合验收 | 待执行 | Rust / VM / HTTP 回归不能代替鼠标、触控板和窗口外观验收 |
| 可见性与资源防护 | 完成 | 2D 屏幕索引、3D BVH / 视锥剔除、场景缓存、连续原生事件版本更新、字形 LOD 和嵌套资源预算 |
| 原生综合验收 | 可取得检查完成 | release 构建与实际窗口截图,运行实例的按钮 / 旋转 / 缩放事件及过期拒绝均通过;系统不允许模拟鼠标,未冒充硬件验收 |
| 完成交付 | 待完成 | 完成实现、检查和文档后,更新可评审 PR,等待用户手动合并 |
## 有限盒子的接口
@@ -74,7 +74,7 @@
固定 Aster 版本不支持数值算术表达式,不能用 `x = x + dx` 编译控制器。因此示例使用可运行的原生 translate/rotate 操作;自定义控制器可以调用这些方法或直接设置摄像机字段。未修改 Aster 源码或 gitlink。
3D 表面最长边按需降采样至 2048,单表面不超过 4194304 像素;缓存最多 8 个不可变表面、总计 8388608 像素。三角形、顶点、场景节点、灯光、光栅工作和摄像机范围均有检查;超过光栅预算返回错误,不发布截断的半幅结果。更完整的嵌套资源预算、空间索引和世界空间 UI 仍在本目标内继续实现。
3D 表面最长边按需降采样至 2048,单表面不超过 4194304 像素;缓存最多 8 个不可变表面、总计 8388608 像素。三角形、顶点、场景节点、灯光、光栅工作和摄像机范围均有检查;超过光栅预算返回错误,不发布截断的半幅结果。降采样保留原始视口宽高比,使绘制与拾取使用相同投影。
示例见 [scene3d.endor](../examples/scene3d.endor)。测试覆盖真正的 Endor 构造/修改、最终渲染像素、深度顺序、透视/正交独立视图、近面裁剪、射线回调、可替换控制器的真实 HTTP 路由以及循环/非法几何拒绝。原生硬件交互尚未验收。
@@ -90,4 +90,20 @@
面板 UI 可以继续嵌入 CanvasViewport 或 Viewport3D,后者也可以再包含 Panel3D;普通 Overlay 的兄弟 UI 仍固定在其 UI 空间。引用环在派生布局前拒绝;世界 UI 最多嵌套 8 层,光栅/纹理像素采用统一总预算。滚动视口与普通画布的世界内容不会撑大外层有限视口或外层滚动范围。
示例见 [world-ui.endor](../examples/world-ui.endor)。世界 UI 阶段的 181 项完整本地回归和 all-target Clippy 均通过;新增回归覆盖真正的像素、托管回调、材质遮挡、多面板独立尺寸、再次嵌入 3D、真实 HTTP 射线/UV 摄像机输入、循环和递归预算。空间索引、运行中的原生窗口及最终交付检查仍在继续。
示例见 [world-ui.endor](../examples/world-ui.endor)。世界 UI 阶段的 181 项完整本地回归和 all-target Clippy 均通过;新增回归覆盖真正的像素、托管回调、材质遮挡、多面板独立尺寸、再次嵌入 3D、真实 HTTP 射线/UV 摄像机输入、循环和递归预算。
## 可见性、连续输入与资源
3D 场景快照构建世界 AABB 的 BVH,摄像机变化复用相同的不可变场景和索引。视锥查询剔除不可见网格,射线查询按近到远缩小候选;序列化不携带缓存,反序列化后可重建。几何或变换变化会生成新场景和新索引。
普通 2D 交互和相机目标使用 128px 屏幕网格。每个对象最多进入 256 格,索引总计最多 65536 项;巨大矩形和项目化的 3D 面板 UI 进入精确命中的后备集合。后备集合仍遵循原有裁剪、投影和反向绘制顺序,不因近面投影或索引预算遗漏对象。源 hitTest 仍逐一执行其真实协议,未更改源覆盖行为。
原生输入使用有界 64 项队列。同一个运行时的连续 viewport / scene / scroll 事件在出队时更新到当前帧版本,保留原窗口、会话和捕获 Mount;目标消失、控制器禁用、运行时更换或失败时丢弃。所有窗口共用同一事件客户端和权威文档。离散点击及外部 HTTP 请求仍严格拒绝过时版本,不自动重放业务动作。队列满时仍可拒绝新输入,不保证任意输入速率下无损。
文字先裁剪再遍历可见字形像素;字体超过 512 设备像素时使用有界字形位图采样,避免高倍率缩放产生巨大单字形分配。字形缓存逐字检查 4096 项 / 32 MiB 阈值,整形缓存继续限制字符串数量与源字节数。极端放大时清晰度受字形 LOD 限制。面板派生槽位限制为非负 i64,避免哈希高位造成偶发排版错误;重复标识拒绝发布。
## 原生验证记录
2026-11-11:本地完整回归、格式、all-target Clippy 和 release 应用打包;实际启动 Tessera.app,通过本机 API 加载 world-ui 示例,并取得原生窗口截图,确认立方体、倾斜面板和内部 Canvas 的显示。系统 `CGPreflightScreenCaptureAccess` 为 true,`CGPreflightPostEventAccess` 为 false;集成的原生自动操作工具还因 OpenSSL 配置读取权限启动失败。因此 API / VM 事件与像素测试不宣称为硬件鼠标或触控板验收。最终检查数量及交付提交在 PR 中记录。
最终完整回归为 186 项通过;运行中的原生实例验证世界 UI 按钮将计数从 0 改为 1、scene-orbit 改变摄像机、scene-zoom 改变观察距离、重复旧帧请求返回 HTTP 409,空闲连续采样的版本保持一致。保留应用窗口供用户直接操作。
+19 -11
View File
@@ -369,6 +369,8 @@ impl CodeNode {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Frame {
#[serde(skip)]
pub hit_index: Arc<crate::viewport::spatial::Index>,
#[serde(default)]
pub surface: crate::page::Surface,
#[serde(default = "default_window")]
@@ -400,6 +402,7 @@ pub struct Frame {
impl Default for Frame {
fn default() -> Self {
Self {
hit_index: Arc::default(),
surface: crate::page::Surface::default(),
window: crate::window::MAIN_WINDOW,
background: default_background(),
@@ -486,23 +489,28 @@ impl MountGeometry {
impl Frame {
pub fn camera_at(&self, point: Point) -> Option<&MountGeometry> {
self.mounts.iter().rev().find(|mount| {
(mount.canvas_viewport || mount.scene3d_viewport && mount.camera_controls)
&& mount.contains(point)
&& mount.viewport_clip.is_some_and(|clip| {
mount.view_point(point, true).is_some_and(|point| {
Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point)
self.hit_index
.candidates(point, self.mounts.len())
.into_iter()
.map(|index| &self.mounts[index])
.find(|mount| {
(mount.canvas_viewport || mount.scene3d_viewport && mount.camera_controls)
&& mount.contains(point)
&& mount.viewport_clip.is_some_and(|clip| {
mount.view_point(point, true).is_some_and(|point| {
Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point)
})
})
})
})
})
}
pub fn hit_test(&self, point: Point) -> Option<&MountGeometry> {
if self.fault.is_some() {
return None;
}
self.mounts
.iter()
.rev()
self.hit_index
.candidates(point, self.mounts.len())
.into_iter()
.map(|index| &self.mounts[index])
.find(|mount| mount.interactive && mount.contains(point))
}
}
+27 -4
View File
@@ -430,6 +430,14 @@ pub fn snapshot(
store: &ObjectStore,
scene: ObjectRef,
camera: ObjectRef,
) -> Result<Snapshot, VmError> {
snapshot_reusing(store, scene, camera, None)
}
pub fn snapshot_reusing(
store: &ObjectStore,
scene: ObjectRef,
camera: ObjectRef,
reuse: Option<Arc<scene3d::Scene>>,
) -> Result<Snapshot, VmError> {
if store.payload::<Node>(scene, "Scene3D")?.0 != NodeKind::Scene {
return Err(invalid("Viewport3D requires a Scene3D"));
@@ -449,12 +457,14 @@ pub fn snapshot(
lights: Vec::new(),
ambient,
background,
acceleration: Arc::default(),
};
let mut work = vec![(scene, Matrix::default(), false, 0u64)];
let mut panels = Vec::new();
let mut path = HashSet::new();
let mut visits = 0;
let mut triangles = 0;
let mut vertices = 0;
while let Some((object, parent, finish, key)) = work.pop() {
if finish {
path.remove(&object);
@@ -494,6 +504,10 @@ pub fn snapshot(
let geometry = object_ref(&reader.value(object, "geometry")?)?;
let geometry = store.payload::<Geometry>(geometry, "Geometry3D")?.0.clone();
triangles += geometry.triangles.len();
vertices += geometry.vertices.len();
if vertices > 262_144 {
return Err(VmError::ExecutionLimit("3D scene vertices"));
}
if triangles > scene3d::MAX_TRIANGLES {
return Err(VmError::ExecutionLimit("3D scene triangles"));
}
@@ -507,6 +521,12 @@ pub fn snapshot(
});
}
NodeKind::Panel => {
// Arrangement slots cross Endor's signed integer API. A raw
// u64 hash can turn negative when native Bounds are created.
let key = key & i64::MAX as u64;
if panels.iter().any(|panel: &PanelMount| panel.key == key) {
return Err(invalid("colliding 3D UI occurrence identities"));
}
if panels.len() >= 64 {
return Err(VmError::ExecutionLimit("3D UI panels"));
}
@@ -582,10 +602,13 @@ pub fn snapshot(
_ => return Err(invalid("invalid node in 3D scene graph")),
}
}
let view = Arc::new(scene3d::View {
scene: Arc::new(output),
camera,
});
let scene = if let Some(scene) = reuse.filter(|scene| **scene == output) {
scene
} else {
output.acceleration = Arc::new(scene3d::spatial::Index::build(&output.meshes));
Arc::new(output)
};
let view = Arc::new(scene3d::View { scene, camera });
if !view.valid() {
return Err(invalid("invalid 3D scene snapshot"));
}
+10 -3
View File
@@ -24,6 +24,7 @@ struct Dependency {
#[derive(Debug, Default)]
pub struct RenderCache {
scenes3d: HashMap<ObjectRef, Arc<crate::scene3d::Scene>>,
entries: HashMap<ObjectRef, Cached>,
dependents: HashMap<Dependency, HashSet<ObjectRef>>,
parents: HashMap<ObjectRef, HashSet<ObjectRef>>,
@@ -90,6 +91,8 @@ impl RenderCache {
}
pub fn prune(&mut self, store: &ObjectStore) {
self.scenes3d
.retain(|object, _| store.object(*object).is_ok());
let dead: Vec<_> = self
.entries
.keys()
@@ -166,13 +169,17 @@ impl RenderCache {
}
let mut panel_sizes = Vec::new();
let view3d = if matches!(data.kind, Kind::Viewport3D { .. }) {
let (view, reads, panels) = crate::scene3d_model::snapshot(
let scene = data
.scene3d
.ok_or(VmError::InvalidArgument("missing Scene3D"))?;
let (view, reads, panels) = crate::scene3d_model::snapshot_reusing(
store,
data.scene3d
.ok_or(VmError::InvalidArgument("missing Scene3D"))?,
scene,
data.camera
.ok_or(VmError::InvalidArgument("missing 3D camera"))?,
self.scenes3d.get(&scene).cloned(),
)?;
self.scenes3d.insert(scene, view.scene.clone());
dependencies.extend(reads.into_iter().map(|(object, name)| Dependency {
object,
property: Some(name),
+124 -29
View File
@@ -2,6 +2,7 @@
//! visibility and picking use the same camera and transformed world triangles.
pub mod math;
pub mod panel;
pub mod spatial;
use math::{Camera, Matrix, Projection, Vec3};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -90,12 +91,22 @@ pub struct Light {
pub color: [f32; 3],
pub intensity: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Scene {
pub meshes: Vec<Mesh>,
pub lights: Vec<Light>,
pub ambient: f32,
pub background: [f32; 3],
#[serde(skip)]
pub acceleration: Arc<spatial::Index>,
}
impl PartialEq for Scene {
fn eq(&self, other: &Self) -> bool {
self.meshes == other.meshes
&& self.lights == other.lights
&& self.ambient == other.ambient
&& self.background == other.background
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct View {
@@ -154,6 +165,20 @@ impl View {
&& color_valid(self.scene.background)
&& self.scene.meshes.len() <= 4096
&& self.scene.lights.len() <= 32
&& self
.scene
.meshes
.iter()
.map(|mesh| mesh.geometry.vertices.len())
.sum::<usize>()
<= 262_144
&& self
.scene
.meshes
.iter()
.map(|mesh| mesh.geometry.triangles.len())
.sum::<usize>()
<= MAX_TRIANGLES
&& self.scene.meshes.iter().all(|mesh| {
mesh.geometry.valid()
&& mesh.world.0.iter().all(|v| v.is_finite())
@@ -173,6 +198,12 @@ impl View {
surface.commands.len() <= 10001
&& surface.commands.len() == surface.views.len()
&& surface.commands.len() == surface.clips.len()
&& surface.views.iter().flatten().all(|view| view.valid())
&& surface.clips.iter().flatten().all(|clip| {
clip.iter().all(|value| value.is_finite())
&& clip[2] >= 0.
&& clip[3] >= 0.
})
&& surface
.commands
.iter()
@@ -187,20 +218,6 @@ impl View {
&& light.intensity.is_finite()
&& (0.0..=32.0).contains(&light.intensity)
})
&& self
.scene
.meshes
.iter()
.map(|mesh| mesh.geometry.vertices.len())
.sum::<usize>()
<= 262_144
&& self
.scene
.meshes
.iter()
.map(|mesh| mesh.geometry.triangles.len())
.sum::<usize>()
<= MAX_TRIANGLES
}
pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option<Hit> {
if !self.camera.valid()
@@ -214,7 +231,18 @@ impl View {
let ray = self.camera.ray(u, v, aspect)?;
let basis = self.camera.basis()?;
let mut hit: Option<Hit> = None;
for (instance, mesh) in self.scene.meshes.iter().enumerate() {
let temporary;
let index = if self.scene.acceleration.matches(self.scene.meshes.len()) {
&*self.scene.acceleration
} else {
temporary = spatial::Index::build(&self.scene.meshes);
&temporary
};
for (entry, instance) in index.ray(ray) {
if hit.as_ref().is_some_and(|hit| entry > hit.distance) {
break;
}
let mesh = &self.scene.meshes[instance];
for (triangle, indices) in mesh.geometry.triangles.iter().enumerate() {
let [a, b, c] =
indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
@@ -225,7 +253,10 @@ impl View {
let depth = self.camera.view(basis, point).z;
if depth < self.camera.near
|| depth > self.camera.far
|| hit.as_ref().is_some_and(|hit| hit.distance <= distance)
|| hit.as_ref().is_some_and(|hit| {
hit.distance < distance
|| hit.distance == distance && hit.instance <= instance
})
{
continue;
}
@@ -258,6 +289,7 @@ struct CachedSurface {
view: Arc<View>,
width: u32,
height: u32,
aspect: f32,
pixels: Arc<[u32]>,
}
#[derive(Default)]
@@ -266,20 +298,28 @@ struct SurfaceCache {
pixels: usize,
}
pub fn surface(view: &Arc<View>, width: u32, height: u32) -> Result<Arc<[u32]>, &'static str> {
pub fn surface(
view: &Arc<View>,
width: u32,
height: u32,
aspect: f32,
) -> Result<Arc<[u32]>, &'static str> {
static CACHE: std::sync::OnceLock<std::sync::Mutex<SurfaceCache>> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
{
let cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
if let Some(entry) = cache.entries.iter().find(|entry| {
Arc::ptr_eq(&entry.view, view) && entry.width == width && entry.height == height
Arc::ptr_eq(&entry.view, view)
&& entry.width == width
&& entry.height == height
&& entry.aspect == aspect
}) {
return Ok(entry.pixels.clone());
}
}
// Do not hold a cache lock across rendering: world UI can contain another
// 3D viewport and recurse through the compositor.
let pixels: Arc<[u32]> = render(view, width, height)?.into();
let pixels: Arc<[u32]> = render_aspect(view, width, height, aspect)?.into();
let mut cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
while cache.entries.len() >= 8 || cache.pixels + pixels.len() > 8_388_608 {
let Some(entry) = cache.entries.pop_front() else {
@@ -292,6 +332,7 @@ pub fn surface(view: &Arc<View>, width: u32, height: u32) -> Result<Arc<[u32]>,
view: view.clone(),
width,
height,
aspect,
pixels: pixels.clone(),
});
Ok(pixels)
@@ -315,7 +356,7 @@ impl Vertex {
}
}
}
fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
let (x, y, slope) = match camera.projection {
Projection::Perspective { fov } => {
let y = (fov.to_radians() / 2.).tan();
@@ -323,15 +364,17 @@ fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
}
Projection::Orthographic { height } => (height * aspect / 2., height / 2., 0.),
};
let planes = [
[
[0., 0., 1., -camera.near],
[0., 0., -1., camera.far],
[1., 0., x * slope, x * (1. - slope)],
[-1., 0., x * slope, x * (1. - slope)],
[0., 1., y * slope, y * (1. - slope)],
[0., -1., y * slope, y * (1. - slope)],
];
for [a, b, c, d] in planes {
]
}
fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
for [a, b, c, d] in frustum_planes(camera, aspect) {
if polygon.is_empty() {
break;
}
@@ -358,11 +401,26 @@ fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
polygon
}
#[cfg(test)]
pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static str> {
render_aspect(view, width, height, width as f32 / height as f32)
}
fn render_aspect(
view: &View,
width: u32,
height: u32,
aspect: f32,
) -> Result<Vec<u32>, &'static str> {
let _scope = crate::software_renderer::RenderScope::enter()?;
let area = width as usize * height as usize;
// Offscreen targets have a hard per-surface resource bound.
if area > 4_194_304 || width == 0 || height == 0 || !view.valid() {
if area > 4_194_304
|| width == 0
|| height == 0
|| !aspect.is_finite()
|| aspect <= 0.
|| !view.valid()
{
return Err("invalid 3D surface or resource budget exceeded");
}
crate::software_renderer::reserve_pixels(area.saturating_mul(2))?;
@@ -370,7 +428,6 @@ pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static
let mut depths = vec![f32::INFINITY; area];
let camera = view.camera;
let basis = camera.basis().unwrap();
let aspect = width as f32 / height as f32;
let perspective = matches!(camera.projection, Projection::Perspective { .. });
let project = |point: Vec3| {
let half = match camera.projection {
@@ -384,7 +441,15 @@ pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static
)
};
let mut work = 0usize;
for mesh in &view.scene.meshes {
let temporary;
let index = if view.scene.acceleration.matches(view.scene.meshes.len()) {
&*view.scene.acceleration
} else {
temporary = spatial::Index::build(&view.scene.meshes);
&temporary
};
for instance in index.visible(camera, aspect) {
let mesh = &view.scene.meshes[instance];
let texture = mesh.panel.as_ref().map(panel::texture).transpose()?;
for indices in &mesh.geometry.triangles {
let [a, b, c] = indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
@@ -494,6 +559,32 @@ pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spatial_index_culls_offscreen_meshes_and_preserves_ray_pick() {
let mut view = view(Geometry::cuboid(1., 1., 1.));
let scene = Arc::make_mut(&mut view.scene);
let mesh = scene.meshes[0].clone();
for index in 1..100 {
let mut far = mesh.clone();
far.identity = format!("far{index}");
far.world = Matrix::trs(
Vec3::new(index as f32 * 100., 0., 0.),
Vec3::default(),
Vec3::new(1., 1., 1.),
);
scene.meshes.push(far);
}
scene.acceleration = Arc::new(spatial::Index::build(&scene.meshes));
assert_eq!(scene.acceleration.visible(view.camera, 1.), vec![0]);
let ray = view.camera.ray(0.5, 0.5, 1.).unwrap();
assert_eq!(scene.acceleration.ray(ray).len(), 1);
let indexed = view.pick(0.5, 0.5, 1.).unwrap();
let mut plain = view.clone();
Arc::make_mut(&mut plain.scene).acceleration = Arc::default();
let fallback = plain.pick(0.5, 0.5, 1.).unwrap();
assert_eq!(indexed.identity, fallback.identity);
assert_eq!(indexed.distance, fallback.distance);
}
fn view(geometry: Geometry) -> View {
View {
camera: Camera::default(),
@@ -508,6 +599,7 @@ mod tests {
lights: vec![],
ambient: 1.,
background: [1., 1., 1.],
acceleration: Arc::default(),
}),
}
}
@@ -560,9 +652,12 @@ mod tests {
#[test]
fn surface_cache_reuses_immutable_frames_and_rejects_invalid_resources() {
let view = Arc::new(view(Geometry::cuboid(1., 1., 1.)));
let first = surface(&view, 64, 64).unwrap();
let second = surface(&view, 64, 64).unwrap();
let first = surface(&view, 64, 64, 1.).unwrap();
let second = surface(&view, 64, 64, 1.).unwrap();
assert!(Arc::ptr_eq(&first, &second));
let wide = surface(&view, 64, 64, 2.).unwrap();
assert!(!Arc::ptr_eq(&first, &wide));
assert_ne!(first, wide);
assert!(render(&view, 4000, 4000).is_err());
let mut invalid = (*view).clone();
invalid.camera.target = invalid.camera.position;
+253
View File
@@ -0,0 +1,253 @@
//! Immutable world-space BVH. Camera changes reuse it; it contains no VM data.
use super::{
Mesh,
math::{Basis, Camera, Ray, Vec3},
};
#[derive(Debug, Clone, Copy)]
pub struct Bounds {
pub min: Vec3,
pub max: Vec3,
}
impl Bounds {
fn mesh(mesh: &Mesh) -> Option<Self> {
let mut points = mesh
.geometry
.vertices
.iter()
.map(|point| mesh.world.point(*point));
let first = points.next()?;
Some(points.fold(
Self {
min: first,
max: first,
},
|bounds, point| Self {
min: Vec3::new(
bounds.min.x.min(point.x),
bounds.min.y.min(point.y),
bounds.min.z.min(point.z),
),
max: Vec3::new(
bounds.max.x.max(point.x),
bounds.max.y.max(point.y),
bounds.max.z.max(point.z),
),
},
))
}
fn union(self, other: Self) -> Self {
Self {
min: Vec3::new(
self.min.x.min(other.min.x),
self.min.y.min(other.min.y),
self.min.z.min(other.min.z),
),
max: Vec3::new(
self.max.x.max(other.max.x),
self.max.y.max(other.max.y),
self.max.z.max(other.max.z),
),
}
}
fn center(self, axis: usize) -> f32 {
let min = [self.min.x, self.min.y, self.min.z];
let max = [self.max.x, self.max.y, self.max.z];
(min[axis] + max[axis]) / 2.
}
fn ray(self, ray: Ray) -> Option<f32> {
let mut near = 0f32;
let mut far = f32::INFINITY;
for (origin, direction, min, max) in [
(ray.origin.x, ray.direction.x, self.min.x, self.max.x),
(ray.origin.y, ray.direction.y, self.min.y, self.max.y),
(ray.origin.z, ray.direction.z, self.min.z, self.max.z),
] {
if direction.abs() < 1e-12 {
if origin < min || origin > max {
return None;
}
continue;
}
let a = (min - origin) / direction;
let b = (max - origin) / direction;
near = near.max(a.min(b));
far = far.min(a.max(b));
if far < near {
return None;
}
}
Some(near)
}
fn visible(self, camera: Camera, basis: Basis, planes: &[[f32; 4]; 6]) -> bool {
let corners: [Vec3; 8] = std::array::from_fn(|index| {
camera.view(
basis,
Vec3::new(
if index & 1 == 0 {
self.min.x
} else {
self.max.x
},
if index & 2 == 0 {
self.min.y
} else {
self.max.y
},
if index & 4 == 0 {
self.min.z
} else {
self.max.z
},
),
)
});
!planes.iter().any(|[a, b, c, d]| {
corners
.iter()
.all(|point| a * point.x + b * point.y + c * point.z + d < 0.)
})
}
}
#[derive(Debug)]
enum Node {
Leaf {
bounds: Bounds,
items: Vec<usize>,
},
Branch {
bounds: Bounds,
left: usize,
right: usize,
},
}
impl Node {
fn bounds(&self) -> Bounds {
match self {
Self::Leaf { bounds, .. } | Self::Branch { bounds, .. } => *bounds,
}
}
}
#[derive(Debug, Default)]
pub struct Index {
bounds: Vec<Option<Bounds>>,
nodes: Vec<Node>,
root: Option<usize>,
}
impl Index {
pub fn build(meshes: &[Mesh]) -> Self {
let bounds: Vec<_> = meshes.iter().map(Bounds::mesh).collect();
let mut items: Vec<_> = bounds
.iter()
.enumerate()
.filter_map(|(index, bounds)| bounds.map(|_| index))
.collect();
let mut index = Self {
bounds,
nodes: Vec::new(),
root: None,
};
if !items.is_empty() {
index.root = Some(index.split(&mut items));
}
index
}
fn split(&mut self, items: &mut [usize]) -> usize {
let bounds = items
.iter()
.map(|&index| self.bounds[index].unwrap())
.reduce(Bounds::union)
.unwrap();
let index = self.nodes.len();
self.nodes.push(Node::Leaf {
bounds,
items: Vec::new(),
});
if items.len() <= 4 {
self.nodes[index] = Node::Leaf {
bounds,
items: items.to_vec(),
};
} else {
let extent = bounds.max - bounds.min;
let axis = if extent.x >= extent.y && extent.x >= extent.z {
0
} else if extent.y >= extent.z {
1
} else {
2
};
items.sort_unstable_by(|&a, &b| {
self.bounds[a]
.unwrap()
.center(axis)
.total_cmp(&self.bounds[b].unwrap().center(axis))
});
let mid = items.len() / 2;
let (left, right) = items.split_at_mut(mid);
let left = self.split(left);
let right = self.split(right);
self.nodes[index] = Node::Branch {
bounds,
left,
right,
};
}
index
}
pub fn matches(&self, meshes: usize) -> bool {
self.bounds.len() == meshes
}
pub fn ray(&self, ray: Ray) -> Vec<(f32, usize)> {
let mut stack: Vec<_> = self.root.into_iter().collect();
let mut output = Vec::new();
while let Some(index) = stack.pop() {
if self.nodes[index].bounds().ray(ray).is_none() {
continue;
}
match &self.nodes[index] {
Node::Leaf { items, .. } => {
for &item in items {
if let Some(distance) = self.bounds[item].unwrap().ray(ray) {
output.push((distance, item));
}
}
}
Node::Branch { left, right, .. } => {
stack.push(*left);
stack.push(*right);
}
}
}
output.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
output
}
pub fn visible(&self, camera: Camera, aspect: f32) -> Vec<usize> {
let Some(basis) = camera.basis() else {
return Vec::new();
};
let planes = super::frustum_planes(camera, aspect);
let mut stack: Vec<_> = self.root.into_iter().collect();
let mut output = Vec::new();
while let Some(index) = stack.pop() {
if !self.nodes[index].bounds().visible(camera, basis, &planes) {
continue;
}
match &self.nodes[index] {
Node::Leaf { items, .. } => output.extend(
items
.iter()
.copied()
.filter(|item| self.bounds[*item].unwrap().visible(camera, basis, &planes)),
),
Node::Branch { left, right, .. } => {
stack.push(*left);
stack.push(*right);
}
}
}
output.sort_unstable();
output
}
}
+22 -5
View File
@@ -79,7 +79,7 @@ impl SoftwareRenderer {
let lod = (2048. / rect.width.max(rect.height)).min(1.);
let w = (rect.width * lod).ceil().max(1.) as u32;
let h = (rect.height * lod).ceil().max(1.) as u32;
let surface = crate::scene3d::surface(view, w, h)?;
let surface = crate::scene3d::surface(view, w, h, rect.width / rect.height)?;
for y in top..bottom {
for x in left..right {
let u = (((x as f32 + 0.5 - rect.x) / rect.width) * w as f32)
@@ -197,7 +197,13 @@ impl Canvas<'_> {
fn draw_text(&mut self, origin: Point, value: &str, style: TextStyle, color: Color) {
let x = origin.x.round() as i32;
let y = origin.y.round() as i32;
text::draw(value, style, color, |dx, dy, pixel| {
let clip = Rect::new(
self.clip.x - x as f32,
self.clip.y - y as f32,
self.clip.width,
self.clip.height,
);
text::draw_clipped(value, style, color, None, clip, |dx, dy, pixel| {
self.blend_pixel(x + dx, y + dy, pixel)
});
}
@@ -217,9 +223,20 @@ impl Canvas<'_> {
);
let left = rect.x.round() as i32;
let top = rect.y.round() as i32;
text::draw_wrapped(value, style, color, rect.width, |dx, dy, pixel| {
self.blend_pixel(left + dx, top + dy, pixel)
});
let clip = Rect::new(
self.clip.x - left as f32,
self.clip.y - top as f32,
self.clip.width,
self.clip.height,
);
text::draw_clipped(
value,
style,
color,
Some(rect.width),
clip,
|dx, dy, pixel| self.blend_pixel(left + dx, top + dy, pixel),
);
self.clip = old;
}
+108 -49
View File
@@ -198,71 +198,104 @@ pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
engine.buffers[&key].size
}
/// Draw at the actual device font size; no scaling of an already rasterized bitmap.
pub fn draw(value: &str, style: TextStyle, color: Color, pixel: impl FnMut(i32, i32, Color)) {
draw_width(value, style, color, None, pixel)
}
pub fn draw_wrapped(
value: &str,
style: TextStyle,
color: Color,
width: f32,
pixel: impl FnMut(i32, i32, Color),
) {
if width > 0.0 {
draw_width(value, style, color, Some(width), pixel)
}
}
fn draw_width(
/// Rasterize only visible glyph pixels. Large camera zooms use a bounded glyph
/// bitmap and sample it at the requested size instead of allocating an enormous
/// font image. Layout and wrapping still use proportionally identical metrics.
pub fn draw_clipped(
value: &str,
style: TextStyle,
color: Color,
width: Option<f32>,
clip: crate::geometry::Rect,
mut pixel: impl FnMut(i32, i32, Color),
) {
if clip.width <= 0.
|| clip.height <= 0.
|| !style.font_size.is_finite()
|| style.font_size <= 0.
{
return;
}
let lod = (512. / style.font_size).min(1.);
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare_width(value, style, width);
let key = engine.prepare_width(value, style.scaled(lod), width.map(|width| width * lod));
let TextEngine {
fonts,
swash,
buffers,
..
} = &mut *engine;
let opacity = u16::from(color.alpha);
buffers.get_mut(&key).expect("prepared text").buffer.draw(
fonts,
swash,
cosmic_text::Color::rgb(color.red, color.green, color.blue),
|x, y, width, height, color| {
for dy in 0..height as i32 {
for dx in 0..width as i32 {
pixel(
x + dx,
y + dy,
Color::rgba(
color.r(),
color.g(),
color.b(),
(u16::from(color.a()) * opacity / 255) as u8,
let buffer = &buffers[&key].buffer;
let mut cache_bytes: usize = swash
.image_cache
.values()
.flatten()
.map(|image| image.data.len())
.sum();
for run in buffer.layout_runs() {
for glyph in run.glyphs {
let physical = glyph.physical((0., run.line_y), 1.);
// Conservative font-sized guard includes bearings/overhangs. The
// exact bitmap bounds below determine the actual visible pixels.
let guard = glyph.font_size * 4. / lod;
let gx = physical.x as f32 / lod;
let gy = physical.y as f32 / lod;
if gx + guard < clip.x
|| gx - guard > clip.x + clip.width
|| gy + guard < clip.y
|| gy - guard > clip.y + clip.height
{
continue;
}
if swash.image_cache.len() >= 4096 || cache_bytes >= 33_554_432 {
*swash = SwashCache::new();
cache_bytes = 0;
}
let cached = swash.image_cache.contains_key(&physical.cache_key);
let Some(image) = swash.get_image(fonts, physical.cache_key) else {
continue;
};
if !cached {
cache_bytes += image.data.len();
}
let left = (physical.x + image.placement.left) as f32 / lod;
let top = (physical.y - image.placement.top) as f32 / lod;
let right = left + image.placement.width as f32 / lod;
let bottom = top + image.placement.height as f32 / lod;
let base = glyph.color_opt.map_or(color, |value| {
Color::rgba(value.r(), value.g(), value.b(), color.alpha)
});
for y in top.max(clip.y).ceil() as i32..bottom.min(clip.y + clip.height).ceil() as i32 {
for x in
left.max(clip.x).ceil() as i32..right.min(clip.x + clip.width).ceil() as i32
{
let u = (((x as f32 + 0.5 - left) * lod) as u32)
.min(image.placement.width.saturating_sub(1));
let v = (((y as f32 + 0.5 - top) * lod) as u32)
.min(image.placement.height.saturating_sub(1));
let index = (v * image.placement.width + u) as usize;
let sample = match image.content {
cosmic_text::SwashContent::Mask => Color::rgba(
base.red,
base.green,
base.blue,
(u16::from(image.data[index]) * u16::from(color.alpha) / 255) as u8,
),
);
cosmic_text::SwashContent::Color => {
let i = index * 4;
Color::rgba(
image.data[i],
image.data[i + 1],
image.data[i + 2],
(u16::from(image.data[i + 3]) * u16::from(color.alpha) / 255) as u8,
)
}
cosmic_text::SwashContent::SubpixelMask => continue,
};
pixel(x, y, sample);
}
}
},
);
// The cache is an optimization, never an archive of all submitted glyphs.
if swash.image_cache.len() > 4096
|| swash
.image_cache
.values()
.flatten()
.map(|image| image.data.len())
.sum::<usize>()
> 33_554_432
{
*swash = SwashCache::new();
}
}
}
@@ -316,6 +349,32 @@ mod tests {
use super::*;
use unicode_segmentation::UnicodeSegmentation;
#[test]
fn extreme_zoom_rasterizes_only_the_clip_with_bounded_glyph_sizes() {
let mut count = 0;
draw_clipped(
"M",
TextStyle::ui(1).scaled(10_000.),
Color::rgba(1, 2, 3, 255),
None,
crate::geometry::Rect::new(50_000., 60_000., 32., 32.),
|x, y, _| {
assert!((50_000..50_032).contains(&x));
assert!((60_000..60_032).contains(&y));
count += 1;
},
);
assert_eq!(count, 1024);
let engine = engine().lock().unwrap();
assert!(
engine
.swash
.image_cache
.keys()
.all(|key| f32::from_bits(key.font_size_bits) <= 512.)
);
}
#[test]
fn width_sensitive_shaping_wraps_words_and_clusters_without_changing_intrinsic_metrics() {
let style = TextStyle::ui(1);
+14 -2
View File
@@ -1,4 +1,5 @@
//! Reversible projections keep camera movement independent of layout.
pub mod spatial;
use crate::geometry::{Point, Rect};
use endor_core::{ObjectPayload, ObjectRef, ObjectStore, VmError};
@@ -172,8 +173,8 @@ impl Transform {
&& self.zoom <= 1_000_000.0
&& self.x.is_finite()
&& self.y.is_finite()
&& self.x.abs() <= 1_000_000.0
&& self.y.abs() <= 1_000_000.0
&& self.x.abs() <= 1_000_000_000_000.0
&& self.y.abs() <= 1_000_000_000_000.0
}
}
@@ -332,6 +333,7 @@ pub fn project(
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)]
@@ -339,6 +341,16 @@ 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,
+128
View File
@@ -0,0 +1,128 @@
//! Bounded screen-space broad phase. Projective world UI uses the precise
//! ray-based narrow phase rather than an unsafe screen rectangle approximation.
use crate::{document::MountGeometry, geometry::Point};
use std::collections::HashMap;
#[derive(Debug, Default)]
pub struct Index {
count: usize,
cells: HashMap<(i32, i32), Vec<usize>>,
fallback: Vec<usize>,
}
impl Index {
pub fn build(mounts: &[MountGeometry]) -> Self {
let mut result = Self {
count: mounts.len(),
..Self::default()
};
let mut entries = 0;
for (index, mount) in mounts.iter().enumerate() {
if !mount.interactive
&& !mount.canvas_viewport
&& !mount.scene3d_viewport
&& mount.scroll.is_none()
{
continue;
}
if mount.panel.is_some() {
result.fallback.push(index);
continue;
}
let a = Point::new(mount.x, mount.y);
let b = Point::new(mount.x + mount.width, mount.y + mount.height);
let a = mount.view.map_or(a, |view| view.point(a));
let b = mount.view.map_or(b, |view| view.point(b));
let (mut x0, mut y0, mut x1, mut y1) =
(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
if let Some([x, y, w, h]) = mount.clip {
x0 = x0.max(x);
y0 = y0.max(y);
x1 = x1.min(x + w);
y1 = y1.min(y + h);
}
if x1 < x0 || y1 < y0 {
continue;
}
if ![x0, y0, x1, y1]
.iter()
.all(|value| value.is_finite() && value.abs() < 1e12)
{
result.fallback.push(index);
continue;
}
let (left, top, right, bottom) = (cell(x0), cell(y0), cell(x1), cell(y1));
let cells = (i64::from(right) - i64::from(left) + 1)
.saturating_mul(i64::from(bottom) - i64::from(top) + 1);
if cells > 256 || entries + cells > 65_536 {
result.fallback.push(index);
continue;
}
entries += cells;
for x in left..=right {
for y in top..=bottom {
result.cells.entry((x, y)).or_default().push(index);
}
}
}
result
}
pub fn candidates(&self, point: Point, count: usize) -> Vec<usize> {
if self.count != count {
return (0..count).rev().collect();
}
let mut output = self.fallback.clone();
if let Some(items) = self.cells.get(&(cell(point.x), cell(point.y))) {
output.extend(items);
}
output.sort_unstable_by(|a, b| b.cmp(a));
output.dedup();
output
}
}
fn cell(value: f32) -> i32 {
(value / 128.)
.floor()
.clamp(i32::MIN as f32, i32::MAX as f32) as i32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn screen_grid_limits_candidates_and_keeps_huge_rectangles_hittable() {
let (_, document) = crate::endor_host::ReplSession::load(
"page=Page()\npage.render(Button(\"hit\"))",
"app.main",
1,
930,
)
.unwrap();
let template = document
.frame
.mounts
.iter()
.find(|mount| mount.interactive)
.unwrap();
let mut mounts = Vec::new();
for index in 0..200 {
let mut mount = template.clone();
mount.x = index as f32 * 400.;
mount.y = 0.;
mount.width = 32.;
mount.height = 32.;
mount.view = None;
mount.clip = None;
mounts.push(mount);
}
let index = Index::build(&mounts);
assert_eq!(index.candidates(Point::new(5., 5.), mounts.len()), vec![0]);
mounts[199].x = -1_000_000.;
mounts[199].width = 2_000_000.;
let index = Index::build(&mounts);
assert_eq!(
index.candidates(Point::new(5., 5.), mounts.len()),
vec![199, 0]
);
assert!(mounts[199].contains(Point::new(5., 5.)));
}
}
+10 -1
View File
@@ -54,7 +54,16 @@ It does not publish a page or change revision. Console does not use this endpoin
All execution endpoints share the eight-entry queue. These are in-process bounded
host executions, not a complete untrusted-code sandbox or a process-isolation design.
## Page modes and camera
## Layout, scenes and cameras
Use Page with nested Row / Column / Overlay for finite layout, ScrollView for
scrolling, and CanvasViewport(width,height,Scene2D(...)) for a finite view into
2D world coordinates. Each viewport owns an explicit Camera2D by default;
assigning the same camera shares navigation. Viewport3D provides the analogous
finite view into Scene3D using programmable perspective/orthographic cameras.
See docs/scene-architecture.md and examples/world-ui.endor for the current model.
### Legacy Page compatibility
Page.mode is "window" (default), "vertical", "horizontal" or "canvas".
Window fills the available page viewport. Vertical constrains width and grows
+22
View File
@@ -5281,6 +5281,28 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() {
)
.unwrap();
document.validate().unwrap();
let viewports = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 1024.,
height: 800.,
},
)]);
for _ in 0..3 {
assert!(
session
.resize_windows_incremental(&viewports, true)
.unwrap()
.is_none(),
"unchanged viewport must stay idle"
);
assert!(
session
.poll_tasks_incremental(&viewports)
.unwrap()
.is_none()
);
}
assert!(
document
.frame
+109 -4
View File
@@ -3,7 +3,10 @@ use serde::{Deserialize, Serialize};
use std::{
io::{Read, Write},
net::TcpStream,
sync::mpsc::{SyncSender, sync_channel},
sync::{
Arc, RwLock,
mpsc::{SyncSender, sync_channel},
},
thread,
time::Duration,
};
@@ -150,14 +153,25 @@ impl Input {
}
}
#[derive(Clone)]
pub struct Client(SyncSender<Input>);
impl Client {
pub fn new(proxy: EventLoopProxy<crate::app::AppEvent>) -> anyhow::Result<Self> {
let (sender, receiver) = sync_channel::<Input>(8);
pub fn new(
proxy: EventLoopProxy<crate::app::AppEvent>,
namespace: Arc<RwLock<crate::document::NamespaceState>>,
) -> anyhow::Result<Self> {
let (sender, receiver) = sync_channel::<Input>(64);
thread::Builder::new()
.name("tessera-native-events".into())
.spawn(move || {
for input in receiver {
for mut input in receiver {
if let Ok(current) = namespace.read() {
if !refresh_continuous_input(&mut input, &current) {
continue;
}
} else {
continue;
}
if let Err(error) = send(&input) {
let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed {
message: error.to_string(),
@@ -172,6 +186,48 @@ impl Client {
}
}
// Native gestures retain the captured Mount across publications. Refresh only
// the version at dequeue, never session, window or target. HTTP callers and
// discrete actions still receive strict stale-frame rejection.
fn refresh_continuous_input(input: &mut Input, current: &crate::document::NamespaceState) -> bool {
if !matches!(
input.kind.as_str(),
"viewport-pan" | "viewport-zoom" | "scene-pan" | "scene-orbit" | "scene-zoom" | "scroll"
) {
return true;
}
let frame = if input.window == crate::window::MAIN_WINDOW {
Some(&current.document.frame)
} else {
current
.document
.windows
.get(&input.window)
.map(|document| &document.frame)
};
let Some(frame) = frame else {
return false;
};
if current.runtime_session != input.session
|| frame.session != input.session
|| frame.fault.is_some()
|| !frame.mounts.iter().any(|mount| {
mount.id == input.mount
&& if input.kind.starts_with("viewport-") {
mount.canvas_viewport
} else if input.kind.starts_with("scene-") {
mount.scene3d_viewport && mount.camera_controls
} else {
mount.scroll.is_some()
}
})
{
return false;
}
input.layout_version = frame.layout_version;
true
}
fn send(input: &Input) -> anyhow::Result<()> {
let body = serde_json::to_string(input)?;
let mut stream =
@@ -208,6 +264,55 @@ fn send(input: &Input) -> anyhow::Result<()> {
mod tests {
use super::*;
#[test]
fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() {
let (mut session, document) = crate::endor_host::ReplSession::load(
"view=CanvasViewport(200,100,Scene2D(RectBlock(20,20,0)))\npage=Page()\npage.render(view)",
"app.main", 1, 912,
).unwrap();
let mount = document
.frame
.mounts
.iter()
.find(|mount| mount.canvas_viewport)
.unwrap()
.id;
let original = Input {
window: 1,
session: 912,
layout_version: document.frame.layout_version,
mount,
kind: "viewport-pan".into(),
point: Some([50., 50.]),
delta: Some([3., 0.]),
press_point: None,
tab_drop: None,
screen_position: None,
};
let mut current = crate::document::NamespaceState {
runtime_session: 912,
document,
..Default::default()
};
for _ in 0..12 {
let mut queued = original.clone();
assert!(refresh_continuous_input(&mut queued, &current));
current.document = session.dispatch(&queued).unwrap().document.unwrap();
}
assert_eq!(
session.execute("view.camera.x", "app.main").unwrap().value,
"36"
);
let mut click = original.clone();
click.kind = "click".into();
assert!(refresh_continuous_input(&mut click, &current));
assert_eq!(click.layout_version, original.layout_version);
current.runtime_session = 913;
assert!(!refresh_continuous_input(&mut original.clone(), &current));
current.runtime_session = 912;
current.document.frame.mounts.clear();
assert!(!refresh_continuous_input(&mut original.clone(), &current));
}
#[test]
fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() {
let mut input:Input=serde_json::from_str(r#"{"window":1,"session":1,"layout_version":2,"mount":3,"kind":"tab-drop","tab_drop":{"window":2,"layout_version":4,"index":1}}"#).unwrap();
assert!(input.validate().is_ok());
+30 -20
View File
@@ -69,8 +69,24 @@ impl TesseraApp {
proxy: EventLoopProxy<AppEvent>,
control: crate::api::RuntimeControl,
) -> anyhow::Result<Self> {
let event_client = crate::events::Client::new(proxy.clone())?;
Ok(Self {
let event_client = crate::events::Client::new(proxy.clone(), namespace.clone())?;
Ok(Self::with_event_client(
namespace,
activity,
proxy,
control,
event_client,
))
}
fn with_event_client(
namespace: Arc<RwLock<NamespaceState>>,
activity: Activity,
proxy: EventLoopProxy<AppEvent>,
control: crate::api::RuntimeControl,
event_client: crate::events::Client,
) -> Self {
Self {
resource: crate::window::MAIN_WINDOW,
control,
exit_requested: false,
@@ -99,7 +115,7 @@ impl TesseraApp {
guest_focus: None,
guest_key: None,
guest_keyboard: true,
})
}
}
fn reconcile_windows(&mut self, event_loop: &ActiveEventLoop) {
@@ -147,28 +163,22 @@ impl TesseraApp {
submission,
..NamespaceState::default()
}));
match Self::new(
let mut child = Self::with_event_client(
namespace,
self.activity.clone(),
self.proxy.clone(),
self.control.clone(),
) {
Ok(mut child) => {
child.resource = id;
child.native_session = session;
child.resumed(event_loop);
if let Some(error) = &child.native_error {
self.developer_tools.input_error = error.clone();
self.developer_tools.open = true;
}
child.document_updated(event_loop);
self.children.insert(id, child);
}
Err(error) => {
self.developer_tools.input_error = format!("Window {id}: {error}");
self.developer_tools.open = true;
}
self.event_client.clone(),
);
child.resource = id;
child.native_session = session;
child.resumed(event_loop);
if let Some(error) = &child.native_error {
self.developer_tools.input_error = error.clone();
self.developer_tools.open = true;
}
child.document_updated(event_loop);
self.children.insert(id, child);
}
}
}