///|
/// 自动修复引擎:Agent 调用 auto_fix 即可清除全部视觉债。
///
/// 修复策略按谓词分类:
/// - contained_in_parent → 缩小尺寸适配父容器(优先缩宽,再缩高)
/// - no_sibling_overlap → 沿 Y 轴平移到无重叠位置
/// - constraint_solvable → Fill→Fixed 降级(取父容器剩余空间)
/// 另有组件规范预修(norms.mbt:按钮放大到文案+留白 / 圆角归档 8 / 全宽条直角),
/// 在谓词迭代之前执行——规范修复可能引入新违规,交给后续谓词循环一并清理。
/// 返回修复操作列表(供 Agent 审阅)+ 修复后的新 Project。
pub(all) struct AutoFixOp {
node_id : String
predicate : String
action : String // 人可读的修复描述
before : String // 修复前值
after : String // 修复后值
}
///|
/// 一键修复:遍历所有违规节点,逐一应用修复策略
pub fn Project::auto_fix(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) => {
let fixes : Array[String] = []
let mut round = 0
// 组件规范先修(按钮卡文案放大/圆角归档/全宽条直角/列表行间分割线):
// 放大可能引入新的重叠/溢出,所以必须在视觉债迭代**之前**执行,让下面
// 的谓词循环把新引入的违规一并清掉。
let norm_layout = solve(ab.doc)
let prof = self.norms_profile()
for f in norm_fix_pass(ab.doc, norm_layout, radius_base=prof.radius_base).iter() {
fixes.push(f)
}
// Norms v2 触控目标(44×44)在同一先修段:norm_fix_pass 改过尺寸,
// 重新求解取真实 rect。放大同样可能引入溢出——交给下方谓词循环清偿。
let touch_layout = solve(ab.doc)
for f in norm_touch_fix_pass(ab.doc, touch_layout, touch_min=prof.touch_min).iter() {
fixes.push(f)
}
for f in self.norm_list_divider_fix(artboard=artboard).iter() {
fixes.push(f)
}
let mut remaining = validate(ab.doc).length()
// 迭代修复(修复一处可能引入新违规,最多 5 轮)
while remaining > 0 && round < 5 {
let violations = validate(ab.doc)
for v in violations.iter() {
match v.predicate {
ContainedInParent => {
let fix = fix_overflow(ab.doc, v.node_id)
match fix {
Some(desc) => fixes.push(desc)
None => ()
}
}
NoSiblingOverlap => {
let fix = fix_overlap(ab.doc, v.node_id)
match fix {
Some(desc) => fixes.push(desc)
None => ()
}
}
_ => ()
}
}
let new_count = validate(ab.doc).length()
if new_count >= remaining {
break // 无进展
}
remaining = new_count
round = round + 1
}
let final_count = validate(ab.doc).length()
if final_count == 0 {
let j = jstr6(fixes)
Ok("{\"ok\":true,\"fixes\":\{fixes.length()},\"detail\":[\{j}]}")
} else {
let j = jstr6(fixes)
Ok(
"{\"ok\":false,\"remaining\":\{final_count},\"fixes\":\{fixes.length()},\"detail\":[\{j}]}",
)
}
}
}
}
///|
/// 修复溢出:缩小尺寸适配父容器内容盒
fn fix_overflow(doc : Document, node_id : String) -> String? {
match doc.nodes.get(node_id) {
None => None
Some(n) =>
match doc.find_parent(node_id) {
None => None
Some(pid) =>
match doc.nodes.get(pid) {
None => None
Some(parent) => {
let layout = solve(doc)
match (layout.rect_of(node_id), layout.rect_of(pid)) {
(Some(nr), Some(pr)) => {
let avail_w = pr.width - (nr.x - pr.x) * 2.0
let avail_h = pr.height - (nr.y - pr.y) * 2.0
// 优先缩宽
let mut new_w = nr.width
let mut new_h = nr.height
if nr.width > avail_w {
new_w = if avail_w > 0.0 { avail_w } else { nr.width }
}
if nr.height > avail_h {
new_h = if avail_h > 0.0 { avail_h } else { nr.height }
}
if new_w != nr.width || new_h != nr.height {
let _ = doc.update_node(node_id, fn(x) {
{ ..x, width: Fixed(new_w), height: Fixed(new_h), }
})
Some(
"{\"node\":\"\{node_id}\",\"action\":\"resize\",\"before\":\"\{nr.width.to_int()}×\{nr.height.to_int()}\",\"after\":\"\{new_w.to_int()}×\{new_h.to_int()}\"}",
)
} else {
None
}
}
_ => None
}
}
}
}
}
}
///|
/// 修复重叠:沿 Y 轴下移到无重叠位置
fn fix_overlap(doc : Document, node_id : String) -> String? {
match doc.nodes.get(node_id) {
None => None
Some(n) =>
match doc.find_parent(node_id) {
None => None
Some(pid) =>
match doc.nodes.get(pid) {
None => None
Some(parent) => {
let layout = solve(doc)
match layout.rect_of(node_id) {
None => None
Some(nr) => {
// 找到重叠的兄弟中最大的 bottom
let mut max_bottom = nr.y
for cid in parent.children.iter() {
if cid != node_id {
match layout.rect_of(cid) {
Some(cr) =>
// 全包含对=有意分层(背景压内容),与谓词层豁免
// 同语义同容差——背景不参与 max_bottom 推挤,否则
// 内容会被推到画板之外(review 5a5641a)
if nr.contains_rect_eps(cr, 0.5) ||
cr.contains_rect_eps(nr, 0.5) {
()
} else if nr.y < cr.y + cr.height &&
nr.y + nr.height > cr.y &&
nr.x < cr.x + cr.width &&
cr.x < nr.x + nr.width {
let b = cr.y + cr.height
if b > max_bottom {
max_bottom = b
}
}
None => ()
}
}
}
if max_bottom > nr.y {
// 下移到重叠兄弟的底部 + 8px 间距
let parent_y = get_parent_y(layout, pid)
let new_y = max_bottom + 8.0 - parent_y
let old_y = pos_to_str(n.y)
let _ = doc.update_node(node_id, fn(x) {
{ ..x, y: AlignStart(new_y), }
})
Some(
"{\"node\":\"\{node_id}\",\"action\":\"move_y\",\"before\":\"\{old_y}\",\"after\":\"\{new_y}\"}",
)
} else {
None
}
}
}
}
}
}
}
}
///|
fn jstr6(parts : Array[String]) -> String {
let sb = StringBuilder::new()
for i in 0.. 0 {
sb.write_string(",")
}
sb.write_string(parts[i])
}
sb.to_string()
}
///|
fn get_parent_y(layout : SolvedLayout, pid : String) -> Double {
match layout.rect_of(pid) {
Some(pr) => pr.y
None => 0.0
}
}
///|
fn pos_to_str(p : PosSpec) -> String {
match p {
AlignStart(v) => v.to_string()
Fixed(v) => v.to_string()
AlignEnd(v) => "end(\{v})"
CenterInParent => "center"
}
}
///|
/// 列表分割线修复:给齐缝 list_item 连排(norms_list_runs)的每一行——
/// 除最后一行——行底插入 1px divider 子节点。走 place_component 真实
/// 实例化路径(divider 变体的 outline_variant 色随主题解析);子节点挂在
/// 行内,不参与兄弟重叠检查(v1 只查 Frame 子节点),包含检查也只对
/// Frame 生效——零门风险,不引入视觉债。行内已有 divider 则跳过(幂等)。
fn Project::norm_list_divider_fix(
self : Project,
artboard~ : String,
) -> Array[String] {
let fixes : Array[String] = []
match self.artboards.get(artboard) {
None => ()
Some(ab) => {
let layout = solve(ab.doc)
for run in norms_list_runs(ab.doc, layout).iter() {
let n = run.row_ids.length()
for idx in 0..<(n - 1) {
let row_id = run.row_ids[idx]
match (ab.doc.nodes.get(row_id), layout.rect_of(row_id)) {
(Some(row), Some(rr)) => {
let mut has = false
for cid in row.children.iter() {
match ab.doc.nodes.get(cid) {
Some(c) => if c.component == "divider" { has = true }
None => ()
}
}
if has {
continue
}
let sep_id = row_id + "_sep"
match self.place_component(
artboard=artboard,
component_id="divider",
instance_id=sep_id,
parent_id=row_id,
x=16.0,
y=rr.height - 1.0,
w=rr.width - 32.0,
h=1.0,
) {
Ok(_) =>
fixes.push(
"{\"node\":\"\{json_escape(row_id)}\",\"action\":\"list_divider\",\"why\":\"列表行间分割线\",\"after\":\"行底 1px divider(\{json_escape(sep_id)})\"}",
)
Err(_) => ()
}
}
_ => ()
}
}
}
}
}
fixes
}