From a133970f20f82761ea34b700deee2ba716cd01d7 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 18:57:19 -0400 Subject: [PATCH] feat: integrate revolution into facade recompute --- config/compatibility-matrix.json | 2 +- docs/freecad-full-parity-plan.zh-CN.md | 4 ++-- docs/web-cad-implementation-plan.zh-CN.md | 2 +- src/App.tsx | 2 +- src/facade/mockFacade.ts | 19 +++++++++++++------ src/facade/recomputeEngine.ts | 17 ++++++++++++++--- src/facade/types.ts | 2 +- tests/facade.test.ts | 20 ++++++++++++++++++++ 8 files changed, 53 insertions(+), 15 deletions(-) diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 875d569..bf8dc29 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "freecadBaseline": "freecad-1.1.1", "workbenches": { - "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "fillet", "chamfer", "linear-pattern", "polar-pattern"] }, + "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "revolution", "fillet", "chamfer", "linear-pattern", "polar-pattern"] }, "Part": { "status": "ui-manifest", "level": "unsupported", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] }, "Sketcher": { "status": "facade-model", "level": "experimental", "commands": ["new-sketch", "line", "arc", "circle", "rectangle", "constrain-horizontal", "constrain-vertical", "solve-sketch"] }, "Draft": { "status": "ui-prototype", "level": "read-only", "commands": ["working-plane", "snap-settings", "draft-line", "draft-wire", "move", "rotate"] }, diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 8a7dd5d..01ecdf1 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -324,7 +324,7 @@ 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/Fillet/Chamfer | 取消、过期版本、依赖失败传播、独立节点并行、闭合 Sketch 特征链和 Facade 提交测试通过 | 默认数据模型仍不持久化 ShapeHandle;Through all/Up to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape | +| 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 树仍待实现 | | 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、视图、表达式和参数化对象 | @@ -334,7 +334,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 后续按以下顺序连续开发: 1. `P4-06/DAG-08`:同步/异步重算统一状态机,增加抑制、恢复上游后的最小闭包重算和诊断树;最近有效 Shape 缓存已进入 Facade,但尚未持久化。 -2. `P3-04/P3-05`:Pad/Pocket/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写;继续补齐 Through all/Up to face、Revolution 参数化、拓扑历史和 Pattern/Hole,未完成前保持 disabled。 +2. `P3-04/P3-05`:Pad/Pocket/Revolution/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写,Pocket Through all 已接入;继续补齐 Up to face、拓扑历史和 Pattern/Hole,未完成前保持 disabled。 3. `TSN-03/05/07/08`:补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。 4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM,按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。 5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 0822cb1..dfc9150 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1599,7 +1599,7 @@ P0 基线/治理 | P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | | P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;`geometry.topology()` 已从 BitBybit 网格生成面/边/点基础签名,OCCT 历史命名仍未实现 | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI | -| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket,Fillet/Chamfer 也有 Shape 缓存回写;Through all/Up to face、Revolution 参数化和 FCStd Shape 持久化仍待完成 | +| 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 持久化仍待完成 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 42 个场景,包括 async generation 重算、DAG 分层并行、OCCT 特征执行器、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;E2E/黄金几何待补齐 | diff --git a/src/App.tsx b/src/App.tsx index b3f30d1..95b933f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -377,7 +377,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi const ids = [...new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))] editor = } else if (property.type === 'App::PropertyColor') editor = - else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor = + else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor = else editor = { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /> return <>
{property.label}
{editor}
{property.expression &&
{property.expression}
} } diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index e8ba974..e091052 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -39,7 +39,7 @@ const initialTree: ModelTreeItem[] = [ { id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] }, ] -const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' +const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [ { name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label }, @@ -71,6 +71,11 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { { name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, ] + if (item.id.startsWith('revolution')) return [ + { name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true }, + { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, + { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + ] if (item.id.startsWith('fillet')) return [ { name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true }, { name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true }, @@ -137,13 +142,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', 'solve-sketch']) 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', 'fillet', 'chamfer', 'solve-sketch']) -const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'fillet', 'chamfer']) +const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'solve-sketch']) +const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer']) 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' }, + revolution: { label: 'Revolution', detail: 'Angle 360 deg' }, fillet: { label: 'Fillet', detail: 'Radius 3 mm' }, chamfer: { label: 'Chamfer', detail: 'Length 2 mm' }, } @@ -162,8 +168,9 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope if (property.readOnly) throw new Error(`${property.label} is read-only.`) if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`) if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`) - if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`) - if (property.type === 'App::PropertyLength' && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`) + if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`) + if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle') && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`) + if (property.type === 'App::PropertyAngle' && (value as number) > 360) throw new RangeError(`${property.label} must be between 0 and 360 degrees.`) if (property.type === 'App::PropertyPercent' && ((value as number) < 0 || (value as number) > 100)) throw new RangeError(`${property.label} must be between 0 and 100.`) if (property.type === 'App::PropertyEnumeration' && !property.options?.includes(value as string)) throw new RangeError(`${property.label} is not a registered enumeration value.`) if (property.type === 'App::PropertyColor' && !/^#[0-9a-f]{6}$/i.test(value as string)) throw new RangeError(`${property.label} requires a #RRGGBB color.`) @@ -300,7 +307,7 @@ export function createMockFacade(): BitBybitWebCadFacade { if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`) const sourceProperty = sourceObject.properties[propertyIndex] if (sourceProperty.readOnly) throw new Error(`${sourceProperty.label} is read-only.`) - if (!['App::PropertyLength', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`) + if (!['App::PropertyLength', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`) const evaluated = expectedExpressionValue(sourceProperty, expression, state.document) const references = expressionReferences(expression) const variables = expressionVariables(state.document) diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts index 1dfed78..72e2d42 100644 --- a/src/facade/recomputeEngine.ts +++ b/src/facade/recomputeEngine.ts @@ -1,6 +1,6 @@ import { DependencyGraph, type RecomputeState } from './dependencyGraph' import { cloneSketch, solveSketch } from './sketcher' -import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, ShapeHandle } from './types' +import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types' export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale' @@ -33,6 +33,7 @@ export type RecomputeGeometryRuntime = { capabilities(): { status: string } pad(input: PadInput): Promise pocket(input: PocketInput): Promise + revolution(input: RevolutionInput): Promise fillet(input: FilletInput): Promise chamfer(input: ChamferInput): Promise release(shape: ShapeHandle): Promise @@ -272,9 +273,9 @@ export const createFacadeGeometryRecomputeExecutor = ( ): RecomputeNodeExecutor => async (object, document, context) => { const base = await executeFacadeRecomputeNode(object, document, context) if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base - if (!['PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base + if (!['PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base - const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' + const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' || object.typeId === 'PartDesign::Revolution' const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined const profile = requiresProfile ? sketchProfile(profileObject?.sketch) : { profile: undefined } if (requiresProfile && !profile.profile) return geometryFailure(object.id, profile.code || 'PROFILE_INVALID', profile.message || 'Feature profile is invalid.') @@ -296,6 +297,16 @@ export const createFacadeGeometryRecomputeExecutor = ( const baseShape = baseObject ? shapes.get(baseObject.id) : undefined if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Pocket base has no valid recomputed Shape.') result = await geometry.pocket({ ...documentContext, base: baseShape, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, throughAll: pocketType === 'Through all' }) + } else if (object.typeId === 'PartDesign::Revolution') { + const angle = numberProperty('Angle', 360) + const reversed = propertyValue(object, 'Reversed') === true + result = await geometry.revolution({ + ...documentContext, + profile: profile.profile as PlanarProfile, + angle, + axisOrigin: [0, 0, 0], + axisDirection: reversed ? [0, -1, 0] : [0, 1, 0], + }) } else if (object.typeId === 'PartDesign::Fillet') { const baseObject = linkedObject(object, 'Base', document) const baseShape = baseObject ? shapes.get(baseObject.id) : undefined diff --git a/src/facade/types.ts b/src/facade/types.ts index 4280126..be5e94a 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -21,7 +21,7 @@ export type ObjectPropertySnapshot = { label: string group: string scope: 'data' | 'view' - type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' + type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' value: PropertyValue unit?: string readOnly?: boolean diff --git a/tests/facade.test.ts b/tests/facade.test.ts index c548b73..62aa759 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -353,6 +353,7 @@ test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains t capabilities: () => ({ status: 'ready' }), pad: async (input) => { calls.push(`pad:${input.profile.outer.length}`); if (padShouldFail) throw new Error('OCCT pad failed'); return shape('pad-shape') }, pocket: async (input) => { calls.push(`pocket:${input.base.id}:${String(input.throughAll)}`); return shape('pocket-shape') }, + revolution: async (input) => { calls.push(`revolution:${input.angle}:${input.axisDirection?.[1]}`); return shape('revolution-shape') }, fillet: async () => shape('fillet-shape'), chamfer: async () => shape('chamfer-shape'), release: async (released) => { calls.push(`release:${released.id}`) }, @@ -396,6 +397,16 @@ test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains t const unsupported = await executor(upToFace, document, context) assert.equal(unsupported.status, 'failed') assert.equal(unsupported.errors?.[0].code, 'UP_TO_FACE_UNSUPPORTED') + + const revolution = { id: 'revolution', typeId: 'PartDesign::Revolution', properties: [ + { name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 270, unit: 'deg' }, + { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' }, + { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }, + ] } + const revolutionResult = await executor(revolution, document, context) + assert.equal(revolutionResult.status, 'success') + assert.equal(shapes.get('revolution')?.id, 'revolution-shape') + assert.ok(calls.includes('revolution:270:-1')) }) test('facade async recompute commits only an accepted generation', async () => { @@ -707,4 +718,13 @@ test('Part Design feature tasks commit a document object and remain undoable', ( 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) + + facade.gui.command.execute({ commandId: 'revolution' }) + assert.equal(facade.task.getActive()?.commandId, 'revolution') + facade.task.apply() + const revolution = facade.app.document.getObject('revolution') + assert.equal(revolution?.typeId, 'PartDesign::Revolution') + assert.equal(revolution?.properties.find((property) => property.name === 'Angle')?.value, 360) + facade.app.document.setProperty({ objectId: 'revolution', propertyName: 'Angle', value: 180 }) + assert.equal(facade.app.document.getObject('revolution')?.properties.find((property) => property.name === 'Angle')?.value, 180) })