From 61360473fecd242578e26bcde759caedb0556141 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 20:40:34 -0400 Subject: [PATCH] feat: restore saved documents through facade --- docs/freecad-full-parity-plan.zh-CN.md | 1 + docs/web-cad-implementation-plan.zh-CN.md | 1 + src/App.tsx | 12 +++++++++--- src/facade/mockFacade.ts | 12 +++++++++++- src/facade/types.ts | 1 + tests/facade.test.ts | 5 +++++ 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 0062fd1..634e837 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -330,6 +330,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 | SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 | | FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 | | FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型;未知类型为 proxy,Python 类型为 blocked;脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip | +| P2-04 Document load | `app.document.load()` 将 Project 快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态并保留 Undo 边界;Projects 页面已调用该 Facade API | 保存→修改→加载后属性恢复、选择清空、任务清空测试通过 | FCStd 导入仍是只读 metadata/proxy;跨文档 ShapeHandle 和恢复后自动几何重算仍待完成 | | P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;命令状态同时检查工作台和对象类型选择谓词;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 | 后续按以下顺序连续开发: diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 4b1f524..b5f5ab1 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1594,6 +1594,7 @@ P0 基线/治理 | P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v4;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚和恢复演练仍待补齐;跨标签写入已由 P2-05 通过 Web Locks/降级能力标记覆盖 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;`project.recovery(documentId)` 已提供 SQLite/内存完整性、最后保存版本和警告报告,崩溃点检测和用户可配置策略仍待补齐 | +| Document load transaction | `PASS` | `app.document.load(documentId)` 通过 Project Facade 恢复 SQLite/内存快照,取消旧重算、释放缓存 Shape、清空选择/任务并提交可撤销的活动文档;工程管理页已接入默认本地快照打开流程 | | 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 自构建脚本,不能标记完成 | diff --git a/src/App.tsx b/src/App.tsx index ab52f9d..3398a78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -468,7 +468,7 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, docume function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice, facade }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { const pages: Record, ReactNode> = { start: , - projects: , + projects: , import: , export: , settings: , @@ -500,8 +500,14 @@ function ProjectCard({ project, index, onClick }: { project: typeof projects[num return } -function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) { - return
} />
/
Sort by
{projects.map((project, index) =>
{project.name}{project.path}
{project.objects} objects{project.modified}{project.state}
)}
Local storage3.4 GB of 10 GB used · Last backup just now
+function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice, facade }: { onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { + const openSavedProject = async () => { + const loaded = await facade.app.document.load('doc-pump-housing') + if (!loaded) { showNotice('No saved local snapshot found'); return } + showNotice(`Loaded ${loaded.label}`) + onOpenWorkspace() + } + return
} />
/
Sort by
{projects.map((project, index) => )}
Local storage3.4 GB of 10 GB used · Last backup just now
} function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 339f338..0d90acb 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -456,6 +456,16 @@ export function createMockFacade(): BitBybitWebCadFacade { emitState() return result } + const loadDocument = async (documentId: string) => { + recomputeCoordinator.cancel() + const loaded = await projectPersistence.load(documentId) + if (!loaded) return null + clearFeatureShapes() + const document = cloneDocumentSnapshot(loaded) + commit({ ...state, document, selectedObjectId: '', task: null }) + notify(`Loaded ${document.label}`) + return getState().document + } const getSketch = (objectId: string) => { const object = state.document.objects.find((candidate) => candidate.id === objectId) return object?.sketch ? cloneSketch(object.sketch) : null @@ -559,7 +569,7 @@ export function createMockFacade(): BitBybitWebCadFacade { } const facade: BitBybitWebCadFacade = { - app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, + app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } }, gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, diff --git a/src/facade/types.ts b/src/facade/types.ts index be5e94a..1c08ab5 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -340,6 +340,7 @@ export interface BitBybitWebCadFacade { getActive(): DocumentSnapshot getObject(objectId: string): DocumentObjectSnapshot | null create(label?: string): DocumentSnapshot + load(documentId: string): Promise markDirty(): void setProperty(input: SetPropertyInput): void setExpression(input: SetExpressionInput): void diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 49cf7d0..7190869 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -675,6 +675,11 @@ test('project persistence remains behind the facade contract', async () => { const loaded = await facade.project.load('doc-pump-housing') assert.equal(loaded?.label, 'Pump Housing') assert.equal(loaded?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55) + facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 61 }) + const restored = await facade.app.document.load('doc-pump-housing') + assert.equal(restored?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55) + assert.equal(facade.selection.getObjectId(), '') + assert.equal(facade.task.getActive(), null) assert.equal(facade.project.capabilities().mode, 'sqlite-memory') const recovery = await facade.project.recovery('doc-pump-housing') assert.equal(recovery.integrity, 'ok')