diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 1c60e8d..2c74a8f 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1547,7 +1547,8 @@ P0 基线/治理 3. `P1-01`:建立 `BitBybitWebCadFacade` 最小 TypeScript 合同,只包含 app.document、gui.workbench、gui.command、selection 和 task。 4. `P1-05`:把当前静态回调替换为 `MockFacadeAdapter`,验证页面不直接修改模型树或属性。 5. `P1-06`:加入 Facade-only 依赖检查和 CI 失败示例。 -6. `P8-01`:为新建文档、工作台切换、选择 Pad、打开 Task Dock 和属性编辑建立第一批自动化场景。 +6. `P4-01/P4-04`:验证最小 Document/Object 投影、特征追加、事务版本和撤销/重做边界。 +7. `P8-01`:为新建文档、工作台切换、选择 Pad、打开 Task Dock、特征提交和属性编辑建立第一批自动化场景。 迭代退出条件:`npm run build`、类型检查、Facade-only 架构检查和桌面/移动页面截图全部通过;提交信息必须包含任务 ID,例如 `P1-01: define facade contract`。 @@ -1588,7 +1589,8 @@ P0 基线/治理 | P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 | | P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 | | P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker | +| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty 标志和 Undo/Redo 已接通,完整属性、依赖 DAG 和重计算仍待实现 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期、禁用命令和请求上下文;E2E/黄金几何待补齐 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 | -本迭代验证命令:`npm run check:facade-boundary`、`npm run test:facade`、`npm run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。下一迭代继续完成 P0-01/P0-02 的精确锁定和 P1-02/P1-03 的命令状态、事件及诊断协议。 +本迭代验证命令:`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/mockFacade.ts b/src/facade/mockFacade.ts index 3fe3f49..8cc1188 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -31,6 +31,14 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => ({ const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area']) const systemCommands = new Set(['new-document', 'save', 'select-object']) +const featureCommands: Record = { + 'create-body': { label: 'Body', detail: 'Part Design body' }, + 'create-sketch': { label: 'Sketch', detail: 'Fully constrained' }, + pad: { label: 'Pad', detail: 'Length 42 mm' }, + pocket: { label: 'Pocket', detail: 'Through all' }, + fillet: { label: 'Fillet', detail: 'Radius 3 mm' }, + chamfer: { label: 'Chamfer', detail: 'Length 2 mm' }, +} const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => { const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId))) @@ -55,6 +63,38 @@ export function createMockFacade(): BitBybitWebCadFacade { const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) } const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() } const beginTask = (commandId: string, draft: Record = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task } + const nextFeatureId = (label: string) => { + const base = label.toLowerCase() + const existing = state.document.tree.filter((item) => item.label.toLowerCase().startsWith(base)).length + return existing === 0 ? base : `${base}${String(existing).padStart(3, '0')}` + } + const appendFeature = (document: DocumentSnapshot, commandId: string): { document: DocumentSnapshot; objectId: string } => { + const definition = featureCommands[commandId] + if (!definition) return { document, objectId: '' } + const objectId = nextFeatureId(definition.label) + const type = commandId === 'create-sketch' ? 'sketch' : commandId === 'create-body' ? 'body' : 'feature' + const item: ModelTreeItem = { id: objectId, label: definition.label, type, state: type === 'body' ? 'active' : 'valid', detail: definition.detail } + const tree: ModelTreeItem[] = document.tree.map((entry) => ({ ...entry, children: entry.children ? [...entry.children] : undefined })) + if (type === 'body') tree.push({ ...item, children: [] }) + else { + const body = tree.find((entry) => entry.type === 'body') + if (body) body.children = [...(body.children || []), objectId] + tree.push(item) + } + return { document: { ...document, version: document.version + 1, dirty: true, tree }, objectId } + } + const applyTask = () => { + const task = state.task + if (!task || task.status !== 'preview') return + const result = appendFeature(state.document, task.commandId) + if (!result.objectId) { + state = { ...state, task: { ...task, status: 'completed' } } + emitState() + return + } + commit({ ...state, document: result.document, selectedObjectId: result.objectId, task: { ...task, status: 'completed' } }) + notify(`${featureCommands[task.commandId].label} created`) + } const execute = ({ commandId, payload }: ExecuteCommandInput) => { const requestId = `req-${++requestSequence}` const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } @@ -68,8 +108,7 @@ export function createMockFacade(): BitBybitWebCadFacade { if (commandId === 'new-document') commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) else if (commandId === 'save') commit({ ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } }) else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) - else if (commandId === 'create-body') beginTask('create-body') - else if (commandId === 'create-sketch') beginTask('create-sketch') + else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null }) emit({ type: 'command.completed', commandId, context }); emitState(); return requestId } @@ -78,7 +117,7 @@ export function createMockFacade(): BitBybitWebCadFacade { history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; 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), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, - task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'completed' } }; emitState() }, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, + task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 45ffdfb..ff39346 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -71,3 +71,24 @@ test('document mutations use the facade history boundary', () => { facade.history.redo() assert.equal(facade.app.document.getActive().label, 'Untitled document') }) + +test('Part Design feature tasks commit a document object and remain undoable', () => { + const facade = createMockFacade() + const before = facade.app.document.getActive() + facade.gui.command.execute({ commandId: 'pad' }) + assert.equal(facade.task.getActive()?.commandId, 'pad') + facade.task.update({ length: 42, reversed: false }) + facade.task.apply() + + const committed = facade.app.document.getActive() + const created = committed.tree.find((item) => item.id === 'pad001') + assert.equal(created?.label, 'Pad') + assert.equal(committed.version, before.version + 1) + assert.equal(facade.selection.getObjectId(), 'pad001') + assert.equal(committed.dirty, true) + + facade.history.undo() + assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), false) + facade.history.redo() + assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), true) +})