feat: add whole-shape mirrored feature

This commit is contained in:
2026-08-03 01:41:27 -04:00
parent 56903dbeb2
commit 4aa6ca48ad
11 changed files with 133 additions and 24 deletions

View File

@@ -2,7 +2,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"freecadBaseline": "freecad-1.1.1", "freecadBaseline": "freecad-1.1.1",
"workbenches": { "workbenches": {
"Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "revolution", "fillet", "chamfer", "linear-pattern", "polar-pattern"] }, "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "revolution", "fillet", "chamfer", "mirrored", "linear-pattern", "polar-pattern"] },
"Part": { "status": "facade-partial", "level": "experimental", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] }, "Part": { "status": "facade-partial", "level": "experimental", "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"] }, "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"] }, "Draft": { "status": "ui-prototype", "level": "read-only", "commands": ["working-plane", "snap-settings", "draft-line", "draft-wire", "move", "rotate"] },
@@ -17,7 +17,7 @@
"Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] } "Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] }
}, },
"facadeCapabilities": { "facadeCapabilities": {
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] }, "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "mirror-plane", "mirrored-whole-shape", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
"document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "undo-redo"] }, "document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "undo-redo"] },
"persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v6", "object-topology-snapshots", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] }, "persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v6", "object-topology-snapshots", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] },
"sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] }, "sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] },

View File

@@ -1,6 +1,6 @@
# Web FreeCAD 接续执行状态 # Web FreeCAD 接续执行状态
更新时间2026-08-03TopoRef 候选替换里程碑完成,待提交) 更新时间2026-08-03Mirrored whole-shape 里程碑完成,待提交)
## 1. 当前目标 ## 1. 当前目标
@@ -10,9 +10,9 @@
- 分支:`main` - 分支:`main`
- 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git` - 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git`
- 最近已推送里程碑:`ffbe2c6 feat: persist generation topology history` - 最近已推送里程碑:`56903db feat: resolve topology reference candidates`
- 最近已推送里程碑的完整门禁:`./npmw run verify` 通过 - 最近已推送里程碑的完整门禁:`./npmw run verify` 通过
- 当前候选替换未提交工作区验证:`./npmw run verify` 通过Facade 64/64diff/JSON 校验与桌面/移动截图通过 - 当前 Mirrored 未提交工作区验证:`./npmw run verify` 通过Facade 65/65diff/JSON 校验通过
- 生产构建:通过 - 生产构建:通过
- 本地开发地址:`http://127.0.0.1:5173/` - 本地开发地址:`http://127.0.0.1:5173/`
- 兼容真值表:`config/compatibility-matrix.json` - 兼容真值表:`config/compatibility-matrix.json`
@@ -30,6 +30,7 @@
8. 对象级 Placement 重算:基本体/特征 Shape 生成后执行平移与轴角旋转;恒等变换跳过额外 Shape变换失败释放临时 Shape 并保留上一次有效缓存;保存后恢复的 Placement 可再次驱动重算。 8. 对象级 Placement 重算:基本体/特征 Shape 生成后执行平移与轴角旋转;恒等变换跳过额外 Shape变换失败释放临时 Shape 并保留上一次有效缓存;保存后恢复的 Placement 可再次驱动重算。
9. TSN generation 写回:重算成功后采集面/边/点签名快照与保守历史;迁移 LinkSub/草图外部几何歧义或删除引用标脏所有者并生成可定位诊断schema v6 保存对象拓扑快照。 9. TSN generation 写回:重算成功后采集面/边/点签名快照与保守历史;迁移 LinkSub/草图外部几何歧义或删除引用标脏所有者并生成可定位诊断schema v6 保存对象拓扑快照。
10. TopoRef 候选替换事务:属性面板/诊断页从源对象当前同 kind 拓扑候选中原子替换 LinkSub 或外部几何引用,重建 DAG、标脏重算并支持 Undo/Redo。 10. TopoRef 候选替换事务:属性面板/诊断页从源对象当前同 kind 拓扑候选中原子替换 LinkSub 或外部几何引用,重建 DAG、标脏重算并支持 Undo/Redo。
11. Mirrored whole-shapeFacade 暴露 OCCT 平面法向镜像PartDesign Task 支持 XY/XZ/YZ 原点平面与可选 Fuse重算释放 union 临时副本并写回 Shape/拓扑 generation。
相关提交按新到旧: 相关提交按新到旧:
@@ -60,7 +61,7 @@
2. 补齐 P3-03 浏览器 OCCT 包围盒/体积黄金测试,并验证 Placement 撤销、保存和恢复的真实 Worker 路径Node/fallback 合同已通过。 2. 补齐 P3-03 浏览器 OCCT 包围盒/体积黄金测试,并验证 Placement 撤销、保存和恢复的真实 Worker 路径Node/fallback 合同已通过。
3. 为 ambiguous/deleted TopoRef 补 3D 候选高亮,并增加真实 OCCT/SQLite 浏览器回放;候选替换 Facade 事务和属性/诊断 UI 已进入当前里程碑。 3. 为 ambiguous/deleted TopoRef 补 3D 候选高亮,并增加真实 OCCT/SQLite 浏览器回放;候选替换 Facade 事务和属性/诊断 UI 已进入当前里程碑。
4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC此前继续补充回放夹具、约束分类和 Sketch 编辑事务。 4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC此前继续补充回放夹具、约束分类和 Sketch 编辑事务。
5. 扩展 PartDesignMirrored、MultiTransformLoft/Pipe/Groove、Thickness/Draft以及 Pattern/Hole 完整语义。 5. 扩展 PartDesignMultiTransform 建立有序变换列表合同,再推进 Loft/Pipe/Groove、Thickness/Draft以及 Mirrored/Pattern/Hole 的局部特征历史与完整语义。
6. 完成 FCStd 原生对象/属性/Shape 双向映射和 STEP/IGES/STL/Web CAD 包导入导出。 6. 完成 FCStd 原生对象/属性/Shape 双向映射和 STEP/IGES/STL/Web CAD 包导入导出。
7. 按核心优先级完成 TechDraw、Spreadsheet、Draft、Assembly、BIM、Mesh、Surface、FEM、CAM 等工作台。 7. 按核心优先级完成 TechDraw、Spreadsheet、Draft、Assembly、BIM、Mesh、Surface、FEM、CAM 等工作台。
8. 最后通过跨浏览器、性能、内存、安全、可访问性、离线/PWA、升级回滚和发布差异报告门禁。 8. 最后通过跨浏览器、性能、内存、安全、可访问性、离线/PWA、升级回滚和发布差异报告门禁。
@@ -124,6 +125,13 @@ Placement 里程碑涉及实现、测试、兼容矩阵和三份状态/方案文
- 属性面板对 ambiguous/deleted LinkSub 显示候选菜单;诊断页对 LinkSub 和草图外部几何显示候选替换控件。非法候选拒绝,不修改文档。 - 属性面板对 ambiguous/deleted LinkSub 显示候选菜单;诊断页对 LinkSub 和草图外部几何显示候选替换控件。非法候选拒绝,不修改文档。
- 完整 `./npmw run verify`、diff/JSON 校验通过Chrome 桌面/移动布局无重叠SwiftShader 延时截图确认 OCCT canvas 非空;提交和推送仍待执行。 - 完整 `./npmw run verify`、diff/JSON 校验通过Chrome 桌面/移动布局无重叠SwiftShader 延时截图确认 OCCT canvas 非空;提交和推送仍待执行。
尚未完成 3D 面/边/点候选预选与高亮,也没有真实 OCCT 浏览器黄金回放,因此 TSN-09/F3 仍保持进行中。 尚未完成 3D 面/边/点候选预选与高亮,也没有真实 OCCT 浏览器黄金回放,因此 TSN-09/F3 仍保持进行中。候选替换里程碑已由 `56903db` 推送。
## 11. 2026-08-03 Mirrored whole-shape 快照
- `geometry.mirror()` 对文档/Shape 句柄、原点和非零法向做 Facade 校验,再调用 Bitbybit `mirrorAlongNormal()`
- `PartDesign::Mirrored` 文档对象保存 Base、Plane 与 FuseTask 更新 Body.Tip 和 DAG。重算按 XY/XZ/YZ 映射法向Fuse 时 union Base 与镜像副本,并始终释放临时副本。
- 当前是整 Shape 镜像,不是 FreeCAD 对选中特征历史的镜像;平面固定经过文档原点,未接 DatumPlane/Sketch axis/TopoRef 支持,也未证明单实体规则。
- 完整 `./npmw run verify`、diff/JSON 校验通过;提交和推送仍待执行。
本里程碑不得声称 P3-03、F3 或完整 FreeCAD 兼容已经通过。当前对象 Placement 是 Shape 生成后的对象级变换,尚未证明 FreeCAD 对 Body/Tip、Support、AttachmentOffset 和局部坐标链的完整等价语义。 本里程碑不得声称 P3-03、F3 或完整 FreeCAD 兼容已经通过。当前对象 Placement 是 Shape 生成后的对象级变换,尚未证明 FreeCAD 对 Body/Tip、Support、AttachmentOffset 和局部坐标链的完整等价语义。

View File

@@ -318,6 +318,8 @@ Bitbybit 的 OCCT `filletEdges` 与 `chamferEdges` 已进入 Geometry Facade
Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 FC-08/FC-09 的第一条交换格式垂直切片。导出数据仍只代表当前 Shape对象树、Expression、Sketch 约束、TopoRef 和重算历史必须由 FCStd/.webcad 格式任务独立保存,不能因“能导出 STEP”而宣称参数化文件兼容。 Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 FC-08/FC-09 的第一条交换格式垂直切片。导出数据仍只代表当前 Shape对象树、Expression、Sketch 约束、TopoRef 和重算历史必须由 FCStd/.webcad 格式任务独立保存,不能因“能导出 STEP”而宣称参数化文件兼容。
PartDesign Mirrored 已形成 experimental whole-shape 垂直切片Facade 验证并调用 OCCT 平面法向镜像,文档对象/Task 保存 Base、XY/XZ/YZ 原点平面和 Fuse重算 union 后释放临时副本并进入 generation 拓扑写回。这不是 FreeCAD 局部特征历史镜像DatumPlane/Sketch axis/TopoRef 平面、单实体规则、跨 Body 支持和 Mirrored 的原生历史映射仍未完成。
本轮又补齐了拓扑和恢复边界:面之外增加了由 BitBybit 网格端点派生的 edge/vertex 签名,并通过 `geometry.topology()` 返回面/边/点引用,签名使用量化坐标、长度/端点而不使用面索引;这仍不是 OCCT `Generated/Modified/Deleted` 历史。FCStd inspection 现在同时生成只读 `proxyDocument`,对象属性带有 `recognized/proxy/blocked` 状态SQLite/OPFS Project API 增加 `recovery(documentId)`报告完整性、schema、最后保存版本和降级警告。持久化写入现在在支持的浏览器使用 Web Locks 跨标签页互斥,并通过 BroadcastChannel 广播保存版本;无 Web Locks 环境明确报告 `local-queue`,不能解释为跨标签页安全。代理文档和恢复报告都不能被解释为 FCStd 参数化写回或崩溃恢复已经完成。 本轮又补齐了拓扑和恢复边界:面之外增加了由 BitBybit 网格端点派生的 edge/vertex 签名,并通过 `geometry.topology()` 返回面/边/点引用,签名使用量化坐标、长度/端点而不使用面索引;这仍不是 OCCT `Generated/Modified/Deleted` 历史。FCStd inspection 现在同时生成只读 `proxyDocument`,对象属性带有 `recognized/proxy/blocked` 状态SQLite/OPFS Project API 增加 `recovery(documentId)`报告完整性、schema、最后保存版本和降级警告。持久化写入现在在支持的浏览器使用 Web Locks 跨标签页互斥,并通过 BroadcastChannel 广播保存版本;无 Web Locks 环境明确报告 `local-queue`,不能解释为跨标签页安全。代理文档和恢复报告都不能被解释为 FCStd 参数化写回或崩溃恢复已经完成。
## 17. 本轮后续实现记录与下一批任务 ## 17. 本轮后续实现记录与下一批任务

View File

@@ -1605,9 +1605,9 @@ P0 基线/治理
| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle``MeshAsset``SubshapeRef` 类型;成功 generation 写入对象拓扑快照、迁移 matches 和保守历史;重算自动迁移 LinkSub/外部几何,歧义/删除保持显式状态和诊断;候选替换事务只接受源对象最新同 kind persistentId属性/诊断 UI 可提交并支持 Undo/Redo。OCCT 原生历史、邻接/曲率签名、跨 Boolean 映射和 3D 候选高亮仍未实现 | | P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle``MeshAsset``SubshapeRef` 类型;成功 generation 写入对象拓扑快照、迁移 matches 和保守历史;重算自动迁移 LinkSub/外部几何,歧义/删除保持显式状态和诊断;候选替换事务只接受源对象最新同 kind persistentId属性/诊断 UI 可提交并支持 Undo/Redo。OCCT 原生历史、邻接/曲率签名、跨 Boolean 映射和 3D 候选高亮仍未实现 |
| P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和浏览器矩阵已验证基础 PlacementFacade 重算 executor 现在对对象 Placement 做严格结构校验,在 Shape 生成后执行平移/轴角旋转,恒等变换跳过额外句柄,失败时释放临时 Shape 并保留最近有效缓存Node/fallback 已覆盖失败释放、畸形输入和保存恢复。0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm自动化浏览器黄金/体积测试尚未进入 CI | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和浏览器矩阵已验证基础 PlacementFacade 重算 executor 现在对对象 Placement 做严格结构校验,在 Shape 生成后执行平移/轴角旋转,恒等变换跳过额外句柄,失败时释放临时 Shape 并保留最近有效缓存Node/fallback 已覆盖失败释放、畸形输入和保存恢复。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/RevolutionRevolution Angle/Reversed 已接入Fillet/Chamfer 也有 Shape 缓存回写Up to face、FCStd Shape 持久化仍待完成 | | P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/RevolutionRevolution Angle/Reversed 已接入Fillet/Chamfer 也有 Shape 缓存回写Up to face、FCStd Shape 持久化仍待完成 |
| P3-05 Fillet/Chamfer/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存Linear/Polar Pattern 已贯通选择谓词、Task、Placement/union 和清理;基础 Hole 用圆形 Profile 复用 Pocket支持 Diameter、Depth、Dimension/Through all。Pattern 当前整 ShapeHole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 | | P3-05 Fillet/Chamfer/Mirrored/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存;Mirrored 使用 OCCT plane-normal mirrorTask 支持 XY/XZ/YZ 原点平面和可选 FuseLinear/Polar Pattern 已贯通 Placement/union基础 Hole 支持 Diameter、Depth、Dimension/Through all。Mirrored/Pattern 当前操作整 ShapeHole 固定原点法向;局部特征历史、Datum/TopoRef 平面、连接单实体过滤及完整孔语义仍待实现 |
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口优先使用重计算缓存的对象 Shape失败时保留最近有效结果无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口优先使用重计算缓存的对象 Shape失败时保留最近有效结果无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 64 个场景,包括 Part/PartDesignPattern/Hole、TopoRef/LinkSub、generation 拓扑快照/迁移/候选替换、Sketch provider、几何重算、诊断修复、FCStd 安全、schema migration、1000 次写队列、检查点和资源治理E2E/黄金几何待补齐 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 65 个场景,包括 Part/PartDesign Mirrored/Pattern/Hole、TopoRef generation/候选替换、Sketch provider、几何重算、诊断修复、FCStd 安全、schema migration、1000 次写队列、检查点和资源治理E2E/黄金几何待补齐 |
本迭代验证命令:`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 构建验证。 本迭代验证命令:`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 构建验证。

View File

@@ -364,7 +364,7 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
const activeCommand = activeTask?.commandId const activeCommand = activeTask?.commandId
const activeDocument = facade.app.document.getActive() const activeDocument = facade.app.document.getActive()
const booleanCommand = activeCommand === 'union' || activeCommand === 'cut' || activeCommand === 'intersection' 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', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].includes(object.typeId)) const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::Mirrored', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].includes(object.typeId))
const objectLabel = (object: DocumentSnapshot['objects'][number]) => { const value = object.properties.find((property) => property.name === 'Label')?.value; return typeof value === 'string' || typeof value === 'number' ? String(value) : object.id } const objectLabel = (object: DocumentSnapshot['objects'][number]) => { const value = object.properties.find((property) => property.name === 'Label')?.value; return typeof value === 'string' || typeof value === 'number' ? String(value) : object.id }
const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : '' const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : ''
const acceptTask = () => { const acceptTask = () => {
@@ -393,6 +393,10 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
<label className="field-label">Base<select className="field-input" value={draftLink('base')} onChange={(event) => facade.task.update({ base: event.target.value })}><option value="">Select base</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{objectLabel(object)}</option>)}</select></label> <label className="field-label">Base<select className="field-input" value={draftLink('base')} onChange={(event) => facade.task.update({ base: event.target.value })}><option value="">Select base</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{objectLabel(object)}</option>)}</select></label>
<label className="field-label">Tool<select className="field-input" value={draftLink('tool')} onChange={(event) => facade.task.update({ tool: event.target.value })}><option value="">Select tool</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{objectLabel(object)}</option>)}</select></label> <label className="field-label">Tool<select className="field-input" value={draftLink('tool')} onChange={(event) => facade.task.update({ tool: event.target.value })}><option value="">Select tool</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{objectLabel(object)}</option>)}</select></label>
</>} </>}
{activeCommand === 'mirrored' && <>
<label className="field-label">Mirror plane<select className="field-input" value={typeof activeTask?.draft.plane === 'string' ? activeTask.draft.plane : 'YZ plane'} onChange={(event) => facade.task.update({ plane: event.target.value })}><option>XY plane</option><option>XZ plane</option><option>YZ plane</option></select></label>
<label className="check-row"><input type="checkbox" checked={activeTask?.draft.fuse !== false} onChange={(event) => facade.task.update({ fuse: event.target.checked })} /><span>Fuse result</span></label>
</>}
{currentFeatureField && <label className="field-label">{currentFeatureField.label} <span className="field-unit">{currentFeatureField.unit}</span><input className="field-input" type="number" min={0.001} max={currentFeatureField.unit === 'deg' ? 360 : undefined} step={currentFeatureField.unit === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[currentFeatureField.key] === 'number' ? Number(activeTask.draft[currentFeatureField.key]) : currentFeatureField.fallback} onChange={(event) => facade.task.update({ [currentFeatureField.key]: Number(event.target.value) })} /></label>} {currentFeatureField && <label className="field-label">{currentFeatureField.label} <span className="field-unit">{currentFeatureField.unit}</span><input className="field-input" type="number" min={0.001} max={currentFeatureField.unit === 'deg' ? 360 : undefined} step={currentFeatureField.unit === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[currentFeatureField.key] === 'number' ? Number(activeTask.draft[currentFeatureField.key]) : currentFeatureField.fallback} onChange={(event) => facade.task.update({ [currentFeatureField.key]: Number(event.target.value) })} /></label>}
{activeCommand === 'linear-pattern' && <> {activeCommand === 'linear-pattern' && <>
<label className="field-label">Occurrences<input className="field-input" type="number" min={2} max={100} step={1} value={typeof activeTask?.draft.occurrences === 'number' ? Number(activeTask.draft.occurrences) : 2} onChange={(event) => facade.task.update({ occurrences: Number(event.target.value) })} /></label> <label className="field-label">Occurrences<input className="field-input" type="number" min={2} max={100} step={1} value={typeof activeTask?.draft.occurrences === 'number' ? Number(activeTask.draft.occurrences) : 2} onChange={(event) => facade.task.update({ occurrences: Number(event.target.value) })} /></label>

View File

@@ -1,6 +1,6 @@
import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker' import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker'
import type { Inputs } from '@bitbybit-dev/occt' import type { Inputs } from '@bitbybit-dev/occt'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef, SubshapeTopology } from './types' import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef, SubshapeTopology } from './types'
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs } from './topologyNaming' import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs } from './topologyNaming'
type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer
@@ -82,6 +82,13 @@ export const validatePlacementInput = (input: ApplyPlacementInput) => {
validateShapeContext(input, input.shape) validateShapeContext(input, input.shape)
} }
export const validateMirrorInput = (input: MirrorInput) => {
validateDocumentContext(input)
validateShapeContext(input, input.shape)
validateVector(input.origin, 'origin')
validateVector(input.normal, 'normal', false)
}
const validateShapeContext = (input: GeometryDocumentContext, shape: ShapeHandle) => { const validateShapeContext = (input: GeometryDocumentContext, shape: ShapeHandle) => {
if (shape.documentId !== input.documentId) throw new Error(`Shape belongs to another document: ${shape.id}`) if (shape.documentId !== input.documentId) throw new Error(`Shape belongs to another document: ${shape.id}`)
if (shape.documentVersion > input.documentVersion) throw new Error(`Shape version is newer than the result context: ${shape.id}`) if (shape.documentVersion > input.documentVersion) throw new Error(`Shape version is newer than the result context: ${shape.id}`)
@@ -339,6 +346,14 @@ export class BitbybitGeometryRuntime {
return this.registerShape(kernelShape, input.documentId, input.documentVersion) return this.registerShape(kernelShape, input.documentId, input.documentVersion)
} }
async mirror(input: MirrorInput): Promise<ShapeHandle> {
validateMirrorInput(input)
const source = this.resolveShape(input.shape)
const client = await this.readyClient()
const kernelShape = await client.occt.transforms.mirrorAlongNormal({ shape: source.reference, origin: input.origin, normal: input.normal })
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
}
async union(input: BooleanUnionInput): Promise<ShapeHandle> { async union(input: BooleanUnionInput): Promise<ShapeHandle> {
validateBooleanUnionInput(input) validateBooleanUnionInput(input)
const shapes = input.shapes.map((shape) => this.resolveShape(shape).reference) const shapes = input.shapes.map((shape) => this.resolveShape(shape).reference)

View File

@@ -2,12 +2,12 @@ export { createMockFacade } from './mockFacade'
export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics' export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore' export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export { ThreeViewportAdapter } from './threeViewport' export { ThreeViewportAdapter } from './threeViewport'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime' export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validateMirrorInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from './projectSchema' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from './projectSchema'
export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema' export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema'
export { assessResourceQuota, planResourceSweep } from './resourcePolicy' export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy' export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue } from './types' export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, ModelTreeItem, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue } from './types'
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming' export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences' export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences' export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences'

View File

@@ -45,7 +45,7 @@ const initialTree: ModelTreeItem[] = [
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] }, { 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('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : 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.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : 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.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [ const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [
{ name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label }, { name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label },
@@ -116,6 +116,11 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
{ name: 'Distance', label: 'Distance', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2, unit: 'mm', recompute: true }, { name: 'Distance', label: 'Distance', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2, unit: 'mm', recompute: true },
{ name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true }, { name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true },
] ]
if (item.id.startsWith('mirrored')) return [
{ name: 'Base', label: 'Base feature', group: 'Mirrored', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
{ name: 'Plane', label: 'Mirror plane', group: 'Mirrored', scope: 'data', type: 'App::PropertyEnumeration', value: 'YZ plane', options: ['XY plane', 'XZ plane', 'YZ plane'], recompute: true },
{ name: 'Fuse', label: 'Fuse result', group: 'Mirrored', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true },
]
if (item.id.startsWith('linear-pattern')) return [ if (item.id.startsWith('linear-pattern')) return [
{ name: 'Base', label: 'Base', group: 'Pattern', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, { name: 'Base', label: 'Base', group: 'Pattern', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
{ name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data', type: 'App::PropertyInteger', value: 2, recompute: true }, { name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data', type: 'App::PropertyInteger', value: 2, recompute: true },
@@ -202,12 +207,12 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
return document return document
} }
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'union', 'cut', 'intersection', 'check-shape', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch']) const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'union', 'cut', 'intersection', 'check-shape', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
const systemCommands = new Set(['new-document', 'save', 'select-object']) 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', 'linear-pattern', 'polar-pattern', 'hole', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch']) const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'linear-pattern', 'polar-pattern', 'hole', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch'])
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'linear-pattern', 'polar-pattern', 'hole']) const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'linear-pattern', 'polar-pattern', 'hole'])
const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape']) const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape'])
const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer', 'linear-pattern', 'polar-pattern', 'hole']) const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer', 'mirrored', 'linear-pattern', 'polar-pattern', 'hole'])
const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution']) const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution'])
const shapeTypeIds = new Set([ const shapeTypeIds = new Set([
'Part::Box', 'Part::Box',
@@ -224,6 +229,7 @@ const shapeTypeIds = new Set([
'PartDesign::Revolution', 'PartDesign::Revolution',
'PartDesign::Fillet', 'PartDesign::Fillet',
'PartDesign::Chamfer', 'PartDesign::Chamfer',
'PartDesign::Mirrored',
'PartDesign::LinearPattern', 'PartDesign::LinearPattern',
'PartDesign::PolarPattern', 'PartDesign::PolarPattern',
'PartDesign::Hole', 'PartDesign::Hole',
@@ -236,6 +242,7 @@ const featureCommands: Record<string, { label: string; detail: string }> = {
revolution: { label: 'Revolution', detail: 'Angle 360 deg' }, revolution: { label: 'Revolution', detail: 'Angle 360 deg' },
fillet: { label: 'Fillet', detail: 'Radius 3 mm' }, fillet: { label: 'Fillet', detail: 'Radius 3 mm' },
chamfer: { label: 'Chamfer', detail: 'Length 2 mm' }, chamfer: { label: 'Chamfer', detail: 'Length 2 mm' },
mirrored: { label: 'Mirrored', detail: 'Whole Shape across YZ plane' },
'linear-pattern': { label: 'Linear Pattern', detail: '2 occurrences over 20 mm' }, 'linear-pattern': { label: 'Linear Pattern', detail: '2 occurrences over 20 mm' },
'polar-pattern': { label: 'Polar Pattern', detail: '3 occurrences over 360 deg' }, 'polar-pattern': { label: 'Polar Pattern', detail: '3 occurrences over 360 deg' },
hole: { label: 'Hole', detail: 'Simple 5 mm diameter hole' }, hole: { label: 'Hole', detail: 'Simple 5 mm diameter hole' },
@@ -764,7 +771,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
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() } }, 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() } },
diagnostics: { list: () => state.diagnostics.map(cloneDiagnostic), tree: () => buildDiagnosticTree(state.diagnostics), repair: repairDiagnostic }, diagnostics: { list: () => state.diagnostics.map(cloneDiagnostic), tree: () => buildDiagnosticTree(state.diagnostics), repair: repairDiagnostic },
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), loadCheckpoint: (documentId, version) => projectPersistence.loadCheckpoint(documentId, version), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource }, project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), loadCheckpoint: (documentId, version) => projectPersistence.loadCheckpoint(documentId, version), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } }, geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), mirror: (input) => geometryRuntime.mirror(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
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,
} }

View File

@@ -1,6 +1,6 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph' import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher' import { cloneSketch, solveSketch } from './sketcher'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types' import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, MirrorInput, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types'
import { captureSignatureTopologyHistory } from './topologyHistory' import { captureSignatureTopologyHistory } from './topologyHistory'
import { migrateTopoRefs } from './topologyReferences' import { migrateTopoRefs } from './topologyReferences'
@@ -39,6 +39,7 @@ export type RecomputeGeometryRuntime = {
createSphere(input: CreateSphereInput): Promise<ShapeHandle> createSphere(input: CreateSphereInput): Promise<ShapeHandle>
createCone(input: CreateConeInput): Promise<ShapeHandle> createCone(input: CreateConeInput): Promise<ShapeHandle>
applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle> applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle>
mirror?(input: MirrorInput): Promise<ShapeHandle>
union(input: BooleanUnionInput): Promise<ShapeHandle> union(input: BooleanUnionInput): Promise<ShapeHandle>
cut(input: BooleanCutInput): Promise<ShapeHandle> cut(input: BooleanCutInput): Promise<ShapeHandle>
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle> intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>
@@ -385,7 +386,7 @@ export const createFacadeGeometryRecomputeExecutor = (
return base return base
} }
if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base
if (!['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].includes(object.typeId)) return base if (!['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::Mirrored', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].includes(object.typeId)) return base
const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' || object.typeId === 'PartDesign::Revolution' const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' || object.typeId === 'PartDesign::Revolution'
const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined
@@ -442,6 +443,19 @@ export const createFacadeGeometryRecomputeExecutor = (
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Fillet base has no valid recomputed Shape.') if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Fillet base has no valid recomputed Shape.')
result = await geometry.fillet({ ...documentContext, base: baseShape, radius: numberProperty('Radius', 1) }) result = await geometry.fillet({ ...documentContext, base: baseShape, radius: numberProperty('Radius', 1) })
} else if (object.typeId === 'PartDesign::Mirrored') {
if (!geometry.mirror) return geometryFailure(object.id, 'MIRROR_RUNTIME_UNAVAILABLE', 'The geometry runtime does not provide plane mirroring.')
const baseObject = linkedObject(object, 'Base', document)
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Mirrored feature base has no valid recomputed Shape.')
const plane = propertyValue(object, 'Plane')
const normal: [number, number, number] = plane === 'XZ plane' ? [0, 1, 0] : plane === 'YZ plane' ? [1, 0, 0] : [0, 0, 1]
const mirrored = await geometry.mirror({ ...documentContext, shape: baseShape, origin: [0, 0, 0], normal })
if (propertyValue(object, 'Fuse') === false) result = mirrored
else {
try { result = await geometry.union({ ...documentContext, shapes: [baseShape, mirrored] }) }
finally { await Promise.allSettled([geometry.release(mirrored)]) }
}
} else if (object.typeId === 'PartDesign::LinearPattern') { } else if (object.typeId === 'PartDesign::LinearPattern') {
const baseObject = linkedObject(object, 'Base', document) const baseObject = linkedObject(object, 'Base', document)
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined const baseShape = baseObject ? shapes.get(baseObject.id) : undefined

View File

@@ -302,6 +302,12 @@ export type ApplyPlacementInput = GeometryDocumentContext & {
placement: Placement placement: Placement
} }
export type MirrorInput = GeometryDocumentContext & {
shape: ShapeHandle
origin: Point3
normal: Point3
}
export type BooleanUnionInput = GeometryDocumentContext & { export type BooleanUnionInput = GeometryDocumentContext & {
shapes: ShapeHandle[] shapes: ShapeHandle[]
keepEdges?: boolean keepEdges?: boolean
@@ -548,6 +554,7 @@ export interface BitBybitWebCadFacade {
createSphere(input: CreateSphereInput): Promise<ShapeHandle> createSphere(input: CreateSphereInput): Promise<ShapeHandle>
createCone(input: CreateConeInput): Promise<ShapeHandle> createCone(input: CreateConeInput): Promise<ShapeHandle>
applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle> applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle>
mirror(input: MirrorInput): Promise<ShapeHandle>
union(input: BooleanUnionInput): Promise<ShapeHandle> union(input: BooleanUnionInput): Promise<ShapeHandle>
cut(input: BooleanCutInput): Promise<ShapeHandle> cut(input: BooleanCutInput): Promise<ShapeHandle>
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle> intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>

View File

@@ -14,7 +14,7 @@ import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher'
import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol' import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol'
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine' import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
import { inspectFcstdArchive } from '../src/facade/fcstd' import { inspectFcstdArchive } from '../src/facade/fcstd'
import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateMirrorInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
import type { DocumentObjectSnapshot, DocumentSnapshot, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types' import type { DocumentObjectSnapshot, DocumentSnapshot, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types'
const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({ const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({
@@ -61,6 +61,7 @@ test('geometry boundary validates dimensions before invoking OCCT', () => {
assert.throws(() => validateSphereInput({ radius: -1, documentId: 'doc', documentVersion: 1 }), /radius/) assert.throws(() => validateSphereInput({ radius: -1, documentId: 'doc', documentVersion: 1 }), /radius/)
assert.throws(() => validateConeInput({ radius1: 0, radius2: 0, height: 2, documentId: 'doc', documentVersion: 1 }), /radius/) assert.throws(() => validateConeInput({ radius1: 0, radius2: 0, height: 2, documentId: 'doc', documentVersion: 1 }), /radius/)
assert.throws(() => validatePlacementInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 0], rotationAngle: 0 }, documentId: 'doc', documentVersion: 2 }), /rotationAxis/) assert.throws(() => validatePlacementInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 0], rotationAngle: 0 }, documentId: 'doc', documentVersion: 2 }), /rotationAxis/)
assert.throws(() => validateMirrorInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, origin: [0, 0, 0], normal: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /normal/)
}) })
test('geometry handles reject forged document ownership', () => { test('geometry handles reject forged document ownership', () => {
@@ -704,6 +705,46 @@ test('Part primitive and boolean nodes execute through the same Shape cache', as
assert.ok(calls.includes('union:box-shape,box-shape')) assert.ok(calls.includes('union:box-shape,box-shape'))
}) })
test('Mirrored recompute mirrors a whole Shape across a document plane and releases fused copies', async () => {
const calls: string[] = []
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-mirrored', documentVersion: 4 })
let mirrorSequence = 0
const runtime: RecomputeGeometryRuntime = {
capabilities: () => ({ status: 'ready' }),
createBox: async () => shape('box-shape'),
createCylinder: async () => shape('cylinder'),
createSphere: async () => shape('sphere'),
createCone: async () => shape('cone'),
applyPlacement: async (input) => input.shape,
mirror: async (input) => { const result = shape(`mirror-${++mirrorSequence}`); calls.push(`mirror:${input.shape.id}:${input.origin.join(',')}:${input.normal.join(',')}`); return result },
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('mirrored-union') },
cut: async () => shape('cut'), intersection: async () => shape('intersection'), pad: async () => shape('pad'), pocket: async () => shape('pocket'), revolution: async () => shape('revolution'), fillet: async () => shape('fillet'), chamfer: async () => shape('chamfer'),
release: async (released) => { calls.push(`release:${released.id}`) },
}
const base: DocumentObjectSnapshot = { id: 'box', typeId: 'Part::Box', properties: [] }
const mirrored: DocumentObjectSnapshot = { id: 'mirrored', typeId: 'PartDesign::Mirrored', properties: [
{ name: 'Base', label: 'Base', group: 'Mirrored', scope: 'data', type: 'App::PropertyLink', value: 'box' },
{ name: 'Plane', label: 'Plane', group: 'Mirrored', scope: 'data', type: 'App::PropertyEnumeration', value: 'XZ plane', options: ['XY plane', 'XZ plane', 'YZ plane'] },
{ name: 'Fuse', label: 'Fuse', group: 'Mirrored', scope: 'data', type: 'App::PropertyBool', value: true },
] }
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-mirrored', version: 4, tree: [{ id: 'box', label: 'Box', type: 'feature' }, { id: 'mirrored', label: 'Mirrored', type: 'feature' }], objects: [base, mirrored], dependencies: [{ sourceId: 'mirrored', targetId: 'box', relation: 'link' }] }
const shapes = new Map<string, ShapeHandle>()
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
await executor(base, document, { documentId: document.id, documentVersion: 4, generation: 1, signal: new AbortController().signal })
const fused = await executor(mirrored, document, { documentId: document.id, documentVersion: 4, generation: 1, signal: new AbortController().signal })
assert.equal(fused.status, 'success')
assert.equal(shapes.get('mirrored')?.id, 'mirrored-union')
assert.ok(calls.includes('mirror:box-shape:0,0,0:0,1,0'))
assert.ok(calls.includes('union:box-shape,mirror-1'))
assert.ok(calls.includes('release:mirror-1'))
const unfused = { ...mirrored, id: 'mirrored-unfused', properties: mirrored.properties.map((property) => property.name === 'Fuse' ? { ...property, value: false } : property) }
const unfusedResult = await executor(unfused, { ...document, objects: [base, unfused] }, { documentId: document.id, documentVersion: 4, generation: 2, signal: new AbortController().signal })
assert.equal(unfusedResult.status, 'success')
assert.equal(shapes.get('mirrored-unfused')?.id, 'mirror-2')
assert.equal(calls.includes('release:mirror-2'), false)
})
test('object Placement transforms recomputed shapes before committing the cache', async () => { test('object Placement transforms recomputed shapes before committing the cache', async () => {
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-placement', documentVersion: 3 }) const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-placement', documentVersion: 3 })
const calls: string[] = [] const calls: string[] = []
@@ -1112,10 +1153,10 @@ test('disabled commands return a reason instead of mutating the document', () =>
test('manifest commands without a Bitbybit executor are explicitly unsupported', () => { test('manifest commands without a Bitbybit executor are explicitly unsupported', () => {
const facade = createMockFacade() const facade = createMockFacade()
const before = facade.getState().document.version const before = facade.getState().document.version
const state = facade.gui.command.getState('mirrored') const state = facade.gui.command.getState('multi-transform')
assert.equal(state.status, 'disabled') assert.equal(state.status, 'disabled')
assert.match(state.reason || '', /business executor/) assert.match(state.reason || '', /business executor/)
facade.gui.command.execute({ commandId: 'mirrored' }) facade.gui.command.execute({ commandId: 'multi-transform' })
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED') assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED')
assert.equal(facade.getState().document.version, before) assert.equal(facade.getState().document.version, before)
}) })
@@ -1486,6 +1527,17 @@ test('Part Design feature tasks commit a document object and remain undoable', (
assert.equal(hole?.properties.find((property) => property.name === 'Depth')?.value, 12) assert.equal(hole?.properties.find((property) => property.name === 'Depth')?.value, 12)
assert.equal(hole?.properties.find((property) => property.name === 'Type')?.value, 'Through all') assert.equal(hole?.properties.find((property) => property.name === 'Type')?.value, 'Through all')
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'hole') assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'hole')
assert.equal(facade.gui.command.getState('mirrored').status, 'enabled')
facade.gui.command.execute({ commandId: 'mirrored' })
facade.task.update({ plane: 'XZ plane', fuse: false })
facade.task.apply()
const mirrored = facade.app.document.getObject('mirrored')
assert.equal(mirrored?.typeId, 'PartDesign::Mirrored')
assert.equal(mirrored?.properties.find((property) => property.name === 'Base')?.value, 'hole')
assert.equal(mirrored?.properties.find((property) => property.name === 'Plane')?.value, 'XZ plane')
assert.equal(mirrored?.properties.find((property) => property.name === 'Fuse')?.value, false)
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'mirrored')
}) })
test('Part workbench commands create primitive and boolean document objects', () => { test('Part workbench commands create primitive and boolean document objects', () => {