P4-01: commit feature tasks to document history
This commit is contained in:
@@ -1547,7 +1547,8 @@ P0 基线/治理
|
|||||||
3. `P1-01`:建立 `BitBybitWebCadFacade` 最小 TypeScript 合同,只包含 app.document、gui.workbench、gui.command、selection 和 task。
|
3. `P1-01`:建立 `BitBybitWebCadFacade` 最小 TypeScript 合同,只包含 app.document、gui.workbench、gui.command、selection 和 task。
|
||||||
4. `P1-05`:把当前静态回调替换为 `MockFacadeAdapter`,验证页面不直接修改模型树或属性。
|
4. `P1-05`:把当前静态回调替换为 `MockFacadeAdapter`,验证页面不直接修改模型树或属性。
|
||||||
5. `P1-06`:加入 Facade-only 依赖检查和 CI 失败示例。
|
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`。
|
迭代退出条件:`npm run build`、类型检查、Facade-only 架构检查和桌面/移动页面截图全部通过;提交信息必须包含任务 ID,例如 `P1-01: define facade contract`。
|
||||||
|
|
||||||
@@ -1588,7 +1589,8 @@ P0 基线/治理
|
|||||||
| P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 |
|
| P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 |
|
||||||
| P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 |
|
| 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 |
|
| 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 场景和资源释放 |
|
| 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 构建验证。
|
||||||
|
|||||||
@@ -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 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 systemCommands = new Set(['new-document', 'save', 'select-object'])
|
||||||
|
const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||||
|
'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 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)))
|
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 setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
|
||||||
const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() }
|
const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() }
|
||||||
const beginTask = (commandId: string, draft: Record<string, unknown> = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task }
|
const beginTask = (commandId: string, draft: Record<string, unknown> = {}) => { 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 execute = ({ commandId, payload }: ExecuteCommandInput) => {
|
||||||
const requestId = `req-${++requestSequence}`
|
const requestId = `req-${++requestSequence}`
|
||||||
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench }
|
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: '' })
|
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 === '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 === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
|
||||||
else if (commandId === 'create-body') beginTask('create-body')
|
else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null })
|
||||||
else if (commandId === 'create-sketch') beginTask('create-sketch')
|
|
||||||
emit({ type: 'command.completed', commandId, context }); emitState(); return requestId
|
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') } },
|
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 } },
|
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('') },
|
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() },
|
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,3 +71,24 @@ test('document mutations use the facade history boundary', () => {
|
|||||||
facade.history.redo()
|
facade.history.redo()
|
||||||
assert.equal(facade.app.document.getActive().label, 'Untitled document')
|
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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user