diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index d27e2a0..cdb508a 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -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", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "undo-redo"] }, + "document": { "level": "experimental", "operations": ["typed-properties", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "signature-topology-history-fallback", "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"] } }, diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index a655a1a..cda187e 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -338,7 +338,8 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 | 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 | -| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef,`App::PropertyLinkSub` 经 Facade 解析/校验和深克隆;依赖图产生带 persistentId 的 `topo-ref` 边;SQLite/内存 Project 以结构化 JSON 保存;Pocket 暴露 `UpToFace` 槽,Property 面板显示状态并可清除 | 非法瞬时索引/缺失对象拒绝、DAG 边、保存加载 round-trip、49 项 Facade 测试、桌面属性面板无横向溢出 | 尚未从 3D 选择创建引用,也未在重算后自动写回迁移结果;Pocket Up to face executor 仍保持结构化 unsupported,等待 OCCT 面历史/解析接线 | +| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef,`App::PropertyLinkSub` 经 Facade 解析/校验和深克隆;依赖图产生带 persistentId 的 `topo-ref` 边;SQLite/内存 Project 以结构化 JSON 保存;Pocket 暴露 `UpToFace` 槽,Property 面板显示状态并可清除 | 非法瞬时索引/缺失对象拒绝、DAG 边、保存加载 round-trip、LinkSub 专项测试、桌面属性面板无横向溢出 | 尚未从 3D 选择创建引用,也未在重算后自动写回迁移结果;Pocket Up to face executor 仍保持结构化 unsupported,等待 OCCT 面历史/解析接线 | +| TSN-03/07 历史适配器回退 | Bitbybit 1.1.1 绑定仅有 `HasGenerated()`,没有逐 Shape 的 Generated/Modified/IsDeleted;新增显式 `signature-fallback` provider,把多输入到输出保守分类为 preserved/modified/generated/deleted/ambiguous,跨对象相同签名不抢占继承 | 平移修改、新增面、删除面和两个输入竞争同一输出的歧义测试通过;全套 50 项测试通过 | 此 provider 不是 OCCT history;只有自构建绑定暴露逐子形状历史并通过布尔黄金模型后,才能将 TSN-03/07 标为完成 | 后续按以下顺序连续开发: diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 92d2dbf..579b7f1 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1605,7 +1605,7 @@ P0 基线/治理 | 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` 已覆盖 49 个场景,包括 Part/PartDesign、Pattern/Hole、TopoRef round-trip/迁移/歧义、LinkSub DAG/持久化、几何重算、async generation、诊断修复、FCStd 安全、资源、任务、历史和表达式;E2E/黄金几何待补齐 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 50 个场景,包括 Part/PartDesign、Pattern/Hole、TopoRef/LinkSub、signature topology history fallback、几何重算、async generation、诊断修复、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 构建验证。 diff --git a/src/facade/index.ts b/src/facade/index.ts index ec5eeb1..3cd9f69 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -8,6 +8,8 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade 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 { captureSignatureTopologyHistory } from './topologyHistory' +export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory' 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' diff --git a/src/facade/topologyHistory.ts b/src/facade/topologyHistory.ts new file mode 100644 index 0000000..1e1cc01 --- /dev/null +++ b/src/facade/topologyHistory.ts @@ -0,0 +1,55 @@ +import type { SubshapeRef } from './types' +import { matchSubshapes, type SubshapeSignature } from './topologyNaming' + +export type TopologyHistoryEntry = { ref: SubshapeRef; signature: SubshapeSignature } + +export type TopologyHistoryRelation = { + relation: 'preserved' | 'modified' | 'generated' | 'deleted' | 'ambiguous' + sourceObjectId?: string + sourcePersistentId?: string + resultPersistentId?: string + candidates?: Array<{ sourceObjectId: string; persistentId: string }> + score: number +} + +export type TopologyHistoryResult = { + operationId: string + provider: 'signature-fallback' + relations: TopologyHistoryRelation[] + counts: Record +} + +const sourceKey = (objectId: string, persistentId: string) => `${encodeURIComponent(objectId)}::${persistentId}` + +export const captureSignatureTopologyHistory = ( + operationId: string, + inputs: Array<{ objectId: string; entries: TopologyHistoryEntry[] }>, + output: TopologyHistoryEntry[], +): TopologyHistoryResult => { + if (!operationId.trim()) throw new RangeError('Topology history operationId is required.') + const sources = new Map() + const previous = inputs.flatMap(({ objectId, entries }) => entries.map((entry) => { + const key = sourceKey(objectId, entry.ref.persistentId) + sources.set(key, { objectId, persistentId: entry.ref.persistentId, signature: entry.signature }) + return { ref: { ...entry.ref, persistentId: key }, signature: entry.signature } + })) + const matches = matchSubshapes(previous, output) + const counts: TopologyHistoryResult['counts'] = { preserved: 0, modified: 0, generated: 0, deleted: 0, ambiguous: 0 } + const relations = matches.map((match): TopologyHistoryRelation => { + if (match.status === 'new') return { relation: 'generated', resultPersistentId: match.current.persistentId, score: match.score } + if (match.status === 'deleted') { + const source = match.previousId ? sources.get(match.previousId) : undefined + return { relation: 'deleted', sourceObjectId: source?.objectId, sourcePersistentId: source?.persistentId, score: 0 } + } + if (match.status === 'ambiguous') { + const candidates = (match.current.candidates ?? []).map((id) => sources.get(id)).filter((candidate): candidate is NonNullable => Boolean(candidate)).map((candidate) => ({ sourceObjectId: candidate.objectId, persistentId: candidate.persistentId })) + return { relation: 'ambiguous', resultPersistentId: match.current.persistentId, candidates, score: match.score } + } + const source = match.previousId ? sources.get(match.previousId) : undefined + const outputEntry = output.find((entry) => entry.ref.persistentId === match.current.persistentId || entry.ref.signature === match.current.signature) + const relation = source && outputEntry && source.signature.hash === outputEntry.signature.hash ? 'preserved' : 'modified' + return { relation, sourceObjectId: source?.objectId, sourcePersistentId: source?.persistentId, resultPersistentId: match.current.persistentId, score: match.score } + }) + for (const relation of relations) counts[relation.relation] += 1 + return { operationId, provider: 'signature-fallback', relations, counts } +} diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 73acb86..747bc48 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -8,6 +8,7 @@ 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 { captureSignatureTopologyHistory } from '../src/facade/topologyHistory' import { createSketch, solveSketch } from '../src/facade/sketcher' import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine' import { inspectFcstdArchive } from '../src/facade/fcstd' @@ -182,6 +183,27 @@ test('versioned TopoRefs serialize without transient indexes and resolve migrati assert.equal(resolveTopoRef(restored, split.refs).status, 'ambiguous') }) +test('signature topology history conservatively classifies boolean fallback relations', () => { + const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] } + const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) } + const triangle = { vertexCoord: [0, 0, 1, 1, 0, 1, 0, 1, 1], normalCoord: [], triIndexes: [0, 1, 2] } + const before = createSubshapeRefs('base-shape', 1, [square]) + const after = createSubshapeRefs('result-shape', 2, [translated, triangle]) + const history = captureSignatureTopologyHistory('boolean-1', [{ objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) }], after.refs.map((ref, index) => ({ ref, signature: after.signatures[index] }))) + assert.equal(history.provider, 'signature-fallback') + assert.equal(history.counts.modified, 1) + assert.equal(history.counts.generated, 1) + + const deleted = captureSignatureTopologyHistory('boolean-2', [{ objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) }], []) + assert.equal(deleted.counts.deleted, 1) + const ambiguous = captureSignatureTopologyHistory('boolean-3', [ + { objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) }, + { objectId: 'tool', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) }, + ], before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] }))) + assert.equal(ambiguous.counts.ambiguous, 1) + assert.deepEqual(new Set(ambiguous.relations[0].candidates?.map((candidate) => candidate.sourceObjectId)), new Set(['base', 'tool'])) +}) + test('edge and vertex topology signatures are orientation/index independent', () => { const edge = signatureForEdge([0, 0, 0], [2, 0, 0]) assert.equal(edge.hash, signatureForEdge([2, 0, 0], [0, 0, 0]).hash)