From ad2c01bc2927eb6dfe7ec9395fc77222555b5177 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 10 Nov 2026 23:19:26 +0800 Subject: [PATCH] feat: render programmable 3d scenes cameras and picking --- Cargo.toml | 2 +- docs/scene-architecture.md | 21 +- examples/README.md | 1 + examples/scene3d.endor | 46 ++ src/bridge/ui_bridge.rs | 55 +++ src/bridge/ui_bridge/bootstrap.rs | 33 +- src/bridge/ui_bridge/calls.rs | 67 +++ src/bridge/ui_bridge/page_input.rs | 97 ++++ src/bridge/ui_bridge/signatures.rs | 33 ++ src/bridge/ui_bridge/tree.rs | 20 + src/main.rs | 3 +- src/model/block.rs | 69 ++- src/model/document.rs | 37 +- src/model/mod.rs | 1 + src/model/mount.rs | 27 +- src/model/scene3d.rs | 761 +++++++++++++++++++++++++++++ src/model/snapshot.rs | 28 +- src/render/display_list.rs | 7 + src/render/layout.rs | 16 +- src/render/mod.rs | 1 + src/render/native_visual.rs | 17 + src/render/scene3d.rs | 456 +++++++++++++++++ src/render/scene3d/math.rs | 222 +++++++++ src/render/software_renderer.rs | 49 +- src/render/source_paint.rs | 19 +- src/render/viewport.rs | 9 +- src/runtime/api/guide.rs | 6 +- src/runtime/api/tests.rs | 91 ++++ src/runtime/api/worker.rs | 17 +- src/runtime/endor_host/tests.rs | 222 ++++++++- src/runtime/event_dispatch.rs | 2 +- src/runtime/events.rs | 10 +- src/shell/app.rs | 60 ++- 33 files changed, 2463 insertions(+), 42 deletions(-) create mode 100644 examples/scene3d.endor create mode 100644 src/model/scene3d.rs create mode 100644 src/render/scene3d.rs create mode 100644 src/render/scene3d/math.rs diff --git a/Cargo.toml b/Cargo.toml index 4d2e1fe..a7571f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ compiler = { path = "vendor/aster/crates/compiler" } cosmic-text = "0.19" endor_core = { package = "core", path = "vendor/aster/crates/core" } frontend = { path = "vendor/aster/crates/frontend" } -serde = { version = "1.0", features = ["derive"] } +serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" softbuffer = "0.4.8" tiny_http = "0.12" diff --git a/docs/scene-architecture.md b/docs/scene-architecture.md index 87580b8..230dc31 100644 --- a/docs/scene-architecture.md +++ b/docs/scene-architecture.md @@ -20,7 +20,7 @@ | 基线审查 | 完成 | 保留共享 Block / 独立 Mount、源协议和已合并工作区示例 | | 嵌套布局与盒模型 | 完成 | Row / Column / Overlay、双轴对齐、子项覆盖、外边距、非对称内边距和边框、尺寸策略、裁剪;见下面接口 | | 独立 2D 场景与视口 | 完成 | 独立场景/摄像机、多视口及嵌套、覆盖 UI、投影/命中、原生输入路由、真实 HTTP 与像素回归;旧 Page.mode 仅保留兼容 | -| 3D 场景、视口和摄像机 | 待实现 | 尚无可运行的 3D 后端、控制器或拾取 | +| 3D 场景、视口和摄像机 | 实现中 | 软件深度缓冲、网格/变换/灯光、透视/正交相机、可替换控制器、射线拾取已接通;世界空间 UI 面板仍待完成 | | 原生综合验收 | 待执行 | Rust / VM / HTTP 回归不能代替鼠标、触控板和窗口外观验收 | | 完成交付 | 待完成 | 完成实现、检查和文档后,更新可评审 PR,等待用户手动合并 | @@ -59,3 +59,22 @@ 示例见 [scene-viewports.endor](../examples/scene-viewports.endor)。回归包含两个视口共享同一业务对象、显式共享相机、嵌套变换、真实点击回调、相机修改时测量数为 0、真实 HTTP 链路及软件渲染像素中的缩放/裁剪。它们不能替代原生鼠标和触控板验收。 2D 阶段最终验证:169 项本地回归全部通过,all-target Clippy 拒绝 warnings 通过,编辑器未报告错误。默认页的真实按钮事件也已迁移验证。未将原生窗口验收记为完成。 + +## 3D 基础接口与当前边界 + +- `Scene3D(nodes...)` 管理世界对象;`Group3D(nodes...)` 组合变换。`children` 是 ObjectList,可显式替换;共享节点可以出现多次,路径循环会拒绝。 +- `Box3D(width,height,depth,color)` 创建网格;`Geometry3D(ObjectList(Vec3(...),...), ObjectList(Triangle3D(a,b,c),...))` 创建不可变顶点/索引缓冲,再用 `Mesh3D(geometry,color)` 实例化。可替换 mesh.geometry;修改原输入 Vec3 不会改写已建立的不可变几何。 +- 网格/组有 x/y/z、rotationX/Y/Z(角度)、scaleX/Y/Z;局部缩放、X/Y/Z 旋转、平移依序应用,再继承父组变换。`translate(dx,dy,dz)`、`rotate(dx,dy,dz)` 可在 Endor 中直接调用。 +- `DirectionalLight(dx,dy,dz,color,intensity)` 使用传播方向,Scene3D.ambient 为基础照明。当前材质是完全不透明的平面着色。 +- `Viewport3D(width,height,scene)` 是普通有限 UI 盒子,可以放入 Row、Column、Overlay 或 Scene2D 中。背景与 3D 内容在独立表面上渲染,再交给现有原生软件合成器;采用实际深度缓冲、六面视锥裁剪,没有用静态图替代三维场景。 +- 每个新 Viewport3D 有独立 PerspectiveCamera;可用 `view.camera = OrthographicCamera()` 替换,或显式共享同一个 camera。摄像机字段为 x/y/z、targetX/Y/Z、upX/Y/Z、near/far、透视 fov(角度)、正交 height。宽高比由每次视口投影提供,不写回共享摄像机。`camera.translate(...)` 同时平移位置与观察目标。 +- `OrbitController()` 独立于摄像机数据,默认绑定到新视口。左键拖动旋转,中键/空格拖动平移,滚轮/捏合缩放。view.controller 可替换为提供 `update(camera, gesture)` 的 Endor 对象,或设为字符串 `none` 禁用。CameraGesture 提供 kind、dx/dy、factor、width/height、pointX/pointY。透视缩放改变观察距离,正交缩放改变 height。 +- `view.mounts().at(index).pick3D(x,y)` 接受投影前页面空间中的指针坐标,返回 RayHit3D 或空值;结果包含实际托管 mesh、世界 point/normal、distance、triangle 和 barycentric。原生指针点击对应视口时,标准 click Event.hit 提供相同结果;点击空白时为空。拾取与绘制共享摄像机和变换,遵守 near/far。 + +固定 Aster 版本不支持数值算术表达式,不能用 `x = x + dx` 编译控制器。因此示例使用可运行的原生 translate/rotate 操作;自定义控制器可以调用这些方法或直接设置摄像机字段。未修改 Aster 源码或 gitlink。 + +3D 表面最长边按需降采样至 2048,单表面不超过 4194304 像素;缓存最多 8 个不可变表面、总计 8388608 像素。三角形、顶点、场景节点、灯光、光栅工作和摄像机范围均有检查;超过光栅预算返回错误,不发布截断的半幅结果。更完整的嵌套资源预算、空间索引和世界空间 UI 仍在本目标内继续实现。 + +示例见 [scene3d.endor](../examples/scene3d.endor)。测试覆盖真正的 Endor 构造/修改、最终渲染像素、深度顺序、透视/正交独立视图、近面裁剪、射线回调、可替换控制器的真实 HTTP 路由以及循环/非法几何拒绝。原生硬件交互尚未验收。 + +3D 基础阶段检查:176 项本地回归全部通过,all-target Clippy 拒绝 warnings 通过。后续世界空间 UI 与嵌套资源/可见性工作尚未完成,因此整体目标及 PR 仍处于进行中。 diff --git a/examples/README.md b/examples/README.md index e51bd30..45ac228 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,7 @@ | File | Purpose | | --- | --- | +| [scene3d.endor](scene3d.endor) | Shared 3D meshes, transforms and light with independent perspective/orthographic cameras, orbit controls and Endor buttons. | | [scene-viewports.endor](scene-viewports.endor) | One Scene2D in two CanvasViewports with independent Camera2D, a nested viewport, shared state and fixed overlay UI. | | [box-layout.endor](box-layout.endor) | Nested Row/Column/Overlay, shared text, finite border-box sizing, margins, borders, alignment and clipping. | | [gc-lifecycle.endor](gc-lifecycle.endor) | A Page/Block/Task/await-frame cycle: hidden retained Blocks keep running, then releasing the last references permits collection despite rendering caches. | diff --git a/examples/scene3d.endor b/examples/scene3d.endor new file mode 100644 index 0000000..7fee8fb --- /dev/null +++ b/examples/scene3d.endor @@ -0,0 +1,46 @@ +class SceneActions { + view: Viewport3D + cube: Mesh3D + function front(self: SceneActions): Unit { + self.view.camera.x = 0 + self.view.camera.y = 0 + self.view.camera.z = 6 + } + function side(self: SceneActions): Unit { + self.view.camera.x = 6 + self.view.camera.y = 2 + self.view.camera.z = 0 + } + function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) } + function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" } +} +cube = Box3D(1.6, 1.6, 1.6, "#6687AB") +cube.rotationY = 25 +cube.rotationX = 15 +small = Box3D(0.7, 0.7, 0.7, "#DBA36E") +small.x = 1.8 +small.z = -0.8 +group = Group3D(cube, small) +floor = Box3D(6, 0.08, 4, "#D8DEE6") +floor.y = -1.1 +light = DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9) +scene = Scene3D(group, floor, light) +scene.ambient = 0.35 +perspective = Viewport3D(480, 360, scene) +perspective.camera.z = 6 +orthographic = Viewport3D(320, 360, scene) +orthographic.camera = OrthographicCamera() +orthographic.camera.height = 5 +orthographic.camera.x = 4 +orthographic.camera.y = 2 +orthographic.camera.z = 5 +actions = SceneActions(perspective, cube) +front = Button("正面") +front.onClick(actions.front) +side = Button("侧面") +side.onClick(actions.side) +rotate = Button("物体旋转 +15°") +rotate.onClick(actions.rotate) +page = Page() +page.title = "场景、视口和摄像机" +page.render(Column(24, 16, TextBlock("同一个 3D 场景,两个摄像机", 2), Row(0, 16, perspective, orthographic), Row(0, 12, front, side, rotate), TextBlock("左键拖动旋转 · 中键或空格拖动平移 · 滚轮缩放\n摄像机和控制器都是可编程对象;2D 控件由 Endor 自己排版。"))) diff --git a/src/bridge/ui_bridge.rs b/src/bridge/ui_bridge.rs index c3fb478..618103b 100644 --- a/src/bridge/ui_bridge.rs +++ b/src/bridge/ui_bridge.rs @@ -48,6 +48,20 @@ pub const TYPE_NAMES: &[&str] = &[ "Scene2D", "CanvasViewport", "Camera2D", + "RayHit3D", + "CameraGesture", + "Vec3", + "Triangle3D", + "Geometry3D", + "Mesh3D", + "Box3D", + "Group3D", + "Scene3D", + "DirectionalLight", + "PerspectiveCamera", + "OrthographicCamera", + "OrbitController", + "Viewport3D", "Button", "Page", "Window", @@ -152,6 +166,11 @@ enum Operation { Scene2D, CanvasViewport, Camera2D, + SceneObject(crate::scene3d_model::Constructor), + Viewport3D, + CameraControl, + Translate3D, + Rotate3D, Button, Page, SetText, @@ -192,6 +211,10 @@ impl Operation { TextOf => (2, 4), Scroll => (3, 3), CanvasViewport => (3, 3), + Viewport3D => (3, 3), + SceneObject(kind) => kind.arity(), + CameraControl => (2, 2), + Translate3D | Rotate3D => (3, 3), Camera2D => (0, 3), Scene2D => (0, MAX_UI_NODES), Text => (1, 3), @@ -465,6 +488,7 @@ fn new_typed_block( Kind::Overlay => "Overlay", Kind::Scene2D => "Scene2D", Kind::CanvasViewport { .. } => "CanvasViewport", + Kind::Viewport3D { .. } => "Viewport3D", Kind::Button => "Button", Kind::Page => "Page", }; @@ -1237,6 +1261,12 @@ impl UiBridge { if matches!(input.kind.as_str(), "viewport-pan" | "viewport-zoom") { return self.dispatch_viewport_view(vm, input); } + if matches!( + input.kind.as_str(), + "scene-pan" | "scene-orbit" | "scene-zoom" + ) { + return self.dispatch_scene_view(vm, input); + } if matches!( input.kind.as_str(), "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" @@ -1326,6 +1356,31 @@ impl UiBridge { }; let result = (|| { let data = vm.payload::(mount, "Mount")?.clone(); + if data.geometry.scene3d_viewport + && input.kind == "click" + && let Some(point) = input.point + { + let function = vm.allocate(ContextNativeFunction::new( + "3D pointer click", + crate::scene3d_model::PointerPick { + mount, + desktop, + point: crate::geometry::Point::new(point[0], point[1]), + }, + vec![mount, desktop], + ))?; + let result = vm.call_with_limits( + function, + &[], + endor_core::ExecutionLimits { + max_frames: 128, + max_instructions: 1_000_000, + }, + ); + vm.release(function)?; + vm.release_value(result?)?; + return Ok(()); + } if !data.mounted { bail!("event targets an obsolete presentation"); } diff --git a/src/bridge/ui_bridge/bootstrap.rs b/src/bridge/ui_bridge/bootstrap.rs index 1269ba2..d20de76 100644 --- a/src/bridge/ui_bridge/bootstrap.rs +++ b/src/bridge/ui_bridge/bootstrap.rs @@ -95,6 +95,19 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("Scene2D", "ContainerBlock", Some("scene2D")), ("CanvasViewport", "ContainerBlock", Some("canvasViewport")), ("Camera2D", "Object", Some("camera2D")), + ("Vec3", "Object", Some("vec3")), + ("Triangle3D", "Object", Some("triangle3D")), + ("Geometry3D", "Object", Some("geometry3D")), + ("Mesh3D", "Object", Some("mesh3D")), + ("Box3D", "Mesh3D", Some("box3D")), + ("Group3D", "Object", Some("group3D")), + ("Scene3D", "Object", Some("scene3D")), + ("DirectionalLight", "Object", Some("directionalLight")), + ("PerspectiveCamera", "Object", Some("perspectiveCamera")), + ("OrthographicCamera", "Object", Some("orthographicCamera")), + ("OrbitController", "Object", Some("orbitController")), + ("Viewport3D", "Block", Some("viewport3D")), + ("RayHit3D", "Object", None), ("Button", "ContainerBlock", Some("button")), ("Page", "ContainerBlock", Some("page")), ("Window", "Object", Some("window-create")), @@ -143,10 +156,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "scene2D" => Operation::Scene2D, "canvasViewport" => Operation::CanvasViewport, "camera2D" => Operation::Camera2D, + "viewport3D" => Operation::Viewport3D, "button" => Operation::Button, "page" => Operation::Page, "window-create" => Operation::NewWindow, - _ => return Err(invalid("unknown native type allocator").into()), + name => crate::scene3d_model::Constructor::from_function(name) + .map(Operation::SceneObject) + .ok_or(invalid("unknown native type allocator"))?, }; let factory = vm.allocate(ContextNativeFunction::new( constructor_name, @@ -185,6 +201,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("Arrangement", vec!["children"]), ("DisplayList", vec!["commands"]), ("PaintCommand", vec![]), + ("CameraGesture", vec![]), ] { let fields = fields .into_iter() @@ -249,11 +266,16 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "scene2D" => Some(Operation::Scene2D), "canvasViewport" => Some(Operation::CanvasViewport), "camera2D" => Some(Operation::Camera2D), + "viewport3D" => Some(Operation::Viewport3D), "button" => Some(Operation::Button), "page" => Some(Operation::Page), "window-create" => Some(Operation::NewWindow), "event" | "object-list" => None, - _ => return Err(invalid("unknown deferred factory").into()), + name => Some( + crate::scene3d_model::Constructor::from_function(name) + .map(Operation::SceneObject) + .ok_or(invalid("unknown deferred factory"))?, + ), }; if let Some(operation) = operation { let options = operation_signature(vm.object_store(), desktop, operation)?; @@ -352,6 +374,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { } for (type_name, name, operation) in [ ("Block", "bind", Operation::Bind), + ("OrbitController", "update", Operation::CameraControl), + ("Mesh3D", "translate", Operation::Translate3D), + ("Group3D", "translate", Operation::Translate3D), + ("PerspectiveCamera", "translate", Operation::Translate3D), + ("OrthographicCamera", "translate", Operation::Translate3D), + ("Mesh3D", "rotate", Operation::Rotate3D), + ("Group3D", "rotate", Operation::Rotate3D), ("Block", "mounts", Operation::Mounts), ("Block", "invalidate", Operation::Invalidate), ("Block", "onClick", Operation::OnClick), diff --git a/src/bridge/ui_bridge/calls.rs b/src/bridge/ui_bridge/calls.rs index 5b43eb9..2e887e3 100644 --- a/src/bridge/ui_bridge/calls.rs +++ b/src/bridge/ui_bridge/calls.rs @@ -20,6 +20,38 @@ impl NativeCallable for Function { let camera = new_camera2d(cx, &self.session, self.requested_type, args)?; return Ok(NativeResult::Return(Value::Object(camera))); } + if let SceneObject(kind) = self.operation { + let desktop = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))? + .desktop; + let definition = + self.requested_type + .unwrap_or(desktop_type(cx.store(), kind.name(), desktop)?); + let object = crate::scene3d_model::allocate(cx, desktop, definition, kind, args)?; + return Ok(NativeResult::Return(Value::Object(object))); + } + if matches!(self.operation, CameraControl) { + crate::scene3d_model::control( + cx, + self.owner + .ok_or(invalid("controller requires a receiver"))?, + object(args.first())?, + object(args.get(1))?, + )?; + return Ok(NativeResult::Return(Value::Nil)); + } + if matches!(self.operation, Translate3D | Rotate3D) { + crate::scene3d_model::transform( + cx, + self.owner + .ok_or(invalid("3D transform requires a receiver"))?, + args, + matches!(self.operation, Rotate3D), + )?; + return Ok(NativeResult::Return(Value::Nil)); + } if matches!(self.operation, Implement | Implements | Supports) { let capability = object(args.get(1))?; let value = match self.operation { @@ -387,6 +419,41 @@ impl NativeCallable for Function { data.camera = Some(new_camera2d(cx, &self.session, None, &[])?); Some(data) } + Viewport3D => { + let desktop = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))? + .desktop; + let definition = desktop_type(cx.store(), "PerspectiveCamera", desktop)?; + let camera = crate::scene3d_model::allocate( + cx, + desktop, + definition, + crate::scene3d_model::Constructor::Perspective, + &[], + )?; + let mut data = Data::new( + Kind::Viewport3D { + width: dimension(args.first())?, + height: dimension(args.get(1))?, + }, + vec![], + 0., + 0., + ); + data.camera = Some(camera); + data.scene3d = Some(object(args.get(2))?); + let definition = desktop_type(cx.store(), "OrbitController", desktop)?; + data.controller = Some(crate::scene3d_model::allocate( + cx, + desktop, + definition, + crate::scene3d_model::Constructor::Orbit, + &[], + )?); + Some(data) + } Button => { let label = new_block( cx, diff --git a/src/bridge/ui_bridge/page_input.rs b/src/bridge/ui_bridge/page_input.rs index c610014..81cd0c4 100644 --- a/src/bridge/ui_bridge/page_input.rs +++ b/src/bridge/ui_bridge/page_input.rs @@ -2,6 +2,103 @@ use super::*; impl UiBridge { + pub(super) fn dispatch_scene_view( + &self, + vm: &mut Vm, + input: &crate::events::Input, + ) -> Result<()> { + let (weak, desktop) = { + let state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + anyhow::ensure!( + state.published_version == input.layout_version, + "obsolete 3D camera view" + ); + ( + *state + .mounts + .active + .get(&input.mount) + .ok_or_else(|| anyhow::anyhow!("3D viewport unavailable"))?, + state.desktop, + ) + }; + let mount = vm + .upgrade(weak)? + .ok_or_else(|| anyhow::anyhow!("3D viewport Mount expired"))?; + let result = (|| { + let occurrence = vm.payload::(mount, "Mount")?.clone(); + anyhow::ensure!( + occurrence.mounted + && occurrence.version == input.layout_version + && occurrence.geometry.scene3d_viewport, + "obsolete 3D viewport" + ); + let (_, data) = block::resolve(vm.object_store(), occurrence.block)?; + let data = block::visual_data(vm.object_store(), data)?; + let camera = data + .camera + .ok_or_else(|| anyhow::anyhow!("missing 3D camera"))?; + let controller = data + .controller + .ok_or_else(|| anyhow::anyhow!("3D controller disabled"))?; + let node = block::snapshot(vm.object_store(), occurrence.block)?; + let inset = crate::layout::insets(&node); + let geometry = &occurrence.geometry; + let outer = geometry.view.unwrap_or_default(); + let delta = input + .delta + .ok_or_else(|| anyhow::anyhow!("3D camera delta required"))?; + let point = input + .point + .map(|p| outer.inverse(crate::geometry::Point::new(p[0], p[1]))) + .unwrap_or(crate::geometry::Point::new( + geometry.x + inset.left, + geometry.y + inset.top, + )); + let gesture = vm.new_object()?; + let result = (|| { + vm.attach_type( + gesture, + desktop_type(vm.object_store(), "CameraGesture", desktop)?, + )?; + vm.set_attr(gesture, "kind", Value::Text(input.kind.clone()))?; + for (name, value) in [ + ("dx", delta[0] / outer.zoom), + ("dy", delta[1] / outer.zoom), + ("factor", delta[0]), + ("width", (geometry.width - inset.horizontal()).max(0.)), + ("height", (geometry.height - inset.vertical()).max(0.)), + ("pointX", point.x - geometry.x - inset.left), + ("pointY", point.y - geometry.y - inset.top), + ] { + vm.set_attr(gesture, name, crate::native_visual::value(value))?; + } + let function = vm.bind_attr(controller, "update")?; + let result = match function { + Value::Object(function) => vm.call_with_limits( + function, + &[Value::Object(camera), Value::Object(gesture)], + endor_core::ExecutionLimits { + max_frames: 128, + max_instructions: 1_000_000, + }, + ), + _ => Err(invalid("controller.update is not callable")), + }; + vm.release_value(function)?; + vm.release_value(result?)?; + Ok(()) + })(); + vm.release(gesture)?; + result + })(); + vm.release(mount)?; + result + } + pub(super) fn dispatch_viewport_view( &self, vm: &mut Vm, diff --git a/src/bridge/ui_bridge/signatures.rs b/src/bridge/ui_bridge/signatures.rs index 9f0a23c..513d1e2 100644 --- a/src/bridge/ui_bridge/signatures.rs +++ b/src/bridge/ui_bridge/signatures.rs @@ -11,6 +11,12 @@ pub(super) fn method_signature( use Operation::*; let nominal = |name| desktop_type(store, name, desktop).map(Nominal); let (arguments, returns, effect) = match operation { + Translate3D | Rotate3D => (vec![Number, Number, Number], Unit, CallEffect::Mutating), + CameraControl => ( + vec![nominal("Object")?, nominal("CameraGesture")?], + Unit, + CallEffect::Mutating, + ), Bind => ( vec![TextType, Function], nominal("Subscription")?, @@ -152,6 +158,33 @@ pub(super) fn operation_signature( CallEffect::ReadOnly, ), Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly), + Viewport3D => ( + vec![Number, Number, nominal("Scene3D")?], + None, + nominal("Viewport3D")?, + CallEffect::ReadOnly, + ), + SceneObject(kind) => { + let parameters = kind + .parameters() + .iter() + .map(|name| match *name { + "Number" => Ok(Number), + "String" => Ok(TextType), + name => nominal(name), + }) + .collect::, VmError>>()?; + ( + parameters, + if kind.variadic() { + Some(nominal("Object")?) + } else { + None + }, + nominal(kind.name())?, + CallEffect::ReadOnly, + ) + } Scene2D => ( vec![], Some(nominal("Block")?), diff --git a/src/bridge/ui_bridge/tree.rs b/src/bridge/ui_bridge/tree.rs index 4e28117..21b2b99 100644 --- a/src/bridge/ui_bridge/tree.rs +++ b/src/bridge/ui_bridge/tree.rs @@ -81,6 +81,20 @@ impl UiNode { .all(|(a, b)| Arc::ptr_eq(a, b)) }; match (&self.kind, &other.kind) { + ( + UiKind::Viewport3D { + width: a, + height: b, + view: c, + controls: g, + }, + UiKind::Viewport3D { + width: d, + height: e, + view: f, + controls: h, + }, + ) => a == d && b == e && c == f && g == h, ( UiKind::CanvasViewport { width: a, @@ -180,6 +194,12 @@ impl UiNode { #[derive(Debug, Clone, PartialEq)] pub enum UiKind { + Viewport3D { + width: f32, + height: f32, + view: Arc, + controls: bool, + }, Scene2D { positions: Vec<[f32; 2]>, children: Vec>, diff --git a/src/main.rs b/src/main.rs index 3c4f015..aa071bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,11 +7,12 @@ mod runtime; mod shell; pub(crate) use bridge::ui_bridge; +pub(crate) use model::scene3d as scene3d_model; pub(crate) use model::{ binding, block, document, mount, object_list, presentation, protocols, snapshot, window, }; pub(crate) use render::{ - box_model, display_list, geometry, layout, native_visual, page, scene, scroll, + box_model, display_list, geometry, layout, native_visual, page, scene, scene3d, scroll, software_renderer, source_hit, source_layout, source_paint, text, viewport, }; pub(crate) use runtime::{ diff --git a/src/model/block.rs b/src/model/block.rs index b11f286..dc97e6e 100644 --- a/src/model/block.rs +++ b/src/model/block.rs @@ -34,6 +34,10 @@ pub enum Kind { Column, Overlay, Scene2D, + Viewport3D { + width: f32, + height: f32, + }, CanvasViewport { width: f32, height: f32, @@ -135,6 +139,8 @@ pub struct Data { pub border_color: String, pub clip_children: bool, pub camera: Option, + pub scene3d: Option, + pub controller: Option, pub scroll_axis: crate::scroll::Axis, pub position: [f32; 2], pub surface: crate::page::Surface, @@ -175,6 +181,8 @@ impl Data { border_color: "#D8DCE2".into(), clip_children: false, camera: None, + scene3d: None, + controller: None, scroll_axis: crate::scroll::Axis::Both, position: [0.0, 0.0], surface: crate::page::Surface::new(crate::page::Mode::Window), @@ -214,6 +222,12 @@ impl ObjectPayload for Data { "tessera-block-data" } fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { + if let Some(scene) = self.scene3d { + visit(scene); + } + if let Some(controller) = self.controller { + visit(controller); + } if let Some(camera) = self.camera { visit(camera); } @@ -415,6 +429,17 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> { )); } match &data.kind { + Kind::Viewport3D { width, height } => fields.extend([ + ("width", number(*width)), + ("height", number(*height)), + ("scene", data.scene3d.map_or(Value::Nil, Value::Object)), + ( + "controller", + data.controller + .map_or_else(|| Value::Text("none".into()), Value::Object), + ), + ("camera", data.camera.map_or(Value::Nil, Value::Object)), + ]), Kind::CanvasViewport { width, height } => fields.extend([ ("width", number(*width)), ("height", number(*height)), @@ -496,6 +521,26 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { )); } } + if matches!(data.kind, Kind::Viewport3D { .. }) + && let Some(controller) = data.controller + { + let definition = store.type_of(controller)?.ok_or(VmError::InvalidArgument( + "3D controller requires a nominal update method", + ))?; + let method = store + .lookup_method(definition, "update")? + .ok_or(VmError::InvalidArgument( + "3D controller must define update(camera, gesture)", + ))?; + if store + .call_arity(method)? + .is_none_or(|arity| !arity.accepts(3)) + { + return Err(VmError::InvalidArgument( + "3D controller update expects camera and CameraGesture", + )); + } + } Ok(data) } @@ -536,13 +581,33 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu return Ok(()); } match name { - "camera" if matches!(data.kind, Kind::CanvasViewport { .. }) => { + "controller" if matches!(data.kind, Kind::Viewport3D { .. }) => { + data.controller = match value { + Value::Object(controller) => Some(*controller), + Value::Text(text) if text == "none" => None, + _ => return Err(bad()), + }; + return Ok(()); + } + "camera" + if matches!( + data.kind, + Kind::CanvasViewport { .. } | Kind::Viewport3D { .. } + ) => + { let Value::Object(camera) = value else { return Err(bad()); }; data.camera = Some(*camera); return Ok(()); } + "scene" if matches!(data.kind, Kind::Viewport3D { .. }) => { + let Value::Object(scene) = value else { + return Err(bad()); + }; + data.scene3d = Some(*scene); + return Ok(()); + } "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { let index = match name { "paddingTop" => 0, @@ -688,6 +753,8 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu return Ok(()); } match (&mut data.kind, name) { + (Kind::Viewport3D { width, .. }, "width") => *width = number()?, + (Kind::Viewport3D { height, .. }, "height") => *height = number()?, (Kind::CanvasViewport { width, .. }, "width") => *width = number()?, (Kind::CanvasViewport { height, .. }, "height") => *height = number()?, (Kind::Text { value: text, .. }, "text") => { diff --git a/src/model/document.rs b/src/model/document.rs index e70ca54..306bde6 100644 --- a/src/model/document.rs +++ b/src/model/document.rs @@ -421,6 +421,10 @@ impl Default for Frame { pub struct MountGeometry { #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub canvas_viewport: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub scene3d_viewport: bool, + #[serde(default)] + pub camera_controls: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub viewport_clip: Option<[f32; 4]>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -452,9 +456,9 @@ impl MountGeometry { } impl Frame { - pub fn canvas_at(&self, point: Point) -> Option<&MountGeometry> { + pub fn camera_at(&self, point: Point) -> Option<&MountGeometry> { self.mounts.iter().rev().find(|mount| { - mount.canvas_viewport + (mount.canvas_viewport || mount.scene3d_viewport && mount.camera_controls) && mount.contains(point) && mount.viewport_clip.is_some_and(|clip| { Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point) @@ -483,6 +487,13 @@ fn default_background() -> String { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum RenderCommand { + Scene3D { + x: f32, + y: f32, + width: f32, + height: f32, + view: Arc, + }, RoundedRect { x: f32, y: f32, @@ -518,6 +529,18 @@ pub enum RenderCommand { impl RenderCommand { pub(crate) fn validate(&self) -> Result<()> { match self { + Self::Scene3D { + x, + y, + width, + height, + view, + } => { + validate_rect(*x, *y, *width, *height, 0.0)?; + if !view.valid() { + bail!("invalid 3D view"); + } + } Self::RoundedRect { x, y, @@ -578,6 +601,16 @@ impl RenderCommand { fn to_draw_command(&self, scale: f32) -> Result { Ok(match self { + Self::Scene3D { + x, + y, + width, + height, + view, + } => DrawCommand::Scene3D { + rect: scaled_rect(*x, *y, *width, *height, scale), + view: view.clone(), + }, Self::RoundedRect { x, y, diff --git a/src/model/mod.rs b/src/model/mod.rs index f17c3b6..0ed7b94 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -6,5 +6,6 @@ pub(crate) mod mount; pub(crate) mod object_list; pub(crate) mod presentation; pub(crate) mod protocols; +pub(crate) mod scene3d; pub(crate) mod snapshot; pub(crate) mod window; diff --git a/src/model/mount.rs b/src/model/mount.rs index ba7533f..8b8516b 100644 --- a/src/model/mount.rs +++ b/src/model/mount.rs @@ -56,6 +56,7 @@ enum Query { ScrollPosition, ScrollIntoView, FitView, + Pick3D, Parent, FirstChild, NextSibling, @@ -80,7 +81,7 @@ impl NativeCallable for Method { Some(endor_core::CallArity::exact( if matches!( self.query, - Query::HitTest | Query::ScrollTo | Query::ScrollBy + Query::HitTest | Query::Pick3D | Query::ScrollTo | Query::ScrollBy ) { 2 } else { @@ -101,6 +102,7 @@ impl NativeCallable for Method { | Query::ScrollBy | Query::ScrollIntoView | Query::FitView + | Query::Pick3D | Query::ScrollPosition ) { @@ -108,7 +110,7 @@ impl NativeCallable for Method { } let arity = if matches!( self.query, - Query::HitTest | Query::ScrollTo | Query::ScrollBy + Query::HitTest | Query::Pick3D | Query::ScrollTo | Query::ScrollBy ) { 2 } else { @@ -139,6 +141,20 @@ impl NativeCallable for Method { crate::viewport::fit(cx, &mount)?; Value::Nil } + Query::Pick3D => { + let desktop = cx + .upgrade(self.desktop)? + .ok_or(VmError::InvalidArgument("viewport desktop expired"))?; + crate::scene3d_model::pick( + cx, + desktop, + &mount, + crate::geometry::Point::new( + crate::scroll::coordinate(&args[0])?, + crate::scroll::coordinate(&args[1])?, + ), + )? + } Query::ScrollPosition => { let p = mount .geometry @@ -264,6 +280,7 @@ fn mount_signature( } Query::Page => CallType::nullable(nominal("Page")?), Query::HitTest => T::Bool.into(), + Query::Pick3D => CallType::nullable(nominal("RayHit3D")?), Query::ScrollPosition => T::Any.into(), _ => T::Unit.into(), }; @@ -275,7 +292,10 @@ fn mount_signature( } else { CallEffect::ReadOnly }; - let parameters = if matches!(query, Query::HitTest | Query::ScrollTo | Query::ScrollBy) { + let parameters = if matches!( + query, + Query::HitTest | Query::Pick3D | Query::ScrollTo | Query::ScrollBy + ) { vec![T::Number, T::Number] } else { vec![] @@ -412,6 +432,7 @@ impl NativeCallable for Sync { ("scrollPosition", Query::ScrollPosition), ("scrollIntoView", Query::ScrollIntoView), ("fitView", Query::FitView), + ("pick3D", Query::Pick3D), ("parent", Query::Parent), ("firstChild", Query::FirstChild), ("nextSibling", Query::NextSibling), diff --git a/src/model/scene3d.rs b/src/model/scene3d.rs new file mode 100644 index 0000000..1c24ef4 --- /dev/null +++ b/src/model/scene3d.rs @@ -0,0 +1,761 @@ +//! Traced 3D objects and checked immutable snapshots. No managed handle enters +//! the renderer; cameras and node transforms remain editable Endor attributes. +use crate::{ + object_list, + scene3d::{ + self, + math::{Camera, Matrix, Projection, Vec3}, + }, +}; +use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, VmError}; +use std::{collections::HashSet, sync::Arc}; + +#[derive(Debug, Clone, Copy)] +pub enum Constructor { + Vec3, + Triangle, + Geometry, + Mesh, + Box, + Group, + Scene, + Light, + Perspective, + Orthographic, + Orbit, +} +impl Constructor { + pub fn name(self) -> &'static str { + match self { + Self::Vec3 => "Vec3", + Self::Triangle => "Triangle3D", + Self::Geometry => "Geometry3D", + Self::Mesh => "Mesh3D", + Self::Box => "Box3D", + Self::Group => "Group3D", + Self::Scene => "Scene3D", + Self::Light => "DirectionalLight", + Self::Perspective => "PerspectiveCamera", + Self::Orthographic => "OrthographicCamera", + Self::Orbit => "OrbitController", + } + } + pub fn function(self) -> &'static str { + match self { + Self::Vec3 => "vec3", + Self::Triangle => "triangle3D", + Self::Geometry => "geometry3D", + Self::Mesh => "mesh3D", + Self::Box => "box3D", + Self::Group => "group3D", + Self::Scene => "scene3D", + Self::Light => "directionalLight", + Self::Perspective => "perspectiveCamera", + Self::Orthographic => "orthographicCamera", + Self::Orbit => "orbitController", + } + } + pub fn from_function(name: &str) -> Option { + [ + Self::Vec3, + Self::Triangle, + Self::Geometry, + Self::Mesh, + Self::Box, + Self::Group, + Self::Scene, + Self::Light, + Self::Perspective, + Self::Orthographic, + Self::Orbit, + ] + .into_iter() + .find(|kind| kind.function() == name) + } + pub fn arity(self) -> (usize, usize) { + match self { + Self::Vec3 | Self::Triangle => (3, 3), + Self::Geometry | Self::Mesh => (2, 2), + Self::Box => (4, 4), + Self::Group | Self::Scene => (0, 4096), + Self::Light => (5, 5), + _ => (0, 0), + } + } + pub fn parameters(self) -> &'static [&'static str] { + match self { + Self::Vec3 | Self::Triangle => &["Number", "Number", "Number"], + Self::Geometry => &["ObjectList", "ObjectList"], + Self::Mesh => &["Geometry3D", "String"], + Self::Box => &["Number", "Number", "Number", "String"], + Self::Light => &["Number", "Number", "Number", "String", "Number"], + _ => &[], + } + } + pub fn variadic(self) -> bool { + matches!(self, Self::Group | Self::Scene) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeKind { + Vector, + Triangle, + Mesh, + Group, + Scene, + Light, + Perspective, + Orthographic, + Orbit, +} +#[derive(Debug)] +pub struct Node(pub NodeKind); +impl ObjectPayload for Node { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn kind_name(&self) -> &'static str { + "scene-3d-object" + } + fn reference_assignment_only(&self) -> bool { + true + } + fn allows_attributes(&self) -> bool { + true + } +} +#[derive(Debug)] +pub struct Geometry(pub Arc); +impl ObjectPayload for Geometry { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn kind_name(&self) -> &'static str { + "geometry-3d" + } + fn reference_assignment_only(&self) -> bool { + true + } + fn allows_attributes(&self) -> bool { + true + } +} +fn invalid(message: &'static str) -> VmError { + VmError::InvalidArgument(message) +} +fn object_ref(value: &Value) -> Result { + if let Value::Object(object) = value { + Ok(*object) + } else { + Err(invalid("3D field requires an object")) + } +} +fn number(value: &Value) -> Result { + crate::scroll::coordinate(value) +} +fn text(value: &Value) -> Result { + if let Value::Text(text) = value { + Ok(text.clone()) + } else { + Err(invalid("3D field requires text")) + } +} +fn set_number( + cx: &mut NativeContext<'_>, + object: ObjectRef, + name: &str, + value: f32, +) -> Result<(), VmError> { + cx.set_attr(object, name, crate::native_visual::value(value)) +} +fn color(value: &str) -> Result<[f32; 3], VmError> { + let color = crate::document::parse_color(value).map_err(|_| invalid("invalid 3D color"))?; + if color.alpha != 255 { + return Err(invalid("3D opaque material color requires full alpha")); + } + Ok([ + color.red as f32 / 255., + color.green as f32 / 255., + color.blue as f32 / 255., + ]) +} + +pub fn allocate( + cx: &mut NativeContext<'_>, + desktop: ObjectRef, + definition: ObjectRef, + constructor: Constructor, + args: &[Value], +) -> Result { + let kind = match constructor { + Constructor::Vec3 => NodeKind::Vector, + Constructor::Triangle => NodeKind::Triangle, + Constructor::Mesh | Constructor::Box => NodeKind::Mesh, + Constructor::Group => NodeKind::Group, + Constructor::Scene => NodeKind::Scene, + Constructor::Light => NodeKind::Light, + Constructor::Perspective => NodeKind::Perspective, + Constructor::Orthographic => NodeKind::Orthographic, + Constructor::Orbit => NodeKind::Orbit, + Constructor::Geometry => { + let vertices = object_list::items(cx.store(), object_ref(&args[0])?)? + .iter() + .map(|&item| { + let p = cx.store(); + if p.payload::(item, "Vec3")?.0 != NodeKind::Vector { + return Err(invalid("geometry vertices require Vec3")); + } + Ok(Vec3::new( + number(&p.get_attr(item, "x")?)?, + number(&p.get_attr(item, "y")?)?, + number(&p.get_attr(item, "z")?)?, + )) + }) + .collect::, VmError>>()?; + let triangles = object_list::items(cx.store(), object_ref(&args[1])?)? + .iter() + .map(|&item| { + if cx.store().payload::(item, "Triangle3D")?.0 != NodeKind::Triangle { + return Err(invalid("geometry indices require Triangle3D")); + } + let mut triangle = [0; 3]; + for (index, name) in ["a", "b", "c"].into_iter().enumerate() { + let value = number(&cx.store().get_attr(item, name)?)?; + if value < 0. || value.fract() != 0. { + return Err(invalid("triangle indices must be nonnegative integers")); + } + triangle[index] = value as u32; + } + Ok(triangle) + }) + .collect::, VmError>>()?; + let geometry = scene3d::Geometry { + vertices, + triangles, + }; + if !geometry.valid() { + return Err(invalid( + "invalid Geometry3D buffers or geometry budget exceeded", + )); + } + let object = cx.allocate(Geometry(Arc::new(geometry)))?; + cx.attach_type(object, definition)?; + return Ok(object); + } + }; + let instance = cx.allocate(Node(kind))?; + cx.attach_type(instance, definition)?; + match kind { + NodeKind::Vector | NodeKind::Triangle => { + for (name, value) in (if kind == NodeKind::Vector { + ["x", "y", "z"] + } else { + ["a", "b", "c"] + }) + .into_iter() + .zip(args) + { + set_number(cx, instance, name, number(value)?)?; + } + } + NodeKind::Mesh => { + let (geometry, material) = if matches!(constructor, Constructor::Box) { + let (w, h, d) = (number(&args[0])?, number(&args[1])?, number(&args[2])?); + if w < 0. || h < 0. || d < 0. { + return Err(invalid("Box3D dimensions must be nonnegative")); + } + let geometry = + cx.allocate(Geometry(Arc::new(scene3d::Geometry::cuboid(w, h, d))))?; + crate::ui_bridge::attach_desktop_type(cx, geometry, "Geometry3D", desktop)?; + (geometry, text(&args[3])?) + } else { + (object_ref(&args[0])?, text(&args[1])?) + }; + cx.store().payload::(geometry, "Geometry3D")?; + color(&material)?; + cx.set_attr(instance, "geometry", Value::Object(geometry))?; + cx.set_attr(instance, "color", Value::Text(material))?; + } + NodeKind::Group | NodeKind::Scene => { + let children = args.iter().map(object_ref).collect::, _>>()?; + for child in &children { + if !matches!( + cx.store().payload::(*child, "3D node")?.0, + NodeKind::Mesh | NodeKind::Group | NodeKind::Light + ) { + return Err(invalid( + "Scene3D/Group3D children require meshes, groups or lights", + )); + } + } + let children = object_list::collection(cx, children, desktop)?; + cx.set_attr(instance, "children", Value::Object(children))?; + if kind == NodeKind::Scene { + set_number(cx, instance, "ambient", 0.25)?; + cx.set_attr(instance, "background", Value::Text("#F1F3F6".into()))?; + } + } + NodeKind::Light => { + for (name, value) in ["directionX", "directionY", "directionZ"] + .into_iter() + .zip(args) + { + set_number(cx, instance, name, number(value)?)?; + } + color(&text(&args[3])?)?; + cx.set_attr(instance, "color", args[3].clone())?; + set_number(cx, instance, "intensity", number(&args[4])?)?; + } + NodeKind::Perspective | NodeKind::Orthographic => { + for (name, value) in [ + ("x", 0.), + ("y", 0.), + ("z", 5.), + ("targetX", 0.), + ("targetY", 0.), + ("targetZ", 0.), + ("upX", 0.), + ("upY", 1.), + ("upZ", 0.), + ("near", 0.1), + ("far", 1000.), + ("fov", 50.), + ("height", 5.), + ] { + set_number(cx, instance, name, value)?; + } + } + NodeKind::Orbit => { + set_number(cx, instance, "rotationSpeed", 0.005)?; + set_number(cx, instance, "panSpeed", 1.)?; + } + } + if matches!(kind, NodeKind::Mesh | NodeKind::Group) { + for (name, value) in [ + ("x", 0.), + ("y", 0.), + ("z", 0.), + ("rotationX", 0.), + ("rotationY", 0.), + ("rotationZ", 0.), + ("scaleX", 1.), + ("scaleY", 1.), + ("scaleZ", 1.), + ] { + set_number(cx, instance, name, value)?; + } + } + Ok(instance) +} + +struct Reader<'a> { + store: &'a ObjectStore, + reads: HashSet<(ObjectRef, String)>, +} +impl Reader<'_> { + fn value(&mut self, object: ObjectRef, name: &str) -> Result { + self.reads.insert((object, name.into())); + self.store.get_attr(object, name) + } + fn number(&mut self, object: ObjectRef, name: &str) -> Result { + number(&self.value(object, name)?) + } + fn vector(&mut self, object: ObjectRef, names: [&str; 3]) -> Result { + Ok(Vec3::new( + self.number(object, names[0])?, + self.number(object, names[1])?, + self.number(object, names[2])?, + )) + } + fn camera(&mut self, object: ObjectRef) -> Result { + let projection = match self.store.payload::(object, "3D camera")?.0 { + NodeKind::Perspective => Projection::Perspective { + fov: self.number(object, "fov")?, + }, + NodeKind::Orthographic => Projection::Orthographic { + height: self.number(object, "height")?, + }, + _ => { + return Err(invalid( + "Viewport3D requires a PerspectiveCamera or OrthographicCamera", + )); + } + }; + let camera = Camera { + position: self.vector(object, ["x", "y", "z"])?, + target: self.vector(object, ["targetX", "targetY", "targetZ"])?, + up: self.vector(object, ["upX", "upY", "upZ"])?, + near: self.number(object, "near")?, + far: self.number(object, "far")?, + projection, + }; + if !camera.valid() { + return Err(invalid("invalid 3D camera basis or projection range")); + } + Ok(camera) + } +} + +pub type Snapshot = (Arc, Vec<(ObjectRef, String)>); +pub fn snapshot( + store: &ObjectStore, + scene: ObjectRef, + camera: ObjectRef, +) -> Result { + if store.payload::(scene, "Scene3D")?.0 != NodeKind::Scene { + return Err(invalid("Viewport3D requires a Scene3D")); + } + let mut reader = Reader { + store, + reads: HashSet::new(), + }; + let camera = reader.camera(camera)?; + let ambient = reader.number(scene, "ambient")?; + if !(0.0..=1.0).contains(&ambient) { + return Err(invalid("Scene3D.ambient expects 0 through 1")); + } + let background = color(&text(&reader.value(scene, "background")?)?)?; + let mut output = scene3d::Scene { + meshes: Vec::new(), + lights: Vec::new(), + ambient, + background, + }; + let mut work = vec![(scene, Matrix::default(), false)]; + let mut path = HashSet::new(); + let mut visits = 0; + let mut triangles = 0; + while let Some((object, parent, finish)) = work.pop() { + if finish { + path.remove(&object); + continue; + } + visits += 1; + if visits > 4096 { + return Err(VmError::ExecutionLimit("3D scene occurrences")); + } + if !path.insert(object) { + return Err(invalid("cyclic 3D scene graph")); + } + work.push((object, parent, true)); + let kind = store.payload::(object, "3D node")?.0; + let world = if matches!(kind, NodeKind::Mesh | NodeKind::Group) { + parent.compose(Matrix::trs( + reader.vector(object, ["x", "y", "z"])?, + reader.vector(object, ["rotationX", "rotationY", "rotationZ"])?, + reader.vector(object, ["scaleX", "scaleY", "scaleZ"])?, + )) + } else { + parent + }; + match kind { + NodeKind::Scene | NodeKind::Group => { + let list = object_ref(&reader.value(object, "children")?)?; + for child in object_list::items(store, list)?.iter().rev() { + work.push((*child, world, false)); + } + } + NodeKind::Mesh => { + let geometry = object_ref(&reader.value(object, "geometry")?)?; + let geometry = store.payload::(geometry, "Geometry3D")?.0.clone(); + triangles += geometry.triangles.len(); + if triangles > scene3d::MAX_TRIANGLES { + return Err(VmError::ExecutionLimit("3D scene triangles")); + } + let material = color(&text(&reader.value(object, "color")?)?)?; + output.meshes.push(scene3d::Mesh { + identity: object.to_string(), + geometry, + world, + color: material, + }); + } + NodeKind::Light => { + if output.lights.len() >= 32 { + return Err(VmError::ExecutionLimit("3D scene lights")); + } + let direction = + reader.vector(object, ["directionX", "directionY", "directionZ"])?; + let direction = (world.point(direction) - world.point(Vec3::default())) + .normalized() + .ok_or(invalid("light direction cannot be zero"))?; + let color = color(&text(&reader.value(object, "color")?)?)?; + let intensity = reader.number(object, "intensity")?; + if !(0.0..=32.0).contains(&intensity) { + return Err(invalid("light intensity expects 0 through 32")); + } + output.lights.push(scene3d::Light { + direction, + color, + intensity, + }); + } + _ => return Err(invalid("invalid node in 3D scene graph")), + } + } + let view = Arc::new(scene3d::View { + scene: Arc::new(output), + camera, + }); + if !view.valid() { + return Err(invalid("invalid 3D scene snapshot")); + } + Ok((view, reader.reads.into_iter().collect())) +} + +pub fn pick( + cx: &mut NativeContext<'_>, + desktop: ObjectRef, + mount: &crate::mount::Mount, + point: crate::geometry::Point, +) -> Result { + if mount.layout.is_some() { + return Err(invalid("pick3D requires a committed viewport")); + } + let (_, data) = crate::block::resolve(cx.store(), mount.block)?; + let data = crate::block::visual_data(cx.store(), data)?; + if !matches!(data.kind, crate::block::Kind::Viewport3D { .. }) { + return Err(invalid("pick3D requires a Viewport3D")); + } + if !mount.geometry.contains(point) { + return Ok(Value::Nil); + } + let node = crate::block::snapshot(cx.store(), mount.block)?; + let inset = crate::layout::insets(&node); + let point = mount.geometry.view.unwrap_or_default().inverse(point); + let (width, height) = ( + (mount.geometry.width - inset.horizontal()).max(0.), + (mount.geometry.height - inset.vertical()).max(0.), + ); + if width <= 0. || height <= 0. { + return Ok(Value::Nil); + } + let (view, reads) = snapshot( + cx.store(), + data.scene3d.ok_or(invalid("missing Scene3D"))?, + data.camera.ok_or(invalid("missing 3D camera"))?, + )?; + let hit = view.pick( + (point.x - mount.geometry.x - inset.left) / width, + (point.y - mount.geometry.y - inset.top) / height, + width / height, + ); + let Some(hit) = hit else { + return Ok(Value::Nil); + }; + let mesh = reads + .iter() + .find_map(|(object, _)| (object.to_string() == hit.identity).then_some(*object)) + .ok_or(invalid("picked mesh is unavailable"))?; + let result = cx.new_object()?; + crate::ui_bridge::attach_desktop_type(cx, result, "RayHit3D", desktop)?; + cx.set_attr(result, "mesh", Value::Object(mesh))?; + cx.set_attr( + result, + "distance", + crate::native_visual::value(hit.distance), + )?; + cx.set_attr( + result, + "triangle", + Value::Number((hit.triangle as i64).into()), + )?; + for (name, vector) in [ + ("point", hit.point), + ("normal", hit.normal), + ( + "barycentric", + Vec3::new(hit.barycentric[0], hit.barycentric[1], hit.barycentric[2]), + ), + ] { + let definition = crate::ui_bridge::desktop_type(cx.store(), "Vec3", desktop)?; + let vector = allocate( + cx, + desktop, + definition, + Constructor::Vec3, + &[ + crate::native_visual::value(vector.x), + crate::native_visual::value(vector.y), + crate::native_visual::value(vector.z), + ], + )?; + cx.set_attr(result, name, Value::Object(vector))?; + } + Ok(Value::Object(result)) +} + +#[derive(Debug)] +pub struct PointerPick { + pub mount: ObjectRef, + pub desktop: ObjectRef, + pub point: crate::geometry::Point, +} + +/// The default controller is replaceable; camera data has no dependency on it. +pub fn control( + cx: &mut NativeContext<'_>, + controller: ObjectRef, + camera_ref: ObjectRef, + gesture: ObjectRef, +) -> Result<(), VmError> { + if cx.store().payload::(controller, "OrbitController")?.0 != NodeKind::Orbit { + return Err(invalid("invalid orbit controller")); + } + let mut reader = Reader { + store: cx.store(), + reads: HashSet::new(), + }; + let camera = reader.camera(camera_ref)?; + let dx = reader.number(gesture, "dx")?; + let dy = reader.number(gesture, "dy")?; + let kind = text(&reader.value(gesture, "kind")?)?; + let offset = camera.position - camera.target; + let radius = offset.length(); + let mut position = camera.position; + let mut target = camera.target; + match kind.as_str() { + "scene-orbit" => { + let speed = reader.number(controller, "rotationSpeed")?; + if !(0.0..=1.0).contains(&speed) { + return Err(invalid("rotationSpeed expects 0 through 1")); + } + let up = camera + .up + .normalized() + .ok_or(invalid("camera up cannot be zero"))?; + let vertical = (offset.dot(up) / radius).clamp(-1., 1.); + let horizontal = (offset - up * offset.dot(up)) + .normalized() + .ok_or(invalid("orbit at pole requires a different up vector"))?; + let (sine, cosine) = (dx * speed).sin_cos(); + let horizontal = horizontal * cosine + up.cross(horizontal) * sine; + let pitch = (vertical.asin() + dy * speed).clamp( + -std::f32::consts::FRAC_PI_2 + 0.01, + std::f32::consts::FRAC_PI_2 - 0.01, + ); + position = target + horizontal * (radius * pitch.cos()) + up * (radius * pitch.sin()); + } + "scene-pan" => { + let speed = reader.number(controller, "panSpeed")?; + if !(0.0..=100.0).contains(&speed) { + return Err(invalid("panSpeed expects 0 through 100")); + } + let height = reader.number(gesture, "height")?.max(1.); + let units = match camera.projection { + Projection::Perspective { fov } => { + 2. * radius * (fov.to_radians() / 2.).tan() / height + } + Projection::Orthographic { height: extent } => extent / height, + }; + let basis = camera.basis().ok_or(invalid("invalid camera basis"))?; + let delta = basis.right * (dx * units * speed) + basis.up * (-dy * units * speed); + position = position + delta; + target = target + delta; + } + "scene-zoom" => { + let factor = reader.number(gesture, "factor")?; + if !(0.1..=10.0).contains(&factor) { + return Err(invalid("zoom factor expects 0.1 through 10")); + } + if let Projection::Orthographic { height } = camera.projection { + set_number( + cx, + camera_ref, + "height", + (height / factor).clamp(0.001, 1_000_000.), + )?; + } else { + let next = + (radius / factor).clamp(camera.near * 2., camera.far.max(camera.near * 2.)); + position = target + offset * (next / radius); + } + } + _ => return Err(invalid("unsupported 3D camera gesture")), + } + if !position.valid() || !target.valid() { + return Err(invalid("camera movement exceeds world budget")); + } + for (name, value) in [ + ("x", position.x), + ("y", position.y), + ("z", position.z), + ("targetX", target.x), + ("targetY", target.y), + ("targetZ", target.z), + ] { + set_number(cx, camera_ref, name, value)?; + } + Ok(()) +} + +pub fn transform( + cx: &mut NativeContext<'_>, + owner: ObjectRef, + args: &[Value], + rotate: bool, +) -> Result<(), VmError> { + let kind = cx.store().payload::(owner, "3D transform target")?.0; + let camera = matches!(kind, NodeKind::Perspective | NodeKind::Orthographic); + if !matches!( + kind, + NodeKind::Mesh | NodeKind::Group | NodeKind::Perspective | NodeKind::Orthographic + ) || rotate && camera + { + return Err(invalid("unsupported 3D transform target")); + } + let names = if rotate { + ["rotationX", "rotationY", "rotationZ"] + } else { + ["x", "y", "z"] + }; + let mut updates = Vec::new(); + for (index, name) in names.into_iter().enumerate() { + let delta = number(&args[index])?; + let value = number(&cx.store().get_attr(owner, name)?)? + delta; + if !value.is_finite() || !rotate && value.abs() > 1_000_000. { + return Err(invalid("3D transform exceeds world budget")); + } + updates.push(( + name, + if rotate { + value.rem_euclid(360.) + } else { + value + }, + )); + if camera { + let name = ["targetX", "targetY", "targetZ"][index]; + let value = number(&cx.store().get_attr(owner, name)?)? + delta; + if value.abs() > 1_000_000. { + return Err(invalid("camera target exceeds world budget")); + } + updates.push((name, value)); + } + } + for (name, value) in updates { + set_number(cx, owner, name, value)?; + } + Ok(()) +} +impl endor_core::NativeCallable for PointerPick { + fn arity(&self) -> Option { + Some(endor_core::CallArity::exact(0)) + } + fn invoke( + &self, + cx: &mut NativeContext<'_>, + _: &[Value], + ) -> Result { + let mount = cx + .store() + .payload::(self.mount, "Mount")? + .clone(); + let hit = pick(cx, self.desktop, &mount, self.point)?; + let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?; + let event = crate::event_dispatch::new_event(cx, definition, "click")?; + cx.set_attr(event, "hit", hit)?; + crate::event_dispatch::dispatch(cx, mount.block, event, Some(self.mount), self.desktop) + } +} diff --git a/src/model/snapshot.rs b/src/model/snapshot.rs index 3e847ee..b7a2849 100644 --- a/src/model/snapshot.rs +++ b/src/model/snapshot.rs @@ -154,7 +154,9 @@ impl RenderCache { }); } } - if let Some(camera) = data.camera { + if matches!(data.kind, Kind::CanvasViewport { .. }) + && let Some(camera) = data.camera + { for name in ["x", "y", "zoom"] { dependencies.push(Dependency { object: camera, @@ -173,6 +175,22 @@ impl RenderCache { continue; } path.remove(&data_ref); + let view3d = if matches!(data.kind, Kind::Viewport3D { .. }) { + let (view, reads) = crate::scene3d_model::snapshot( + store, + data.scene3d + .ok_or(VmError::InvalidArgument("missing Scene3D"))?, + data.camera + .ok_or(VmError::InvalidArgument("missing 3D camera"))?, + )?; + dependencies.extend(reads.into_iter().map(|(object, name)| Dependency { + object, + property: Some(name), + })); + Some(view) + } else { + None + }; let mut protocol_key = String::new(); let mut custom_event = false; if let Some(definition) = store.type_of(block)? { @@ -267,6 +285,12 @@ impl RenderCache { children, }, Kind::Spacer(size) => UiKind::Spacer(*size), + Kind::Viewport3D { width, height } => UiKind::Viewport3D { + width: *width, + height: *height, + view: view3d.ok_or(VmError::InvalidArgument("missing 3D view"))?, + controls: data.controller.is_some(), + }, Kind::CanvasViewport { width, height } => UiKind::CanvasViewport { width: *width, height: *height, @@ -344,7 +368,7 @@ impl RenderCache { block_id: block.to_string(), protocol_key, slots: data.children.iter().map(|c| c.slot).collect(), - interactive: matches!(&data.kind, Kind::Button) + interactive: matches!(&data.kind, Kind::Button | Kind::Viewport3D { .. }) || data.click.is_some() || click_connection || custom_event, diff --git a/src/render/display_list.rs b/src/render/display_list.rs index c656750..5533704 100644 --- a/src/render/display_list.rs +++ b/src/render/display_list.rs @@ -31,6 +31,10 @@ impl Color { #[derive(Clone, Debug)] pub enum DrawCommand { + Scene3D { + rect: Rect, + view: std::sync::Arc, + }, Clear(Color), PushClip(Rect), PopClip, @@ -63,6 +67,7 @@ impl DrawCommand { pub fn translated(mut self, delta: Point) -> Self { match &mut self { Self::WrappedText { rect, .. } + | Self::Scene3D { rect, .. } | Self::PushClip(rect) | Self::FillRoundedRect { rect, .. } | Self::StrokeRoundedRect { rect, .. } => { @@ -93,6 +98,7 @@ impl DisplayList { Some(rect) } DrawCommand::PushClip(rect) => Some(rect), + DrawCommand::Scene3D { rect, .. } => Some(rect), DrawCommand::FillRoundedRect { rect, radius, .. } => { *radius *= scale; Some(rect) @@ -131,6 +137,7 @@ impl DisplayList { for command in &mut self.commands { match command { DrawCommand::WrappedText { rect, .. } + | DrawCommand::Scene3D { rect, .. } | DrawCommand::PushClip(rect) | DrawCommand::FillRoundedRect { rect, .. } | DrawCommand::StrokeRoundedRect { rect, .. } => { diff --git a/src/render/layout.rs b/src/render/layout.rs index 6efcff1..93b39af 100644 --- a/src/render/layout.rs +++ b/src/render/layout.rs @@ -432,7 +432,9 @@ fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result Shape::Rect(*width, *height), + UiKind::CanvasViewport { width, height, .. } | UiKind::Viewport3D { width, height, .. } => { + Shape::Rect(*width, *height) + } UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } => { let mut size = Size::default(); for (position, child) in positions.iter().zip(children) { @@ -490,6 +492,13 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option Some(RenderCommand::Scene3D { + x: x + inset.left, + y: y + inset.top, + width: (size.width - inset.horizontal()).max(0.), + height: (size.height - inset.vertical()).max(0.), + view: view.clone(), + }), UiKind::Text { value, size, color } => Some(RenderCommand::Text { x: x + inset.left, y: y + inset.top, @@ -1009,6 +1018,11 @@ impl LayoutRuntime { coordinate(y + size.height)?; mounts.push(MountGeometry { canvas_viewport: matches!(item.node.kind, UiKind::CanvasViewport { .. }), + scene3d_viewport: matches!(item.node.kind, UiKind::Viewport3D { .. }), + camera_controls: matches!( + item.node.kind, + UiKind::CanvasViewport { .. } | UiKind::Viewport3D { controls: true, .. } + ), viewport_clip: None, view: None, id: item.id, diff --git a/src/render/mod.rs b/src/render/mod.rs index 3ed1501..44f5d37 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod layout; pub(crate) mod native_visual; pub(crate) mod page; pub(crate) mod scene; +pub(crate) mod scene3d; pub(crate) mod scroll; pub(crate) mod software_renderer; pub(crate) mod source_hit; diff --git a/src/render/native_visual.rs b/src/render/native_visual.rs index bce935c..579a8af 100644 --- a/src/render/native_visual.rs +++ b/src/render/native_visual.rs @@ -275,6 +275,23 @@ impl NativeCallable for Method { let mut commands = Vec::new(); for command in layout::paint_commands(&node, x, y, Size { width, height }) { let fields = match command { + RenderCommand::Scene3D { + x, + y, + width, + height, + view, + } => { + let snapshot = cx.allocate(crate::scene3d::PaintView(view))?; + vec![ + ("kind", Value::Text("scene3d".into())), + ("x", value(x)), + ("y", value(y)), + ("width", value(width)), + ("height", value(height)), + ("scene", Value::Object(snapshot)), + ] + } RenderCommand::Text { x, y, diff --git a/src/render/scene3d.rs b/src/render/scene3d.rs new file mode 100644 index 0000000..580f3ca --- /dev/null +++ b/src/render/scene3d.rs @@ -0,0 +1,456 @@ +//! Software 3D surfaces share the application's existing CPU compositor. Mesh +//! visibility and picking use the same camera and transformed world triangles. +pub mod math; +use math::{Camera, Matrix, Projection, Vec3}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +pub const MAX_TRIANGLES: usize = 65_536; +pub const MAX_VERTICES: usize = 32_768; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Geometry { + pub vertices: Vec, + pub triangles: Vec<[u32; 3]>, +} +impl Geometry { + pub fn valid(&self) -> bool { + self.vertices.len() <= MAX_VERTICES + && self.triangles.len() <= MAX_TRIANGLES + && self.vertices.iter().all(|point| point.valid()) + && self + .triangles + .iter() + .flatten() + .all(|&index| (index as usize) < self.vertices.len()) + } + pub fn cuboid(width: f32, height: f32, depth: f32) -> Self { + let (x, y, z) = (width / 2., height / 2., depth / 2.); + Self { + vertices: vec![ + Vec3::new(-x, -y, -z), + Vec3::new(x, -y, -z), + Vec3::new(x, y, -z), + Vec3::new(-x, y, -z), + Vec3::new(-x, -y, z), + Vec3::new(x, -y, z), + Vec3::new(x, y, z), + Vec3::new(-x, y, z), + ], + triangles: vec![ + [0, 2, 1], + [0, 3, 2], + [4, 5, 6], + [4, 6, 7], + [0, 1, 5], + [0, 5, 4], + [3, 7, 6], + [3, 6, 2], + [0, 4, 7], + [0, 7, 3], + [1, 2, 6], + [1, 6, 5], + ], + } + } +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Mesh { + pub identity: String, + pub geometry: Arc, + pub world: Matrix, + pub color: [f32; 3], +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Light { + pub direction: Vec3, + pub color: [f32; 3], + pub intensity: f32, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Scene { + pub meshes: Vec, + pub lights: Vec, + pub ambient: f32, + pub background: [f32; 3], +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct View { + pub scene: Arc, + pub camera: Camera, +} +#[derive(Debug)] +pub struct PaintView(pub Arc); +impl endor_core::ObjectPayload for PaintView { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn kind_name(&self) -> &'static str { + "immutable-3d-paint" + } +} +#[derive(Debug, Clone, PartialEq)] +pub struct Hit { + pub identity: String, + pub point: Vec3, + pub normal: Vec3, + pub distance: f32, + pub triangle: usize, + pub barycentric: [f32; 3], +} + +impl View { + pub fn valid(&self) -> bool { + let color_valid = |color: [f32; 3]| { + color + .iter() + .all(|value| value.is_finite() && (0.0..=1.0).contains(value)) + }; + self.camera.valid() + && self.scene.ambient.is_finite() + && (0.0..=1.0).contains(&self.scene.ambient) + && color_valid(self.scene.background) + && self.scene.meshes.len() <= 4096 + && self.scene.lights.len() <= 32 + && self.scene.meshes.iter().all(|mesh| { + mesh.geometry.valid() + && mesh.world.0.iter().all(|v| v.is_finite()) + && color_valid(mesh.color) + && mesh + .geometry + .vertices + .iter() + .all(|point| mesh.world.point(*point).valid()) + }) + && self.scene.lights.iter().all(|light| { + color_valid(light.color) + && light.direction.valid() + && (light.direction.length() - 1.).abs() < 0.0001 + && light.intensity.is_finite() + && (0.0..=32.0).contains(&light.intensity) + }) + && self + .scene + .meshes + .iter() + .map(|mesh| mesh.geometry.vertices.len()) + .sum::() + <= 262_144 + && self + .scene + .meshes + .iter() + .map(|mesh| mesh.geometry.triangles.len()) + .sum::() + <= MAX_TRIANGLES + } + pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option { + if !self.camera.valid() + || !(0.0..=1.0).contains(&u) + || !(0.0..=1.0).contains(&v) + || !aspect.is_finite() + || aspect <= 0. + { + return None; + } + let ray = self.camera.ray(u, v, aspect)?; + let basis = self.camera.basis()?; + let mut hit: Option = None; + for mesh in &self.scene.meshes { + 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])); + let Some((distance, barycentric)) = ray.triangle(a, b, c) else { + continue; + }; + let point = ray.origin + ray.direction * distance; + 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) + { + continue; + } + let Some(normal) = (b - a).cross(c - a).normalized() else { + continue; + }; + hit = Some(Hit { + identity: mesh.identity.clone(), + point, + normal, + distance, + triangle, + barycentric, + }); + } + } + hit + } +} + +fn channel(value: f32) -> u32 { + (value.clamp(0., 1.) * 255.).round() as u32 +} +pub fn rgb(color: [f32; 3]) -> u32 { + channel(color[0]) << 16 | channel(color[1]) << 8 | channel(color[2]) +} + +struct CachedSurface { + view: Arc, + width: u32, + height: u32, + pixels: Arc<[u32]>, +} +#[derive(Default)] +struct SurfaceCache { + entries: std::collections::VecDeque, + pixels: usize, +} + +pub fn surface(view: &Arc, width: u32, height: u32) -> Result, &'static str> { + static CACHE: std::sync::OnceLock> = 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 + }) { + 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 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 { + break; + }; + cache.pixels -= entry.pixels.len(); + } + cache.pixels += pixels.len(); + cache.entries.push_back(CachedSurface { + view: view.clone(), + width, + height, + pixels: pixels.clone(), + }); + Ok(pixels) +} + +/// Homogeneous frustum planes expressed in view space. Clipping before division +/// handles triangles crossing the near plane without huge screen coordinates. +fn clip(mut polygon: Vec, camera: Camera, aspect: f32) -> Vec { + let (x, y, slope) = match camera.projection { + Projection::Perspective { fov } => { + let y = (fov.to_radians() / 2.).tan(); + (y * aspect, y, 1.) + } + 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 { + if polygon.is_empty() { + break; + } + let distance = |p: Vec3| a * p.x + b * p.y + c * p.z + d; + let mut output = Vec::new(); + let mut previous = *polygon.last().unwrap(); + let mut before = distance(previous); + for current in polygon { + let after = distance(current); + if (before >= 0.) != (after >= 0.) { + output.push(previous + (current - previous) * (before / (before - after))); + } + if after >= 0. { + output.push(current); + } + previous = current; + before = after; + } + polygon = output; + } + polygon +} + +pub fn render(view: &View, width: u32, height: u32) -> Result, &'static str> { + 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() { + return Err("invalid 3D surface or resource budget exceeded"); + } + let mut pixels = vec![rgb(view.scene.background); area]; + 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 { + Projection::Perspective { fov } => point.z * (fov.to_radians() / 2.).tan(), + Projection::Orthographic { height } => height / 2., + }; + Vec3::new( + (point.x / (half * aspect) + 1.) * width as f32 / 2., + (1. - point.y / half) * height as f32 / 2., + point.z, + ) + }; + let mut work = 0usize; + for mesh in &view.scene.meshes { + for indices in &mesh.geometry.triangles { + let [a, b, c] = indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize])); + let Some(normal) = (b - a).cross(c - a).normalized() else { + continue; + }; + let mut light = [view.scene.ambient; 3]; + for source in &view.scene.lights { + let intensity = normal.dot(source.direction * -1.).max(0.) * source.intensity; + for (channel, color) in light.iter_mut().zip(source.color) { + *channel += intensity * color; + } + } + let color = rgb(std::array::from_fn(|i| mesh.color[i] * light[i])); + let polygon = clip( + vec![ + camera.view(basis, a), + camera.view(basis, b), + camera.view(basis, c), + ], + camera, + aspect, + ); + if polygon.len() < 3 { + continue; + } + for index in 1..polygon.len() - 1 { + let [a, b, c] = [polygon[0], polygon[index], polygon[index + 1]].map(project); + let min_x = a.x.min(b.x).min(c.x).floor().max(0.) as u32; + let max_x = a.x.max(b.x).max(c.x).ceil().min(width as f32) as u32; + let min_y = a.y.min(b.y).min(c.y).floor().max(0.) as u32; + let max_y = a.y.max(b.y).max(c.y).ceil().min(height as f32) as u32; + work += max_x.saturating_sub(min_x) as usize * max_y.saturating_sub(min_y) as usize; + if work > 32_000_000 { + return Err("3D raster work budget exceeded"); + } + let edge = |a: Vec3, b: Vec3, x: f32, y: f32| { + (x - a.x) * (b.y - a.y) - (y - a.y) * (b.x - a.x) + }; + let area = edge(a, b, c.x, c.y); + if area.abs() < 1e-6 { + continue; + } + for y in min_y..max_y { + for x in min_x..max_x { + let px = x as f32 + 0.5; + let py = y as f32 + 0.5; + let weights = [ + edge(b, c, px, py) / area, + edge(c, a, px, py) / area, + edge(a, b, px, py) / area, + ]; + if weights.iter().any(|weight| *weight < -1e-5) { + continue; + } + let depth = if perspective { + 1. / (weights[0] / a.z + weights[1] / b.z + weights[2] / c.z) + } else { + weights[0] * a.z + weights[1] * b.z + weights[2] * c.z + }; + let index = y as usize * width as usize + x as usize; + if depth < depths[index] { + depths[index] = depth; + pixels[index] = color; + } + } + } + } + } + } + Ok(pixels) +} + +#[cfg(test)] +mod tests { + use super::*; + fn view(geometry: Geometry) -> View { + View { + camera: Camera::default(), + scene: Arc::new(Scene { + meshes: vec![Mesh { + identity: "mesh".into(), + geometry: Arc::new(geometry), + world: Matrix::default(), + color: [1., 0., 0.], + }], + lights: vec![], + ambient: 1., + background: [1., 1., 1.], + }), + } + } + #[test] + fn triangles_crossing_near_plane_are_clipped_and_pick_uses_the_same_range() { + let mut view = view(Geometry { + vertices: vec![ + Vec3::new(-1., -1., 0.5), + Vec3::new(1., -1., 2.), + Vec3::new(0., 1., 2.), + ], + triangles: vec![[0, 1, 2]], + }); + view.camera.position = Vec3::default(); + view.camera.target = Vec3::new(0., 0., 1.); + view.camera.near = 1.; + view.camera.far = 10.; + view.camera.projection = Projection::Perspective { fov: 90. }; + let pixels = render(&view, 64, 64).unwrap(); + assert!(pixels.contains(&0xFF0000)); + let hit = view.pick(0.5, 0.5, 1.).unwrap(); + assert!(hit.point.z >= view.camera.near && hit.point.z <= view.camera.far); + view.camera.far = 1.1; + assert!(view.pick(0.5, 0.5, 1.).is_none()); + } + #[test] + fn group_transform_order_and_orthographic_rays_are_consistent() { + let local = Matrix::trs( + Vec3::new(1., 0., 0.), + Vec3::new(0., 90., 0.), + Vec3::new(1., 1., 1.), + ); + let parent = Matrix::trs( + Vec3::new(10., 0., 0.), + Vec3::default(), + Vec3::new(1., 1., 1.), + ); + let transformed = parent.compose(local).point(Vec3::new(0., 0., 1.)); + assert!((transformed.x - 12.).abs() < 1e-5 && transformed.z.abs() < 1e-5); + let camera = Camera { + projection: Projection::Orthographic { height: 4. }, + ..Camera::default() + }; + let left = camera.ray(0.25, 0.5, 1.).unwrap(); + let right = camera.ray(0.75, 0.5, 1.).unwrap(); + assert_eq!(left.direction, right.direction); + assert_eq!(right.origin.x - left.origin.x, 2.); + } + #[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(); + assert!(Arc::ptr_eq(&first, &second)); + assert!(render(&view, 4000, 4000).is_err()); + let mut invalid = (*view).clone(); + invalid.camera.target = invalid.camera.position; + assert!(!invalid.valid()); + assert!(render(&invalid, 64, 64).is_err()); + } +} diff --git a/src/render/scene3d/math.rs b/src/render/scene3d/math.rs new file mode 100644 index 0000000..b5f107e --- /dev/null +++ b/src/render/scene3d/math.rs @@ -0,0 +1,222 @@ +use serde::{Deserialize, Serialize}; +use std::ops::{Add, Mul, Sub}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct Vec3 { + pub x: f32, + pub y: f32, + pub z: f32, +} +impl Vec3 { + pub const fn new(x: f32, y: f32, z: f32) -> Self { + Self { x, y, z } + } + pub fn dot(self, b: Self) -> f32 { + self.x * b.x + self.y * b.y + self.z * b.z + } + pub fn cross(self, b: Self) -> Self { + Self::new( + self.y * b.z - self.z * b.y, + self.z * b.x - self.x * b.z, + self.x * b.y - self.y * b.x, + ) + } + pub fn length(self) -> f32 { + self.dot(self).sqrt() + } + pub fn normalized(self) -> Option { + let length = self.length(); + (length.is_finite() && length > 1e-6).then(|| self * (1.0 / length)) + } + pub fn valid(self) -> bool { + [self.x, self.y, self.z] + .iter() + .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) + } +} +impl Add for Vec3 { + type Output = Self; + fn add(self, b: Self) -> Self { + Self::new(self.x + b.x, self.y + b.y, self.z + b.z) + } +} +impl Sub for Vec3 { + type Output = Self; + fn sub(self, b: Self) -> Self { + Self::new(self.x - b.x, self.y - b.y, self.z - b.z) + } +} +impl Mul for Vec3 { + type Output = Self; + fn mul(self, b: f32) -> Self { + Self::new(self.x * b, self.y * b, self.z * b) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Matrix(pub [f32; 16]); +impl Default for Matrix { + fn default() -> Self { + Self([ + 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., + ]) + } +} +impl Matrix { + pub fn compose(self, local: Self) -> Self { + let mut out = [0.; 16]; + for row in 0..4 { + for col in 0..4 { + out[row * 4 + col] = (0..4) + .map(|k| self.0[row * 4 + k] * local.0[k * 4 + col]) + .sum(); + } + } + Self(out) + } + pub fn point(self, p: Vec3) -> Vec3 { + Vec3::new( + self.0[0] * p.x + self.0[1] * p.y + self.0[2] * p.z + self.0[3], + self.0[4] * p.x + self.0[5] * p.y + self.0[6] * p.z + self.0[7], + self.0[8] * p.x + self.0[9] * p.y + self.0[10] * p.z + self.0[11], + ) + } + pub fn trs(position: Vec3, rotation: Vec3, scale: Vec3) -> Self { + let (sx, cx) = rotation.x.to_radians().sin_cos(); + let (sy, cy) = rotation.y.to_radians().sin_cos(); + let (sz, cz) = rotation.z.to_radians().sin_cos(); + // Local X, then Y, then Z rotation; scale first, translation last. + Self([ + cz * cy * scale.x, + (cz * sy * sx - sz * cx) * scale.y, + (cz * sy * cx + sz * sx) * scale.z, + position.x, + sz * cy * scale.x, + (sz * sy * sx + cz * cx) * scale.y, + (sz * sy * cx - cz * sx) * scale.z, + position.y, + -sy * scale.x, + cy * sx * scale.y, + cy * cx * scale.z, + position.z, + 0., + 0., + 0., + 1., + ]) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum Projection { + Perspective { fov: f32 }, + Orthographic { height: f32 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Camera { + pub position: Vec3, + pub target: Vec3, + pub up: Vec3, + pub near: f32, + pub far: f32, + pub projection: Projection, +} +impl Default for Camera { + fn default() -> Self { + Self { + position: Vec3::new(0., 0., 5.), + target: Vec3::default(), + up: Vec3::new(0., 1., 0.), + near: 0.1, + far: 1000., + projection: Projection::Perspective { fov: 50. }, + } + } +} +#[derive(Debug, Clone, Copy)] +pub struct Basis { + pub right: Vec3, + pub up: Vec3, + pub forward: Vec3, +} +impl Camera { + pub fn basis(self) -> Option { + let forward = (self.target - self.position).normalized()?; + let right = forward.cross(self.up).normalized()?; + let up = right.cross(forward).normalized()?; + Some(Basis { right, up, forward }) + } + pub fn valid(self) -> bool { + self.position.valid() + && self.target.valid() + && self.up.valid() + && self.basis().is_some() + && self.near.is_finite() + && self.far.is_finite() + && self.near > 0. + && self.far > self.near + && self.far <= 1_000_000. + && match self.projection { + Projection::Perspective { fov } => fov.is_finite() && (1.0..=175.0).contains(&fov), + Projection::Orthographic { height } => { + height.is_finite() && height > 0. && height <= 1_000_000. + } + } + } + pub fn view(self, basis: Basis, p: Vec3) -> Vec3 { + let p = p - self.position; + Vec3::new(p.dot(basis.right), p.dot(basis.up), p.dot(basis.forward)) + } + pub fn ray(self, u: f32, v: f32, aspect: f32) -> Option { + let basis = self.basis()?; + let (x, y) = (u * 2. - 1., 1. - v * 2.); + match self.projection { + Projection::Perspective { fov } => { + let half = (fov.to_radians() / 2.).tan(); + Some(Ray { + origin: self.position, + direction: (basis.forward + + basis.right * (x * half * aspect) + + basis.up * (y * half)) + .normalized()?, + }) + } + Projection::Orthographic { height } => Some(Ray { + origin: self.position + + basis.right * (x * height * aspect / 2.) + + basis.up * (y * height / 2.), + direction: basis.forward, + }), + } + } +} +#[derive(Debug, Clone, Copy)] +pub struct Ray { + pub origin: Vec3, + pub direction: Vec3, +} +impl Ray { + pub fn triangle(self, a: Vec3, b: Vec3, c: Vec3) -> Option<(f32, [f32; 3])> { + let ab = b - a; + let ac = c - a; + let p = self.direction.cross(ac); + let determinant = ab.dot(p); + if determinant.abs() < 1e-7 { + return None; + } + let inverse = 1.0 / determinant; + let t = self.origin - a; + let u = t.dot(p) * inverse; + if !(0.0..=1.0).contains(&u) { + return None; + } + let q = t.cross(ab); + let v = self.direction.dot(q) * inverse; + if v < 0. || u + v > 1. { + return None; + } + let distance = ac.dot(q) * inverse; + (distance >= 0.).then_some((distance, [1. - u - v, u, v])) + } +} diff --git a/src/render/software_renderer.rs b/src/render/software_renderer.rs index 14cc4d3..cb92229 100644 --- a/src/render/software_renderer.rs +++ b/src/render/software_renderer.rs @@ -7,7 +7,12 @@ use crate::{ pub struct SoftwareRenderer; impl SoftwareRenderer { - pub fn render(list: &DisplayList, target: &mut [u32], width: u32, height: u32) { + pub fn render( + list: &DisplayList, + target: &mut [u32], + width: u32, + height: u32, + ) -> Result<(), &'static str> { let mut canvas = Canvas { pixels: target, width, @@ -18,6 +23,39 @@ impl SoftwareRenderer { for command in list.commands() { match command { + DrawCommand::Scene3D { rect, view } => { + let left = rect.x.max(canvas.clip.x).max(0.).floor() as u32; + let top = rect.y.max(canvas.clip.y).max(0.).floor() as u32; + let right = (rect.x + rect.width) + .min(canvas.clip.x + canvas.clip.width) + .min(width as f32) + .ceil() + .max(0.) as u32; + let bottom = (rect.y + rect.height) + .min(canvas.clip.y + canvas.clip.height) + .min(height as f32) + .ceil() + .max(0.) as u32; + if left >= right || top >= bottom || rect.width <= 0. || rect.height <= 0. { + continue; + } + 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)?; + for y in top..bottom { + for x in left..right { + let u = (((x as f32 + 0.5 - rect.x) / rect.width) * w as f32) + .clamp(0., (w - 1) as f32) + as u32; + let v = (((y as f32 + 0.5 - rect.y) / rect.height) * h as f32) + .clamp(0., (h - 1) as f32) + as u32; + canvas.pixels[y as usize * width as usize + x as usize] = + surface[v as usize * w as usize + u as usize]; + } + } + } DrawCommand::PushClip(rect) => { clips.push(canvas.clip); let x = canvas.clip.x.max(rect.x); @@ -66,6 +104,7 @@ impl SoftwareRenderer { } => canvas.draw_text(*origin, value, *style, *color), } } + Ok(()) } } @@ -245,7 +284,7 @@ mod tests { color: Color::rgb(0, 0, 255), }); let mut pixels = vec![0; 80 * 60]; - SoftwareRenderer::render(&list, &mut pixels, 80, 60); + SoftwareRenderer::render(&list, &mut pixels, 80, 60).unwrap(); let mut second_line = false; for y in 0..60 { for x in 0..80 { @@ -270,7 +309,7 @@ mod tests { color: Color::rgb(255, 0, 0), }); let mut pixels = vec![0; 16]; - SoftwareRenderer::render(&list, &mut pixels, 4, 4); + SoftwareRenderer::render(&list, &mut pixels, 4, 4).unwrap(); assert_eq!(pixels[5], 0x00ff0000); assert_eq!(pixels[0], 0); } @@ -286,7 +325,7 @@ mod tests { color: Color::rgba(255, 255, 255, 128), }); let mut pixels = vec![0; 256 * 48]; - SoftwareRenderer::render(&list, &mut pixels, 256, 48); + SoftwareRenderer::render(&list, &mut pixels, 256, 48).unwrap(); assert!(pixels.iter().any(|pixel| *pixel > 0)); assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128)); assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255)))); @@ -299,7 +338,7 @@ mod tests { color: Color::rgba(255, 255, 255, 0), }); let mut pixels = vec![0; 256 * 48]; - SoftwareRenderer::render(&invisible, &mut pixels, 256, 48); + SoftwareRenderer::render(&invisible, &mut pixels, 256, 48).unwrap(); assert!(pixels.iter().all(|pixel| *pixel == 0)); } } diff --git a/src/render/source_paint.rs b/src/render/source_paint.rs index c3b52e0..8b9fe30 100644 --- a/src/render/source_paint.rs +++ b/src/render/source_paint.rs @@ -68,9 +68,26 @@ fn decode( } let x = number(store, command, "x")?; let y = number(store, command, "y")?; - let color = text(store, command, "color")?; let kind = text(store, command, "kind")?; + let color = if kind == "scene3d" { + String::new() + } else { + text(store, command, "color")? + }; let result = match kind.as_str() { + "scene3d" => { + let snapshot = object(store.get_attr(command, "scene")?)?; + RenderCommand::Scene3D { + x, + y, + width: number(store, command, "width")?, + height: number(store, command, "height")?, + view: store + .payload::(snapshot, "immutable 3D paint snapshot")? + .0 + .clone(), + } + } "roundedRect" | "strokeRoundedRect" => { let width = number(store, command, "width")?; let height = number(store, command, "height")?; diff --git a/src/render/viewport.rs b/src/render/viewport.rs index 027a896..811a159 100644 --- a/src/render/viewport.rs +++ b/src/render/viewport.rs @@ -232,6 +232,13 @@ pub fn project( (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( @@ -249,7 +256,7 @@ pub fn project( 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 { + if mount.canvas_viewport || mount.scene3d_viewport { mount.viewport_clip = child_clip; } } diff --git a/src/runtime/api/guide.rs b/src/runtime/api/guide.rs index 8b9346e..f2830e6 100644 --- a/src/runtime/api/guide.rs +++ b/src/runtime/api/guide.rs @@ -142,9 +142,11 @@ ScrollBlock.axis is a managed visual attribute: "both" (default), "vertical" or ContainerBlock.mainAlign accepts "start" (default), "center", "end" or "space-between". Native arrangement uses allocated Bounds after padding/minimum gap, distributes positive remaining main-axis space and keeps child sizes unchanged. Overflow keeps start placement. Explicit source arrange placements remain authoritative; this is alignment, not flex sizing. -Finite box layout: Overlay(padding, gap, children...) stacks children (gap is unused); mainAlign aligns horizontally and crossAlign vertically. Row/Column crossAlign accepts "start", "center", "end" or "stretch". Block.alignSelf defaults to "auto" and can override crossAlign per occurrence. marginTop/Right/Bottom/Left participate in measurement, alignment and flex allocation, without collapsing. paddingTop/Right/Bottom/Left override a side of the container padding; "auto" restores its fallback. borderTop/Right/Bottom/Left reserve inner border space; borderColor controls native border paint. Source paint overrides remain authoritative. boxWidth/boxHeight accept "auto", a nonnegative border-box dimension, or "fill" (finite parent required). Main-axis fill shares remaining space with weight 1 unless flex is set; minWidth/minHeight/maxWidth/maxHeight bound each dimension. Stretch changes only auto cross-axis dimensions. overflow is "visible" or "clip"; clipping affects descendant paint and hits. ScrollBlock handles scrolling independently of its content layout. See examples/box-layout.endor and docs/scene-architecture.md. Independent 2D scenes are described below; Scene3D/Viewport3D are still pending. Page.mode remains a compatibility API. +Finite box layout: Overlay(padding, gap, children...) stacks children (gap is unused); mainAlign aligns horizontally and crossAlign vertically. Row/Column crossAlign accepts "start", "center", "end" or "stretch". Block.alignSelf defaults to "auto" and can override crossAlign per occurrence. marginTop/Right/Bottom/Left participate in measurement, alignment and flex allocation, without collapsing. paddingTop/Right/Bottom/Left override a side of the container padding; "auto" restores its fallback. borderTop/Right/Bottom/Left reserve inner border space; borderColor controls native border paint. Source paint overrides remain authoritative. boxWidth/boxHeight accept "auto", a nonnegative border-box dimension, or "fill" (finite parent required). Main-axis fill shares remaining space with weight 1 unless flex is set; minWidth/minHeight/maxWidth/maxHeight bound each dimension. Stretch changes only auto cross-axis dimensions. overflow is "visible" or "clip"; clipping affects descendant paint and hits. ScrollBlock handles scrolling independently of its content layout. See examples/box-layout.endor and docs/scene-architecture.md. Independent 2D scenes are described below; 3D support is described below. Page.mode remains a compatibility API. -Independent 2D scenes are now implemented: Scene2D(children...) stores world content at child x/y; CanvasViewport(width,height,scene) is a finite layout box with its own Camera2D(). Camera2D(x=0,y=0,zoom=1) fields are ordinary managed attributes; zoom is 0.01–100. Sharing a scene does not share its camera; assigning second.camera=first.camera does. Scene content may contain layout containers and nested finite viewports; Overlay siblings stay fixed. Mount.fitView() fits finite direct scene-child bounds using that occurrence's allocation. Native wheel/pinch/space or middle drag targets the innermost viewport; drag retains its initial target. viewport-pan/viewport-zoom carry the viewport Mount and current frame identities, with signed logical page coordinates. Pan delta is measured before the viewport projection; zoom delta is [factor,0] with an in-content pointer anchor. Worker validation rejects stale/invalid input before invoking the VM. Draw and hit transforms compose consistently while layout geometry and measurement caches remain in world coordinates. ScrollView is a nominal ScrollBlock subtype. The default page uses these APIs; old Page.mode/panX/panY/zoom remain for compatibility. Scene3D/Viewport3D are still pending. See examples/scene-viewports.endor. +Independent 2D scenes are now implemented: Scene2D(children...) stores world content at child x/y; CanvasViewport(width,height,scene) is a finite layout box with its own Camera2D(). Camera2D(x=0,y=0,zoom=1) fields are ordinary managed attributes; zoom is 0.01–100. Sharing a scene does not share its camera; assigning second.camera=first.camera does. Scene content may contain layout containers and nested finite viewports; Overlay siblings stay fixed. Mount.fitView() fits finite direct scene-child bounds using that occurrence's allocation. Native wheel/pinch/space or middle drag targets the innermost viewport; drag retains its initial target. viewport-pan/viewport-zoom carry the viewport Mount and current frame identities, with signed logical page coordinates. Pan delta is measured before the viewport projection; zoom delta is [factor,0] with an in-content pointer anchor. Worker validation rejects stale/invalid input before invoking the VM. Draw and hit transforms compose consistently while layout geometry and measurement caches remain in world coordinates. ScrollView is a nominal ScrollBlock subtype. The default page uses these APIs; old Page.mode/panX/panY/zoom remain for compatibility. 3D support is described below. See examples/scene-viewports.endor. + +3D basics are now implemented: Scene3D/Group3D, Box3D, immutable Geometry3D made from Vec3/Triangle3D ObjectLists, Mesh3D, DirectionalLight, and finite Viewport3D. PerspectiveCamera and OrthographicCamera expose x/y/z, targetX/Y/Z, upX/Y/Z, near/far, fov or height; viewport aspect is derived separately. New viewports have independent cameras and OrbitController; explicit camera assignment shares a camera. A controller can be replaced with an Endor object defining update(camera,gesture), or disabled by assigning "none". Native scene-orbit/scene-pan/scene-zoom events carry viewport Mount/frame identities. Mesh/group translate and rotate use native arithmetic; camera.translate moves position and target. This avoids the fixed Aster version's unsupported numeric arithmetic expressions. Mount.pick3D(x,y) returns nullable RayHit3D with managed mesh and world intersection data; pointer click Event.hit carries the same result. Rendering uses frustum clipping and a depth buffer in a bounded cached surface. World-space interactive UI panels and complete nesting budgets remain pending. See examples/scene3d.endor and docs/scene-architecture.md. Block.flex is a nonnegative weight (default 0). Native containers reserve fixed measured children, padding and gaps, then share remaining allocated main-axis space between positive weights with zero basis. Actual child allocation feeds remeasurement. It does not request parent expansion. Further controls are described below. diff --git a/src/runtime/api/tests.rs b/src/runtime/api/tests.rs index 46a618c..3a62ac9 100644 --- a/src/runtime/api/tests.rs +++ b/src/runtime/api/tests.rs @@ -665,3 +665,94 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin assert!(namespace.read().unwrap().document.frame.fault.is_none()); } } + +#[test] +fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState { + viewport: Some(crate::layout::Size { + width: 400., + height: 300., + }), + ..NamespaceState::default() + })); + let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + let source = r##" + class PanOnly { + function update(self: PanOnly, camera: Object, gesture: CameraGesture): Unit { camera.translate(gesture.dx, gesture.dy, 0) } + } + view = Viewport3D(200,100,Scene3D(Box3D(2,2,2,"#CC4455"))) + page = Page() + page.render(view) + "##; + let response = client.request("PUT", "/v1/namespaces/app.main/code", source); + assert_eq!(response.0, 200, "{}", response.2); + let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { + let current = namespace.read().unwrap(); + let frame = ¤t.document.frame; + serde_json::to_string(&crate::events::Input { + window: 1, + session: frame.session, + layout_version: frame.layout_version, + mount: frame + .mounts + .iter() + .find(|mount| mount.scene3d_viewport) + .unwrap() + .id, + kind: kind.into(), + delta: Some(delta), + point, + press_point: None, + tab_drop: None, + screen_position: None, + }) + .unwrap() + }; + let event_path = "/v1/namespaces/app.main/events"; + let zoom = gesture("scene-zoom", [2., 0.], Some([100., 50.])); + let response = client.request("POST", event_path, &zoom); + assert_eq!(response.0, 200, "{}", response.2); + assert_eq!(client.request("POST", event_path, &zoom).0, 409); + let value = |expression: &str| { + let response = client.request("POST", "/v1/namespaces/app.main/repl", expression); + assert_eq!(response.0, 200, "{}", response.2); + serde_json::from_str::(&response.2).unwrap()["value"] + .as_str() + .unwrap() + .to_owned() + }; + assert_eq!(value("view.camera.z"), "2.5"); + let response = client.request("POST", event_path, &gesture("scene-orbit", [20., 0.], None)); + assert_eq!(response.0, 200, "{}", response.2); + let x = value("view.camera.x").parse::().unwrap(); + assert!((x - 2.5 * 0.1f32.sin()).abs() < 0.0001); + value("view.camera = OrthographicCamera()"); + let response = client.request( + "POST", + event_path, + &gesture("scene-zoom", [2., 0.], Some([100., 50.])), + ); + assert_eq!(response.0, 200, "{}", response.2); + assert_eq!(value("view.camera.height"), "2.5"); + value("view.controller = PanOnly()"); + let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None)); + assert_eq!(response.0, 200, "{}", response.2); + assert_eq!(value("view.camera.x"), "7"); + let response = client.request( + "POST", + event_path, + &gesture("scene-zoom", [2., 0.], Some([250., 50.])), + ); + assert_eq!(response.0, 400); + assert!(namespace.read().unwrap().document.frame.fault.is_none()); + value("view.controller = \"none\""); + let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None)); + assert_eq!(response.0, 409); + assert!(namespace.read().unwrap().document.frame.fault.is_none()); +} diff --git a/src/runtime/api/worker.rs b/src/runtime/api/worker.rs index d15543c..f421512 100644 --- a/src/runtime/api/worker.rs +++ b/src/runtime/api/worker.rs @@ -340,6 +340,9 @@ impl Worker { | "page-zoom" | "viewport-pan" | "viewport-zoom" + | "scene-orbit" + | "scene-pan" + | "scene-zoom" | "tab-activate" | "tab-close" | "tab-detach" @@ -439,9 +442,15 @@ impl Worker { } else { !frame.mounts.iter().any(|mount| { mount.id == input.mount - && (mount.interactive - || input.kind.starts_with("viewport-") && mount.canvas_viewport - || input.kind == "scroll" && mount.scroll.is_some()) + && if input.kind.starts_with("viewport-") { + mount.canvas_viewport + } else if input.kind.starts_with("scene-") { + mount.scene3d_viewport && mount.camera_controls + } else if input.kind == "scroll" { + mount.scroll.is_some() + } else { + mount.interactive + } }) } || self.repl.as_ref().is_none_or(|(context, runtime)| { @@ -463,7 +472,7 @@ impl Worker { "camera input is unavailable in this Page mode", )); } - if input.kind == "viewport-zoom" + if matches!(input.kind.as_str(), "viewport-zoom" | "scene-zoom") && !frame.mounts.iter().any(|mount| { mount.id == input.mount && input.point.is_some_and(|point| { diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index 20cbf4c..755fc76 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -4560,7 +4560,7 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { assert_eq!(button.clip, Some([40.0, 40.0, 160.0, 120.0])); let point = crate::geometry::Point::new(55.0, 55.0); assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id); - let inner_id = initial.frame.canvas_at(point).unwrap().id; + let inner_id = initial.frame.camera_at(point).unwrap().id; let input = crate::events::Input { window: 1, session: initial.frame.session, @@ -4593,7 +4593,8 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { &mut pixels, 200, 200, - ); + ) + .unwrap(); assert_eq!(pixels[5 * 200 + 5], 0x0000FF); assert_ne!(pixels[55 * 200 + 45], pixels[190 * 200 + 190]); } @@ -4623,7 +4624,8 @@ fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { &mut pixels, 80, 80, - ); + ) + .unwrap(); assert_eq!(pixels[5 * 80 + 5], 0x0000FF); assert_eq!(pixels[25 * 80 + 5], 0xFF0000); assert_eq!(pixels[55 * 80 + 5], pixels[79 * 80 + 79]); @@ -4708,6 +4710,220 @@ fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() { assert_eq!(screen, crate::geometry::Point::new(48.0, 48.0)); } +#[test] +fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_cameras() { + let (mut session, initial) = ReplSession::load( + r##" + back = Box3D(2, 2, 2, "#FF0000") + front = Box3D(1, 1, 1, "#0000FF") + front.z = 1.5 + scene = Scene3D(front, back) + scene.ambient = 1 + perspective = Viewport3D(120, 100, scene) + orthographic = Viewport3D(120, 100, scene) + orthographic.camera = OrthographicCamera() + orthographic.camera.height = 4 + page = Page() + page.render(Row(0, 10, perspective, orthographic)) + "##, + "app.main", + 309, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + let commands: Vec<_> = initial + .frame + .commands + .iter() + .filter_map(|command| { + if let RenderCommand::Scene3D { view, .. } = command { + Some(view) + } else { + None + } + }) + .collect(); + assert_eq!(commands.len(), 2); + let hit = commands[0].pick(0.5, 0.5, 1.2).unwrap(); + assert!((hit.distance - 3.0).abs() < 1e-5); + assert_eq!(hit.point, crate::scene3d::math::Vec3::new(0., 0., 2.)); + assert!(matches!( + commands[1].camera.projection, + crate::scene3d::math::Projection::Orthographic { height: 4.0 } + )); + let mut pixels = vec![0; 250 * 100]; + crate::software_renderer::SoftwareRenderer::render( + &initial.display_list(1.).unwrap(), + &mut pixels, + 250, + 100, + ) + .unwrap(); + assert_eq!(pixels[50 * 250 + 60], 0x0000FF); + assert_eq!(pixels[50 * 250 + 190], 0x0000FF); + let updated = session + .execute("front.x=10\nperspective.camera.z=8", "app.main") + .unwrap() + .document + .unwrap(); + updated.validate().unwrap(); + let cameras: Vec<_> = updated + .frame + .commands + .iter() + .filter_map(|command| { + if let RenderCommand::Scene3D { view, .. } = command { + Some(view.camera.position.z) + } else { + None + } + }) + .collect(); + assert_eq!(cameras, vec![8., 5.]); + crate::software_renderer::SoftwareRenderer::render( + &updated.display_list(1.).unwrap(), + &mut pixels, + 250, + 100, + ) + .unwrap(); + assert_eq!(pixels[50 * 250 + 60], 0xFF0000); + assert_eq!(pixels[50 * 250 + 190], 0xFF0000); +} + +#[test] +fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() { + let (mut session, initial) = ReplSession::load( + r##" + class Actions { + function select(self: Actions, event: Event): Unit { event.hit.mesh.color = "#00FF00" } + } + mesh = Box3D(2,2,2,"#FF0000") + scene = Scene3D(mesh) + scene.ambient = 1 + view = Viewport3D(100,100,scene) + actions = Actions() + view.onClick(actions.select) + page = Page() + page.render(view) + "##, + "app.main", + 310, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + assert_eq!( + session + .execute("view.mounts().at(0).pick3D(50,50).point.z", "app.main") + .unwrap() + .value, + "1" + ); + assert_eq!( + session + .execute("view.mounts().at(0).pick3D(50,50).mesh.color", "app.main") + .unwrap() + .value, + "\"#FF0000\"" + ); + let frame = session + .execute("view.invalidate(\"paint\")", "app.main") + .unwrap() + .document + .unwrap() + .frame; + let mount = frame + .mounts + .iter() + .find(|mount| mount.scene3d_viewport) + .unwrap(); + let updated = session + .dispatch(&crate::events::Input { + window: 1, + session: frame.session, + layout_version: frame.layout_version, + mount: mount.id, + kind: "click".into(), + point: Some([50., 50.]), + press_point: Some([50., 50.]), + delta: None, + tab_drop: None, + screen_position: None, + }) + .unwrap() + .document + .unwrap(); + assert_eq!( + session.execute("mesh.color", "app.main").unwrap().value, + "\"#00FF00\"" + ); + let mut pixels = vec![0; 100 * 100]; + crate::software_renderer::SoftwareRenderer::render( + &updated.display_list(1.).unwrap(), + &mut pixels, + 100, + 100, + ) + .unwrap(); + assert_eq!(pixels[50 * 100 + 50], 0x00FF00); +} + +#[test] +fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { + let (mut session, document) = ReplSession::load( + include_str!("../../../examples/scene3d.endor"), + "app.main", + 311, + 1, + ) + .unwrap(); + document.validate().unwrap(); + session.execute("actions.rotate()", "app.main").unwrap(); + assert_eq!( + session.execute("cube.rotationY", "app.main").unwrap().value, + "40" + ); + session.execute(r##" + geometry=Geometry3D(ObjectList(Vec3(-1,-1,0),Vec3(1,-1,0),Vec3(0,1,0)),ObjectList(Triangle3D(0,1,2))) + custom=Mesh3D(geometry,"#112233") + scene.children=ObjectList(custom) + "##,"app.main").unwrap().document.unwrap().validate().unwrap(); + let error = ReplSession::load( + r##" + group=Group3D() + scene=Scene3D(group) + group.children=ObjectList(group) + page=Page() + page.render(Viewport3D(100,100,scene)) + "##, + "app.main", + 312, + 1, + ) + .err() + .unwrap(); + assert!( + format!("{error:#}").contains("cyclic 3D scene graph"), + "{error:#}" + ); + let error = ReplSession::load( + r##" + geometry=Geometry3D(ObjectList(Vec3(0,0,0)),ObjectList(Triangle3D(0,1,2))) + "##, + "app.main", + 313, + 1, + ) + .err() + .unwrap(); + assert!( + format!("{error:#}").contains("invalid Geometry3D buffers"), + "{error:#}" + ); +} + #[test] fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { let (mut session, initial) = ReplSession::load( diff --git a/src/runtime/event_dispatch.rs b/src/runtime/event_dispatch.rs index 8355c45..24971ed 100644 --- a/src/runtime/event_dispatch.rs +++ b/src/runtime/event_dispatch.rs @@ -223,7 +223,7 @@ pub fn new_event( let event = cx.allocate(Event::default())?; cx.attach_type(event, definition)?; cx.set_attr(event, "kind", Value::Text(kind.into()))?; - for name in ["target", "mount", "currentTarget", "currentMount"] { + for name in ["target", "mount", "currentTarget", "currentMount", "hit"] { cx.set_attr(event, name, Value::Nil)?; } cx.set_attr(event, "phase", Value::Text("none".into()))?; diff --git a/src/runtime/events.rs b/src/runtime/events.rs index 8d50626..98947a6 100644 --- a/src/runtime/events.rs +++ b/src/runtime/events.rs @@ -75,9 +75,15 @@ impl Input { } if matches!( self.kind.as_str(), - "page-pan" | "page-zoom" | "viewport-pan" | "viewport-zoom" + "page-pan" + | "page-zoom" + | "viewport-pan" + | "viewport-zoom" + | "scene-orbit" + | "scene-pan" + | "scene-zoom" ) { - let viewport = self.kind.starts_with("viewport-"); + let viewport = self.kind.starts_with("viewport-") || self.kind.starts_with("scene-"); let valid_delta = self.delta.is_some_and(|delta| { delta .iter() diff --git a/src/shell/app.rs b/src/shell/app.rs index f6bdc35..31aeb12 100644 --- a/src/shell/app.rs +++ b/src/shell/app.rs @@ -51,6 +51,7 @@ pub struct TesseraApp { sidebar_keyboard: bool, panning: bool, pan_viewport: Option, + orbiting: bool, space_down: bool, modifiers: ModifiersState, activity: Activity, @@ -89,6 +90,7 @@ impl TesseraApp { sidebar_keyboard: false, panning: false, pan_viewport: None, + orbiting: false, space_down: false, modifiers: ModifiersState::default(), activity, @@ -616,7 +618,7 @@ impl TesseraApp { return false; }; let frame = ¤t.document.frame; - self.pan_viewport = frame.canvas_at(point).map(|mount| mount.id); + self.pan_viewport = frame.camera_at(point).map(|mount| mount.id); self.pan_viewport.is_some() || frame.surface.mode != crate::page::Mode::Window } @@ -641,13 +643,14 @@ impl TesseraApp { let world = frame.surface.to_world(point); let viewport = if self.panning { self.pan_viewport.and_then(|id| { - frame - .mounts - .iter() - .find(|mount| mount.id == id && mount.canvas_viewport) + frame.mounts.iter().find(|mount| { + mount.id == id + && (mount.canvas_viewport + || mount.scene3d_viewport && mount.camera_controls) + }) }) } else { - frame.canvas_at(world) + frame.camera_at(world) }; if self.panning && self.pan_viewport.is_some() && viewport.is_none() { return None; @@ -658,7 +661,15 @@ impl TesseraApp { session: frame.session, layout_version: frame.layout_version, mount: viewport.id, - kind: if kind == "page-zoom" { + kind: if viewport.scene3d_viewport { + if kind == "page-zoom" { + "scene-zoom" + } else if self.orbiting { + "scene-orbit" + } else { + "scene-pan" + } + } else if kind == "page-zoom" { "viewport-zoom" } else { "viewport-pan" @@ -925,7 +936,8 @@ impl TesseraApp { let mut buffer = surface .buffer_mut() .map_err(|error| anyhow::anyhow!("{error}"))?; - SoftwareRenderer::render(&scene.display_list, &mut buffer, size.width, size.height); + SoftwareRenderer::render(&scene.display_list, &mut buffer, size.width, size.height) + .map_err(anyhow::Error::msg)?; buffer .present() .map_err(|error| anyhow::anyhow!("{error}"))?; @@ -1073,6 +1085,24 @@ impl ApplicationHandler for TesseraApp { self.sidebar .interaction .pointer_moved(next, &self.sidebar.targets); + if !self.panning + && let Some(press) = &self.guest_pressed + && let Some(point) = self.guest_point() + && let Some(start) = press.point + { + let is_3d = self.namespace.read().is_ok_and(|current| { + current.document.frame.mounts.iter().any(|mount| { + mount.id == press.mount + && mount.scene3d_viewport + && mount.camera_controls + }) + }); + if is_3d && (point.x - start[0]).hypot(point.y - start[1]) > 3. { + self.orbiting = true; + self.panning = self.begin_pan(); + self.guest_pressed = None; + } + } if self.panning { self.page_view("page-pan", [-delta.x, -delta.y]); window.request_redraw(); @@ -1113,6 +1143,7 @@ impl ApplicationHandler for TesseraApp { button: MouseButton::Middle, .. } => { + self.orbiting = false; self.panning = state == ElementState::Pressed && self.begin_pan(); self.guest_pressed = None; window.request_redraw(); @@ -1143,6 +1174,7 @@ impl ApplicationHandler for TesseraApp { let _ = window.drag_window(); } } else if self.space_down && self.guest_point().is_some() { + self.orbiting = false; self.panning = self.begin_pan(); self.guest_pressed = None; } else { @@ -1210,13 +1242,23 @@ impl ApplicationHandler for TesseraApp { let point = self.guest_point(); let canvas = self.namespace.read().is_ok_and(|n| { n.document.frame.surface.mode == crate::page::Mode::Canvas - || point.is_some_and(|point| n.document.frame.canvas_at(point).is_some()) + || point.is_some_and(|point| n.document.frame.camera_at(point).is_some()) }); if canvas && (self.modifiers.super_key() || self.modifiers.control_key()) { self.page_view("page-zoom", [(dy * 0.005).exp().clamp(0.1, 10.0), 0.0]); window.request_redraw(); return; } + let scene3d = self.namespace.read().is_ok_and(|current| { + point + .and_then(|point| current.document.frame.camera_at(point)) + .is_some_and(|mount| mount.scene3d_viewport) + }); + if scene3d { + self.page_view("page-zoom", [(dy * 0.005).exp().clamp(0.1, 10.), 0.]); + window.request_redraw(); + return; + } let point = self.guest_point(); let input = point.and_then(|point| { self.namespace.read().ok().and_then(|namespace| {