docs: explain text shaping and viewport projection flows

This commit is contained in:
2026-11-12 19:04:37 +08:00
parent 03241d4ac1
commit 9517053a75
2 changed files with 108 additions and 1 deletions
+57
View File
@@ -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<String>,
// 字形栅格缓存与整段 Buffer 缓存分开维护,draw_clipped 根据位图条数/字节量整批清理。
swash: SwashCache,
buffers: HashMap<TextKey, PreparedText>,
// 累计缓存文本的 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<f32>) -> 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<TextEngine> {
}
/// 返回不受换行宽度约束时的字体整形尺寸。
// 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<u8>, family: &str) -> anyhow::Result<TextFamily> {
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<u8>, family: &str) -> anyhow::Result<TextFamily>
*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();
+51 -1
View File
@@ -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<CameraView, VmError> {
// 先验证实际宿主 payload,避免只有 x/y/zoom 同名字段的普通对象冒充相机。
store.payload::<Camera2D>(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<CameraView, VmEr
/// Fit finite scene bounds using this occurrence's allocation. Sharing a camera
/// is explicit, so fitting one view then intentionally affects all its users.
/// 根据这个 CanvasViewport 出现的有限内容范围调整其相机;显式共享相机会同步影响其他视口。
// model/mount 的 fitView 协议调用,按指定出现的内容分配更新其共享相机,不重新排列业务节点。
pub fn fit(
cx: &mut endor_core::NativeContext<'_>,
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::<crate::mount::Mount>(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<Arc<crate::scene3d::panel::Mapping>>,
);
// 上下文按父出现 ID 传给孩子;遍历要求 frame.mounts 父先子后,避免读取尚未建立的父投影。
let mut contexts = HashMap::<u64, Scope>::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::<u64, (Arc<crate::scene3d::panel::Mapping>, 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);