feat: add versioned topology reference migration

This commit is contained in:
2026-08-02 23:31:33 -04:00
parent 1ed35273a5
commit 9def4708ea
7 changed files with 180 additions and 26 deletions

View File

@@ -18,7 +18,7 @@
},
"facadeCapabilities": {
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
"document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "undo-redo"] },
"document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "undo-redo"] },
"sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "persistence"] },
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
},

View File

@@ -308,7 +308,7 @@
本轮已完成 `EXU-02..EXU-06` 的基础版本Quantity 以 mm/deg 等规范单位保存,表达式支持四则运算、对象属性引用和维度错误;已完成 `DAG-01..DAG-06` 的领域版本Link/Expression 边、下游传播、SCC 循环检测、稳定拓扑计划、generation 和 recompute 状态已进入 Facade 与 SQLite schema v3。还未把每个 DAG 节点接入真实 OCCT/Sketcher 计算 Worker因此当前是可验证的业务调度层不是完整几何重算。
TSN 已完成面量化签名、唯一/重复候选判定和匹配 API并挂接到 Bitbybit `MeshAsset`签名刻意不使用 transient `faceIndex`;但是它还没有 OCCT 历史 `Generated/Modified/Deleted` 信息,也没有边/顶点和跨特征布尔历史映射TSN-03、TSN-05、TSN-07、TSN-08 仍是完成 FreeCAD 稳定子形状命名的必要任务。
TSN 已完成面/边/顶点量化签名、唯一/重复候选判定和匹配 API并挂接到 Bitbybit `MeshAsset`重复候选使用唯一候选 ID一旧多新明确保持 ambiguous面匹配可跨纯平移迁移。版本化 TopoRef JSON 只保存对象、种类、持久 ID、拓扑版本、generation、状态、签名和候选显式拒绝 transient `faceIndex/edgeIndex/vertexIndex/subshapeIndex`。它仍没有 OCCT 历史 `Generated/Modified/Deleted` 信息、曲线/曲面参数签名、邻接图和跨特征布尔历史映射TSN-03、TSN-07 与完整 TSN-08 仍是 FreeCAD 稳定子形状命名的必要任务。
Sketcher 已有 Facade 事务模型和基础求解器适配器Sketch 几何/约束快照进入 `DocumentObjectSnapshot`,新增几何、约束和求解会更新 `ConstraintStatus`、历史、自动保存与 SQLite schema v4基础约束现覆盖 Diameter、Symmetric、Tangent。该适配器的职责是验证数据模型和提供可替换协议它不宣称已经等价于 FreeCAD `planegcs`。SK-03 的 planegcs WASM、SK-07 的完整约束分类、SK-09 的交互回放和 SK-10 的 B-spline 仍是兼容门禁。
@@ -337,12 +337,13 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
| P1-02 命令准确性 | manifest 仍可完整展示Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor命令状态同时检查工作台和对象类型选择谓词其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`mirrored` 未实现状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |
| P3-05 Pattern 实验切片 | `linear-pattern``polar-pattern` 通过 Part Design 选择谓词、Task 和类型化属性创建对应对象Linear 按总 Length 等距平移Polar 在小于 360° 时覆盖首末角、360° 时不复制重合端点;两者均由 OCCT union 合并并释放临时副本 | 线性间距/方向、圆周完整/部分角度分布、联合输入、句柄释放、非法参数诊断、Base 链接、Body.Tip、属性校验和任务提交回归测试通过 | 当前为 FreeCAD 1.0+ 的 Transform body 近似,只复制整个 Base Shape未实现 Transform tool shapes、连接单实体过滤、稳定 TopoRef、历史映射、反向轴和引用轴因此保持 `experimental` |
| P3-05 Hole 基础切片 | `hole` 创建 `PartDesign::Hole`,用 32 边圆形 Profile 调用现有 OCCT Pocket支持 Diameter、Depth 以及 Dimension/Through all校验正尺寸并纳入 Base 链接和 Body.Tip | 圆孔 Profile、有限深度调用、非法直径诊断、Task 属性提交和 Tip 更新测试通过 | 仅为基体原点 XY 面、+Z 法向的直孔;草图圆/面定位、沉孔、锪孔、钻尖、螺纹、尺寸标准和稳定支撑面引用仍待实现,保持 `experimental` |
| TSN-02/05/06/08 TopoRef 基础迁移 | `topologyReferences` 提供 schema v1 创建、序列化、安全解析、解析和跨版本迁移;重复几何候选 ID 唯一,一旧多新不会错误继承;匹配按 kind 隔离并用形状尺度归一化几何评分支持纯平移 | round-trip、瞬时索引拒绝、resolved/ambiguous/deleted、重复拆分和纯平移回归测试通过 | 仍是网格签名启发式;没有 OCCT Generated/Modified/Deleted、邻接/曲率签名、布尔历史、数据库对象属性接线和黄金模型规模验证,不得提升为 compatible |
后续按以下顺序连续开发:
1. `P4-06/DAG-08`:同步/异步重算已共享抑制传播和根因诊断语义并支持定位、解除错误后的最小闭包重算与合法特征抑制继续补齐多配置抑制、Body Tip 重定向、最近有效 Shape 显式回滚和性能计数器。Shape 缓存已进入 Facade但尚未持久化。
2. `P3-04/P3-05`Pad/Pocket/Revolution/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写Pocket Through all 已接入Linear/Polar Pattern 与原点法向直孔已完成实验切片;继续补齐 Up to face、局部特征/拓扑历史及 Hole 高级形态,未完成能力保持 disabled 或 experimental。
3. `TSN-03/05/07/08`补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。
3. `TSN-03/07/08`TopoRef schema、面/边/点签名、基础迁移和歧义处理已落地;继续补齐 OCCT Generated/Modified/Deleted、邻接/曲率签名、跨布尔历史和数据库属性接线;任何 transient index 不得写入持久引用。
4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。
5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。
6. `FC-04/05/06/10`:增加 XML/Shape/Expression/Link 映射,未知字段原样保留,完成 WebCAD↔FCStd↔FreeCAD 差异报告。

View File

@@ -1600,12 +1600,12 @@ P0 基线/治理
| P2-05 多标签页写入协调 | `DONE (降级可观测)` | `SqliteProjectPersistence` 在保存和资源写入外包 `navigator.locks` 独占锁,成功保存通过 `BroadcastChannel` 广播文档版本Facade 暴露 `project.subscribeExternalChanges()`;无 Web Locks 的运行时能力标记为 `local-queue`,不宣称跨标签页互斥 |
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放React 视口通过 Facade OCCT 预览链生成 Pad/Pocket Mesh |
| P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 |
| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle``MeshAsset``SubshapeRef` 类型;句柄携带 documentId/version底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝`geometry.topology()` 已从 BitBybit 网格生成面/边/点基础签名OCCT 历史命名仍未实现 |
| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle``MeshAsset``SubshapeRef` 类型;句柄携带 documentId/version底层 OCCT hash 不出运行时;`geometry.topology()` 生成面/边/点签名TopoRef schema v1 可序列化/解析/迁移并拒绝瞬时索引,重复拆分保持歧义、纯平移面可继承 ID。OCCT 历史命名、邻接/曲率签名和布尔映射仍未实现 |
| P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm自动化浏览器黄金测试尚未进入 CI |
| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/RevolutionRevolution Angle/Reversed 已接入Fillet/Chamfer 也有 Shape 缓存回写Up to face、FCStd Shape 持久化仍待完成 |
| P3-05 Fillet/Chamfer/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存Linear/Polar Pattern 已贯通选择谓词、Task、Placement/union 和清理;基础 Hole 用圆形 Profile 复用 Pocket支持 Diameter、Depth、Dimension/Through all。Pattern 当前为整 ShapeHole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 |
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口优先使用重计算缓存的对象 Shape失败时保留最近有效结果无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 47 个场景,包括 Part 命令、基本体/布尔重算、async generation 重算、DAG 分层并行、抑制传播/恢复、根因诊断/修复、OCCT 特征执行器、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式E2E/黄金几何待补齐 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 48 个场景,包括 Part/PartDesign 命令、Pattern/Hole、TopoRef round-trip/迁移/歧义、基本体/布尔重算、async generation、DAG诊断修复、FCStd 安全、持久化、资源、任务、历史和表达式E2E/黄金几何待补齐 |
本迭代验证命令:`npm run check:facade-boundary``npm run test:facade``npm run build`。构建产物将 Three.js 拆为独立 chunk避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。

View File

@@ -6,6 +6,8 @@ export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitM
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types'
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences'
export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'

View File

@@ -94,11 +94,18 @@ export const signatureForVertex = (point: [number, number, number], tolerance =
const refsForSignatures = (shapeId: string, topologyVersion: number, signatures: SubshapeSignature[]): SubshapeRef[] => {
const occurrences = new Map<string, number>()
signatures.forEach((signature) => occurrences.set(`${signature.kind}:${signature.hash}`, (occurrences.get(`${signature.kind}:${signature.hash}`) ?? 0) + 1))
return signatures.map((signature) => {
const indexes = new Map<string, number>()
const identities = signatures.map((signature) => {
const key = `${signature.kind}:${signature.hash}`
const duplicate = (occurrences.get(key) ?? 0) > 1
const persistentId = `topo-${signature.kind}-${signature.hash}`
return { shapeId, kind: signature.kind, persistentId, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? signatures.filter((candidate) => candidate.kind === signature.kind && candidate.hash === signature.hash).map((candidate) => `topo-${candidate.kind}-${candidate.hash}`) : undefined }
const index = (indexes.get(key) ?? 0) + 1
indexes.set(key, index)
return { key, persistentId: `topo-${signature.kind}-${signature.hash}${duplicate ? `~${index}` : ''}` }
})
return signatures.map((signature, index) => {
const identity = identities[index]
const duplicate = (occurrences.get(identity.key) ?? 0) > 1
return { shapeId, kind: signature.kind, persistentId: identity.persistentId, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? identities.filter((candidate) => candidate.key === identity.key).map((candidate) => candidate.persistentId) : undefined }
})
}
@@ -127,39 +134,46 @@ export const createVertexSubshapeRefs = (shapeId: string, topologyVersion: numbe
export const createSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => {
const signatures = faces.map((face) => signatureForFace(face, tolerance))
const occurrences = new Map<string, number>()
signatures.forEach((signature) => occurrences.set(signature.hash, (occurrences.get(signature.hash) ?? 0) + 1))
const refs = signatures.map((signature) => {
const duplicate = (occurrences.get(signature.hash) ?? 0) > 1
return { shapeId, kind: 'face' as const, persistentId: `topo-face-${signature.hash}`, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? signatures.filter((candidate) => candidate.hash === signature.hash).map((candidate) => `topo-face-${candidate.hash}`) : undefined }
})
return { refs, signatures }
return { refs: refsForSignatures(shapeId, topologyVersion, signatures), signatures }
}
const signatureScore = (left: SubshapeSignature, right: SubshapeSignature, tolerance: number) => {
const distance = Math.hypot(...left.centroid.map((value, axis) => value - right.centroid[axis]))
const areaDelta = Math.abs(left.area - right.area)
const boundsDelta = Math.hypot(...left.bounds.min.map((value, axis) => value - right.bounds.min[axis]), ...left.bounds.max.map((value, axis) => value - right.bounds.max[axis]))
if (left.kind !== right.kind || left.kind === 'vertex') return 0
const scale = Math.max(Math.sqrt(Math.max(left.area, right.area)), magnitude(left.bounds.max.map((value, axis) => value - left.bounds.min[axis]) as [number, number, number]), magnitude(right.bounds.max.map((value, axis) => value - right.bounds.min[axis]) as [number, number, number]), tolerance)
const areaDelta = Math.abs(left.area - right.area) / Math.max(left.area, right.area, tolerance * tolerance)
const leftExtent = left.bounds.max.map((value, axis) => value - left.bounds.min[axis])
const rightExtent = right.bounds.max.map((value, axis) => value - right.bounds.min[axis])
const boundsDelta = Math.hypot(...leftExtent.map((value, axis) => value - rightExtent[axis])) / scale
const normalDelta = Math.hypot(...left.normal.map((value, axis) => value - right.normal[axis]))
return Math.max(0, 1 - (distance + areaDelta + boundsDelta + normalDelta) / Math.max(tolerance, 1e-9))
return Math.max(0, 1 - (areaDelta + boundsDelta + normalDelta) / 4)
}
export const matchSubshapes = (previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>, current: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>, tolerance = 1e-4): SubshapeMatch[] => {
const matches: SubshapeMatch[] = []
const used = new Set<string>()
const contested = new Set<string>()
const currentHashCounts = new Map<string, number>()
for (const entry of current) currentHashCounts.set(`${entry.signature.kind}:${entry.signature.hash}`, (currentHashCounts.get(`${entry.signature.kind}:${entry.signature.hash}`) ?? 0) + 1)
for (const candidate of current) {
const exact = previous.filter((entry) => entry.signature.hash === candidate.signature.hash && !used.has(entry.ref.persistentId))
if (exact.length === 1) { used.add(exact[0].ref.persistentId); matches.push({ current: { ...candidate.ref, persistentId: exact[0].ref.persistentId, status: 'stable' }, previousId: exact[0].ref.persistentId, score: 1, status: 'stable' }); continue }
const scored = previous.map((entry) => ({ entry, score: signatureScore(entry.signature, candidate.signature, tolerance) })).filter((entry) => !used.has(entry.entry.ref.persistentId)).sort((left, right) => right.score - left.score)
const hashKey = `${candidate.signature.kind}:${candidate.signature.hash}`
const exact = previous.filter((entry) => entry.signature.kind === candidate.signature.kind && entry.signature.hash === candidate.signature.hash && !used.has(entry.ref.persistentId))
if (exact.length === 1 && currentHashCounts.get(hashKey) === 1 && candidate.ref.status !== 'ambiguous') { used.add(exact[0].ref.persistentId); matches.push({ current: { ...candidate.ref, persistentId: exact[0].ref.persistentId, status: 'stable', candidates: undefined }, previousId: exact[0].ref.persistentId, score: 1, status: 'stable' }); continue }
if (exact.length > 0 && (exact.length > 1 || (currentHashCounts.get(hashKey) ?? 0) > 1 || candidate.ref.status === 'ambiguous')) {
exact.forEach((entry) => contested.add(entry.ref.persistentId))
matches.push({ current: { ...candidate.ref, status: 'ambiguous', candidates: [...new Set([...exact.map((entry) => entry.ref.persistentId), ...(candidate.ref.candidates ?? [])])] }, score: 1, status: 'ambiguous' })
continue
}
const scored = previous.map((entry) => ({ entry, score: entry.signature.kind === candidate.signature.kind ? signatureScore(entry.signature, candidate.signature, tolerance) : 0 })).filter((entry) => !used.has(entry.entry.ref.persistentId)).sort((left, right) => right.score - left.score || left.entry.ref.persistentId.localeCompare(right.entry.ref.persistentId))
const best = scored[0]
const second = scored[1]
if (best && best.score > 0.9 && (!second || best.score - second.score > 0.05)) {
used.add(best.entry.ref.persistentId)
matches.push({ current: { ...candidate.ref, persistentId: best.entry.ref.persistentId, status: 'stable' }, previousId: best.entry.ref.persistentId, score: best.score, status: 'stable' })
} else if (best && best.score > 0.5 && second && Math.abs(best.score - second.score) <= 0.05) {
contested.add(best.entry.ref.persistentId); contested.add(second.entry.ref.persistentId)
matches.push({ current: { ...candidate.ref, status: 'ambiguous', candidates: [best.entry.ref.persistentId, second.entry.ref.persistentId] }, score: best.score, status: 'ambiguous' })
} else matches.push({ current: { ...candidate.ref, status: 'new' }, score: best?.score ?? 0, status: 'new' })
}
for (const entry of previous) if (!used.has(entry.ref.persistentId)) matches.push({ current: { ...entry.ref, status: 'deleted' }, previousId: entry.ref.persistentId, score: 0, status: 'deleted' })
for (const entry of previous) if (!used.has(entry.ref.persistentId) && !contested.has(entry.ref.persistentId)) matches.push({ current: { ...entry.ref, status: 'deleted' }, previousId: entry.ref.persistentId, score: 0, status: 'deleted' })
return matches
}

View File

@@ -0,0 +1,110 @@
import type { SubshapeRef } from './types'
import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming'
export type PersistedTopoRef = {
schemaVersion: 1
objectId: string
kind: SubshapeRef['kind']
persistentId: string
topologyVersion: number
generation: number
status: NonNullable<SubshapeRef['status']>
signature?: string
candidates?: string[]
}
export type TopoRefResolution = {
status: 'resolved' | 'ambiguous' | 'deleted'
ref?: SubshapeRef
candidates?: SubshapeRef[]
}
export type TopologyMigration = {
records: PersistedTopoRef[]
matches: SubshapeMatch[]
counts: Record<NonNullable<SubshapeRef['status']>, number>
}
const transientIndexKeys = new Set(['faceIndex', 'edgeIndex', 'vertexIndex', 'subshapeIndex'])
const statuses = new Set<NonNullable<SubshapeRef['status']>>(['stable', 'ambiguous', 'new', 'deleted'])
const kinds = new Set<SubshapeRef['kind']>(['face', 'edge', 'vertex'])
const finiteNonNegativeInteger = (value: unknown) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
export const createPersistedTopoRef = (objectId: string, ref: SubshapeRef, generation: number): PersistedTopoRef => {
if (!objectId.trim()) throw new RangeError('TopoRef objectId is required.')
if (!finiteNonNegativeInteger(generation)) throw new RangeError('TopoRef generation must be a non-negative integer.')
if (!ref.persistentId.trim()) throw new RangeError('TopoRef persistentId is required.')
if (!finiteNonNegativeInteger(ref.topologyVersion)) throw new RangeError('TopoRef topologyVersion must be a non-negative integer.')
return {
schemaVersion: 1,
objectId,
kind: ref.kind,
persistentId: ref.persistentId,
topologyVersion: ref.topologyVersion,
generation,
status: ref.status ?? 'stable',
signature: ref.signature,
candidates: ref.candidates ? [...new Set(ref.candidates)] : undefined,
}
}
export const serializeTopoRef = (record: PersistedTopoRef) => JSON.stringify(createPersistedTopoRef(record.objectId, {
shapeId: record.objectId,
kind: record.kind,
persistentId: record.persistentId,
topologyVersion: record.topologyVersion,
status: record.status,
signature: record.signature,
candidates: record.candidates,
}, record.generation))
export const parseTopoRef = (json: string): PersistedTopoRef => {
let value: unknown
try { value = JSON.parse(json) } catch { throw new SyntaxError('TopoRef JSON is invalid.') }
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('TopoRef must be a JSON object.')
const source = value as Record<string, unknown>
for (const key of transientIndexKeys) if (key in source) throw new RangeError(`TopoRef must not persist transient ${key}.`)
if (source.schemaVersion !== 1) throw new RangeError('TopoRef schemaVersion is not supported.')
if (typeof source.objectId !== 'string' || typeof source.persistentId !== 'string') throw new TypeError('TopoRef objectId and persistentId must be strings.')
if (!kinds.has(source.kind as SubshapeRef['kind'])) throw new RangeError('TopoRef kind is invalid.')
if (!statuses.has(source.status as NonNullable<SubshapeRef['status']>)) throw new RangeError('TopoRef status is invalid.')
if (!finiteNonNegativeInteger(source.topologyVersion) || !finiteNonNegativeInteger(source.generation)) throw new RangeError('TopoRef version fields must be non-negative integers.')
if (source.signature !== undefined && typeof source.signature !== 'string') throw new TypeError('TopoRef signature must be a string.')
if (source.candidates !== undefined && (!Array.isArray(source.candidates) || !source.candidates.every((candidate) => typeof candidate === 'string'))) throw new TypeError('TopoRef candidates must be strings.')
return createPersistedTopoRef(source.objectId, {
shapeId: source.objectId,
kind: source.kind as SubshapeRef['kind'],
persistentId: source.persistentId,
topologyVersion: source.topologyVersion as number,
status: source.status as NonNullable<SubshapeRef['status']>,
signature: source.signature as string | undefined,
candidates: source.candidates as string[] | undefined,
}, source.generation as number)
}
export const resolveTopoRef = (record: PersistedTopoRef, current: SubshapeRef[]): TopoRefResolution => {
const sameKind = current.filter((ref) => ref.kind === record.kind)
const byId = sameKind.filter((ref) => ref.persistentId === record.persistentId)
if (byId.length === 1 && byId[0].status !== 'ambiguous') return { status: 'resolved', ref: byId[0] }
const bySignature = record.signature ? sameKind.filter((ref) => ref.signature === record.signature) : []
const candidates = byId.length > 0 ? byId : bySignature
if (candidates.length === 1 && candidates[0].status !== 'ambiguous') return { status: 'resolved', ref: candidates[0] }
if (candidates.length > 0) return { status: 'ambiguous', candidates }
return { status: 'deleted' }
}
export const migrateTopoRefs = (
objectId: string,
previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
current: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
generation: number,
): TopologyMigration => {
const matches = matchSubshapes(previous, current)
const counts: TopologyMigration['counts'] = { stable: 0, ambiguous: 0, new: 0, deleted: 0 }
const records = matches.map((match) => {
counts[match.status] += 1
return createPersistedTopoRef(objectId, match.current, generation)
})
return { records, matches, counts }
}

View File

@@ -7,6 +7,7 @@ import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS
import { DependencyGraph } from '../src/facade/dependencyGraph'
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
import { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
import { createSketch, solveSketch } from '../src/facade/sketcher'
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
import { inspectFcstdArchive } from '../src/facade/fcstd'
@@ -148,11 +149,37 @@ test('topology signatures are independent of transient face indexes and flag amb
const duplicate = createSubshapeRefs('shape-b', 2, [square, square])
assert.equal(duplicate.refs[0].status, 'ambiguous')
assert.deepEqual(duplicate.refs[0].candidates, [duplicate.refs[0].persistentId, duplicate.refs[1].persistentId])
assert.notEqual(duplicate.refs[0].persistentId, duplicate.refs[1].persistentId)
const matches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: duplicate.refs[0], signature: duplicate.signatures[0] }])
assert.equal(matches[0].status, 'stable')
assert.equal(matches[0].previousId, first.refs[0].persistentId)
const deleted = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }, { ref: duplicate.refs[1], signature: duplicate.signatures[1] }], [{ ref: duplicate.refs[0], signature: duplicate.signatures[0] }])
assert.equal(matches[0].status, 'ambiguous')
const shifted = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 10 : value) }
const previousShifted = createSubshapeRefs('shape-shifted', 1, [shifted])
const currentSingle = createSubshapeRefs('shape-current', 2, [square])
const deleted = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }, { ref: previousShifted.refs[0], signature: previousShifted.signatures[0] }], [{ ref: currentSingle.refs[0], signature: currentSingle.signatures[0] }])
assert.equal(deleted.at(-1)?.status, 'deleted')
const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }
const moved = createSubshapeRefs('shape-moved', 3, [translated])
const movedMatches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: moved.refs[0], signature: moved.signatures[0] }])
assert.equal(movedMatches[0].status, 'stable')
assert.equal(movedMatches[0].previousId, first.refs[0].persistentId)
})
test('versioned TopoRefs serialize without transient indexes and resolve migration states', () => {
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
const previous = createSubshapeRefs('shape-old', 4, [face])
const record = createPersistedTopoRef('pad', previous.refs[0], 7)
const restored = parseTopoRef(serializeTopoRef(record))
assert.deepEqual(restored, record)
assert.equal(resolveTopoRef(restored, previous.refs).status, 'resolved')
assert.throws(() => parseTopoRef(JSON.stringify({ ...record, faceIndex: 1 })), /must not persist transient faceIndex/)
const split = createSubshapeRefs('shape-new', 5, [face, face])
const migration = migrateTopoRefs('pad', [{ ref: previous.refs[0], signature: previous.signatures[0] }], split.refs.map((ref, index) => ({ ref, signature: split.signatures[index] })), 8)
assert.equal(migration.counts.ambiguous, 2)
assert.equal(migration.counts.deleted, 0)
assert.ok(migration.records.every((candidate) => candidate.status === 'ambiguous' && candidate.candidates && candidate.candidates.length >= 2))
assert.equal(resolveTopoRef(restored, split.refs).status, 'ambiguous')
})
test('edge and vertex topology signatures are orientation/index independent', () => {