diff --git a/src/model/scene3d.rs b/src/model/scene3d.rs index d46af8e..06e75cb 100644 --- a/src/model/scene3d.rs +++ b/src/model/scene3d.rs @@ -2,6 +2,10 @@ //! 构造器和字段仍属于 VM;快照复制必要的数值、不可变网格和依赖,渲染器不读取托管堆。 //! 场景遍历组合局部变换、检查环和资源限制,并为 Panel3D 提取独立 UI 根。控制器更新相机;拾取结果再映射回托管节点。 //! +//! 调用路径:bootstrap 用 Constructor 元数据安装类型/工厂,ui_bridge::calls 调用 allocate/transform/control。 +//! RenderCache 调用 snapshot_reusing 生成 View 与面板孩子;Mount.pick3D 和原生指针适配器调用 pick。 +//! 三维 renderer 只接收不可变原生数值;ObjectRef 读取依赖与 PanelMount.content 始终留在 VM 驱动侧。 +//! //! Traced 3D objects and checked immutable snapshots. No managed handle enters //! the renderer; cameras and node transforms remain editable Endor attributes. use crate::{ @@ -17,22 +21,35 @@ use std::{collections::HashSet, sync::Arc}; #[derive(Debug, Clone, Copy)] /// 源 3D 类型的构造元数据入口,统一管理名称、参数、数量和可变子项。 pub enum Constructor { + // 三轴数值对象,供顶点、交点、法线与重心权重使用。 Vec3, + // 三角形顶点索引记录 a/b/c,组装 Geometry 时再验证整数与范围。 Triangle, + // 复制顶点/三角形列表为不可变原生网格,不安装可放置节点标记。 Geometry, + // 组合已有 Geometry3D 与不透明颜色,拥有几何的托管属性引用。 Mesh, + // 创建长宽深盒网格,再沿 Mesh 路径保存几何与材质。 Box, + // 可放置场景组合节点,孩子列表可包含共享节点。 Group, + // 场景根,额外保存 ambient/background,不参与网格式 TRS。 Scene, + // 方向光,用方向向量、颜色和强度描述,不使用网格欧拉角。 Light, + // 透视相机,快照时读取 fov 作为投影参数。 Perspective, + // 正交相机,快照时读取 height 作为可见世界高度。 Orthographic, + // 默认轨道控制器,只保存可编辑速度,处理相机手势时才修改相机。 Orbit, + // 世界平面承载普通 Block UI,世界尺寸和纹理分辨率分别保存。 Panel, } impl Constructor { /// 返回内部枚举或对象对应的稳定源名称。 pub fn name(self) -> &'static str { + // bootstrap 用这些稳定名义类型名安装/查询桌面注册表,不能与小写工厂名混用。 match self { Self::Vec3 => "Vec3", Self::Triangle => "Triangle3D", @@ -50,6 +67,7 @@ impl Constructor { } /// 取得本构造器对应的源工厂函数名称。 pub fn function(self) -> &'static str { + // 桥接函数路由按这些公开工厂名识别构造器,与 name 的名义类型标识一一对应。 match self { Self::Vec3 => "vec3", Self::Triangle => "triangle3D", @@ -67,6 +85,7 @@ impl Constructor { } /// 反查源构造器名称对应的原生 3D 种类,不认识的名称返回空值。 pub fn from_function(name: &str) -> Option { + // 只在显式注册的构造器集合里反查,未知名字交给其他桥接分支处理。 [ Self::Vec3, Self::Triangle, @@ -99,6 +118,7 @@ impl Constructor { } /// 列出本构造器的参数类型名称,供签名安装与源调用检查使用。 pub fn parameters(self) -> &'static [&'static str] { + // 安装参数注解时按显式实参顺序提供类型;Group/Scene 的可变节点规则另由 variadic/arity 解释。 match self { Self::Vec3 | Self::Triangle => &["Number", "Number", "Number"], Self::Geometry => &["ObjectList", "ObjectList"], @@ -177,6 +197,7 @@ fn invalid(message: &'static str) -> VmError { } /// 把源 Value 转换为受检查的托管对象引用,拒绝非对象输入。 fn object_ref(value: &Value) -> Result { + // 构造与快照共用对象边界检查,只有真正托管对象才能交给 payload/列表读取。 if let Value::Object(object) = value { Ok(*object) } else { @@ -189,6 +210,7 @@ fn number(value: &Value) -> Result { } /// 把当前源值或文本选择解码成此调用需要的文本表示。 fn text(value: &Value) -> Result { + // 材质、背景和手势种类只接受源文本,不把对象 repr 隐式转换为策略或颜色。 if let Value::Text(text) = value { Ok(text.clone()) } else { @@ -202,12 +224,14 @@ fn set_number( name: &str, value: f32, ) -> Result<(), VmError> { + // 所有构造/控制器/变换写回都走 NativeContext 属性入口,保留类型检查与变化日志。 cx.set_attr(object, name, crate::native_visual::value(value)) } /// 解析颜色值并检查其满足当前原生绘制约定。 fn color(value: &str) -> Result<[f32; 3], VmError> { // 3D 不透明材质拒绝半透明颜色,转换为 [0,1] RGB 数值;普通二维 UI 的 alpha 不受这里限制。 let color = crate::document::parse_color(value).map_err(|_| invalid("invalid 3D color"))?; + // 三维表面目前按不透明材质处理,非全 alpha 明确拒绝,不能静默丢透明通道。 if color.alpha != 255 { return Err(invalid("3D opaque material color requires full alpha")); } @@ -238,12 +262,16 @@ pub fn allocate( Constructor::Orthographic => NodeKind::Orthographic, Constructor::Orbit => NodeKind::Orbit, Constructor::Panel => NodeKind::Panel, + // Geometry 是特殊返回路径,复制原生缓冲后立即附请求类型,不继续分配 Node 标记。 Constructor::Geometry => { // Geometry 构造只接受受控 ObjectList,逐成员验证 Vec3 payload 并复制数值;以后改源 Vec3 不会改已建几何。 + // 第一 ObjectList 的每个成员都必须是 Vec3,逐字段读取后复制进原生 Vec3 数组。 let vertices = object_list::items(cx.store(), object_ref(&args[0])?)? .iter() .map(|&item| { + // 短借用当前 ObjectStore,不在原生几何内保存成员 ObjectRef。 let p = cx.store(); + // 名义字段形状不够,payload 种类必须确为 Vector,防止 Triangle/任意对象冒充顶点。 if p.payload::(item, "Vec3")?.0 != NodeKind::Vector { return Err(invalid("geometry vertices require Vec3")); } @@ -254,16 +282,21 @@ pub fn allocate( )) }) .collect::, VmError>>()?; + // 第二 ObjectList 转为 u32 三元索引数组,值检查与整网格的下标范围检查分开执行。 let triangles = object_list::items(cx.store(), object_ref(&args[1])?)? .iter() .map(|&item| { + // 成员必须为 Triangle 标记,不能因有 a/b/c 属性就接受其他对象。 if cx.store().payload::(item, "Triangle3D")?.0 != NodeKind::Triangle { return Err(invalid("geometry indices require Triangle3D")); } + // 先准备三个原生索引槽,全部校验成功后再加入输出数组。 let mut triangle = [0; 3]; // 索引先从 Number 检查非负整数,再转 u32;是否越出顶点范围在整份 geometry.valid 校验。 for (index, name) in ["a", "b", "c"].into_iter().enumerate() { + // 通过统一有限坐标数值解析读取索引,再要求非负且无小数部分。 let value = number(&cx.store().get_attr(item, name)?)?; + // 负数/小数索引立即失败,不能通过 as u32 截断生成另一个三角形。 if value < 0. || value.fract() != 0. { return Err(invalid("triangle indices must be nonnegative integers")); } @@ -272,6 +305,7 @@ pub fn allocate( Ok(triangle) }) .collect::, VmError>>()?; + // 将两份复制后的数组组成无 UV 的自定义几何;顶点/三角预算在 valid 统一检查。 let geometry = scene3d::Geometry { vertices, triangles, @@ -283,15 +317,20 @@ pub fn allocate( "invalid Geometry3D buffers or geometry budget exceeded", )); } + // 只有完整网格合法才将原生 Arc 包装为托管 Geometry,之后改源列表不会改变它。 let object = cx.allocate(Geometry(Arc::new(geometry)))?; cx.attach_type(object, definition)?; return Ok(object); } }; + // 其余构造器先分配可编辑 Node 标记,实际数值/引用由普通托管属性保存。 let instance = cx.allocate(Node(kind))?; cx.attach_type(instance, definition)?; + // 按节点种类初始化必需字段;通用 TRS 在各专有字段之后统一安装。 match kind { + // 这两种记录同为三数输入,只是公开字段名不同;Geometry 消费时再施加索引专用约束。 NodeKind::Vector | NodeKind::Triangle => { + // 按构造种类选择 xyz 或 abc,再与已由桥接检查数量的 args 逐项配对。 for (name, value) in (if kind == NodeKind::Vector { ["x", "y", "z"] } else { @@ -303,13 +342,17 @@ pub fn allocate( set_number(cx, instance, name, number(value)?)?; } } + // Mesh 与 Box 最终都保存 Geometry 引用及颜色文本,但网格来源不同。 NodeKind::Mesh => { + // Box 现场生成 cuboid,普通 Mesh 使用调用方给定几何,两路径合并后统一校验材质。 let (geometry, material) = if matches!(constructor, Constructor::Box) { + // 三轴尺寸先转为有限 f32,再拒绝负值;零值允许生成退化面。 let (w, h, d) = (number(&args[0])?, number(&args[1])?, number(&args[2])?); // Box 允许零尺寸产生退化面,后续光栅/拾取跳过退化三角形;负尺寸在构造入口拒绝。 if w < 0. || h < 0. || d < 0. { return Err(invalid("Box3D dimensions must be nonnegative")); } + // 生成盒网格后为它安装 Geometry3D 名义类型,作为 Mesh.geometry 的强属性引用。 let geometry = cx.allocate(Geometry(Arc::new(scene3d::Geometry::cuboid(w, h, d))))?; crate::ui_bridge::attach_desktop_type(cx, geometry, "Geometry3D", desktop)?; @@ -324,8 +367,11 @@ pub fn allocate( } // 孩子只接受 Mesh/Group/Light/Panel 标记,不允许把相机、控制器或任意 Object 放进场景组成。 NodeKind::Group | NodeKind::Scene => { + // Group/Scene 的可变实参必须全为对象,逐项 payload 检查后才包装列表。 let children = args.iter().map(object_ref).collect::, _>>()?; + // 每个成员都必须是能放进场景的节点;相机和控制器不是可绘制组成孩子。 for child in &children { + // 检查 NodeKind 而非仅检查共同的 Node payload,因为相机/向量也使用该 payload。 if !matches!( cx.store().payload::(*child, "3D node")?.0, NodeKind::Mesh | NodeKind::Group | NodeKind::Light | NodeKind::Panel @@ -338,12 +384,15 @@ pub fn allocate( // 托管 children 列表拥有子节点;Node payload 自身只是标记,属性拥有边由 VM 正常追踪。 let children = object_list::collection(cx, children, desktop)?; cx.set_attr(instance, "children", Value::Object(children))?; + // 只有场景根初始化环境光和背景,普通 Group 不隐式提供这些全局场景字段。 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) @@ -356,6 +405,7 @@ pub fn allocate( } // 世界宽高与 UI 像素分辨率独立:初始纹理 512×320,content 是普通 Block 图,稍后参与独立有限布局。 NodeKind::Panel => { + // Panel 的第三参数是普通 UI 根,构造时先验证其 Block 图再安装 content 强引用。 let content = object_ref(&args[2])?; crate::block::validate(cx.store(), content, None)?; set_number(cx, instance, "width", number(&args[0])?)?; @@ -365,7 +415,9 @@ pub fn allocate( cx.set_attr(instance, "content", Value::Object(content))?; cx.set_attr(instance, "background", Value::Text("#FFFFFF".into()))?; } + // 两种相机共用位置/target/up/near/far 默认字段,快照按种类只取实际投影参数。 NodeKind::Perspective | NodeKind::Orthographic => { + // 为相机建立完整非退化初始观察基,保留 fov/height 以支持对应投影。 for (name, value) in [ ("x", 0.), ("y", 0.), @@ -384,6 +436,7 @@ pub fn allocate( set_number(cx, instance, name, value)?; } } + // 控制器仅初始化转速与平移速度;不持有相机,源可替换控制器对象。 NodeKind::Orbit => { set_number(cx, instance, "rotationSpeed", 0.005)?; set_number(cx, instance, "panSpeed", 1.)?; @@ -391,6 +444,7 @@ pub fn allocate( } // 只有可放置节点安装 TRS;相机用 position/target/up,方向光用方向向量,不复用网格欧拉角字段。 if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) { + // Mesh/Group/Panel 统一从单位缩放、零位置/角度开始,世界矩阵在快照遍历时组合。 for (name, value) in [ ("x", 0.), ("y", 0.), @@ -410,7 +464,9 @@ pub fn allocate( /// 快照字段读取器,同时记录实际观察到的对象/属性依赖。 struct Reader<'a> { + // 借用当前 VM 堆,Reader 只在同步快照/控制器调用内存活,不跨线程。 store: &'a ObjectStore, + // 精确对象/字段键集合,返回 RenderCache 建反向依赖;集合不 retain 身份。 reads: HashSet<(ObjectRef, String)>, } impl Reader<'_> { @@ -448,6 +504,7 @@ impl Reader<'_> { )); } }; + // 将相机所有输入复制为原生数值,后续 valid 同时验证观察基与投影近远范围。 let camera = Camera { position: self.vector(object, ["x", "y", "z"])?, target: self.vector(object, ["targetX", "targetY", "targetZ"])?, @@ -456,6 +513,7 @@ impl Reader<'_> { far: self.number(object, "far")?, projection, }; + // 单字段有限不代表组合有效;位置=目标、退化 up 或非法投影范围必须整体拒绝。 if !camera.valid() { return Err(invalid("invalid 3D camera basis or projection range")); } @@ -465,8 +523,11 @@ impl Reader<'_> { /// 从场景中提取的有限 UI 根,携带稳定面板 key 和逻辑排版尺寸。 pub struct PanelMount { + // 此面板出现路径的稳定 key,交给 Block 组成 slot 及纹理映射,不等于面板业务身份。 pub key: u64, + // 仍在 VM 侧参与独立有限布局的 Block 根,只作为返回的借用身份,不进入 renderer。 pub content: ObjectRef, + // 面板 UI 的逻辑像素分辨率,与世界平面的 width/height 无关。 pub size: crate::layout::Size, } // 三部分分别是可跨线程的不可变 View、VM 内缓存用的属性依赖、仍由 VM 驱动布局的面板 Block 根。 @@ -481,6 +542,7 @@ pub fn snapshot( scene: ObjectRef, camera: ObjectRef, ) -> Result { + // 一次性提取入口无旧 Scene/BVH 可复用;pick 使用此路径获得本次实际字段读取。 snapshot_reusing(store, scene, camera, None) } /// 遍历场景并记录字段依赖;如果世界数据未变则复用不可变场景和 BVH,只替换摄像机视图。 @@ -490,19 +552,26 @@ pub fn snapshot_reusing( camera: ObjectRef, reuse: Option>, ) -> Result { + // 根必须是 Scene 标记,不能直接把 Mesh/Group 当作完整场景根使用。 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)?; + // 读取场景环境光,数值解析只保证有限范围,下一步再限为 [0,1]。 let ambient = reader.number(scene, "ambient")?; + // 拒绝非法环境光系数,不以渲染时钳色掩盖错误源参数。 if !(0.0..=1.0).contains(&ambient) { return Err(invalid("Scene3D.ambient expects 0 through 1")); } + // 背景使用同一不透明三维颜色解码,与普通二维 UI alpha 规则区分。 let background = color(&text(&reader.value(scene, "background")?)?)?; + // 构建本轮完整世界描述,暂不建立加速结构;最后若值相等可以复用旧 Scene/BVH。 let mut output = scene3d::Scene { meshes: Vec::new(), lights: Vec::new(), @@ -512,12 +581,19 @@ pub fn snapshot_reusing( }; // 显式进入/退出栈保存节点、父世界矩阵、退出标记与路径 key;path 仅追踪祖先,因此允许共享子树多次出现。 let mut work = vec![(scene, Matrix::default(), false, 0u64)]; + // 收集面板 key/UI 根/逻辑尺寸,顺序要与输出 Mesh 中的面板对应。 let mut panels = Vec::new(); + // 当前祖先链用于拒绝组成环,退出后移除,允许共享业务节点在其他路径出现。 let mut path = HashSet::new(); + // 实例预算按展开出现次数,重复业务对象同样消耗绘制工作。 let mut visits = 0; + // 累计所有普通 Mesh 出现的三角数,Arc 共享不能绕开场景工作预算。 let mut triangles = 0; + // 累计普通 Mesh 出现顶点数;面板与整体预算另由 View.valid 等检查。 let mut vertices = 0; + // 显式 DFS 同时传递父世界矩阵与路径 key,不用宿主递归栈遍历深图。 while let Some((object, parent, finish, key)) = work.pop() { + // 当前子树已完整展开,移除祖先标记以允许后续兄弟再次使用同一对象。 if finish { path.remove(&object); continue; @@ -527,11 +603,14 @@ pub fn snapshot_reusing( 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, key)); + // 每项必须是托管 Node 标记,再按具体 NodeKind 限制可进入场景图的节点。 let kind = store.payload::(object, "3D node")?.0; + // 仅可放置的 Mesh/Group/Panel 读取自身 TRS,其他种类继承父矩阵。 let world = if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) { // 局部先缩放、旋转、平移,再乘父 world;Scene/Light 不新增 TRS,方向光仍受祖先 Group 变换。 parent.compose(Matrix::trs( @@ -542,11 +621,15 @@ pub fn snapshot_reusing( } else { parent }; + // Group/Scene 压孩子,Mesh/Panel 产生绘制实例,Light 产生照明;其他类型明确拒绝。 match kind { NodeKind::Scene | NodeKind::Group => { + // 通过 Reader 记录 children 属性引用,items 再验证它确为 ObjectList。 let list = object_ref(&reader.value(object, "children")?)?; + // 逆序压栈保留列表原始遍历次序,每个子出现都继承当前 world。 for (index, child) in object_list::items(store, list)?.iter().enumerate().rev() { use std::hash::{Hash, Hasher}; + // 路径哈希从父 key/下标/子身份构造,面板后续显式检测碰撞。 let mut hasher = std::collections::hash_map::DefaultHasher::new(); // 路径 key 包含父 key、当前孩子索引和托管身份,区分同一 Panel 节点在不同路径的出现。 // 此 key 与普通 Block slot 不同,场景列表重排会改变路径身份。 @@ -557,17 +640,22 @@ pub fn snapshot_reusing( } } NodeKind::Mesh => { + // Mesh.geometry 字段读取建立依赖,随后要求其 payload 是不可变 Geometry。 let geometry = object_ref(&reader.value(object, "geometry")?)?; + // 克隆原生 Arc 保活数值缓冲,renderer 不需要托管 geometry 句柄。 let geometry = store.payload::(geometry, "Geometry3D")?.0.clone(); // 每次 mesh 出现都累计完整几何,Arc 共享只节约存储,不减少本帧实例绘制工作预算。 triangles += geometry.triangles.len(); vertices += geometry.vertices.len(); + // 顶点工作超过场景预算立即拒绝,不因为几何缓冲共享就少计实例成本。 if vertices > 262_144 { return Err(VmError::ExecutionLimit("3D scene vertices")); } + // 三角工作量同样按实例累计,用独立上限防止大网格或大量共享网格超预算。 if triangles > scene3d::MAX_TRIANGLES { return Err(VmError::ExecutionLimit("3D scene triangles")); } + // 材质字段每轮提取为 RGB,颜色改变可通过精确依赖驱动 Mesh 快照更新。 let material = color(&text(&reader.value(object, "color")?)?)?; output.meshes.push(scene3d::Mesh { identity: object.to_string(), @@ -582,6 +670,7 @@ pub fn snapshot_reusing( // u64 hash can turn negative when native Bounds are created. // Bounds.slot 经有符号整数 API 传递,清最高位避免变负;仍显式检测碰撞,不能假设哈希必然唯一。 let key = key & i64::MAX as u64; + // 清最高位后仍需按已收集的出现 key 检测碰撞;不能让两块 UI 共享同一个纹理/组成身份。 if panels.iter().any(|panel: &PanelMount| panel.key == key) { return Err(invalid("colliding 3D UI occurrence identities")); } @@ -589,10 +678,15 @@ pub fn snapshot_reusing( if panels.len() >= 64 { return Err(VmError::ExecutionLimit("3D UI panels")); } + // 读取世界平面宽高,必须为正;它们决定场景几何,不是纹理像素尺寸。 let width = reader.number(object, "width")?; + // 世界高度与宽度独立,不能以 uiHeight 替代。 let height = reader.number(object, "height")?; + // UI 横向分辨率用有限整数逻辑像素,稍后作为面板 Block 布局宽度。 let ui_width = reader.number(object, "uiWidth")?; + // UI 纵向分辨率单独检查整数和 1..2048 范围。 let ui_height = reader.number(object, "uiHeight")?; + // 同一检查同时约束正世界尺寸与两轴 UI 整数分辨率,拒绝零面积面板。 if width <= 0. || height <= 0. || ![ui_width, ui_height] @@ -603,8 +697,10 @@ pub fn snapshot_reusing( "Panel3D requires positive world dimensions and integer UI dimensions from 1 through 2048", )); } + // 取当前 Panel.content 的真实 Block 引用,并验证可以沿共享转发解析。 let content = object_ref(&reader.value(object, "content")?)?; crate::block::resolve(store, content)?; + // 面板底色也为三维不透明颜色,用于独立表面的清屏。 let background = color(&text(&reader.value(object, "background")?)?)?; // 面板平面以中心为原点,顶点 y 向上,UV 左上为 (0,0);两三角形共用四个角点。 let geometry = Arc::new(scene3d::Geometry { @@ -641,17 +737,22 @@ pub fn snapshot_reusing( }); } NodeKind::Light => { + // 方向光数量独立限制为 32,不由网格实例预算代替。 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"))?; + // 方向光颜色转原生 RGB,与强度分量分别检查。 let color = color(&text(&reader.value(object, "color")?)?)?; + // 有限值还需限在 [0,32],避免把任意世界数值当作照明系数。 let intensity = reader.number(object, "intensity")?; + // 超范围强度报告源错误,不能只在像素输出时饱和掩盖。 if !(0.0..=32.0).contains(&intensity) { return Err(invalid("light intensity expects 0 through 32")); } @@ -671,6 +772,7 @@ pub fn snapshot_reusing( output.acceleration = Arc::new(scene3d::spatial::Index::build(&output.meshes)); Arc::new(output) }; + // 将最终世界 Arc 与本轮相机组合为 View;renderer/拾取共享同一套原生场景描述。 let view = Arc::new(scene3d::View { scene, camera }); // 提取各项之后仍做完整视图校验,覆盖累计世界变换、光照、面板及整体资源不变量。 if !view.valid() { @@ -686,11 +788,15 @@ pub fn pick( mount: &crate::mount::Mount, point: crate::geometry::Point, ) -> Result { + // 布局未提交时不允许射线查询,避免临时 Bounds 与旧视口/面板投影混用。 if mount.layout.is_some() { return Err(invalid("pick3D requires a committed viewport")); } + // 先沿 Mount.block 的共享赋值解析当前描述,而非信任挂载时的旧快照。 let (_, data) = crate::block::resolve(cx.store(), mount.block)?; + // 用当前托管属性覆盖副本,再验证实际种类确为 Viewport3D。 let data = crate::block::visual_data(cx.store(), data)?; + // 普通 Block 或二维视口没有三维射线协议,明确拒绝而非返回任意 Nil。 if !matches!(data.kind, crate::block::Kind::Viewport3D { .. }) { return Err(invalid("pick3D requires a Viewport3D")); } @@ -698,8 +804,11 @@ pub fn pick( if !mount.geometry.contains(point) { return Ok(Value::Nil); } + // 为计算当前盒模型 inset 提取原生视觉根,射线归一化必须使用实际内容区域。 let node = crate::block::snapshot(cx.store(), mount.block)?; + // padding/border 从有效视觉样式取得,不能把整个边框盒宽高直接作为相机视口。 let inset = crate::layout::insets(&node); + // 先逆投影面板/二维 view 到布局坐标;遮挡或不可逆时返回无命中。 let Some(point) = mount.geometry.logical_point(point) else { return Ok(Value::Nil); }; @@ -708,19 +817,23 @@ pub fn pick( (mount.geometry.width - inset.horizontal()).max(0.), (mount.geometry.height - inset.vertical()).max(0.), ); + // 零内容面积没有可定义的视口归一化坐标,提前返回 Nil 避免除零。 if width <= 0. || height <= 0. { return Ok(Value::Nil); } + // 本次重新提取场景与相机,并保留实际字段读取对象用于关联原托管 Mesh。 let (view, reads, _) = snapshot( cx.store(), data.scene3d.ok_or(invalid("missing Scene3D"))?, data.camera.ok_or(invalid("missing 3D camera"))?, )?; + // 减去 Mount 布局原点与内容 inset,再归一到视口;宽高比用于相机投影。 let hit = view.pick( (point.x - mount.geometry.x - inset.left) / width, (point.y - mount.geometry.y - inset.top) / height, width / height, ); + // 射线没有穿过可见几何时正常返回 Nil,不创建伪造 RayHit3D。 let Some(hit) = hit else { return Ok(Value::Nil); }; @@ -729,6 +842,7 @@ pub fn pick( .iter() .find_map(|(object, _)| (object.to_string() == hit.identity).then_some(*object)) .ok_or(invalid("picked mesh is unavailable"))?; + // 创建本桌面的名义 RayHit3D,mesh/node 属性强持有原业务对象。 let result = cx.new_object()?; crate::ui_bridge::attach_desktop_type(cx, result, "RayHit3D", desktop)?; cx.set_attr(result, "mesh", Value::Object(mesh))?; @@ -757,7 +871,9 @@ pub fn pick( Vec3::new(hit.barycentric[0], hit.barycentric[1], hit.barycentric[2]), ), ] { + // 结果向量字段须为本桌面 Vec3 名义类型,不能只返回相似的普通 xyz 记录。 let definition = crate::ui_bridge::desktop_type(cx.store(), "Vec3", desktop)?; + // 按相同构造规则生成交点/法线/重心记录,结果属性负责持有这些返回对象。 let vector = allocate( cx, desktop, @@ -777,8 +893,11 @@ pub fn pick( #[derive(Debug)] /// 指针拾取的中间结果,保留交点与所属实例信息供 Event/RayHit3D 使用。 pub struct PointerPick { + // 原生指针目标的托管 Mount,由调用方包装的 captures 保活。 pub mount: ObjectRef, + // 当前窗口桌面,用来构造 Event/RayHit3D 名义值与派发。 pub desktop: ObjectRef, + // 原输入的页面屏幕坐标,invoke 再按当前 Mount 投影执行精确拾取。 pub point: crate::geometry::Point, } @@ -790,38 +909,55 @@ pub fn control( camera_ref: ObjectRef, gesture: ObjectRef, ) -> Result<(), VmError> { + // 只处理默认 OrbitController 标记;自定义源控制器由桥接协议调用自己的 update。 if cx.store().payload::(controller, "OrbitController")?.0 != NodeKind::Orbit { return Err(invalid("invalid orbit controller")); } + // 控制器复用字段解码器以取得一致的数值检查;这里的 reads 只为本次读取使用,不返回视觉缓存。 let mut reader = Reader { store: cx.store(), reads: HashSet::new(), }; + // 先复制并验证相机观察基,再基于已知有效状态计算新位置/target。 let camera = reader.camera(camera_ref)?; + // 手势逻辑横位移,由窗口/面板映射后的 CameraGesture 提供。 let dx = reader.number(gesture, "dx")?; + // 手势逻辑纵位移,平移计算会显式处理屏幕 y 向下的方向。 let dy = reader.number(gesture, "dy")?; + // 策略字符串决定 orbit/pan/zoom,未知手势不能默认为某种运动。 let kind = text(&reader.value(gesture, "kind")?)?; // 默认轨道控制围绕 target 使用 position-target 偏移和半径;相机有效性保证观察方向不退化。 let offset = camera.position - camera.target; + // position−target 的长度为轨道半径,相机有效性已排除零观察方向。 let radius = offset.length(); + // 先只在原生数值副本中计算,最后统一检查世界预算并写回。 let mut position = camera.position; + // orbit 通常固定目标,pan 同时移动目标;变量副本与源属性暂时分离。 let mut target = camera.target; + // 分支各自检查速度/倍率,计算结束再共享写回位置与目标。 match kind.as_str() { + // 围绕相机 target 旋转位置,使用源 up 向量建立轨道基,不强制世界固定 y 轴。 "scene-orbit" => { + // 旋转速度单位为手势像素到弧度系数,必须在 [0,1] 内。 let speed = reader.number(controller, "rotationSpeed")?; + // 非法速度拒绝整个手势,不先修改某一轴再报错。 if !(0.0..=1.0).contains(&speed) { return Err(invalid("rotationSpeed expects 0 through 1")); } + // 归一相机 up,若退化则无法建立稳定轨道水平面。 let up = camera .up .normalized() .ok_or(invalid("camera up cannot be zero"))?; // 将偏移分解为 up 方向和水平面方向,用水平旋转更新 yaw、asin(vertical) 更新 pitch。 let vertical = (offset.dot(up) / radius).clamp(-1., 1.); + // 去除 up 分量后归一,位于极点时无法定义 yaw 水平基,明确要求更换 up。 let horizontal = (offset - up * offset.dot(up)) .normalized() .ok_or(invalid("orbit at pole requires a different up vector"))?; + // 横位移乘速度形成 yaw 弧度,sin_cos 用同一角度提供旋转系数。 let (sine, cosine) = (dx * speed).sin_cos(); + // 用 up×horizontal 生成绕 up 的正交旋转分量,保留水平单位方向。 let horizontal = horizontal * cosine + up.cross(horizontal) * sine; // 俯仰限制在两极内侧 0.01 弧度,避免绕极点时水平基退化并导致轨道跳变。 let pitch = (vertical.asin() + dy * speed).clamp( @@ -830,27 +966,38 @@ pub fn control( ); position = target + horizontal * (radius * pitch.cos()) + up * (radius * pitch.sin()); } + // 沿当前相机 right/up 平移,位置和目标加同一 delta,保持朝向与半径。 "scene-pan" => { + // 平移速度可取 [0,100],与旋转速度使用不同范围和单位。 let speed = reader.number(controller, "panSpeed")?; + // 速度超范围时拒绝,不能把任意源数字当成无限世界平移系数。 if !(0.0..=100.0).contains(&speed) { return Err(invalid("panSpeed expects 0 through 100")); } + // 手势视口高至少按 1 处理,避免像素→世界比例计算除零。 let height = reader.number(gesture, "height")?.max(1.); // 把逻辑像素位移换成世界单位:透视按 target 距离处的可见高度,正交按固定投影高度。 let units = match camera.projection { + // 透视按当前 target 距离截面的可见高度,把屏幕位移换算为世界单位。 Projection::Perspective { fov } => { 2. * radius * (fov.to_radians() / 2.).tan() / height } + // 正交可见高度固定,世界单位/像素与轨道距离无关。 Projection::Orthographic { height: extent } => extent / height, }; + // 使用相机实际观察基的 right/up,不把屏幕平移误映射成世界固定 xyz。 let basis = camera.basis().ok_or(invalid("invalid camera basis"))?; + // 横位移沿 right,屏幕向下 dy 对应负 up,最后再乘用户 panSpeed。 let delta = basis.right * (dx * units * speed) + basis.up * (-dy * units * speed); // 平移同时移动 position 和 target,保留观察方向与轨道半径;屏幕 y 向下所以 up 分量取负。 position = position + delta; target = target + delta; } + // 倍率大于一拉近:透视缩短轨道距离,正交则缩小投影可见高度。 "scene-zoom" => { + // 倍率从 CameraGesture 读取,必须为正且处于约定 [0.1,10]。 let factor = reader.number(gesture, "factor")?; + // 拒绝零/负/过大倍率,后续无需为反向或无限缩放定义额外语义。 if !(0.1..=10.0).contains(&factor) { return Err(invalid("zoom factor expects 0.1 through 10")); } @@ -863,6 +1010,7 @@ pub fn control( (height / factor).clamp(0.001, 1_000_000.), )?; } else { + // 透视新距离夹在近裁面两倍与 far 边界之间,再沿原 offset 方向缩放。 let next = (radius / factor).clamp(camera.near * 2., camera.far.max(camera.near * 2.)); position = target + offset * (next / radius); @@ -874,6 +1022,7 @@ pub fn control( if !position.valid() || !target.valid() { return Err(invalid("camera movement exceeds world budget")); } + // 位置与 target 逐轴经 NativeContext 写回,变化日志让下一视觉快照观察到更新。 for (name, value) in [ ("x", position.x), ("y", position.y), @@ -894,8 +1043,11 @@ pub fn transform( args: &[Value], rotate: bool, ) -> Result<(), VmError> { + // 桥接 translate/rotate 入口先根据 payload 判定可放置目标,不按字段形状接受普通 Object。 let kind = cx.store().payload::(owner, "3D transform target")?.0; + // 相机可做位置平移,但本接口不以网格欧拉角方式旋转相机。 let camera = matches!(kind, NodeKind::Perspective | NodeKind::Orthographic); + // 限制 Mesh/Group/Panel/相机这组明确目标,额外拒绝 rotate+camera 组合。 if !matches!( kind, NodeKind::Mesh @@ -915,14 +1067,19 @@ pub fn transform( }; // 先收集并验证全部轴再写入,避免验证到第二/三轴才发现越界时已修改前面轴。 let mut updates = Vec::new(); + // 按固定 xyz 顺序读取三轴增量,桥接调用方已经检查了实参个数。 for (index, name) in names.into_iter().enumerate() { + // 每个增量先按带符号有限坐标规则解析,不能因最终角度归一而接受 NaN。 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, + // 只有旋转应用 rem_euclid(360),平移仍保留带符号世界坐标。 if rotate { // 角度归一到 [0,360),支持负增量而不使用产生负余数的普通取模。 value.rem_euclid(360.) @@ -932,14 +1089,18 @@ pub fn transform( )); // 相机平移同步改 target,保留朝向;不把“移动摄像机”意外变成绕固定目标转动。 if camera { + // 相机平移还需同步相应 target 轴,不能移动位置却把观察目标固定不动。 let name = ["targetX", "targetY", "targetZ"][index]; + // 读取 target 旧值加相同增量,与 position 一起先收集验证。 let value = number(&cx.store().get_attr(owner, name)?)? + delta; + // target 越界时也拒绝全部更新,不能只回滚相机位置而保留一部分目标写入。 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)?; } @@ -956,12 +1117,16 @@ impl endor_core::NativeCallable for PointerPick { cx: &mut NativeContext<'_>, _: &[Value], ) -> Result { + // VM 序列执行此适配器时重读当前 Mount payload,不使用窗口线程提交的旧业务快照。 let mount = cx .store() .payload::(self.mount, "Mount")? .clone(); + // 先从当前几何/场景做精确射线拾取,结果保留原业务 Mesh 身份或 Nil。 let hit = pick(cx, self.desktop, &mount, self.point)?; + // 点击 Event 使用同桌面的名义类型,避免拾取后转为无契约的临时记录。 let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?; + // 创建真实 click Event,再写入 hit 字段并交统一事件派发处理传播与监听器。 let event = crate::event_dispatch::new_event(cx, definition, "click")?; // 点击事件把精确拾取值放到 hit(未击中则 Nil),仍以 Viewport 的 Block/Mount 作为事件传播目标。 cx.set_attr(event, "hit", hit)?;