diff --git a/src/render/text.rs b/src/render/text.rs index 2febb77..2fe5bf5 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -14,6 +14,7 @@ use crate::display_list::Color; #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] /// 界面比例字体与源码等宽字体的选择,不直接指定某个必然安装的系统字体。 +// TextStyle 携带字体选择,TextEngine::prepare_width 将其解析为实际字体族;Custom ID 由 register_font 返回。 pub enum TextFamily { Interface, Code, @@ -24,6 +25,7 @@ pub enum TextFamily { #[derive(Clone, Copy, Debug)] /// 字号、行高和字体类别,测量与绘制必须使用兼容样式。 pub struct TextStyle { + // 目标坐标空间中的字体像素尺寸;scaled 同时缩放它与 line_height,以保持排版比例。 pub font_size: f32, pub line_height: f32, pub family: TextFamily, @@ -31,6 +33,7 @@ pub struct TextStyle { impl TextStyle { /// 生成界面文字的基准字号与行高,size 是协议使用的字号倍率。 + // 布局默认文本与宿主 UI 使用,协议等级乘基础 14/20 得到字号及行高;构造本身不验证等级范围。 pub fn ui(size: u32) -> Self { Self { font_size: 14.0 * size as f32, @@ -40,6 +43,7 @@ impl TextStyle { } /// 生成源码/Console 使用的等宽风格基础尺寸。 + // 侧栏、Console 和源码预览使用的等宽样式,实际字体由引擎启动时发现。 pub fn code() -> Self { Self { font_size: 13.0, @@ -49,6 +53,7 @@ impl TextStyle { } /// 同比缩放字号与行高,保持设备比例变化后的排版比例一致。 + // display_list 的设备比例、页面缩放和 draw_clipped 的字形 LOD 都用同一缩放操作。 pub fn scaled(self, scale: f32) -> Self { Self { font_size: self.font_size * scale, @@ -68,6 +73,7 @@ pub struct TextSize { #[derive(Clone, Hash, PartialEq, Eq)] /// 文字整形缓存键,包含文本、样式和可选换行宽度。 struct TextKey { + // 缓存拥有文本副本,命中比较全部文本内容;不会借用源文件或 VM 字符串的生命周期。 value: String, // 用浮点原始位模式做键,精确区分整形参数;不是把字号取整为 u32。 font_size: u32, @@ -86,8 +92,10 @@ struct PreparedText { /// 共享字体系统、字形缓存和整形缓存;调用通过全局锁串行访问。 struct TextEngine { fonts: FontSystem, + // 已注册字体族名与内容哈希,数组下标就是 TextFamily::Custom ID,最多保留 32 个族。 custom: Vec<(String, u64)>, code_family: Option, + // 字形栅格缓存与整段 Buffer 缓存分开维护,draw_clipped 根据位图条数/字节量整批清理。 swash: SwashCache, buffers: HashMap, // 累计缓存文本的 UTF-8 字节数,只衡量源文本预算,并非 Buffer/字形的总内存。 @@ -96,6 +104,7 @@ struct TextEngine { impl TextEngine { /// 扫描系统字体,选择可用的界面/等宽字体偏好,并建立空整形和栅格缓存。 + // engine 的 OnceLock 首次使用时调用,加载系统字体只做一次,测量与绘制随后共享此实例。 fn new() -> Self { let mut fonts = FontSystem::new(); // Select installed, readable families rather than assuming an OS font @@ -110,6 +119,7 @@ impl TextEngine { .then(|| (*choice).to_owned()) }) }; + // 界面族按安装情况选第一个可用偏好,不保证每个平台得到同一个字体或相同逐像素结果。 let sans = choose(&[ ".AppleSystemUIFont", "SF Pro Text", @@ -119,10 +129,13 @@ impl TextEngine { "DejaVu Sans", "Liberation Sans", ]); + // 保留等宽族名供 Code 使用;不存在偏好族时回退 FontSystem 的 Monospace。 let mono = choose(&["Menlo", "Consolas", "DejaVu Sans Mono", "Liberation Mono"]); + // 只有找到已安装的偏好族才替换字体库默认 sans-serif 配置。 if let Some(name) = sans { fonts.db_mut().set_sans_serif_family(name); } + // 代码族配置与保存的 code_family 保持一致,具体字符缺失仍由整形系统做字体回退。 if let Some(name) = &mono { fonts.db_mut().set_monospace_family(name); } @@ -137,11 +150,13 @@ impl TextEngine { } /// 对不指定换行宽度的文本复用统一整形准备过程。 + // measure 的无换行入口,实际整形和缓存逻辑统一委托 prepare_width,避免另建自然宽度算法。 fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey { self.prepare_width(value, style, None) } /// 为文本、字体样式和可选换行宽度建立整形缓存,超过缓存预算先清理旧结果。 + // measure_wrapped 和 draw_clipped 共用;调用方已持引擎锁,返回键可直接用于访问 buffers 中的结果。 fn prepare_width(&mut self, value: &str, style: TextStyle, wrap_width: Option) -> TextKey { // 键包含全部排版输入;命中后测量与绘制都复用同一 Buffer,避免两套排版产生偏差。 let key = TextKey { @@ -151,6 +166,7 @@ impl TextEngine { family: style.family, wrap_width: wrap_width.map(f32::to_bits), }; + // 完全相同的文本、样式与宽度复用已整形 Buffer,返回前不再次累计 source_bytes。 if self.buffers.contains_key(&key) { return key; } @@ -161,6 +177,7 @@ impl TextEngine { self.buffers.clear(); self.source_bytes = 0; } + // Custom 直接用注册 ID 查族名,因此调用方应只使用 register_font 返回的有效 ID。 let family = match style.family { TextFamily::Interface => Family::SansSerif, TextFamily::Custom(id) => Family::Name(&self.custom[id as usize].0), @@ -171,6 +188,7 @@ impl TextEngine { .as_deref() .map_or(Family::Monospace, Family::Name), }; + // 先建立对应字体尺寸/行高的 Buffer,再设置换行宽度、文本和对齐,最后执行整形。 let mut buffer = Buffer::new( &mut self.fonts, Metrics::new(style.font_size, style.line_height), @@ -196,10 +214,12 @@ impl TextEngine { width: 0.0, height: style.line_height, }; + // 逐行汇总最大实际宽度和最下行底,不按字符数量估算中文、比例字或换行高度。 for run in buffer.layout_runs() { size.width = size.width.max(run.line_w); size.height = size.height.max(run.line_top + run.line_height); } + // 只在新整形条目插入时累计源文本字节;返回克隆键供调用方马上取测量值或字形。 self.source_bytes += value.len(); self.buffers .insert(key.clone(), PreparedText { buffer, size }); @@ -215,13 +235,16 @@ fn engine() -> &'static Mutex { } /// 返回不受换行宽度约束时的字体整形尺寸。 +// layout 的原生文本测量、宿主控件尺寸及 fit 的宽度比较共用此入口,不设置自动换行宽度。 pub fn measure(value: &str, style: TextStyle) -> TextSize { let mut engine = engine().lock().expect("text engine lock"); + // prepare 确保当前缓存里存在此键,借用的 size 可复制返回而不把 Buffer 或锁带给调用方。 let key = engine.prepare(value, style); engine.buffers[&key].size } /// 按可用宽度整形并测量多行高度,供布局与实际段落绘制共用。 +// layout 在内容宽度约束下调用,完整段落高度用于布局;屏幕 clip 不影响测量宽度或行数。 pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize { // 零或负可用宽度直接返回零宽与一行高,避免给整形器无效换行宽度。 if width <= 0.0 { @@ -239,6 +262,7 @@ pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize { /// bitmap and sample it at the requested size instead of allocating an enormous /// font image. Layout and wrapping still use proportionally identical metrics. /// 限制字形分辨率并只采样可见像素;保留文字布局比例,同时避免极端相机缩放产生巨型位图。 +// software_renderer 的普通/换行文字路径调用,clip 和回调坐标都相对文字原点,最终平移与混合由 Canvas 负责。 pub fn draw_clipped( value: &str, style: TextStyle, @@ -247,6 +271,7 @@ pub fn draw_clipped( clip: crate::geometry::Rect, mut pixel: impl FnMut(i32, i32, Color), ) { + // 空裁剪或无效字号直接跳过,不进入字体锁和字形位图生成;其他样式约束由调用边界保证。 if clip.width <= 0. || clip.height <= 0. || !style.font_size.is_finite() @@ -258,6 +283,7 @@ pub fn draw_clipped( // 字体、行高和换行宽度一起缩小,后续用 1/lod 恢复目标几何比例。 let lod = (512. / style.font_size).min(1.); let mut engine = engine().lock().expect("text engine lock"); + // 用同一 lod 缩放字号、行高和可选换行宽度,在低分辨率整形后以倒 lod 恢复输出空间。 let key = engine.prepare_width(value, style.scaled(lod), width.map(|width| width * lod)); // 拆借可变字段以便在保留 Buffer 借用时访问字体和 Swash 缓存,不复制整形结果。 let TextEngine { @@ -266,6 +292,7 @@ pub fn draw_clipped( buffers, .. } = &mut *engine; + // 以下遍历只读已整形 Buffer,同时分开借用 fonts/swash 生成字形,不克隆整段布局。 let buffer = &buffers[&key].buffer; // 从已有 image_cache 统计位图字节;不包含整形 Buffer,也不含字体数据库本身。 let mut cache_bytes: usize = swash @@ -274,8 +301,10 @@ pub fn draw_clipped( .flatten() .map(|image| image.data.len()) .sum(); + // 逐行保留基线位置,再逐个整形字形处理;一个源字素不一定对应一个渲染字形。 for run in buffer.layout_runs() { for glyph in run.glyphs { + // 将字形布局转换为可缓存的物理位置/键,run.line_y 提供该行基线偏移。 let physical = glyph.physical((0., run.line_y), 1.); // Conservative font-sized guard includes bearings/overhangs. The // exact bitmap bounds below determine the actual visible pixels. @@ -283,6 +312,7 @@ pub fn draw_clipped( let guard = glyph.font_size * 4. / lod; let gx = physical.x as f32 / lod; let gy = physical.y as f32 / lod; + // 粗筛完全远离 clip 的字形可省下位图生成;通过粗筛后仍用实际 placement 做精确可见范围。 if gx + guard < clip.x || gx - guard > clip.x + clip.width || gy + guard < clip.y @@ -296,10 +326,12 @@ pub fn draw_clipped( cache_bytes = 0; } // 只对新生成的图像增加字节统计;缓存中的生成失败项由 None 跳过,不参与绘制。 + // get_image 可能命中或新生成;只给新条目累加数据字节,避免重复命中把预算重复计费。 let cached = swash.image_cache.contains_key(&physical.cache_key); let Some(image) = swash.get_image(fonts, physical.cache_key) else { continue; }; + // 生成成功才增加位图数据长度;无可用图像的字形在上面 None 分支直接跳过。 if !cached { cache_bytes += image.data.len(); } @@ -322,6 +354,7 @@ pub fn draw_clipped( .min(image.placement.width.saturating_sub(1)); let v = (((y as f32 + 0.5 - top) * lod) as u32) .min(image.placement.height.saturating_sub(1)); + // u/v 是位图内像素下标,先计算单像素索引;不同 content 分支再决定每像素字节数。 let index = (v * image.placement.width + u) as usize; // 灰度 Mask 把覆盖率乘整体 alpha;Color 保留位图 RGB 并乘其 alpha。 // SubpixelMask 不适用此软件合成路径,直接跳过。 @@ -332,6 +365,7 @@ pub fn draw_clipped( base.blue, (u16::from(image.data[index]) * u16::from(color.alpha) / 255) as u8, ), + // 彩色字形每像素占 RGBA 四字节,保留位图 RGB 而非普通文字的 base RGB。 cosmic_text::SwashContent::Color => { let i = index * 4; Color::rgba( @@ -353,6 +387,7 @@ pub fn draw_clipped( /// Fit a single displayed source line without slicing a UTF-8/grapheme cluster. /// 按真实字体测量截断到给定宽度,以字素簇边界拼接省略号,避免切开组合字符。 +// 侧栏和工具的单行展示调用,结果只用于预览;不会改源文本、AST 范围或编辑器存储。 pub fn fit(value: &str, width: f32, style: TextStyle) -> String { use unicode_segmentation::UnicodeSegmentation; @@ -361,6 +396,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String { // 仅预览截断至约 8 KiB 字素边界,不改保存的源码。单个超长字素也不能被拆开。 let truncated = value.len() > 8192; let value = if truncated { + // 在 8 KiB 内选择最后一个字素起点作为切片边界,超长首字素可能让预览正文变空。 let end = value .grapheme_indices(true) .map(|(index, _)| index) @@ -376,6 +412,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String { return value.to_owned(); } let ellipsis = "…"; + // 没有正宽度或连省略号都放不下时返回空串,不输出超出分配宽度的占位符。 if width <= 0.0 || measure(ellipsis, style).width > width { return String::new(); } @@ -384,6 +421,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String { .grapheme_indices(true) .map(|(start, part)| start + part.len()) .collect(); + // 二分上下界是完整字素数量,不是 UTF-8 字节数;真正切片位置由 ends 转换。 let mut low = 0; let mut high = ends.len(); // 二分搜索可容纳的最长字素前缀;取上中位数使成功分支能推进 low,避免相邻边界时死循环。 @@ -392,6 +430,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String { let end = ends[count - 1]; // 省略号也参与真实整形测量,不能简单从总宽度减去字符数估算。 let candidate = format!("{}{ellipsis}", &value[..end]); + // 每次候选连同省略号一起整形测量;成功提高下界,失败排除当前长度及更长候选。 if measure(&candidate, style).width <= width { low = count; } else { @@ -405,19 +444,26 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String { /// Register an explicit face for graphics resources. Repeated identical loads /// reuse the family; conflicting content and unbounded global growth fail. +/// 注册显式字体族并返回稳定 ID;同名但内容哈希不同,或超过族数量预算时拒绝注册。 +// graphics 的字体资源 API 读取有限字体数据后调用,返回可放入绘制样式的稳定族 ID。 pub fn register_font(bytes: Vec, family: &str) -> anyhow::Result { use std::hash::{Hash, Hasher}; + // 内容哈希与族名用于重复注册检查,哈希留在内存中,不替代文件路径或字体族名。 let mut hash = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut hash); let hash = hash.finish(); + // 字体注册与整形/绘制共用全局锁,修改字体数据库期间不会有另一调用同时使用旧数据库。 let mut e = engine().lock().expect("text engine lock"); + // 同名同哈希复用原 ID,同名不同内容直接报错,不替换已经发布给样式的字体身份。 if let Some((id, (_, old))) = e.custom.iter().enumerate().find(|(_, v)| v.0 == family) { anyhow::ensure!(*old == hash, "font family already has different content"); return Ok(TextFamily::Custom(id as u32)); } anyhow::ensure!(e.custom.len() < 32, "font registry exceeds 32 families"); + // 保存数据库副本用于请求族名验证失败时回滚;成功注册才写 custom 和清整形缓存。 let previous = e.fonts.db().clone(); e.fonts.db_mut().load_font_data(bytes); + // 加载后检查字体库是否存在请求族名;这里按整个库查询,不是单独解析此次文件的族名列表。 if !e .fonts .db() @@ -427,6 +473,7 @@ pub fn register_font(bytes: Vec, family: &str) -> anyhow::Result *e.fonts.db_mut() = previous; anyhow::bail!("font data does not contain requested family"); } + // 尾部追加保持既有 ID 不变;字体库变化后旧 Buffer 可能引用旧选择,因此清空整形结果。 let id = e.custom.len() as u32; e.custom.push((family.into(), hash)); e.buffers.clear(); @@ -443,6 +490,7 @@ mod tests { /// 用一万倍缩放检验绘制回调被限制在32x32裁剪内,同时检查字形缓存字号不超过512以控制中间资源。 fn extreme_zoom_rasterizes_only_the_clip_with_bounded_glyph_sizes() { // 极端缩放测试限制实际回调在 32×32 裁剪区,同时检查缓存字号不超过 512。 + // 统计实际输出回调数,坐标断言验证只遍历 32×32 可见区域,不按巨型字形面积扫描。 let mut count = 0; draw_clipped( "M", @@ -457,6 +505,7 @@ mod tests { }, ); assert_eq!(count, 1024); + // 绘制结束已释放全局锁,再获取它检查真实字形缓存键中的字号,而非只看局部 lod 变量。 let engine = engine().lock().unwrap(); assert!( engine @@ -473,6 +522,7 @@ mod tests { let style = TextStyle::ui(1); // 混合中文、长英文词、组合字符和 emoji,验证窄/宽换行缓存隔离且不改变无换行测量结果。 let text = "中文段落 with words and long_unbroken_identifier é 👩‍💻 中文段落"; + // 先记录无约束宽度,再分别测窄宽段落,验证两套缓存不会污染自然宽度。 let intrinsic = measure(text, style); let narrow = measure_wrapped(text, style, 70.0); let wide = measure_wrapped(text, style, 240.0); @@ -481,7 +531,9 @@ mod tests { assert!(narrow.width <= 70.1); assert_eq!(measure(text, style).width, intrinsic.width); assert_eq!(measure_wrapped(text, style, 0.0).width, 0.0); + // 检查真实缓存键和 Buffer 高度,除了测量结果还验证宽度确实参与缓存区分。 let mut engine = engine().lock().unwrap(); + // 文本与样式相同,仅 Some(width) 不同,键及排版高度都应不同。 let narrow_key = engine.prepare_width(text, style, Some(70.0)); let wide_key = engine.prepare_width(text, style, Some(240.0)); assert!(narrow_key != wide_key); @@ -497,6 +549,7 @@ mod tests { let mono = TextStyle::code(); assert!((measure("WWWW", mono).width - measure("iiii", mono).width).abs() < 0.1); assert_eq!(measure("one\ntwo", ui).height, ui.line_height * 2.0); + // 以实际整形结果为基准检查同比例字号的宽度变化,容许少量字体整形舍入差异。 let width = measure("Hello Endor", ui).width; assert!((measure("Hello Endor", ui.scaled(1.25)).width - width * 1.25).abs() < 0.5); } @@ -506,8 +559,10 @@ mod tests { fn system_fallback_shapes_chinese_without_missing_glyphs() { let mut engine = engine().lock().unwrap(); // 两种字体风格都要求四个中文字符生成非零 glyph_id,确保系统回退实际找到了字形。 + // 界面与代码字体各检查一次中文回退,缺少环境 CJK 字体时该测试会暴露缺字,不能只凭非零宽度判断。 for style in [TextStyle::ui(1), TextStyle::code()] { let key = engine.prepare("中文字体", style); + // 直接查看整形 glyph_id,ID=0 表示缺字;中文用例期望四个字符各有可用字形。 let glyphs: Vec<_> = engine.buffers[&key] .buffer .layout_runs() @@ -523,10 +578,12 @@ mod tests { /// 按实际测量宽度截断带组合字符的文本,结果需是原文字素边界前缀加省略号,且整体不超宽。 fn ellipsis_fits_actual_width_and_preserves_graphemes() { let style = TextStyle::code(); + // 短代码行能完全放下时应原样返回,随后再测试带组合音标的必须截断输入。 let short_source = "let s = ui.text(\"中文字体\")"; assert_eq!(fit(short_source, 365.0, style), short_source); let value = "e\u{301}e\u{301}中文字体e\u{301}e\u{301}"; let width = measure("e\u{301}e\u{301}中…", style).width + 0.01; + // 目标宽度来自已知带省略号文本的真实测量,加少量余量避免边缘舍入影响判断。 let fitted = fit(value, width, style); // 检查结果前缀确实来自原文且结束于字素边界,而不仅是合法 UTF-8 字节边界。 let prefix = fitted.strip_suffix('…').unwrap(); diff --git a/src/render/viewport.rs b/src/render/viewport.rs index 0f8afe5..68f3e92 100644 --- a/src/render/viewport.rs +++ b/src/render/viewport.rs @@ -10,6 +10,7 @@ use endor_core::{ObjectPayload, ObjectRef, ObjectStore, VmError}; /// remain ordinary traced, bindable VM attributes. #[derive(Debug)] /// 托管 2D 相机的名义标记,实际位置/缩放是被追踪的普通属性。 +// ui_bridge 创建相机时分配此 payload;camera 读取入口据此验证名义身份,属性值仍保存在 VM 属性存储。 pub struct Camera2D; impl ObjectPayload for Camera2D { /// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。 @@ -25,22 +26,27 @@ impl ObjectPayload for Camera2D { true } /// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。 + // 标记自身没有托管引用;x/y/zoom 等普通属性的追踪由对象存储负责,不在此重复枚举。 fn trace(&self, _: &mut dyn FnMut(ObjectRef)) {} } #[derive(Debug, Clone, PartialEq)] /// 从 VM 提取的独立相机数值描述,渲染与布局投影不直接读取托管堆。 pub struct CameraView { + // 相机托管身份的文本形式,供快照比较和输入重新关联;原生描述本身不拥有 VM 根。 pub identity: String, + // 相机在世界空间的左上位置,投影时先从内容坐标中扣除,再乘 zoom。 pub x: f32, pub y: f32, pub zoom: f32, } /// 读取并校验托管 Camera2D 的位置和缩放,生成原生投影描述。 +// block/snapshot 提取 CanvasViewport 视觉、相机创建校验及 page_input 交互读取都共用此入口。 pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result { // 先验证实际宿主 payload,避免只有 x/y/zoom 同名字段的普通对象冒充相机。 store.payload::(object, "Camera2D")?; + // 三个属性都经有限 Number 转换,位置允许负值;zoom 随后再用专门正范围收紧。 let x = crate::scroll::coordinate(&store.get_attr(object, "x")?)?; let y = crate::scroll::coordinate(&store.get_attr(object, "y")?)?; let zoom = crate::scroll::coordinate(&store.get_attr(object, "zoom")?)?; @@ -61,6 +67,7 @@ pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result, mount: &crate::mount::Mount, @@ -71,17 +78,20 @@ pub fn fit( "fitView requires a committed CanvasViewport Mount", )); } + // 从当前 Block 解析有效视觉属性,取得真实相机引用,不能只信旧视觉快照里的相机数值。 let (_, data) = crate::block::resolve(cx.store(), mount.block)?; let data = crate::block::visual_data(cx.store(), data)?; let camera = data .camera .ok_or(VmError::InvalidArgument("missing viewport camera"))?; + // 首子出现是场景根,弱身份在 NativeContext 内升级保活;没有根不能推导场景边界。 let root = mount .first_child .map(|weak| cx.upgrade(weak)) .transpose()? .flatten() .ok_or(VmError::InvalidArgument("missing scene occurrence"))?; + // 复制场景根 Mount 的原生描述,后面遍历其直接孩子并以它的原点作坐标基准。 let scene = cx .store() .payload::(root, "Mount")? @@ -90,11 +100,14 @@ pub fn fit( let mut next = scene.first_child; let mut bounds: Option<[f32; 4]> = None; let mut count = 0; + // 沿稳定 sibling 链逐项汇总,每步递增预算,异常长链不能让 fitView 无限扫描。 while let Some(weak) = next { count += 1; + // 最多处理一万项,超过时在写相机属性之前报执行限制错误。 if count > 10_000 { return Err(VmError::ExecutionLimit("fitView scene bounds")); } + // 每个直接孩子必须仍然存活;失效不能静默跳过,否则拟合范围会遗漏内容。 let object = cx .upgrade(weak)? .ok_or(VmError::InvalidArgument("expired scene child"))?; @@ -115,13 +128,16 @@ pub fn fit( b[3].max(rect[3]), ] })); + // 在读取当前孩子后推进到同层下一出现,不递归进入其内部世界或后代布局。 next = child.next_sibling; } + // 读取视口当前盒模型,以扣除 padding/border 后的内容尺寸决定相机倍率。 let node = crate::block::snapshot(cx.store(), mount.block)?; let inset = crate::layout::insets(&node); // 扣除布局 inset 后才是相机可用的内容区域;负值归零,避免用边框占用部分计算缩放。 let width = (mount.geometry.width - inset.horizontal()).max(0.0); let height = (mount.geometry.height - inset.vertical()).max(0.0); + // 有内容时居中拟合,空场景则恢复原点与单位缩放;不会自动改变视口分配尺寸。 let (x, y, zoom) = if let Some(bounds) = bounds { // 两个轴分别计算可容纳倍率再取较小者;内容跨度增加 48 世界单位形成留白,最终钳到相机范围。 let zoom = (width / (bounds[2] - bounds[0] + 48.0)) @@ -137,6 +153,7 @@ pub fn fit( (0.0, 0.0, 1.0) }; // 最终通过普通属性写入走 VM 变更追踪/绑定机制;共享该相机的其他视口也会观察到更新。 + // 逐个普通属性写入,任一写入失败向上传播;这里没有把三次 set_attr 包成原子事务。 for (name, value) in [("x", x), ("y", y), ("zoom", zoom)] { cx.set_attr( camera, @@ -150,12 +167,14 @@ pub fn fit( #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] /// 可组合的均匀缩放与平移;正向绘制和逆向命中必须使用同一结果。 pub struct Transform { + // 均匀缩放,point/rect 正向乘此值,inverse 撤销时除以此值;累计变换须经 valid。 pub zoom: f32, pub x: f32, pub y: f32, } impl Default for Transform { - /// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。 + /// 返回不改变坐标的单位缩放与零平移,供根视图和面板纹理空间使用。 + // 单位变换供无相机或独立面板纹理空间使用;None 的 mount.view 也表示这一效果。 fn default() -> Self { Self { zoom: 1.0, @@ -166,11 +185,13 @@ impl Default for Transform { } impl Transform { /// 将逻辑点按当前缩放和平移变换到外层视图。 + // document 的坐标查询、绘制命令投影与 panel 映射共用,公式先缩放后平移。 pub fn point(self, point: Point) -> Point { Point::new(point.x * self.zoom + self.x, point.y * self.zoom + self.y) } /// 将视图点反算回逻辑坐标,供命中和手势锚点计算使用。 // 不在这里额外校验 zoom,调用方必须先保证 valid;零缩放无法反算。 + // MountGeometry 和 page_input 的手势计算使用,先撤平移再除缩放,不修改原布局几何。 pub fn inverse(self, point: Point) -> Point { Point::new( (point.x - self.x) / self.zoom, @@ -178,7 +199,9 @@ impl Transform { ) } /// 使用同一缩放和平移转换矩形的位置与尺寸。 + // project 用于把视口边界投到父空间;缩放为正的约束保证宽高无需翻转。 pub fn rect(self, rect: Rect) -> Rect { + // 只变换左上点,宽高乘同一 zoom;此类型不支持旋转或非均匀缩放。 let point = self.point(Point::new(rect.x, rect.y)); Rect::new( point.x, @@ -189,6 +212,7 @@ impl Transform { } /// 将当前变换再放入外层变换,保持绘制与逆向命中的组合顺序一致。 // 组合为 outer(self(point)):内层平移也要乘外层缩放,不能只把两次平移相加。 + // project 在嵌套 CanvasViewport 时组合局部相机与祖先变换,返回 outer ∘ self。 pub fn then(self, outer: Self) -> Self { Self { zoom: self.zoom * outer.zoom, @@ -197,6 +221,7 @@ impl Transform { } } /// 验证累计二维变换:zoom 有限且在 (0,1000000],平移有限且绝对值不超过一万亿。 + // 嵌套投影和命令校验在使用前检查累计预算,单个 Camera2D 合法不意味着任意多层组合都合法。 pub fn valid(self) -> bool { // 允许累计平移远大于单个世界坐标范围,因为相机偏移还会乘缩放;同时限制嵌套累计放大。 self.zoom.is_finite() @@ -210,6 +235,7 @@ impl Transform { } /// 计算视口与祖先裁剪的共同可见矩形。 +// project 将当前视口边界与已累计祖先 clip 求交,不能扩大祖先允许绘制的范围。 fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { // clip 用 [x,y,width,height],分离的矩形返回零面积,不产生负宽高。 let x = a[0].max(b[0]); @@ -223,6 +249,7 @@ fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { } /// 遍历出现树,组合相机与父视图变换,建立裁剪和世界 UI 映射,最后重建屏幕命中索引。 +// ui_bridge::finish_layout_frame 在 scroll::project 之后调用,完成后 Frame 同时具备命令投影和输入宽阶段索引。 pub fn project( root: &crate::ui_bridge::UiNode, frame: &mut crate::document::Frame, @@ -230,8 +257,10 @@ pub fn project( use crate::ui_bridge::UiKind; use std::collections::HashMap; use std::sync::Arc; + // 按 Block 身份建立语义视觉索引;多个 Mount 可查到同一节点,但每个出现单独组合视图。 let mut nodes = HashMap::new(); let mut work = vec![root]; + // 显式栈只展开视觉节点树,不以此遍历顺序决定绘制或输入优先级。 while let Some(node) = work.pop() { nodes.insert(node.block_id.as_str(), node); work.extend(node.children().iter().map(|node| node.as_ref())); @@ -242,6 +271,7 @@ pub fn project( Option<[f32; 4]>, Option>, ); + // 上下文按父出现 ID 传给孩子;遍历要求 frame.mounts 父先子后,避免读取尚未建立的父投影。 let mut contexts = HashMap::::new(); // 保留 next_sibling 查找表,遍历可变 Mount 时仍可按稳定子出现顺序为面板根建立映射。 let next: HashMap<_, _> = frame @@ -249,8 +279,10 @@ pub fn project( .iter() .map(|mount| (mount.id, mount.next_sibling)) .collect(); + // 3D 视口先为后续面板根登记映射和纹理尺寸,轮到该根时再切换独立纹理坐标空间。 let mut panels = HashMap::, crate::layout::Size)>::new(); + // 逐出现处理,读取当前业务视觉但只改 Frame 的派生投影,不写 VM 相机或业务节点属性。 for mount in &mut frame.mounts { let node = nodes[mount.block_id.as_str()]; // 面板根进入独立纹理像素空间,二维变换重置为单位,裁剪重置为面板尺寸;普通节点沿用父上下文。 @@ -266,6 +298,7 @@ pub fn project( .and_then(|parent| contexts.get(&parent).cloned()) .unwrap_or_default() }; + // 检查继承/重置后的累计二维变换,超预算时结束草稿处理,不把非法缩放传给后续命令。 anyhow::ensure!( view.valid(), "nested viewport transform exceeds numeric budget" @@ -275,10 +308,13 @@ pub fn project( mount.panel = panel.clone(); mount.clip = clip; mount.viewport_clip = None; + // 当前出现使用继承的 view/clip;仅其子树才叠加本层相机或本层子裁剪。 let mut child_view = view; let mut child_clip = clip; let inset = crate::layout::insets(node); + // 独立二维/三维视口裁内容盒,普通 clip_children 裁边框内,Scroll 最后补默认容器裁剪。 let viewport = if let UiKind::CanvasViewport { camera, .. } = &node.kind { + // 内容原点含左/上 inset,局部相机围绕该原点锚定,不围绕全页原点缩放。 let x = mount.x + inset.left; let y = mount.y + inset.top; // 围绕视口内容原点建立相机变换:原点保持锚定,世界内容先减相机位置再缩放,最后套父变换。 @@ -294,6 +330,7 @@ pub fn project( (mount.width - inset.horizontal()).max(0.0), (mount.height - inset.vertical()).max(0.0), )) + // 3D 摄像机由 View 负责,这里只建立二维内容矩形,不给世界网格再套一个 Camera2D。 } else if matches!(node.kind, UiKind::Viewport3D { .. }) { Some(Rect::new( mount.x + inset.left, @@ -315,11 +352,13 @@ pub fn project( } else { None }; + // 只有产生了本层可见区域才收窄 child_clip;当前 Mount 的外层 clip 保持为继承值。 if let Some(rect) = viewport { // 本视口边界按父变换投影,不能套当前相机的 child_view,否则相机会连自己的视口框一起移动。 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))); + // 独立视口另存 viewport_clip,后续命中和面板 Mapping 使用内容区裁剪而非整个外框。 if mount.canvas_viewport || mount.scene3d_viewport { mount.viewport_clip = child_clip; } @@ -331,8 +370,10 @@ pub fn project( } = &node.kind { // 沿面板映射链限制嵌套深度,防止递归纹理装配和输入逆投影无限展开。 + // 沿已有父面板映射链计数,不把普通二维嵌套计作 3D 纹理深度。 let mut depth = 0; let mut ancestor = panel.clone(); + // 只读遍历不可变 Arc 链,每前进一步计一层,限制下面新建映射后的总嵌套规模。 while let Some(mapping) = ancestor { depth += 1; ancestor = mapping.parent.clone(); @@ -341,7 +382,9 @@ pub fn project( let mut child = mount.first_child; // slots、面板尺寸和直接子出现按同序配对;缺少预期子出现时立即报错,不能把内容映射到错误面板。 for (key, size) in node.slots.iter().zip(sizes) { + // slots 与 sizes 由场景快照提供,当前直接子出现必须存在,不能因缺项错位关联后续面板。 let id = child.ok_or_else(|| anyhow::anyhow!("missing 3D UI root occurrence"))?; + // Mapping 保存所属视口的相机、布局矩形、外层二维变换和父面板链,供正逆坐标转换共用。 let mapping = Arc::new(crate::scene3d::panel::Mapping { owner: mount.id, key: *key, @@ -358,11 +401,14 @@ pub fn project( }); // 记录待遍历面板根的新上下文;之后走到该孩子时切换纹理空间,不把外层 clip 误当纹理像素。 panels.insert(id, (mapping, *size)); + // 依照预存 sibling 表推进,不在可变遍历 frame.mounts 时再次借用整个出现数组。 child = next[&id]; } } + // 保存孩子最终上下文;3D 面板根随后通过 panels 表覆盖成纹理空间入口。 contexts.insert(mount.id, (child_view, child_clip, panel)); } + // 投影完成后按命令 owner 查回对应出现,重建平行元数据;此时不再修改 Mount 几何。 let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect(); // 按每条命令的 Mount 归属重建 view/clip,确保源绘制、默认绘制和输入都使用这一轮最终投影。 frame.command_views.clear(); @@ -371,6 +417,7 @@ pub fn project( frame.command_mounts.len() == frame.commands.len(), "missing command occurrence table" ); + // 每条命令复制所属出现的 view/clip,不能按命令自身位置猜测归属。 for owner in &frame.command_mounts { let mount = mounts .get(owner) @@ -397,16 +444,19 @@ mod tests { } .valid() ); + // 构造非单位内外层变换,包含不同缩放和平移,防止只测试平移而漏掉组合次序。 let inner = Transform { zoom: 2.0, x: 12.0, y: -40.0, }; + // 外层缩放也会缩放内层平移,后面的逐次点变换是独立于组合公式的参照。 let outer = Transform { zoom: 0.5, x: 80.0, y: 20.0, }; + // 用非零且带负分量的输入点核对正向与逆向恢复,不改变该布局点本身。 let point = Point::new(-5.0, 100.0); // 以两次实际点变换验证组合顺序,再检查逆变换和矩形尺寸,避免只验证某一种公式写法。 let composed = inner.then(outer);