//! 连接 3D 平面与普通 UI 的坐标和绘制桥梁。 //! Mapping 沿嵌套面板逐层把页面指针变成纹理坐标;可见命中还须检查最近射线交点,防止点穿遮挡物。 //! compose 把各 Mount 的绘制命令按所属面板分组并递归装配;texture 再调用普通软件渲染器绘制面板内容。 //! //! Perspective UI mapping. The same world plane supplies texture coordinates //! during rendering and inverse pointer coordinates during event dispatch. use super::{ View, math::{Projection, Vec3}, }; use crate::{ geometry::{Point, Rect}, viewport::Transform, }; use std::sync::Arc; #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] /// 一次面板出现的正逆投影信息,可以链接父面板形成嵌套映射。 pub struct Mapping { // 包含本面板的 3D 视口 Mount ID,与 key 一起区分同一业务场景的多次出现。 pub owner: u64, // 面板在场景中的稳定标识,不能仅凭 Block 身份选择某个具体面板出现。 pub key: u64, // 保存这次出现所使用的不可变场景和相机;命中必须与生成画面的视图一致。 pub view: Arc, // [x,y,width,height] 是所属二维空间中的视口布局矩形,宽高也决定射线的 aspect。 pub rect: [f32; 4], // 外层二维相机变换;逆映射先处理它,再进入 3D 摄像机射线计算。 pub outer: Transform, // 当前层的二维裁剪,在逆 outer 之前检查;None 表示这一层没有额外矩形裁剪。 pub clip: Option<[f32; 4]>, // 嵌套面板的外层链;反投影从父到子,正投影从子到父。 pub parent: Option>, } impl Mapping { /// 沿父面板逆投影到当前平面的 UI 像素;要求可见时使用最近射线命中拒绝被遮挡的目标。 // RenderDocument 的面板坐标转换调用这里服务命中与事件派发;clipped=false 用于捕获后的连续拖动。 pub fn texture_point(&self, point: Point, clipped: bool) -> Option { // 先把最外层窗口坐标逐层反投影为父面板像素,再检查当前面板所属空间的裁剪。 let point = if let Some(parent) = &self.parent { parent.texture_point(point, clipped)? } else { point }; if clipped && self .clip .is_some_and(|clip| !Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point)) { return None; } // 先撤销当前层二维平移/缩放,后面的 rect 才能按未变换布局坐标归一化。 let point = self.outer.inverse(point); let [x, y, width, height] = self.rect; // 零或负视口没有可用面积,不能计算归一化位置或宽高比。 if width <= 0. || height <= 0. { return None; } // rect 是 3D 视口在当前二维空间的位置,归一化坐标传给该视口相机;不是面板纹理坐标。 let (u, v) = ((point.x - x) / width, (point.y - y) / height); // key 标识当前出现的面板网格;找不到时不退回其他面板,以免把事件发给错误对象。 let mesh = self.view.scene.meshes.iter().find(|mesh| { mesh.panel .as_ref() .is_some_and(|panel| panel.key == self.key) })?; // panel 提供实际纹理像素宽高;它与世界几何尺寸分开,缩放网格不会改变 UI 的像素坐标。 let panel = mesh.panel.as_ref()?; // 普通命中要求当前可见;捕获手势跳过这一步,使指针离开面板或被遮挡后仍能继续拖动。 if clipped { // 指针落在平面投影内还不够:最近三角形必须属于本面板,不能点穿前方网格。 let hit = self.view.pick(u, v, width / height)?; if self.view.scene.meshes[hit.instance] .panel .as_ref() .is_none_or(|panel| panel.key != self.key) { return None; } } // 捕获拖动可以不要求指针仍位于面板内,但仍使用原面板平面的连续逆映射。 let ray = self.view.camera.ray(u, v, width / height)?; // 平面中心与两条世界轴共同确定相交平面;不直接把局部 z 轴当作非均匀缩放后的法线。 let center = mesh.world.point(Vec3::default()); let normal = (mesh.world.point(Vec3::new(1., 0., 0.)) - center) .cross(mesh.world.point(Vec3::new(0., 1., 0.)) - center); // 用世界变换后的局部 x/y 轴叉积求平面法线,支持旋转和非均匀缩放;平行射线无交点。 let denominator = normal.dot(ray.direction); if denominator.abs() < 1e-7 { return None; } // 解射线与无限平面交点,拒绝非有限及相机后方结果;clipped=false 时允许点落在面板边界外。 let distance = (center - ray.origin).dot(normal) / denominator; if !distance.is_finite() || distance <= 0. { return None; } // 把世界交点乘逆 world 回到面板局部坐标,逆矩阵退化时拒绝。 let local = mesh .world .inverse()? .point(ray.origin + ray.direction * distance); // 面板网格约定以局部原点为中心;x 向右、y 向上,转换为纹理左上原点、y 向下。 let world_width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x; let world_height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y; let point = Point::new( (local.x / world_width + 0.5) * panel.width as f32, (0.5 - local.y / world_height) * panel.height as f32, ); (point.x.is_finite() && point.y.is_finite()).then_some(point) } /// 把当前面板的 UI 像素正向投影到外层页面空间,供坐标查询和交互测试使用。 // model/mount 的坐标查询使用此正投影,将面板内布局位置还原到外层页面供调用者定位。 pub fn project_point(&self, point: Point) -> Option { let mesh = self.view.scene.meshes.iter().find(|mesh| { mesh.panel .as_ref() .is_some_and(|panel| panel.key == self.key) })?; let panel = mesh.panel.as_ref()?; // 面板几何由矩形网格构造路径保证顶点顺序;这里读取局部宽高,再与纹理分辨率换算。 let width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x; let height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y; // 正向路径与 texture_point 对称:纹理像素先转局部平面点,再进入世界空间。 let world = mesh.world.point(Vec3::new( (point.x / panel.width as f32 - 0.5) * width, (0.5 - point.y / panel.height as f32) * height, 0., )); // 世界点先转换成相机的右/上/前坐标,后续用正向深度决定裁剪和透视除数。 let camera = self.view.camera; let local = camera.view(camera.basis()?, world); // 点在近远面之外时无可用投影;此方法仅算位置,不检查前方遮挡或二维 clip。 if local.z <= camera.near || local.z >= camera.far { return None; } let [x, y, w, h] = self.rect; // 透视半高随相机深度增长,正交半高恒定;横向除数还包含视口宽高比。 let half = match camera.projection { Projection::Perspective { fov } => local.z * (fov.to_radians() / 2.).tan(), Projection::Orthographic { height } => height / 2., }; // 归一化投影恢复为视口逻辑坐标,最后施加所属二维层的 outer 变换。 let point = self.outer.point(Point::new( x + (local.x / (half * w / h) + 1.) * w / 2., y + (1. - local.y / half) * h / 2., )); // 当前投影得到的是父面板纹理空间;继续向外层正投影,直到窗口页面坐标。 if let Some(parent) = &self.parent { parent.project_point(point) } else { Some(point) } } } #[derive(Clone)] /// 归属明确的一条内部 UI 绘制命令,装配时还保留裁剪、变换和 owner。 struct Entry { command: crate::document::RenderCommand, clip: Option<[f32; 4]>, view: Option, owner: u64, } /// Collect a panel's ordinary paint subtree into its texture. Nested 3D /// commands are assembled recursively before their containing texture. /// 按面板所属视口和稳定 key 分组绘制命令,递归将内部 UI 装配进 3D 面板表面。 /// source_paint 在生成各 Mount 命令后调用;输入的 command_clips、command_views、command_mounts 必须与 commands 对齐,归属 ID 必须能查到 Mount。 pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> { use crate::document::RenderCommand; use std::collections::{HashMap, HashSet}; // 按 Mount ID 建立查找表,让每条命令直接取到所属面板映射,避免逐条扫描完整 Mount 列表。 let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect(); // 分组键包含视口 owner 和面板 key,防止同一场景在两个视口出现时互相混入绘制命令。 let mut groups = HashMap::<(u64, u64), Vec>::new(); let mut roots = Vec::new(); // 每条命令都有出现归属;据此分流为窗口根命令或某个世界面板的内部命令。 for (index, command) in frame.commands.iter().enumerate() { // owner 是该条绘制命令的 Mount ID;面板分组使用 mount.panel 内的外层视口 owner。 let owner = frame.command_mounts[index]; let mount = mounts[&owner]; // 克隆命令并携带同索引裁剪、二维变换,移动到纹理后仍保持原始绘制语义。 let entry = Entry { command: command.clone(), clip: frame.command_clips[index], view: frame.command_views[index], owner, }; // 面板后代收进对应纹理分组;不属于面板的命令保留为根层,二者都维持输入相对顺序。 if let Some(panel) = &mount.panel { groups .entry((panel.owner, panel.key)) .or_default() .push(entry); } else { roots.push(entry); } } // 面板内焦点框也必须作为纹理内容绘制,不能留在窗口根层,否则会脱离世界面板位置。 if let Some(mount) = frame.focus.and_then(|id| mounts.get(&id)) && let Some(panel) = &mount.panel { groups .entry((panel.owner, panel.key)) .or_default() .push(Entry { command: RenderCommand::StrokeRoundedRect { x: mount.x, y: mount.y, width: mount.width, height: mount.height, radius: 7., stroke_width: 2., color: "#4F6FDB".into(), }, clip: mount.clip, view: mount.view, owner: mount.id, }); } /// 递归装配一个绘制条目包含的世界 UI,检查嵌套环与深度,再生成独立不可变视图。 fn assemble( entry: Entry, groups: &HashMap<(u64, u64), Vec>, path: &mut HashSet, depth: usize, ) -> anyhow::Result { // 仅本次条目可变;没有面板的普通命令可原样返回,不需要复制整个场景。 let mut entry = entry; if let RenderCommand::Scene3D { view, .. } = &mut entry.command && view.scene.meshes.iter().any(|mesh| mesh.panel.is_some()) { // 只有含面板的 Scene3D 命令才递归装配;path 跟踪当前祖先出现,限制深度小于 8 并拒绝环。 anyhow::ensure!( depth < 8 && path.insert(entry.owner), "3D UI paint nesting cycle or depth budget exceeded" ); // 只构造新的原生快照,不写回源 Scene3D;同一业务场景仍可被其他视口共享。 let mut scene = (*view.scene).clone(); // 每个面板取得自己分组的 UI 命令;普通网格没有纹理子树,直接跳过。 for mesh in &mut scene.meshes { if let Some(panel) = &mut mesh.panel { // 一张表面保存三组平行数据;每个子条目装配成功后同时追加,保持索引对应。 let mut surface = super::UiSurface { commands: Vec::new(), clips: Vec::new(), views: Vec::new(), }; // 按照原命令顺序递归装配面板内容;嵌套 3D 子命令先完成,父面板才取得完整表面。 for child in groups.get(&(entry.owner, panel.key)).into_iter().flatten() { let child = assemble(child.clone(), groups, path, depth + 1)?; surface.commands.push(child.command); surface.clips.push(child.clip); surface.views.push(child.view); } // 即使分组为空也发布空表面,避免继承源快照中与本次出现无关的旧绘制内容。 panel.surface = Some(Arc::new(surface)); } } // 新场景替换本条目引用,相机保持不变;其他命令持有的 Arc 不受影响。 *view = Arc::new(View { scene: Arc::new(scene), camera: view.camera, }); // 退出当前分支时撤销祖先标记,允许同一身份在其他独立分支中出现而不误判成环。 path.remove(&entry.owner); } Ok(entry) } // 所有根条目共用临时祖先集合,每条递归返回时删除标记;depth 从根的 0 开始计算。 let mut path = HashSet::new(); // 面板内部命令已经移入 UiSurface,根帧只保留外层命令;四张平行元数据表同步重建。 frame.commands.clear(); frame.command_clips.clear(); frame.command_views.clear(); frame.command_mounts.clear(); // 递归错误会在清空之后返回,此函数本身不保证出错时恢复原帧;调用方需处理整帧失败。 for entry in roots { let entry = assemble(entry, &groups, &mut path, 0)?; frame.commands.push(entry.command); frame.command_clips.push(entry.clip); frame.command_views.push(entry.view); frame.command_mounts.push(entry.owner); } Ok(()) } /// 为一个面板分配有限像素表面,并调用普通 UI 软件渲染;当前实现不会按面板内容独立缓存纹理。 /// 软件光栅化的面板纹理路径调用;本函数每次产生新像素数组,复用策略由外层调用方决定。 pub fn texture(panel: &super::Panel) -> Result, &'static str> { // 先向统一像素预算申请有限纹理面积,再分配背景缓冲;嵌套面板同样受该预算约束。 crate::software_renderer::reserve_pixels(panel.width as usize * panel.height as usize)?; // u32 缓冲按纹理宽高铺满背景;没有 UiSurface 时直接返回这张纯背景纹理。 let mut pixels = vec![super::rgb(panel.background); panel.width as usize * panel.height as usize]; // 有 UI 子树才生成显示列表并绘制;解析或渲染错误向上返回,不把半成品作为成功纹理。 if let Some(surface) = &panel.surface { // 将普通 UI 表面包装为临时文档复用现有显示列表和软件渲染路径;比例 1 对应纹理像素。 let document = crate::document::RenderDocument { frame: crate::document::Frame { background: format!("#{:06X}", super::rgb(panel.background)), commands: surface.commands.clone(), command_views: surface.views.clone(), command_clips: surface.clips.clone(), ..Default::default() }, ..Default::default() }; // 比例为 1,保持子 UI 坐标和纹理像素一一对应;无效命令转换成纹理路径的简短错误。 let list = document .display_list(1.) .map_err(|_| "invalid 3D UI texture commands")?; crate::software_renderer::SoftwareRenderer::render( &list, &mut pixels, panel.width, panel.height, )?; } Ok(pixels) }