From 498e3f79badd7779c826be2823fb128dfed956a2 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 20:30:04 -0400 Subject: [PATCH] feat: complete boolean task links and recompute closure --- docs/freecad-full-parity-plan.zh-CN.md | 3 +- docs/web-cad-implementation-plan.zh-CN.md | 4 ++- src/App.tsx | 11 ++++++- src/facade/mockFacade.ts | 37 +++++++++++++++++++---- tests/facade.test.ts | 13 ++++++-- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index a2a4883..0062fd1 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -326,10 +326,11 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 |---|---|---|---| | P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation;后发运行取消前发;节点完成后校验 AbortSignal、文档 ID/版本和当前 generation;结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度;可选的 `createFacadeGeometryRecomputeExecutor` 在 OCCT Worker ready 时执行真实 Pad/Pocket/Revolution/Fillet/Chamfer | 取消、过期版本、依赖失败传播、独立节点并行、闭合 Sketch 特征链、Revolution 角度/反向和 Facade 提交测试通过 | 默认数据模型仍不持久化 ShapeHandle;Up to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape | | DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 | +| P4-06 任务提交与重算闭包 | Task 草稿在 Facade 事务内写入 Boolean Base/Tool 等链接;新对象和依赖闭包进入 `touched/dirty`,React OK 通过 `recomputeAsync()` 提交并反馈结果 | Boolean Task 链接/依赖测试、Task dirty 快照和完整验证通过 | Apply 预览仍未持久化临时 Shape;多选选择集、抑制对象和完整 FreeCAD Task 校验仍待实现 | | 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 | -| P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、`linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 | +| 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 051be70..4b1f524 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1585,7 +1585,7 @@ P0 基线/治理 | P0-02 运行时版本基线 | `IN PROGRESS` | `config/runtime-baseline.json` 已建立;Three.js 锁定 `0.185.1`,Bitbybit OCCT Worker/OCCT/Base 锁定 `1.1.1` 并记录 WASM SHA-256;FreeCAD 自构建内核仍待锁定 | | P0-03 兼容矩阵 | `DONE` | `config/compatibility-matrix.json` 覆盖 13 个工作台和命令状态 | | P1-01 Facade 最小合同 | `DONE` | `src/facade/types.ts` 定义 App/Gui/Command/Selection/Task/Viewport 合同 | -| P1-02 命令状态和选择前置 | `DONE` | MockFacade 根据工作台、选择对象和命令清单返回 enabled/disabled 原因 | +| P1-02 命令状态和选择前置 | `DONE` | MockFacade 根据工作台、选择对象类型和命令清单返回 enabled/disabled 原因;Part 布尔/检查仅接受 Shape-producing 对象,Sketcher 求解仅接受 Sketch,Part Design 特征拒绝文件夹和无几何对象 | | 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 | @@ -1693,7 +1693,9 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值 | View/Data 分离 | `PASS` | Visibility 等 View 属性提交文档事务但不把几何对象标记 Touched | | 类型控件 | `PASS` | View 标签由 metadata 生成 1 个 checkbox、2 个 enumeration、2 个 numeric、2 个 color 控件 | | Part primitive Task | `PASS` | Part 工作台任务可选择 Box/Cylinder/Sphere/Cone,尺寸和角度草稿写入 `Part::*` 对象属性并进入 OCCT 重算 | +| Part Boolean Task | `PASS` | Union/Cut/Intersection 任务提供 Base/Tool 对象选择槽,链接写入对象依赖图;确认后将新对象及其依赖闭包标记 dirty 并触发异步重算 | | Part Design Task | `PASS` | Pad/Pocket/Revolution/Fillet/Chamfer 任务草稿写入 Length/Type/Angle/Reversed/Midplane/Radius/Distance 属性,确认后仍通过 Facade 历史边界提交 | +| 选择谓词 | `PASS` | 选择 Origin/文件夹后 Part Union/Check geometry 被禁用;选择非 Sketch 对象后 Sketcher Solve 被禁用,并返回可解释原因 | | 校验 | `PASS` | 负 Length、越界 Percent、未知 Enumeration、无效颜色、缺失 Link、只读 Property 在 Facade 拒绝 | | Undo/Redo | `PASS` | Length 42→50 可撤销回 42、重做至 50,Property 和树投影同步 | | SQLite/OPFS | `PASS` | Chrome `sqlite-opfs` 保存 v19 后重新加载 Length=61、Status=Touched、tree state=dirty | diff --git a/src/App.tsx b/src/App.tsx index 2d8fa74..ab52f9d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -360,7 +360,16 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa } const currentFeatureField = activeTask?.commandId ? featureFields[activeTask.commandId] : undefined const activeCommand = activeTask?.commandId - return
Active command

{isSketch ? 'Edit Sketch' : definition.taskTitle}

Preview
1
{isSketch ? 'Geometry and constraints' : definition.objectType}{definition.taskSummary}
{activeCommand === 'primitive' && <>{primitiveFields.map((field) => )}}{currentFeatureField && }{activeCommand === 'pocket' && }{['pad', 'pocket', 'revolution'].includes(activeCommand || '') && }{activeCommand === 'pad' && }{!activeCommand || activeCommand === 'create-sketch' ? <> : null}
Changes remain local until the document is recomputed.
+ const activeDocument = facade.app.document.getActive() + const booleanCommand = activeCommand === 'union' || activeCommand === 'cut' || activeCommand === 'intersection' + const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) + const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : '' + const acceptTask = () => { + if (!activeTask || activeTask.status !== 'preview') { showNotice('No active task'); return } + facade.task.apply() + void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Task accepted and recompute completed' : `Task accepted; recompute ${result.status}`)) + } + return
Active command

{isSketch ? 'Edit Sketch' : definition.taskTitle}

Preview
1
{isSketch ? 'Geometry and constraints' : definition.objectType}{definition.taskSummary}
{activeCommand === 'primitive' && <>{primitiveFields.map((field) => )}}{booleanCommand && <>}{currentFeatureField && }{activeCommand === 'pocket' && }{['pad', 'pocket', 'revolution'].includes(activeCommand || '') && }{activeCommand === 'pad' && }{!activeCommand || activeCommand === 'create-sketch' ? <> : null}
Changes remain local until the document is recomputed.
} function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) { diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index edcdc91..339f338 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -168,6 +168,24 @@ const systemCommands = new Set(['new-document', 'save', 'select-object']) const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch']) const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer']) const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape']) +const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer']) +const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution']) +const shapeTypeIds = new Set([ + 'Part::Box', + 'Part::Cylinder', + 'Part::Sphere', + 'Part::Cone', + 'Part::Fuse', + 'Part::Cut', + 'Part::Common', + 'Part::Feature', + 'PartDesign::Feature', + 'PartDesign::Pad', + 'PartDesign::Pocket', + 'PartDesign::Revolution', + 'PartDesign::Fillet', + 'PartDesign::Chamfer', +]) const featureCommands: Record = { 'create-body': { label: 'Body', detail: 'Part Design body' }, 'create-sketch': { label: 'Sketch', detail: 'Fully constrained' }, @@ -182,7 +200,7 @@ const featureCommands: Record = { intersection: { label: 'Intersection', detail: 'Boolean common' }, } -const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => { +const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string, selectedTypeId?: string): CommandState => { const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId))) if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' } if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' } @@ -190,6 +208,10 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO if (partCommands.has(commandId) && activeWorkbench !== 'Part') return { id: commandId, status: 'disabled', reason: `Switch to Part to use ${commandId}.` } if ((commandId === 'new-sketch' || commandId === 'solve-sketch') && activeWorkbench !== 'Sketcher') return { id: commandId, status: 'disabled', reason: `Switch to Sketcher to use ${commandId}.` } if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' } + if (selectedObjectId && !selectedTypeId) return { id: commandId, status: 'disabled', reason: 'The selected object no longer exists in this document.' } + if (commandId === 'solve-sketch' && selectedTypeId !== 'Sketcher::SketchObject') return { id: commandId, status: 'disabled', reason: 'Select a Sketcher sketch before solving constraints.' } + if (shapeSelectionCommands.has(commandId) && selectedTypeId && !shapeTypeIds.has(selectedTypeId)) return { id: commandId, status: 'disabled', reason: 'Select a solid or shape-producing feature for this operation.' } + if (featureSelectionCommands.has(commandId) && selectedTypeId && !shapeTypeIds.has(selectedTypeId) && selectedTypeId !== 'Sketcher::SketchObject') return { id: commandId, status: 'disabled', reason: 'Select a sketch or solid feature as the Part Design source.' } return { id: commandId, status: 'enabled' } } @@ -304,20 +326,22 @@ export function createMockFacade(): BitBybitWebCadFacade { } const objectSnapshot = createObjectSnapshot(item) objectSnapshot.properties = objectSnapshot.properties.map((property) => { - if (!property.recompute || property.type === 'App::PropertyLink') return property + if (!property.recompute) return property const candidate = draft[property.name.toLowerCase()] + if (property.type === 'App::PropertyLink' && typeof candidate === 'string' && candidate.length > 0) return { ...property, value: candidate } if (property.type === 'App::PropertyBool' && typeof candidate === 'boolean') return { ...property, value: candidate } if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && typeof candidate === 'number' && Number.isFinite(candidate)) return { ...property, value: candidate } if (property.type === 'App::PropertyEnumeration' && typeof candidate === 'string' && property.options?.includes(candidate)) return { ...property, value: candidate } return property }) if (isPartObject && commandId !== 'primitive') { - objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property) + objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && typeof draft.base !== 'string' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property) } const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot] const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects } nextDocument.dependencies = collectDependencyEdges(nextDocument) nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0) + markDocumentTouched(nextDocument, [objectId]) return { document: nextDocument, objectId } } const setProperty = ({ objectId, propertyName, value }: SetPropertyInput) => { @@ -493,7 +517,8 @@ export function createMockFacade(): BitBybitWebCadFacade { 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 } - const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId) + const selectedTypeId = state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId + const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId, selectedTypeId) if (status.status === 'disabled') { const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED' const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId } @@ -529,14 +554,14 @@ export function createMockFacade(): BitBybitWebCadFacade { } else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') } else if (commandId === 'new-sketch') beginTask('create-sketch', { source: state.selectedObjectId || null }) - else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null }) + else if (featureCommands[commandId]) beginTask(commandId, partCommands.has(commandId) && commandId !== 'primitive' ? { source: state.selectedObjectId || null, base: state.selectedObjectId || '', tool: '' } : { source: state.selectedObjectId || null }) emit({ type: 'command.completed', commandId, context }); emitState(); return requestId } 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 } }, 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), 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, 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('') }, 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() } }, project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource }, diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 99dc361..49cf7d0 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -229,6 +229,9 @@ test('Sketcher solve command is selection-aware and emits the standard command l facade.gui.command.execute({ commandId: 'solve-sketch' }) assert.deepEqual(events, ['command.started', 'command.completed']) assert.equal(facade.app.document.getObject('sketch')?.sketch?.solver.status, 'solved') + facade.selection.select('pad') + assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled') + assert.match(facade.gui.command.getState('solve-sketch').reason || '', /Sketcher sketch/) facade.selection.clear() assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled') }) @@ -770,6 +773,7 @@ test('Part Design feature tasks commit a document object and remain undoable', ( assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Length')?.value, 55) assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Reversed')?.value, true) assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Midplane')?.value, true) + assert.ok(committed.recompute?.dirtyObjects.includes('pad001')) facade.history.undo() assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), false) @@ -806,9 +810,14 @@ test('Part workbench commands create primitive and boolean document objects', () assert.equal(facade.gui.command.getState('union').status, 'enabled') facade.gui.command.execute({ commandId: 'union' }) + facade.task.update({ base: 'box', tool: 'cylinder' }) facade.task.apply() assert.equal(facade.app.document.getObject('union')?.typeId, 'Part::Fuse') - facade.app.document.setProperty({ objectId: 'union', propertyName: 'Base', value: 'box' }) - facade.app.document.setProperty({ objectId: 'union', propertyName: 'Tool', value: 'cylinder' }) + assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Base')?.value, 'box') + assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Tool')?.value, 'cylinder') assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'union').map((edge) => edge.targetId).sort(), ['box', 'cylinder']) + facade.selection.select('origin') + assert.equal(facade.gui.command.getState('union').status, 'disabled') + assert.match(facade.gui.command.getState('union').reason || '', /solid or shape-producing/) + assert.equal(facade.gui.command.getState('check-shape').status, 'disabled') })