基于 FigmaMCP 的 D2C 工具(四): ingest——把 Figma 噪声收成 DesignIntentIR
在前三篇中,我们已经完成:
- 同一份设计稿反复生成,结果为什么会漂移
- 为什么我不靠设计师再出一份干净稿,而是在工具侧做归一化
- catalog 三层 generated / overrides / links,运行时只读 CatalogSnapshot
本篇在此基础上做消费端的第一步:ingest。输入是 Figma 原始节点,输出是 DesignIntentIR。后面的 decide 和 verify 只认这份 IR,不再回头读图层。所以这一步如果把一个真按钮压没了,后面整条链路都会跟着错。
我的目的是把归一化的流程讲清楚 不是把 schema 里每个字段都列全 所以下面贴的类型和 JSON 都是精简版 只保留文中用到的字段 完整 schema 大家可以自行对照 zod 定义
1. Figma 原始节点有多脏
先看一个最常见的例子。设计师画了一个主按钮:圆角矩形加一行文字。图层面板里是这样:
Group 12
├── Decoration line RECTANGLE 1px 高
└── Frame 1000011563 FRAME 圆角 4 实心填充
└── 确认 TEXT对应的 MCP dump 精简之后如下,第 9 节的前后对照会用这份数据:
{
"node": {
"id": "1:2", "name": "Group 12", "type": "GROUP",
"absoluteBoundingBox": { "x": 16, "y": 600, "width": 343, "height": 84 },
"children": [
{ "id": "1:3", "name": "Decoration line", "type": "RECTANGLE", "fills": [{ "type": "SOLID" }],
"absoluteBoundingBox": { "x": 16, "y": 600, "width": 343, "height": 1 }, "children": [] },
{ "id": "1:4", "name": "Frame 1000011563", "type": "FRAME", "cornerRadius": 4, "fills": [{ "type": "SOLID" }],
"absoluteBoundingBox": { "x": 16, "y": 640, "width": 343, "height": 44 },
"children": [
{ "id": "1:5", "name": "确认", "type": "TEXT", "characters": "确认", "fills": [{ "type": "SOLID" }],
"boundVariables": { "fills": { "id": "VariableID:12:34" } },
"absoluteBoundingBox": { "x": 165, "y": 652, "width": 45, "height": 20 }, "children": [] }
] }
]
}
}这份数据的问题:
- 没有自动布局。
Frame 1000011563没有layoutMode,子节点全靠绝对坐标摆位 - 组嵌套没有语义。
Group 12只是设计师框选之后按了一下 Cmd+G - 命名随意。真正的按钮叫
Frame 1000011563,反而是装饰线有个能看懂的名字 - adapter 丢字段。TEXT 节点少了
fontSize。这是 MCP 序列化时丢的,Figma 里这个 key 一定存在
人看一眼就知道这是个按钮。机器拿到的是四个散装节点。ingest 要做的就是把它们收成一棵能用的树,并且把每一步判断记下来。
2. DesignIntentIR 长什么样
2.1 类型
我们先来看输出的类型。按 zod schema 精简:
interface DesignIntentIR {
schemaVersion: 2
kind: 'design-intent'
id: string // design:{documentId}:{rootIds}
source: {
adapter: 'figma-rest' | 'figma-mcp' | 'design-intent-v2'
documentId: string
fingerprint: string // 整份输入 JSON 的 sha256
evidence: EvidenceRef[]
}
roots: DesignIntentNode[] // 至少一个 空树在 schema 层就过不去
normalization: NormalizationTrace[] // 每个节点的 keep / flatten / drop 记录
layout: LayoutConstraintGraph // 布局约束图 单独成图 不挂在节点上
unresolved: UnresolvedFact[] // 解不开的事实 不是警告
evidence: EvidenceRef[]
}
// EvidenceRef = { source, locator, extractorVersion, fingerprint, confidence }
// locator 格式 figma://{documentId}/{nodeId} 后面 ProofObligation 引用设计侧证据就靠它
interface DesignIntentNode {
id: string
name: string
nodeType: 'frame' | 'group' | 'instance' | 'text' | 'shape' | 'vector' | 'unknown' | /* ... */
role?: DesignRole // button / input / table / dialog / display ... 可以为空
visible?: boolean | UnresolvedFact
component?: { componentKey?: string, codeComponentId?: string, variantProperties?: Record<string, string>, evidence: EvidenceRef[] }
text?: { characters: string, evidence: EvidenceRef[] }
bounds?: { x: number, y: number, width: number, height: number }
tokens?: DesignToken[] // 见第 7 节
interactions?: Array<{ trigger: string, action: string, destinationNodeId?: string, evidence: EvidenceRef[] }>
children: DesignIntentNode[]
evidence: EvidenceRef[]
}要点:
normalization记每个节点的动作。后面排查某个按钮为什么不见了,全靠它layout单独成图,verify 阶段的 skeleton 比较只读这张图role可选。名字和形状都对不上的节点照样 keep,role 空着,到 decide 里落成 unknown,不会随手贴一个 button
2.2 三种 adapter
入口是 ingestDesign(input, options),adapter 三种:
figma-rest:从document(GET /files)或nodes.*.document(GET /nodes)取根节点,然后清洗figma-mcp:形状不固定,依次兼容nodes数组、nodes对象、node、document、单个带 id 的节点,然后清洗design-intent-v2:已经是 v2 IR,只过DesignIntentIRSchema.parse做一下解析。schemaVersion不是 2 直接报错 “D2C v1 design IR is unsupported. Re-ingest the original Figma payload.”
CLI 用法:
d2c ingest --input button.json --adapter figma-mcp --document-id demo-file --out .d2c/design-intent.json约束:
- 找不到根节点在洗之前就报错 “figma-mcp payload contains no design nodes.”。这是输入形状问题,不是清洗过猛
- 整棵树被扔光也报错 “All design nodes were dropped by deterministic normalization.”,空 roots 不许往下传
fingerprint是整份输入 JSON 的内容哈希。在 Figma 里动一个无关图层再 dump 一次,指纹就变。这是故意的:DecisionRecord 绑定的是这次喂进引擎的字节,不是我觉得还是同一份稿
2.3 ingest 流程
flowchart TD
A[Figma payload] --> B{adapter}
B -->|design-intent-v2| C[schemaVersion 必须是 2<br/>直接过 schema]
B -->|figma-rest / figma-mcp| D[算 fingerprint<br/>rootsForAdapter 取根]
D --> E[normalizeNode 深度递归]
E --> F{normalizationAction}
F -->|drop| G[写 trace 返回空]
F -->|flatten| H[写 trace 带 mappedNodeIds<br/>子节点上浮到父级]
F -->|keep| I[写 trace<br/>进 layout.nodes 写 containment]
I --> J[addLayoutRelations]
J --> K[flagMissingAdapterFields<br/>缺 key 落 unresolved]
K --> L[inferRole 角色提示<br/>命中再补一条 trace]
L --> M[extractTokens<br/>组装 DesignIntentNode]
G --> N{roots 为空?}
H --> N
M --> N
N -->|是| O[报错]
N -->|否| P[DesignIntentIRSchema.parse]
3. 归一化:每个节点三选一
3.1 keep / flatten / drop
对每个 Figma 节点,normalizationAction 只给一个动作:
keep:进意图树,继续挂子节点,进布局图flatten:自己消失,子节点上浮到父级。trace 里记mappedNodeIds,标明这层壳对应哪些子节点drop:自己和子树都不要。只有明确的装饰叶子才会走到这里
drop 的条件最苛刻,keep 是默认值。这个顺序是我改过一次之后定下来的,3.3 节会讲。
3.2 决策伪代码
直接上代码,具体思路在注释里:
function normalizationAction(node): { action: 'keep' | 'flatten' | 'drop', rule: string, confidence: number } {
const name = node.name?.trim() ?? ''
const children = node.children ?? []
const hasIdentity = Boolean(node.componentKey || node.mainComponent?.key || node.componentSetKey)
const hasContent = typeof node.characters === 'string' && node.characters.length > 0
const hasInteraction = Boolean(node.reactions?.length || node.prototypeInteractions?.length)
// 1. 装饰叶子才 drop 五个条件同时满足 少一个都不行
const decorativeName = /^(?:background|divider|decoration|shadow|line)(?:\s|$)/i.test(name)
if (!hasIdentity && !hasContent && !hasInteraction && !children.length && decorativeName)
return { action: 'drop', rule: 'explicit-decorative-leaf', confidence: 0.98 }
// 2. 匿名包裹 Group 12 / Frame 1000011563 这类名字 自己没有身份没有文字
const isAnonymousWrapper = !hasIdentity && !hasContent && !hasInteraction && children.length
&& /^(?:group|frame)\s*\d*$/i.test(name)
if (isAnonymousWrapper) {
// 压扁之前先看形状 数字名 Frame 里经常住着真按钮 见第 4 节
const structural = inferStructuralRole(node)
if (structural && ['button', 'input', 'media', 'dialog'].includes(structural.role))
return { action: 'keep', rule: `${structural.rule}+pre-flatten`, confidence: structural.confidence }
return { action: 'flatten', rule: 'anonymous-structural-wrapper', confidence: 0.95 }
}
// 3. 表格预检 必须在子节点被 flatten 之前用原始树判断 见 4.2
if (isTableAreaShape(node))
return { action: 'keep', rule: 'role-inference:structure:table+header+pre-flatten', confidence: 0.65 }
// 4. 其余全部 keep 有组件身份的置信度 1 没有的 0.7 一样留下
return hasIdentity
? { action: 'keep', rule: 'semantic-component-identity', confidence: 1 }
: { action: 'keep', rule: 'preserve-uncertain-node', confidence: 0.7 }
}trace 的结构很简单:
interface NormalizationTrace {
nodeId: string
action: 'keep' | 'flatten' | 'drop'
rule: string // 上面那些规则名 排查时直接搜
confidence: number
mappedNodeIds?: string[] // 只有 flatten 有 记上浮的子节点 id
evidence: EvidenceRef[]
}要点:
- 判断顺序就是优先级:装饰叶子、匿名包裹、表格预检、默认 keep
- 设计师改过名的 Frame 不匹配匿名正则,走默认 keep
- 置信度只写进 trace,不参与动作本身。keep 0.7 和 keep 1 在树里没有区别,区别在 decide 排候选的时候
normalizeNode先递归子节点再写自己的 trace,所以子节点的记录排在父节点前面。flatten 时子节点的 parentId 直接接到上一级,containment 不会断- 角色推断(第 5 节)命中时会再补一条 keep,所以一个节点可能有两条 trace,规则名不同
排查时最常见的问题是:这个按钮为什么在 IR 里没了?打开 normalization 按 nodeId 搜,就能看到它是被 anonymous-structural-wrapper 压掉了,还是被 explicit-decorative-leaf 误删了。没有 trace 的时候,我只能拿原始 JSON 和洗后的树手工 diff,几轮下来人就废了。
3.3 踩坑:不认识的节点,早期我是直接丢的
早期版本的默认分支是 drop:没有组件身份、名字对不上别名表、形状也认不出来的节点,直接扔。当时的想法是树越干净,后面 decide 越好做。
结果是业务节点成批失踪。一个改过名的 Frame 叫 “操作区”,别名表里没有,形状也不像任何控件,就被丢了。decide 拿到的树很瘦,大量 needs-review,人工一看设计稿明明是标准布局。更麻烦的是丢了就没有痕迹,模型侧拿原始 MCP 树在脑补,两边对不上。
我后来把默认分支改成 keep,规则 preserve-uncertain-node,置信度 0.7。同时把另一类丢掉的东西也收回来:adapter 该给却没给的字段,不补默认值,落进 unresolved(第 8 节)。现在唯一会主动 drop 的只有装饰叶子。
多留噪声的成本是 decide 召回时候选多一些,可以靠 role 和硬约束过滤。误删的成本是下游对着空洞补东西,补出来的没有证据,查不了。两个我选前者。
4. 匿名 Frame 里可能住着按钮
4.1 先看形状再 flatten
匿名包裹这条规则还有一个坑:早期是先 flatten 所有 Frame 12 / Group 3,再对清爽的树做角色推断。图层树立刻干净,按钮也立刻消失。第 1 节的例子里,Frame 1000011563 一压扁,圆角、实心填充这些形状信号就跟着没了,剩下一个 TEXT 孤零零挂在上层。
所以现在匿名包裹在 flatten 之前先跑 inferStructuralRole。形状判断都是布尔加几何阈值,没有 embedding:
- 按钮:有 TEXT 子节点,
cornerRadius > 0,有可见 SOLID fill。三个都要。直角块加文字不算,直角块太常见,会误伤页面分区 - 输入框:宽不小于高的 3 倍且高不超过 64,子 TEXT 文案匹配
请输入|输入|搜索|placeholder。只靠扁宽不够,横向导航条也是扁宽的 - 媒体:有可见的 IMAGE fill
- 弹层:三个信号同时出现。遮罩(非 FRAME / INSTANCE 的实心矩形,宽高都不小于父节点的 98%)、内容卡(完全落在父节点内部且比父节点小)、关闭图标(不超过 64x64 的小 FRAME / INSTANCE,在某张内容卡上沿之上)。缺一条就不认,只靠全屏背景块会误伤普通页面根节点
约束:
- 形状检测的置信度全部压在 0.6 ~ 0.7。它只影响 decide 排候选的顺序,不跳过任何硬约束
- 阈值全是线上误伤案例逼出来的,比如输入框的
height <= 64。调阈值靠加回归夹具 - 只有 button / input / media / dialog 四种形状能阻止 flatten。table 不在列表里,因为同形的行容器本身就该 flatten
4.2 表格要读 flatten 前的原始树
表格是另一类 flatten 陷阱。常见结构不是一个叫 Table 的父节点下面整齐挂行,而是表头文字和行容器平级:
Frame 88 表区域根
├── 币种 / 年化 / 期限 / 操作 4 个 TEXT
├── Frame 91 / Frame 92 / Frame 93 同形行容器 343 x 48 纵向排列行容器 Frame 91 这些自己就符合匿名包裹条件,会被 flatten。如果等子节点洗完再推断,表头 TEXT 还在,行已经变成散装单元格,“表头 + 同形行组” 这个信号永远触发不了。所以 normalizationAction 在默认 keep 之前,用原始的 node.children 做一次表格预检 isTableAreaShape:
- 子节点至少 6 个(3 个表头 + 3 行)
- 同一层至少 3 个有内容的 TEXT
- 至少 3 个 FRAME / GROUP 名字去掉数字后相同,几何上宽扁(高不超过 64,宽不小于 2 倍高),并且 y 至少三个不同值。不看 y 的话横向导航条也会被认成重复行
命中之后表区域根 keep,置信度 0.65。行容器仍然按匿名规则处理,该 flatten 就 flatten。表区域根留下来,role 才能挂上 table。
5. 角色推断只是提示
keep 之后每个节点会过一遍 inferRole。这一步只给一个粗标签,供 decide 召回候选时排序,不代表已经认准了某个组件。推断分层,置信度从高到低,命中一层就返回:
- catalog links 已经链到代码组件,0.95,规则
role-inference:link - 英文名按空白和
/ _ -拆词,精确撞别名表(button / btn / modal / dialog / nav …),0.9,role-inference:name - 中文名按子串匹配,0.85,
role-inference:name-zh - 纯 TEXT 先当
display,0.8,role-inference:type - 结构形状(第 4 节),0.55 ~ 0.7
- 只剩
layoutMode的容器,container,0.5
中文别名为什么要单独处理:中文图层名没有空白可以拆词,只能子串匹配。别名表里既有 “搜索” 也有 “搜索框”,短的先匹配的话,所有搜索框都会先被打成更泛的 “搜索”。所以别名表按长度降序扫,“搜索框” 先命中:
const chineseAliases = Object.entries(CHINESE_ROLE_ALIASES).sort((a, b) => b[0].length - a[0].length)
for (const [alias, role] of chineseAliases) {
if (name.includes(alias)) return { role, confidence: 0.85, rule: 'role-inference:name-zh' }
}要点:
- 名字信号优先于形状信号。一个叫 “Submit button” 的圆角实心 Frame,trace 里只有
role-inference:name,不会再跑形状检测 - 全部对不上就没有 role。不猜
- modal / dialog / 弹窗 / 对话框 / 模态 都映到
dialog,抽屉单独drawer
6. LayoutConstraintGraph 怎么从节点推出来
图由 nodes、relations、unresolved 三部分组成。关系是 discriminated union,这里只列 ingest 会写的:
type LayoutRelation =
| { kind: 'containment', parentId: string, childId: string }
| { kind: 'order', parentId: string, childIds: string[] }
| { kind: 'axis', nodeId: string, value: 'row' | 'column' | 'grid' | UnresolvedFact }
| { kind: 'alignment', nodeId: string, axis: 'main' | 'cross', value: 'start' | 'center' | 'end' | 'space-between' | /* ... */ }
| { kind: 'gap', nodeId: string, value: number | UnresolvedFact }
| { kind: 'padding', nodeId: string, value: { top: number, right: number, bottom: number, left: number } }
| { kind: 'sizing', nodeId: string, axis: 'horizontal' | 'vertical', mode: 'fixed' | 'hug' | 'fill' }
| { kind: 'grid-tracks' | 'wrap' | 'overflow' | 'positioning', /* ... */ }
// 每条 relation 还带 evidence 上面省略了只有 keep 的节点进图。containment 在 normalizeNode 里写,其余在 addLayoutRelations 里按 Figma 字段逐条补:
function addLayoutRelations(node, id, children, evidence, relations) {
// 有子节点就写 order 顺序就是 children 数组顺序
if (children.length)
relations.push({ kind: 'order', parentId: id, childIds: children.map(c => c.id), evidence })
// auto-layout 主轴 HORIZONTAL → row / VERTICAL → column / GRID → grid
const axis = { HORIZONTAL: 'row', VERTICAL: 'column', GRID: 'grid' }[node.layoutMode]
if (axis) relations.push({ kind: 'axis', nodeId: id, value: axis, evidence })
// 对齐 MIN → start / CENTER → center / MAX → end / SPACE_BETWEEN → space-between 主轴交叉轴各一条
if (typeof node.itemSpacing === 'number')
relations.push({ kind: 'gap', nodeId: id, value: node.itemSpacing, evidence })
// padding 四边齐了才写一条 缺一边整条不写 不写半吊子数字
if ([node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft].every(v => typeof v === 'number'))
relations.push({ kind: 'padding', nodeId: id, value: { top: node.paddingTop, right: node.paddingRight, bottom: node.paddingBottom, left: node.paddingLeft }, evidence })
// sizing:layoutSizingHorizontal / Vertical 的 FIXED / HUG / FILL 两个轴各一条
// grid 只知道行列数 track 统一写 1fr / auto
// layoutWrap → wrap clipsContent → overflow positioning === 'ABSOLUTE' → positioning
}约束:
- 没开 auto-layout 的节点只有
containment和order。第 1 节那个按钮就是这样,没有 axis 也没有 gap。ingest 不会从子节点坐标算出一个 gap 塞进去 padding四边齐全才写。缺的那一边会在第 8 节落进 unresolved- 图里的
unresolved和顶层的unresolved是两个数组。图的那份留给 verify:设计侧有布局意图、源码侧抽不出布局事实时,verify 会标runtime-required,第六、七篇再展开
绝对定位的子节点怎么变成 flex 或 grid 这里不展开 ingest 只负责把 Figma 给的字段如实写进图 几何反推布局是 verify 阶段 skeleton 投影的事
7. token 归一化
Figma 里绑了变量的属性会出现在 boundVariables 上。ingest 把它们拍平成 DesignToken,只记 variableId 和属性名,不在这一步解析变量值:
interface DesignToken {
kind: 'color' | 'typography' | 'spacing' | 'radius' | 'shadow' | 'opacity' | 'other'
property: string // Figma 里绑定变量的属性名 比如 fills
variableId?: string // VariableID:xxx
evidence: EvidenceRef[]
}
// kind 按属性名猜:color|fill|stroke → color font|text → typography
// padding|gap|spacing → spacing radius → radius shadow|effect → shadow 猜不出落 other第 1 节的 TEXT 节点出来就是一条 { kind: 'color', property: 'fills', variableId: 'VariableID:12:34' }。变量对应哪个 CSS 变量、值是多少,是 decide 拿 catalog 去对的事。ingest 只记录设计师绑了什么。
8. unresolved 为什么保留
UnresolvedFact 是 { kind: 'unresolved', reason, detail?, evidence }。ingest 阶段只产出 adapter-field-missing 这一种 reason,其余是 decide / verify 写的。规则是缺 key 才算缺:
const SHAPE_NODE_TYPES = new Set(['FRAME', 'INSTANCE', 'RECTANGLE', 'VECTOR', 'LINE', 'ELLIPSE', 'POLYGON', 'STAR'])
// 只报 key 缺失 不报值为 0 或空数组
// Figma 数据源里这些 key 必然存在 缺 key 只可能是 adapter 序列化时丢了
function flagMissingAdapterFields(node, id, evidence, context) {
const missing: string[] = []
// 开了 auto-layout 的 FRAME / INSTANCE 必须有 itemSpacing 和四边 padding
if ((node.type === 'FRAME' || node.type === 'INSTANCE') && node.layoutMode && node.layoutMode !== 'NONE') {
if (typeof node.itemSpacing !== 'number') missing.push('itemSpacing')
for (const key of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'])
if (typeof node[key] !== 'number') missing.push(key)
}
if (node.type === 'TEXT' && typeof node.fontSize !== 'number') missing.push('fontSize')
// 可见的形状节点必须有 fills 数组 空数组也算有
if (SHAPE_NODE_TYPES.has(node.type) && node.visible !== false && !Array.isArray(node.fills)) missing.push('fills')
if (missing.length)
context.unresolved.push(unresolvedFact('adapter-field-missing',
`Node ${id} (${node.type}) is missing expected adapter fields: ${missing.join(', ')}. Re-dump the design with a single complete serialization pass.`, evidence))
}要点:
- 值为 0 不算缺。
paddingTop: 0是设计师真的设了 0,paddingTop这个 key 不存在才是 adapter 丢了 - 隐藏的形状不查 fills,没开 auto-layout 的 Frame 不查 spacing
- detail 里直接写修法:重新用一次完整的序列化导出
为什么留而不是补一个默认值。我试过给缺 itemSpacing 的节点补 0,结果 verify 拿这个 0 去和源码里的 gap: 8px 比,判定不等价,而且 report 里看不出这个 0 是设计师设的还是我补的。落进 unresolved 之后,verify 看到这条会把对应的 ProofObligation 标成 unknown,不是 pass 也不是 fail。缺证据就标记缺证据,从 ingest 开始就要遵守这个约定。
9. 前后对照:原始节点到 DesignIntentIR
我们执行一下 2.2 节的命令,把第 1 节那份 payload 喂进去。输出去掉重复的 evidence 之后是这样:
{
"schemaVersion": 2,
"kind": "design-intent",
"id": "design:demo-file:1:4", // Group 12 被 flatten 根直接变成 Frame 1000011563
"source": {
"adapter": "figma-mcp", "documentId": "demo-file",
"fingerprint": "3f9c…e21a", // 整份输入 JSON 的 sha256
"evidence": [{ "source": "figma-mcp", "locator": "figma://demo-file/document", "extractorVersion": "2.0.0", "fingerprint": "3f9c…e21a", "confidence": 1 }]
},
"roots": [{
"id": "1:4", "name": "Frame 1000011563", "nodeType": "frame",
"role": "button", // 靠形状推出来的 置信度 0.6 在 trace 里
"visible": true,
"bounds": { "x": 16, "y": 640, "width": 343, "height": 44 },
"tokens": [], "interactions": [],
"children": [{
"id": "1:5", "name": "确认", "nodeType": "text", "role": "display", "visible": true,
"text": { "characters": "确认", "evidence": [/* figma://demo-file/1:5 */] },
"bounds": { "x": 165, "y": 652, "width": 45, "height": 20 },
"tokens": [{ "kind": "color", "property": "fills", "variableId": "VariableID:12:34", "evidence": [/* 同上 */] }],
"interactions": [], "children": [],
"evidence": [/* figma://demo-file/1:5 */]
}],
"evidence": [/* figma://demo-file/1:4 */]
}],
"normalization": [
// 子节点先写 父节点后写
{ "nodeId": "1:3", "action": "drop", "rule": "explicit-decorative-leaf", "confidence": 0.98 },
{ "nodeId": "1:5", "action": "keep", "rule": "preserve-uncertain-node", "confidence": 0.7 },
{ "nodeId": "1:5", "action": "keep", "rule": "role-inference:type", "confidence": 0.8 },
{ "nodeId": "1:4", "action": "keep", "rule": "role-inference:structure:button+pre-flatten", "confidence": 0.6 },
{ "nodeId": "1:4", "action": "keep", "rule": "role-inference:structure:button", "confidence": 0.6 },
{ "nodeId": "1:2", "action": "flatten", "rule": "anonymous-structural-wrapper", "mappedNodeIds": ["1:4"], "confidence": 0.95 }
],
"layout": {
"nodes": [{ "id": "1:5", "label": "确认" }, { "id": "1:4", "label": "Frame 1000011563" }],
"relations": [
{ "kind": "containment", "parentId": "1:4", "childId": "1:5" },
{ "kind": "order", "parentId": "1:4", "childIds": ["1:5"] }
// 没有 axis / gap / padding 因为原始节点没开 auto-layout ingest 不从坐标反推
],
"unresolved": []
},
"unresolved": [{
"kind": "unresolved", "reason": "adapter-field-missing",
"detail": "Node 1:5 (TEXT) is missing expected adapter fields: fontSize. Re-dump the design with a single complete serialization pass.",
"evidence": [/* figma://demo-file/1:5 */]
}],
"evidence": [/* 同 source.evidence */]
}对照原始输入看几处:
Group 12没了,但 trace 里有它的 flatten 记录,mappedNodeIds指向1:4。想知道按钮原来在哪个组里,查这条就行Decoration line没了,trace 里是 drop。哪天设计师把一条业务分隔线也叫 Decoration,查 trace 能发现是被这条规则删的Frame 1000011563留下了,role 是 button。两条 trace 一条是 pre-flatten 检测给的,一条是inferRole再跑形状检测给的,能区分是哪一步认出来的- 布局图里只有 containment 和 order。设计师没开 auto-layout,ingest 就不写 axis 和 gap
- TEXT 缺
fontSize落进了unresolved,节点本身照常 keep,文案和 token 都在
如果把 Frame 1000011563 改成真正的装饰:改名 divider,删掉子节点,去掉圆角。再跑一次 ingest,它应该变成 drop。这个我做成了回归夹具,比口头说装饰规则生效了有用。
同一份输入、同一个 extractorVersion,两次 ingest 的输出必须逐字节一致,包括 trace 顺序。这是 FingerprintSet 里 design 那一项能拿来比对的前提。
小结
本篇完成了:
- Figma 原始节点的四类噪声:无自动布局、组嵌套、命名随意、adapter 丢字段
DesignIntentIR的精简类型和三种 adapter,d2c ingest用法normalizationAction的 keep / flatten / drop 判定顺序,以及NormalizationTrace- 匿名 Frame 的 pre-flatten 形状检测,表格用原始树预检
- 分层的角色推断和中文别名长度排序
LayoutConstraintGraph从 auto-layout 字段逐条推出来,padding 四边齐才写- token 拍平,缺字段落 unresolved 而不是补默认值
- 一份完整的前后对照
两个踩坑也写了:早期默认 drop 不认识的节点,我改成默认 keep 0.7;早期先 flatten 再推断,我改成 flatten 前先看形状。
下一篇写 decide:拿着这份 IR 和 CatalogSnapshot,候选怎么召回、硬约束怎么卡、BindingPlan 和 ExtensionRecipe 长什么样,以及 verdict 为什么只有 reuse / extend / new / needs-review 四种。