feat: add versioned topology reference migration
This commit is contained in:
@@ -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"] }
|
||||
},
|
||||
|
||||
@@ -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 差异报告。
|
||||
|
||||
@@ -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/Revolution,Revolution 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 当前为整 Shape,Hole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 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 构建验证。
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
110
src/facade/topologyReferences.ts
Normal file
110
src/facade/topologyReferences.ts
Normal 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 }
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user