布局算法
布局将 LoweringResult 中的时间列、轨道和附属关系转换为 DocumentLayoutResult。它确定对象的尺寸、位置、分页和最终几何,不重新解释语法,也不决定演奏顺序。
布局的核心是约束求解:同列对象需要横向对齐,同轨对象需要共用视觉基线,装饰与附属对象需要申报空间,页面则限定可用宽高。音乐时间提供排列依据,不直接等于像素距离。
理解布局时,需要区分三种范围:
| 范围 | 表达什么 | 谁负责 |
|---|---|---|
| 正文列 | 参与全局对齐的可见 Temporal;复合体作为整体占列 | Lowering 提供时间列,布局筛选可见主体 |
| 子域 | 复合体内部的一小段乐谱:局部列、私有 Track 树和属于它的附件,先排成刚性块 | 复合体声明,布局按正文规则排版 |
| 内容作用域 | 函数内容中实际产生的主体和附件,用于几何包含 | Lowering 回调收集,布局测量 |
子域对外只是一个主体;内容包含也不等于轨道占用。下面分别说明这些数据在布局各阶段的职责。
LayoutBox 与对齐基准
Section titled “LayoutBox 与对齐基准”每个可见 Temporal 都有一个 LayoutBox,其中 x/y/w/h 表示定位矩形的位置和尺寸,另外两个字段用于对齐:
anchor:横向对齐点到盒子左边界的距离,全局对齐点为x + anchor。visualAxis:轨道视觉轴到盒子顶部的距离,全局视觉轴为y + visualAxis。
prepareLayout 填写固有尺寸和对齐基准,即 w/h/anchor/visualAxis;布局器随后写入 x/y。字号在解析时已经换算为 AST 上的 px 值,布局阶段直接读取。
定位矩形不等于完整占用:上方装饰可以使用负的局部 y,纯留白也不必写入 box.h。布局内部通过 getLayoutBounds(host) 读取包含上下装饰与留白的完整占用,通过 getLayoutBounds(host, true) 读取不含纯留白的内容边界。上方装饰不移动主体的局部原点、数字或视觉轴;需要叠放、撑行或汇总画布边界时,不能只读取 box.h。
layoutDocument 会修改 Temporal 和弹簧配置,所以一份 LoweringResult 只支持布局一次。需要重新布局时,请从 AST 重新执行 Lowering。
主体、装饰与附属几何
Section titled “主体、装饰与附属几何”主体以 LayoutBox 参与对齐;局部装饰申报所属主体的上下空间,也可以扩展宽度或提供端口;附属对象根据主体位置生成独立几何,可以跨轨、跨行或属于整页。
regions 描述绘制与命中的几何范围,occupancy 描述应向轨道申请的排版空间。跨轨连线的外接矩形可能包含轨道之间的空白,不应把这部分空间重复计入某条轨道的行高。
局部端口相对 LayoutBox 左上角;最终 attachment 区域使用全局坐标。连接关系依赖端口契约,不应依赖某种音符的内部绘制结构。
横向、纵向与页面约束
Section titled “横向、纵向与页面约束”| 层次 | 输入约束 | 求解结果 |
|---|---|---|
| 横向 | 时间列、主体宽度、时值间距与局部定宽约束 | 各列对齐点及主体的 x |
| 纵向 | Track 层级、视觉轴和相对轨道的占用 | 轨道偏移与自然行高 |
| 分页 | 自然行高、页面尺寸和边距 | 页面划分及每行的全局顶部 |
横向使用弹簧模型平衡时值间距与空间压缩,详细推导见本文末尾。纵向按整行占用求解,不根据横向空隙交错压缩轨道。附属对象可能增加纵向占用,因此主体初次放置后会根据几何反馈至多再调整一次纵向布局。
布局执行顺序
Section titled “布局执行顺序”layoutDocument 按以下顺序执行:
- 按
layoutLine切行,对主体执行prepareLayoutHost:调用prepareLayout,生成固有尺寸、端口和装饰;复合体在内部准备成员并排版子域; - 每个主体分别排列上方、下方装饰,记录内容边界与完整占用,再调用
finalizeLayout; - 补齐 spring config,调用主体和正文 attachment 的
prepareHorizontal; - 每行独立进行横向求解,写回
box.x; - 汇总主体纵向占用,沿 Track 树求轴和自然行高;
- 分页,写回
box.y,调用onPlaced; - 按登记顺序测量正文 attachment,记录本轮几何和占用,供后续附件查询;后置平移不参与占用测量;
- attachment 扩张轨道占用时,再进行一次纵向放置并重新测量;
- 按最终坐标生成需要后置平移的绘制几何,子域附件随所在块平移,再合并页面、主体完整占用和 attachment 的最终边界。
horizontalOnly: true 在第 4 步后返回,供 MIDI 自动换行等内部测宽使用。它保留主体尺寸、横向约束和行数,但不执行文档级 onPlaced、纵向放置、分页与附件几何测量;attachments 和 pages 为空,bounds 为零矩形,结果不能直接用于渲染。第 1 步中复合体尺寸依赖的子域布局仍会完成,包括内部的纵向放置与附件测量。
回调在什么时候执行
Section titled “回调在什么时候执行”| 回调 | 可用的信息与调用次数 |
|---|---|
prepareLayout | 计算固有尺寸;复合体可准备内部成员并排版子域。 |
finalizeLayout | 装饰排列与边界已经完成,可以发布依赖装饰的端口,例如 note 的歌词水平对齐点。 |
prepareHorizontal | 在所属横向视图建立后执行;复合体可能转发成员回调,约束只向包含目标的视图注册。attachment 的横向准备每次文档布局执行一次。 |
onPlaced | 本轮坐标已经写入,每轮纵向放置后都会调用。 |
createGeometry | 根据本轮坐标生成新几何;占用测量至多两轮,有后置平移时还会单独生成最终绘制几何。 |
paint | 只读取最终几何,不重新测量或定位,可以重复调用。 |
head 等复合对象还可能在局部平移后再次调用成员的 onPlaced。因此,这个回调应根据当前坐标同步内部位置,不能在上一次结果上累加偏移。
createGeometry 的首轮结果可能被丢弃。只有首轮占用参与重新求解,每次调用都应根据当前主体坐标计算新几何,不累加上一次结果。若第二轮相对本轨视觉轴的占用又发生变化,引擎仍保留本轮行高。有后置平移时,被重排丢弃的试测轮次不生成绘制几何。
@adjust 的 dw/dh 修改定位矩形的尺寸,参与布局;dx/dy 是放置后的平移,不推动邻轨,也不被附件占用重排抵消。显式平移通过主体或 attachment 的可选 placementOffset 声明,折叠成员还会继承外层主体的平移。
引擎在占用测量时使用未平移的布局位置,有后置平移的测量上下文带有 layoutOnly: true;最终绘制几何使用实际位置。自定义 attachment 若自行施加后置平移,应在 layoutOnly 阶段抑制它。附件已经通过被平移的主体或端点跟随移动时,不应重复平移。dx/dy 不提供避让保证,仍可能产生视觉重叠。
纵向布局与分页
Section titled “纵向布局与分页”每轮布局先将主体和已知 attachment 的占用换算为相对轨道视觉轴的 Extent,再沿 Track 树递归求解:
solve(track): ext = 本轨占用 for group of track.groups: members = group.members.map(solve) if members 全为空: continue placements = group.measure(members, gap) 缓存组内偏移和分组占用 把无需宿主定位的分组并回 ext 用完整 ext 调用其余分组的 place,再把整体平移后的占用并回 extplace(root, -ext.top)measure 决定组内成员的相对位置,可以先对所有分组完成测量。可选的 place 则等到宿主占用完整后,再决定整组平移多少。两者都由分组声明,引擎只负责递归和合并,不需要识别 stack 或 voices。
行高取全行纵向占用的并集,不会根据对象在横向上是否错开来进一步压缩。即使某个逻辑行没有主体,attachment 也可以通过 line + track 为它申请纵向空间。
如果 measure 和 place 需要共享测量中间值,应在 Lowering 时为本轮创建策略,并把状态保存在本轮对象上,不能通过 AST 中的闭包跨轮复用。head 的两侧底边对齐就采用这种方式。
page.ts 接收自然行高和可选的强制分页数组,返回页面边界和每行的全局顶部 lineTops,不读取 Track 或具体对象。数组按行号索引:undefined 不强制分页,true / false 强制分页并指定上一页是否分配余白。
@page 的长度在解析时换算为 px。内容宽度等于页宽减去左右边距;height <= 0 表示无限高,有限页面则至少需要容纳一行。排满的非末页保持首行贴上边距,扣除最小行距后,将剩余高度均分到每个系统下方。末页保留自然行距。
@pagebreak(justify=true)(别名 @pb)产生不可见控制事件,在类型化 addon 中声明 { breakBefore: 1, pageBreakBefore: justify },复用换行合列规则。核心只识别通用字段,不依赖函数名称或 AST 类。拆行时直接收集分页数组,同一边界的 justify 用逻辑与合并,即 false 优先。新开的尾行即使没有可见主体也保留,保证开头、连续和末尾调用的空白页语义。
强制边界与自然溢出复用同一个封页过程,每轮纵向布局都使用这些边界和本轮真实行高;justify=false 保留最小行距。无限页高也遵守强制边界,但不拉伸,空白页只有上下边距,所有页面边界仍为有限数。
@page(numbering=...) 自己注册一个 foreground attachment 来绘制页码。它读取 AttachmentLayoutContext.pages,在每页下边距内垂直居中,使用固定的字号和颜色;字体按显式 font、page.font、font、sans-serif 的顺序选择,并在解析期固化。页码的 regions 不带 line + track,因此只计入画布边界,不占行高。
页码模式串中的 1 是计数符号:最后一个表示总页数,其余表示当前页。例如,第 2 页、共 5 页时,"1 / 1" 显示为 2 / 5。
非法页面配置抛出 E_INVALID_PAGE_CONFIG。单行高于内容区时,有可见源码内容则抛出 E_PAGE_OVERFLOW;无法归因到源码的 attachment-only 空行保留分页器的结构化错误。
横向求解前准备
Section titled “横向求解前准备”HorizontalSpringConfig 保存 alpha_L/R、mu_L/R、beta_L/R 六个弹簧参数。主体尺寸准备好后,引擎先用 completeSpringConfig 补齐默认值,再运行 hook,函数此时可以直接使用这些字段。修改 alpha 不会自动重算已经补齐的 beta。
prepareHorizontalLine 建立一行视图、补齐配置并调用成员的 prepareHorizontal,此时只注册约束。返回对象的 layout 方法才创建本轮 LayoutElement、执行 hook 并求解。全局布局在两步之间调用 attachment 的 prepareHorizontal,使其配置修改也进入本轮输入。
每个 attachment 的 LayoutAttachment.prepareHorizontal 在整篇布局中只调用一次,收到各谱面行的只读视图 HorizontalLineView:
index使用与host.layoutLine相同的行号。trackRuns按列顺序列出同一 Track 上的主体,相邻项就是视觉上的前后邻居。columnOf(host)返回主体的时间列下标,不在本行时返回 -1。registerHorizontalLayoutHook(from, to, hook)为一段列注册局部布局操作。
同一行的局部 hook 按跨度从小到大稳定执行,共享列坐标 X 和固定间隙标记 fixed。具体函数可以调整弹簧参数或调用区域布局,引擎负责执行顺序,不解释函数的具体意图。
局部横向求解
Section titled “局部横向求解”layoutHorizontal 每行只做一次占位补齐,随后创建共享的 X 与 fixed。局部 hook 和最终整行布局都调用 layoutHorizontalRegion:
- 连续 fixed 列先折叠成刚性虚拟列。虚拟列的左边界参数继承最左元素,右边界参数继承最右元素;普通元素的
duration_L/R相同,虚拟列分别保留首尾时长。 - 内部自由求解器进行预排列;自然宽度超限时,通过墙力、三对角线性求解和阻尼牛顿迭代求平衡,不需要处理 hook 或 fixed。
- 求解后把虚拟列的整体位移展开回原列,fixed 内部坐标差保持不变。
定宽 box 使用这套机制冻结内部间隙;固定区域可以嵌套,但不能部分交叉或对相同列声明冲突宽度。
box 在 Lowering 退出时把横向注册组合到首成员的回调上,只有全部成员都在当前视图时才注册。hook 先按需执行定宽求解,再给本框首列成员的 WL、末列成员的 WR 加上 padding + stroke / 2。定宽读取内层 hook 已累加的占位,本层留白仍在指定宽度之外。同列其他轨道的对象不增加这层留白,内容盒、端口和时间列保持不变。
layoutLocalSequence 为复合体排一段局部序列:准备尺寸、建立视图、注册约束、创建输入、执行 hook、自然宽度求解。局部序列遵循书写顺序;hook 执行前仅把临时 margin_L/R 归零,不修改成员的时值或弹簧配置。求解上限为 Infinity,不启用撑满,因此无定宽约束时直接采用自然预排列。up/down 与 grace 的宿主单列直接使用它;子域横排走同一流程,只是同列成员按各自 Track 分行。
局部宽度按最终 WL/WR 与锚点位置计算,包含首尾留白,占位左沿归零后返回。
为什么需要子域
Section titled “为什么需要子域”{@tuplet({1 2 3},2) & 1}>1 在正文里只有一个 GraceTemporal。倚音内部有自己的列、声部和括线;括线压在下声部上时,上声部必须让开。正文只看到复合体,看不到倚音声部之间的空隙,所以这件事只能在倚音内部解决。
子域把这类内部内容当作一小段乐谱:先按正文规则排完,高度和附件都定下来,再作为刚性块交给复合体定位。正文时间列、弹簧求解和播放语义保持不变。
复合体在 prepareLayout 中调用 layoutSubdomain(owner, columns, root, attachments)。columns 的外层表示先后,内层是同一时刻不同声部的成员;成员位于 root 的 Track 树上。grace 的写法:
const block = context.layoutSubdomain!(this, this.graceColumns, this.root, this.attachments);引擎按正文行的流程排版:
- 准备成员并零间隙横排;
- 沿
root的 Track 树求轴并放置成员:同轨按视觉轴对齐,&、@voices等分组按自己声明的 measure/place 排列,间隙取成员最大字号的 0.75 倍; - 按登记顺序测量子域附件,占用扩张时重排一次;
- 返回块宽、块高和成员的块内位置。
块内坐标以块的左上角为原点。positions 不含成员 onPlaced 追加的平移(例如 @adjust),复合体每次同步坐标都从这里出发。grace 以宿主的 shoulder 端口确定倚音块底边的高度,再留出视觉间隙;onPlaced 只同步绝对坐标。
grace 的私有根轨由宿主轨分出,但不挂进宿主轨的分组,因此正文的 Track 树看不到它,不同倚音也不会按 laneKey 共用声部。
layoutSubdomain 由 layoutDocument 在主体准备阶段注入;调用方创建的上下文里没有它。
- 内容附件没有
endPoints,例如 box、声部括号、歌词,跟随声明位置。grace 用 Lowering 分组收集倚音内容里声明的附件,交给子域。 - 关系附件带
endPoints,属于同时包含全部端点的最内层子域,与写在哪里无关:{2@a 3@b}>1 @tie(a,b)与把 tie 写进倚音里完全相同。
引擎在准备阶段开始前,把端点落在折叠内容里的关系按所在复合体分组;子域横排完成后,认领端点全部落在自己成员上、且尚未被认领的那些。横排会先排好内层子域,所以每条关系只归入一个最内层子域,在该域完成测量与重排。宿主不属于倚音子域,端点连到倚音外或宿主的关系交给外层测量,不在块内预留空间。
子域附件在块内测量,此后不再参与正文测量,也不调用 prepareHorizontal。最终几何随块平移:引擎比较块内一个成员在测量时和最终的位置,把差值作用到区域与绘制上。有后置平移时,占用使用布局位置下的快照,绘制使用实际位置下的快照。正文附件依赖子域附件时,例如外层框的内容里有一条这样的 tie,查询会按当前测量阶段读取相应的盒子和占用。
对外只是一个主体
Section titled “对外只是一个主体”- 正文的范围查询把折叠端点沿
foldedInto上溯到正文里的复合体,不进入子域内部。 - 私有轨沿
Track.parent归入复合体所在的轨,getVisualAxis、getHostExtent和附件占用都按那条轨计算。 - 复合体的盒子包含子域附件申报的占用,外层框、叠放和正文避让因此看到完整的块。
up/down 没有子域:成员由折叠体直接叠放,同样以整个复合盒参与正文。
装饰与关系函数通过命名端口获取几何基准,不必判断主体是什么类型。端口坐标相对于 LayoutBox 左上角:
body.left/body.right:主体核心有效范围的左右边界;缺省为盒左右边界decoration.below:下方装饰的排列起点,只使用 y;缺省为正文盒底部。音符提供数字基线下方0.04em的位置,排除 em 盒底部的多余留白shoulder:倚音相对宿主的纵向定位基准;音符提供数字顶边(不含高八度点),grace与up/down转发内部宿主端口,缺省为盒顶tie.top:可选的连音线端点覆盖;缺省使用对象 anchor 与内容上沿,不把纯留白作为连接点div.0.left/div.0.right:各级减时线的左右端点dot:附点锚点;缺省为目标右边界和视觉轴,目标可同时覆盖 x/ylyric:歌词水平对齐点
端口用于覆盖默认连接位置,读取端口的代码需要处理未提供的情况。装饰 handler 可以在扩展盒子时一并提供相应端口。
shoulder 由 grace 读取。up/down 自身按成员的完整上下占用与间隙叠放,包含八度点和装饰留白,不使用肩线定位。琶音标记按宿主内容边界绘制,复合体尺寸则包含完整占用。
上下装饰空间
Section titled “上下装饰空间”LayoutDecoration.above 与 LayoutDecoration.below 使用同一个 LayoutDecorationSpace 协议,分别申请主体上方、下方的空间。同一装饰可以声明两侧,也可以只绘制而不申请纵向空间。
| 字段 | 含义 |
|---|---|
order | 由内向外的顺序,值越小越靠近主体;相同值保持注册顺序,Infinity 位于最外层 |
gap | 与主体或前一个装饰的间距,默认 0;允许负值,使区域重叠或越过正文 |
height | 区域高度,默认 0;布局时限制为非负数 |
place(y) | 可选回调,接收区域顶边相对主体定位矩形顶部的 y |
两侧分别从正文顶部、底部出发排列;宿主可通过 decoration.below 端口覆盖下方起点,不改变正文绘制坐标或上方起点。上方区域通常使用负 y;负间距不会改变另一侧的起点。下方每项按 gap + height 推进排列游标,后续装饰自然跟随,不需要识别前一项的类型。place 只保存局部几何并提供端口,不读写正在排列的 box.h 或尚未放置的 box.y。绘制时再加上主体的最终 box.x/y。空间排列不改变 decorations 的绘制顺序。全部装饰处理完成后,引擎才调用 finalizeLayout。
paint 是可选的。省略它表示纯留白:参与完整占用、叠放、行高和画布边界,但不进入内容边界,也不生成绘制命令。下方纯留白至少从已有内容底边开始排列,不会被位于正文盒内的 decoration.below 起点吞掉,因而外框 padding 仍完整包围内容。纯留白不需要空的 paint 或 place 回调,例如:
host.decorations.push({ above: { order: Infinity, height: 8 }, below: { order: Infinity, height: 8 },});减时线使用 below、order=0:gap 是原有画线顶边(正文盒底部上方 0.08em)与排列起点的差,height 是描边与层间距的总高度。每列八度点共用一个装饰,使用 order=100、固定 gap=0.08em;高八度点申请 above,低八度点申请 below。下八度点只跟随实际装饰的占高,不读取减时线状态;无减时线时自然贴近数字,有减时线时自然排在最后一条线下方。附点只扩展宽度并绘制,不声明上下空间。装饰由 prepareLayout 或 addon handler 创建,引擎在每次准备主体时重建,保留到本次绘制结束。
附属对象的几何与占用
Section titled “附属对象的几何与占用”createGeometry 返回本轮的 AttachmentGeometry。其中 regions 使用全局坐标,用于最终外接盒、绘制和命中,也默认作为轨道占用。
如果绘制边界不能全部算到一条 Track 上,可以另行返回 occupancy。省略它时使用 regions;显式返回 [] 表示不申请轨道空间,而不是回退到 regions。例如,跨轨 @tie 只向某条轨申报自身到该轨主体上沿的那段占用,避免把两轨间的空白重复计入较高的轨道。本行没有主体时,则按几何本身申报。
只有同时带有 line + track 的有效占用区域才计入轨道与行高。未声明归属的区域不撑行;真实 regions 仍计入附件外接盒、内容包含和最终画布边界。
引擎只对 LoweringResult.attachments 中实现了 LayoutAttachment 的对象执行布局,再根据最终 geometry 生成只读的 PlacedAttachment。DocumentLayoutResult.attachments 保存的是最终结果,不包含中间测量状态。
查询占用与避让
Section titled “查询占用与避让”附件在自己所属的域里查询:正文附件查正文行,子域附件查子域。根据所需数据选择接口:
| 接口 | 数据与空结果 |
|---|---|
getHostExtent(line, track) | 整行该轨的固有主体占用,无附件;没有主体时为 undefined |
getRangeColumns(line, range?, track?) | 所选主体列,用于字号等统计;范围为空时返回空数组,按轨过滤时保留空列 |
getRangeExtents(line, range?) | 主体与已登记附件占用,返回按 Track 分组的只读 Map;无占用时为空 Map |
getRangeExtents(line, range, track) | 指定轨道的只读 Extent;无占用时为 undefined,不创建结果 Map |
getHorizontalExtents(line, left, right) | 按全局 x 区间筛选横向相交的主体及已登记附件占用,返回按 Track 分组的只读 Map;空区间或无占用时为空 Map |
getAttachmentBox(attachment) | 指定已测附件的完整几何边界;尚未测量时抛错 |
getAttachmentOccupancy(attachment) | 指定已测附件的有效占用,即 occupancy ?? regions;保留显式空数组,尚未测量时为 undefined |
getContentBounds(content) | 显式内容清单中主体内容边界与已测附件的几何并集,不含主体纯留白;没有有效几何时为 undefined |
范围参数的含义如下,所有端点都包含在范围内:
| 参数 | 范围解释 |
|---|---|
对象端点 [from, to] | 折叠成员沿 foldedInto 上溯到本域的列,覆盖首尾列之间的全部列;指定 Track 时两端都必须位于该轨 |
数字端点 [from, to] | 下标指本域视图的列;指定 Track 只过滤成员,不改变列号含义 |
| 省略范围 | 查询整行,可指定 Track 过滤轨道 |
对象端点可以逆序或跨行;系统归一顺序,并截取 line 指定行内的部分。数字区间保持调用方给定的顺序,起点大于终点时为空。标签仍指向原来的 AST 和 Temporal;tie 连接具体成员时直接使用其最终盒子和端口,范围查询才决定需要统计哪些对象。
单轨整行查询使用 getRangeExtents(line, undefined, track)。getHostExtent 是主体准备后保存的轴局部基准;getRangeExtents 从本轮主体位置换算高度,并包含可见附件占用。在 layoutOnly 阶段使用未平移的布局位置,最终绘制测量则使用实际位置。两者的数据范围与坐标基准不同,不能互换。跨轨比较时,先通过 getVisualAxis(line, track) 将各轨的 top/bottom 转成同一坐标系。
tuplet、volta 和 dyn 根据范围内最高点或最低点避让;tie 端点依附于宿主端口或内容上沿,歌词按宿主端口水平对齐,并避让 voice 内容作用域内的框底。它们不会因为任意范围中存在其他对象,就自动改成全局避让布局。系统提供测量事实,各函数决定使用哪一种结果及留白规则。
volta 每行按开头折钩与编号、整段横线、末尾折钩三部分查询横向相交的占用,取能同时满足三者的最高位置。横线下方保留 0.2em 间隙,中间内容不必额外避开两端折钩的高度。跨行只有真正的首尾保留折钩,编号只在首行出现。regions 保留每行的外接矩形,occupancy 则按实际存在的部分分别申报;测量阶段即使用此轮廓,行距同步收紧。横向查询与范围查询使用相同的域、测量顺序和轴局部坐标规则,但直接按 x 相交筛选主体,不限制为端点之间的列;仅接触区间边缘不算相交。
dyn 的插值与绘制使用同一闭区间,楔形线从起点完整宿主盒的左边界画到终点完整宿主盒的右边界;折叠成员参与播放,但布局只读取其可见宿主,端点里的 $p、$f 也计入主体占用。
附件的测量顺序
Section titled “附件的测量顺序”measureAttachments 按 Lowering 登记顺序生成并发布几何,正文和子域共用这段代码,各有一张占用表。本域查询只读取本轮已完成的附件,后登记的避让对象可排在前者之外;几何与内容查询还可以读取已完成子域的平移快照。范围占用不混入其他子域的内部附件,相邻 grace 即使因压缩而横向相交,也不会互相抬高括线。范围占用表每轮重建;供纵向求解使用的 attachmentExtents 则保留首轮汇总,让第二轮放置为附件腾出空间。这两份数据分别服务当前查询和后续求解,不能混用。
内容包含与几何边界
Section titled “内容包含与几何边界”内容包含按声明作用域确定。函数通过 Lowering 分组的 onTemporal/onAttachment 回调收集 LoweringContent,退出时取回本轮数据,随后交给附件私有保存;AST 不持有这份运行状态。getContentBounds 合并可见主体的内容边界和已测布局附件,忽略非布局附件及全零盒。本域尚未测量的依赖会报错;交给域外测量且尚无几何的关系不计入,例如子域内容里端点越出子域的关系。
例如 @box({1@a 2@b @tie(a,b)}) 的框包含 tie;@box({1@a 2@b}) @tie(a,b) 的框不包含 tie,即使两端都在框内。端点归属和几何相交都不能代替这一作用域规则。
box 用内容边界确定框尺寸;tuplet 同时查询内容边界与范围占用,让内部框、嵌套括线和此前关系得到正确避让。附件的真实几何是否被包含,与它是否申请轨道占用是两件事。
@box 的边框由 background attachment 绘制。框的外接边界在内容边界之外扩展 padding + stroke / 2,横向留白通过局部 hook 参与排版,纵向空间按内容结构申报:
- 只有一个可见主体,且内部附件都是同一主体上可提前预留的嵌套框时,上下空间由
order=Infinity的纯留白装饰预留,框附件返回occupancy: [],避免重复撑行。这种留白也参与up/down的成员堆叠。 - 多主体或包含后测关系的范围框在测量后申报外围上下沿;跨轨时,上沿归最上轨,下沿归最下轨,不将框内轨距重复计入各轨占用。只有关系的框也可通过关系端点及有效占用确定归属。
嵌套框逐层包含内层框的真实边界,各层 padding 只计算一次。负 padding 向内收缩;收缩后不能容纳边框时抛出 E_BOX_PADDING_TOO_SMALL。同一框不能跨谱面行,跨行抛出 E_BOX_CROSS_LINE。
范围框申报的是所属域的外围空间,不会重新排列 up/down 已经堆叠好的内部成员。跨宿主关系也不能获得通用的内部避让保证:框若必须包含宿主端点,就不能同时要求整个框位于宿主上方并保留间隙。
歌词按同一行、同一原始轨共用下沿,读取宿主完整占用及 voice 内容作用域内与宿主横向相交的已测背景附件。@voice(@box(...), ..., "歌词") 的歌词排在框外;@box(@voice(..., ..., "歌词")) 则将歌词作为框内内容,不避让自己的外框。跨轨框下方的歌词占用归最低轨,来自不同原始轨的歌词块按视觉轴顺序依次排列。
写一个跨行 attachment
Section titled “写一个跨行 attachment”- 按
layoutLine排序端点; - 首末段连接端点与内容区边界;
- 是否跨越没有可见主体的中间行由具体语义决定:volta 连到页边,dyn 只连接本轨有内容的行;
- 每段返回准确的
line + track占用; createGeometry每次返回新的完整结果,不修改或累加上一次结果。
横向间距:弹簧模型
Section titled “横向间距:弹簧模型”横向间距遵循两个原则:音符时值越长,通常需要更大的间距;空间不足时先压缩间距,压缩到零后才允许元素重叠。
可以通过这个独立的 HTML 示例 查看横向求解效果。
每个元素有自己的固有宽度 W 和固有时长 T,左右连着两根相同长度的弹簧,弹簧的原始长度为 L,固有时长越大弹簧越长:
劲度系数 k 为弹簧实际长度和固有时长的函数:
当 时,表示元素已经重叠。此后每继续压缩 1,力 增加 。
压缩量 (拉长时为负数)与力 的关系是分段线性、单调连续的:
空间充足时,元素可以保留自然间距。空间不足时,弹簧按比例压缩;长度降到 0 时,力达到与元素无关的常数 ,因此各处 margin 同时归零。继续压缩后,各处力的增量斜率相同,重叠程度也保持一致。
相邻两个元素之间的弹簧可以串联成一根等效弹簧,实现中使用这一形式计算。
多轨共享时间列
Section titled “多轨共享时间列”多个轨道上的元素需要按列对齐时,求解器把整列看作一个整体,叠加各轨道的受力,再求平衡位置。
此时,各轨道的 margin 不一定同时归零,需要增大 来限制重叠程度。现有测试中,取 的几倍即可,不需要再提高很多。
求解方法与限制
Section titled “求解方法与限制”这套模型需要迭代求解。由于不同区间的梯度变化较大,早期通用优化器即使限制最大步长并配合余弦退火,收敛效果也不够理想。然后使用共轭梯度法(CG),提高了精度并减少了迭代次数;最终利用三对角结构进行前向消元与回代,消除 CG 截断误差。
自然预排列能放入容器时,直接保留自然位置,不进入迭代。超限时,先等比压缩初始坐标,再以阻尼牛顿法求平衡:每轮重新计算合力和当前分段的刚度,求出位移后按 damping 更新位置。默认阻尼为 0.6,最大迭代 100 轮,最大列合力低于 eps=0.01 时停止。
各声部共享同一组列坐标,因此合力和刚度都可以按列汇总。设有 列, 是左墙总刚度, 是右墙总刚度,()是第 、 列之间的总刚度。所有弹簧只连接相邻列或边墙,牛顿步的矩阵为三对角:
其中 保留原有正则项;非负刚度下矩阵严格正定,即使部分弹簧不受力也可求解。内部使用前向消元、反向回代直接求解,全部缓存采用 Float64Array。每轮受力计算为 ( 为轨道数),线性求解为 ,额外求解缓存为 ;不再使用最多 8 步的内部 CG 或逐声部刚度矩阵乘法。消元主元非正或非有限时会抛错,而不是返回无效坐标。
直接求解消除了内部 CG 的截断误差,但外层仍是分段模型的迭代近似;压缩行的最终坐标不保证与旧算法逐位一致。固定列先合并再求解,不改变三对角结构,固定区域内部的相对位置及最终撑满策略保持不变。
当前使用远大于 的 限制元素穿透边界,属于近似约束:增大跨墙弹簧的系数可以减轻越界程度,但不能完全消除。严格边界约束需要在初始条件和每次迭代中都保证不越界。
时长与边界处理
Section titled “时长与边界处理”零时长事件使用最小时长参与弹簧计算,避免刚度公式除零。实际时长会进行幂次变换,使视觉间距不与音乐时值保持生硬的线性比例。
只靠弹簧不能精确对齐左右边界:轻微压缩时,内容两端仍有空隙;过度压缩时,又可能越过边界。多行放在一起时,这种差异很明显,尤其是行末的小节线。
需要两端对齐时,布局会进一步调整自由间隙:忽略两端弹簧,计算内容实际边界与容器边界之间的差值,再平均分配到各个自由间隙。剩余空间为正时增大间隙,溢出时则减小间隙。