From b09c5aaf3400176aeeac880d498916572dda7bcd Mon Sep 17 00:00:00 2001 From: wangdequan Date: Mon, 3 Aug 2026 02:38:09 -0400 Subject: [PATCH] feat: add structured multi-transform --- config/compatibility-matrix.json | 6 +- docs/continuation-status.zh-CN.md | 18 +++- docs/freecad-full-parity-plan.zh-CN.md | 5 +- docs/web-cad-implementation-plan.zh-CN.md | 6 +- src/App.tsx | 47 +++++++++- src/facade/index.ts | 2 +- src/facade/mockFacade.ts | 50 ++++++++-- src/facade/projectStore.ts | 1 + src/facade/recomputeEngine.ts | 72 +++++++++++++- src/facade/types.ts | 11 ++- src/styles.css | 13 +++ tests/facade.test.ts | 109 +++++++++++++++++++++- 12 files changed, 311 insertions(+), 29 deletions(-) diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 226ddbe..815e4b3 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", "revolution", "fillet", "chamfer", "mirrored", "linear-pattern", "polar-pattern"] }, + "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "revolution", "fillet", "chamfer", "mirrored", "multi-transform", "linear-pattern", "polar-pattern"] }, "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"] }, "Draft": { "status": "ui-prototype", "level": "read-only", "commands": ["working-plane", "snap-settings", "draft-line", "draft-wire", "move", "rotate"] }, @@ -17,8 +17,8 @@ "Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] } }, "facadeCapabilities": { - "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"] }, + "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", "multi-transform-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-multi-transform", "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"] }, "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"] }, "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] } diff --git a/docs/continuation-status.zh-CN.md b/docs/continuation-status.zh-CN.md index ea475ba..2c3f50c 100644 --- a/docs/continuation-status.zh-CN.md +++ b/docs/continuation-status.zh-CN.md @@ -1,6 +1,6 @@ # Web FreeCAD 接续执行状态 -更新时间:2026-08-03(Mirrored whole-shape 里程碑已推送) +更新时间:2026-08-03(MultiTransform whole-shape 里程碑) ## 1. 当前目标 @@ -12,7 +12,7 @@ - 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git` - 最近已推送里程碑:`4aa6ca4 feat: add whole-shape mirrored feature` - 最近已推送里程碑的完整门禁:`./npmw run verify` 通过 -- 当前 Mirrored 里程碑验证:`./npmw run verify` 通过(Facade 65/65),diff/JSON 校验通过 +- 当前 MultiTransform 里程碑验证:`./npmw run verify` 通过(Facade 67/67),diff/JSON 校验通过 - 生产构建:通过 - 本地开发地址:`http://127.0.0.1:5173/` - 兼容真值表:`config/compatibility-matrix.json` @@ -31,6 +31,7 @@ 9. TSN generation 写回:重算成功后采集面/边/点签名快照与保守历史;迁移 LinkSub/草图外部几何;歧义或删除引用标脏所有者并生成可定位诊断;schema v6 保存对象拓扑快照。 10. TopoRef 候选替换事务:属性面板/诊断页从源对象当前同 kind 拓扑候选中原子替换 LinkSub 或外部几何引用,重建 DAG、标脏重算并支持 Undo/Redo。 11. Mirrored whole-shape:Facade 暴露 OCCT 平面法向镜像,PartDesign Task 支持 XY/XZ/YZ 原点平面与可选 Fuse,重算释放 union 临时副本并写回 Shape/拓扑 generation。 +12. MultiTransform whole-shape:新增结构化有序 Linear/Polar/Mirrored 步骤属性、Task/属性编辑器、最多 6 步和 100 实例校验;重算按顺序组合全部实例,失败与成功均释放中间 Shape,并保留最近有效缓存。 相关提交按新到旧: @@ -49,7 +50,7 @@ ## 4. 已确认限制 - 仓库没有 FreeCAD C++ 源码、Emscripten 工程或 planegcs WASM 产物;`SK-03` 不能标记完成。现有 `typescript-basic` solver 仅用于协议与基础模型验证。 -- Pattern 当前复制整个 Shape,不是 FreeCAD 局部特征历史。 +- Pattern 与 MultiTransform 当前复制整个 Shape,不是 FreeCAD 局部特征历史。 - Hole 固定原点/法向,尚无面定位、沉孔、锪孔、螺纹和完整孔标准。 - 拓扑历史来自签名保守回退,不是 OCCT/FreeCAD 原生 Generated/Modified/Deleted 历史。 - Placement 已进入文档属性,并接入 Facade 的对象级 Shape 重算;尚未完整映射到 Body/Tip、Support、AttachmentOffset 或所有特征执行器的 FreeCAD 局部坐标链语义。 @@ -61,7 +62,7 @@ 2. 补齐 P3-03 浏览器 OCCT 包围盒/体积黄金测试,并验证 Placement 撤销、保存和恢复的真实 Worker 路径;Node/fallback 合同已通过。 3. 为 ambiguous/deleted TopoRef 补 3D 候选高亮,并增加真实 OCCT/SQLite 浏览器回放;候选替换 Facade 事务和属性/诊断 UI 已进入当前里程碑。 4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC;此前继续补充回放夹具、约束分类和 Sketch 编辑事务。 -5. 扩展 PartDesign:为 MultiTransform 建立有序变换列表合同,再推进 Loft/Pipe/Groove、Thickness/Draft,以及 Mirrored/Pattern/Hole 的局部特征历史与完整语义。 +5. 扩展 PartDesign:推进 Loft/Pipe/Groove、Thickness/Draft,以及 Mirrored/MultiTransform/Pattern/Hole 的局部特征历史与完整语义。 6. 完成 FCStd 原生对象/属性/Shape 双向映射和 STEP/IGES/STL/Web CAD 包导入导出。 7. 按核心优先级完成 TechDraw、Spreadsheet、Draft、Assembly、BIM、Mesh、Surface、FEM、CAM 等工作台。 8. 最后通过跨浏览器、性能、内存、安全、可访问性、离线/PWA、升级回滚和发布差异报告门禁。 @@ -134,6 +135,13 @@ Placement 里程碑涉及实现、测试、兼容矩阵和三份状态/方案文 - 当前是整 Shape 镜像,不是 FreeCAD 对选中特征历史的镜像;平面固定经过文档原点,未接 DatumPlane/Sketch axis/TopoRef 支持,也未证明单实体规则。 - 完整 `./npmw run verify`、diff/JSON 校验通过;里程碑已由 `4aa6ca4` 推送。 -下一执行点:为 MultiTransform 定义结构化有序步骤属性(而不是解析自由字符串),至少覆盖 Linear/Polar/Mirrored 组合、步骤级校验、Task 编辑、持久化、失败资源回收和 generation 拓扑写回。完成这一合同前,`multi-transform` 继续保持显式 unsupported。 +## 12. 2026-08-03 MultiTransform whole-shape 快照 + +- `App::PropertyMultiTransform` 保存最多 6 个有序步骤;每个步骤使用稳定 ID 和 Linear/Polar/Mirrored 判别联合,不解析自由字符串。Facade 写入边界校验枚举、数值、唯一 ID 和笛卡尔组合后的 100 实例上限。 +- Task 面板与 Data 属性面板共用步骤编辑器,可添加、删除、切换类型并编辑 Count/Length/Direction、Count/Angle/Axis 或 Plane;提交绑定 Base、更新 Body.Tip,并进入 Undo/Redo、自动保存和深拷贝持久化。 +- 重算从 Base whole Shape 开始,每一步作用于前一步的全部实例,最终 union;成功或中途失败都释放镜像/平移/旋转副本,失败不覆盖上一次成功缓存。接受的 generation 继续走统一拓扑采集和写回。 +- 合同测试覆盖 Mirrored→Linear 的 6 实例组合、参数映射、完整临时句柄回收、故障注入、最近有效缓存保留、100 实例拒绝、任务提交、深拷贝、Undo/Redo 和 fallback 持久化。 + +当前实现不是 FreeCAD Transform feature history 的完全等价实现:它组合并融合整个 Base Shape,未实现局部特征列表、Datum/TopoRef 轴面、单实体过滤或 OCCT 原生历史。下一执行点是 Additive/Subtractive Loft 的结构化截面合同与 whole-shape experimental 执行边界。 本里程碑不得声称 P3-03、F3 或完整 FreeCAD 兼容已经通过。当前对象 Placement 是 Shape 生成后的对象级变换,尚未证明 FreeCAD 对 Body/Tip、Support、AttachmentOffset 和局部坐标链的完整等价语义。 diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index d815172..8d8bbc4 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -340,11 +340,12 @@ PartDesign Mirrored 已形成 experimental whole-shape 垂直切片:Facade 验 | P2-03 Resource/recovery fallback | SQLite Worker 在无 OPFS 时使用内存资源表;OPFS 模式增加配额预留、重复内容免写、缺失文件重建和孤儿/零引用/缺失文件对账清扫;Facade 暴露 sweep 报告 | quota/sweep 纯策略、fallback API、Worker 类型检查和生产构建通过 | 真实 OPFS 孤儿清扫、QuotaExceeded、大文件压力及浏览器崩溃注入仍待完成 | | P2-04 Version checkpoints | 文档规范化保存与完整 JSON checkpoint 同事务;每文档保留最近 5 版;Recovery 列出版本,Facade 可加载指定/最新 checkpoint | 7 版保存后仅保留 3..7、指定版本/最新版加载、过期版本删除和深克隆测试通过 | 当前测试使用 transient fallback;SQLite/OPFS 断电点恢复和保留策略 UI 待完成 | | PART-07 Shape check | `check-shape` 使用 Facade `geometry.topology()` 生成面/边/顶点数量和 `SHAPE_CHECK_PASSED` 信息诊断;空缓存、空拓扑和 Worker 错误分别返回结构化失败诊断 | 无 Shape、错误选择前置和拓扑调用边界通过测试/构建 | 当前是网格派生拓扑检查,不等价于 OCCT `BRepCheck_Analyzer`,完整修复建议和自交诊断仍待实现 | -| P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;命令状态同时检查工作台和对象类型选择谓词;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`mirrored` 未实现状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 | +| P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;命令状态同时检查工作台和对象类型选择谓词;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`additive-loft` 未实现状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 | | P3-05 Pattern 实验切片 | `linear-pattern` 和 `polar-pattern` 通过 Part Design 选择谓词、Task 和类型化属性创建对应对象;Linear 按总 Length 等距平移,Polar 在小于 360° 时覆盖首末角、360° 时不复制重合端点;两者均由 OCCT union 合并并释放临时副本 | 线性间距/方向、圆周完整/部分角度分布、联合输入、句柄释放、非法参数诊断、Base 链接、Body.Tip、属性校验和任务提交回归测试通过 | 当前为 FreeCAD 1.0+ 的 Transform body 近似,只复制整个 Base Shape;未实现 Transform tool shapes、连接单实体过滤、稳定 TopoRef、历史映射、反向轴和引用轴,因此保持 `experimental` | +| P3-07 MultiTransform 实验切片 | `multi-transform` 使用结构化有序 Linear/Polar/Mirrored 步骤,Task 与属性面板共用编辑器;重算逐步扩展 whole-shape 实例并最终 union,限制最多 6 步和 100 个组合实例 | 6 实例组合、方向/平面映射、故障注入、中间 Shape 完整回收、最近有效缓存、深拷贝、Undo/Redo、fallback 保存恢复与任务提交测试通过 | 不是 FreeCAD 局部 Transform feature history;未实现特征列表、Datum/TopoRef 轴面、单实体规则与原生拓扑历史,保持 `experimental` | | P3-05 Hole 基础切片 | `hole` 创建 `PartDesign::Hole`,用 32 边圆形 Profile 调用现有 OCCT Pocket;支持 Diameter、Depth 以及 Dimension/Through all,校验正尺寸并纳入 Base 链接和 Body.Tip | 圆孔 Profile、有限深度调用、非法直径诊断、Task 属性提交和 Tip 更新测试通过 | 仅为基体原点 XY 面、+Z 法向的直孔;草图圆/面定位、沉孔、锪孔、钻尖、螺纹、尺寸标准和稳定支撑面引用仍待实现,保持 `experimental` | | TSN-02/05/06/08 TopoRef 基础迁移 | `topologyReferences` 提供 schema v1 创建、序列化、安全解析、解析和跨版本迁移;重复几何候选 ID 唯一,一旧多新不会错误继承;匹配按 kind 隔离并用形状尺度归一化几何评分支持纯平移 | round-trip、瞬时索引拒绝、resolved/ambiguous/deleted、重复拆分和纯平移回归测试通过 | 仍是网格签名启发式;没有 OCCT Generated/Modified/Deleted、邻接/曲率签名、布尔历史和黄金模型规模验证,不得提升为 compatible | -| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef;重算 generation 自动迁移 LinkSub/草图外部几何,歧义/删除不强绑;schema v6 保存对象拓扑快照;Facade 候选替换事务验证当前同 kind persistentId,重建 DAG 并支持 Undo/Redo;属性/诊断 UI 可替换;Pocket 暴露 `UpToFace` 槽 | 非法瞬时索引/候选拒绝、DAG 边、稳定/歧义/删除迁移、跨 generation 回退解析、候选替换、Undo/Redo、保存加载和 64 项全套测试通过 | 尚未从 3D 拾取、预选或高亮候选;Pocket Up to face executor 仍保持结构化 unsupported,等待原生面历史/解析接线 | +| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef;重算 generation 自动迁移 LinkSub/草图外部几何,歧义/删除不强绑;schema v6 保存对象拓扑快照;Facade 候选替换事务验证当前同 kind persistentId,重建 DAG 并支持 Undo/Redo;属性/诊断 UI 可替换;Pocket 暴露 `UpToFace` 槽 | 非法瞬时索引/候选拒绝、DAG 边、稳定/歧义/删除迁移、跨 generation 回退解析、候选替换、Undo/Redo、保存加载和 67 项全套测试通过 | 尚未从 3D 拾取、预选或高亮候选;Pocket Up to face executor 仍保持结构化 unsupported,等待原生面历史/解析接线 | | TSN-03/07 历史适配器回退 | Bitbybit 1.1.1 绑定仅有 `HasGenerated()`,没有逐 Shape 的 Generated/Modified/IsDeleted;`signature-fallback` 已进入真实重算 generation,对每个成功 Shape 保存快照/matches/history,采集失败释放临时 Shape并保留最近有效缓存 | 平移修改、新增/删除/重复面、跨对象竞争、generation 写回、采集失败隔离和持久化测试通过;全套 63 项测试通过 | 此 provider 不是 OCCT history;多输入 Boolean 当前仍缺逐输入原生映射,只有自构建绑定暴露逐子形状历史并通过黄金模型后,才能将 TSN-03/07 标为完成 | 后续按以下顺序连续开发: diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 5c679e4..89f161b 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -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-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和浏览器矩阵已验证基础 Placement;Facade 重算 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/Revolution,Revolution Angle/Reversed 已接入,Fillet/Chamfer 也有 Shape 缓存回写;Up to face、FCStd Shape 持久化仍待完成 | -| P3-05 Fillet/Chamfer/Mirrored/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存;Mirrored 使用 OCCT plane-normal mirror,Task 支持 XY/XZ/YZ 原点平面和可选 Fuse;Linear/Polar Pattern 已贯通 Placement/union;基础 Hole 支持 Diameter、Depth、Dimension/Through all。Mirrored/Pattern 当前操作整 Shape,Hole 固定原点法向;局部特征历史、Datum/TopoRef 平面、连接单实体过滤及完整孔语义仍待实现 | +| P3-05 Fillet/Chamfer/Mirrored/MultiTransform/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存;Mirrored 使用 OCCT plane-normal mirror;MultiTransform 组合结构化 Linear/Polar/Mirrored 步骤并限制最多 100 实例;Linear/Polar Pattern 已贯通 Placement/union;基础 Hole 支持 Diameter、Depth、Dimension/Through all。变换类当前操作整 Shape,Hole 固定原点法向;局部特征历史、Datum/TopoRef 平面、连接单实体过滤及完整孔语义仍待实现 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 65 个场景,包括 Part/PartDesign Mirrored/Pattern/Hole、TopoRef generation/候选替换、Sketch provider、几何重算、诊断修复、FCStd 安全、schema migration、1000 次写队列、检查点和资源治理;E2E/黄金几何待补齐 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 67 个场景,包括 Part/PartDesign Mirrored/MultiTransform/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 构建验证。 @@ -1702,7 +1702,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值 | 类型控件 | `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 任务草稿写入核心参数;Linear/Polar Pattern 写入 Base、Occurrences、Length/Direction 或 Angle/Axis;Hole 写入 Base、Diameter、Depth、Type;确认后均通过 Facade 历史边界提交并更新 Body.Tip | +| Part Design Task | `PASS` | Pad/Pocket/Revolution/Fillet/Chamfer 任务草稿写入核心参数;Linear/Polar Pattern 与结构化 MultiTransform 写入 Base 和变换参数;Hole 写入 Base、Diameter、Depth、Type;确认后均通过 Facade 历史边界提交并更新 Body.Tip | | 选择谓词 | `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 和树投影同步 | diff --git a/src/App.tsx b/src/App.tsx index 7829d9e..0859645 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,7 +54,7 @@ import { ZoomOut, } from 'lucide-react' import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' -import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade' +import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Workbench = WorkbenchId @@ -344,6 +344,45 @@ function TreeItem({ item, level, expanded, onToggle, itemsById, selectedObject, } +const defaultMultiTransform: MultiTransformValue = { steps: [{ id: 'linear-1', type: 'linear', occurrences: 2, length: 20, direction: 'Horizontal' }] } + +function multiTransformValue(value: unknown): MultiTransformValue { + if (!value || typeof value !== 'object' || Array.isArray(value) || !('steps' in value) || !Array.isArray(value.steps) || value.steps.length === 0) return { steps: defaultMultiTransform.steps.map((step) => ({ ...step })) } + return { steps: value.steps.map((step) => ({ ...(step as MultiTransformStep) })) } +} + +function MultiTransformEditor({ value, onChange }: { value: MultiTransformValue; onChange: (value: MultiTransformValue) => void }) { + const updateStep = (index: number, update: Partial) => onChange({ steps: value.steps.map((step, stepIndex) => stepIndex === index ? { ...step, ...update } as MultiTransformStep : { ...step }) }) + const replaceStep = (index: number, type: MultiTransformStep['type']) => { + const used = new Set(value.steps.map((step) => step.id)) + let sequence = index + 1 + while (used.has(`${type}-${sequence}`) && value.steps[index].id !== `${type}-${sequence}`) sequence += 1 + const id = `${type}-${sequence}` + const step: MultiTransformStep = type === 'linear' + ? { id, type, occurrences: 2, length: 20, direction: 'Horizontal' } + : type === 'polar' + ? { id, type, occurrences: 3, angle: 360, axis: 'Normal' } + : { id, type, plane: 'YZ plane' } + onChange({ steps: value.steps.map((candidate, stepIndex) => stepIndex === index ? step : { ...candidate }) }) + } + const addStep = () => { + if (value.steps.length >= 6) return + const used = new Set(value.steps.map((step) => step.id)) + let sequence = value.steps.length + 1 + while (used.has(`linear-${sequence}`)) sequence += 1 + onChange({ steps: [...value.steps.map((step) => ({ ...step })), { id: `linear-${sequence}`, type: 'linear', occurrences: 2, length: 20, direction: 'Horizontal' }] }) + } + return
+ {value.steps.map((step, index) =>
+
Step {index + 1}
+ {step.type === 'linear' &&
} + {step.type === 'polar' &&
} + {step.type === 'mirrored' &&
} +
)} + +
+} + function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) { const definition = workbenchDefinitions[workbench] const isSketch = workbench === 'Sketcher' @@ -362,9 +401,10 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa } const currentFeatureField = activeTask?.commandId ? featureFields[activeTask.commandId] : undefined const activeCommand = activeTask?.commandId + const multiTransform = multiTransformValue(activeTask?.draft.transformations) 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', 'PartDesign::Mirrored', '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::MultiTransform', '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 draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : '' const acceptTask = () => { @@ -397,6 +437,7 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa } + {activeCommand === 'multi-transform' && facade.task.update({ transformations })} />} {currentFeatureField && } {activeCommand === 'linear-pattern' && <> @@ -466,6 +507,8 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi } catch (error) { showNotice(error instanceof Error ? error.message : String(error)) } } editor = ref ?
{ref.objectId} / {ref.kind} · {ref.status}{(ref.status === 'ambiguous' || ref.status === 'deleted') && candidates.length > 0 ? : null} commit(null)} />
: None + } else if (property.type === 'App::PropertyMultiTransform') { + editor = } else if (property.type === 'App::PropertyPlacement') { const placement = property.value as PlacementValue const updatePlacement = (path: 'position' | 'axis' | 'angle', key: 'x' | 'y' | 'z' | null, next: number) => { diff --git a/src/facade/index.ts b/src/facade/index.ts index dc4afb7..27d4881 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -7,7 +7,7 @@ export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema' export { assessResourceQuota, planResourceSweep } 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, 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 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, MultiTransformStep, MultiTransformValue, 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 { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences' export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 1a6756a..2ccd422 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -12,6 +12,7 @@ import type { FacadeRequestContext, FacadeState, ModelTreeItem, + MultiTransformValue, DocumentObjectSnapshot, ObjectPropertySnapshot, PropertyValue, @@ -45,7 +46,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('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 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('multi-transform') ? 'PartDesign::MultiTransform' : 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[] => [ { name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label }, @@ -121,6 +122,10 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { { 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('multi-transform')) return [ + { name: 'Base', label: 'Base feature', group: 'Multi-transform', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, + { name: 'Transformations', label: 'Transformations', group: 'Multi-transform', scope: 'data', type: 'App::PropertyMultiTransform', value: { steps: [{ id: 'linear-1', type: 'linear', occurrences: 2, length: 20, direction: 'Horizontal' }] }, recompute: true }, + ] if (item.id.startsWith('linear-pattern')) return [ { 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 }, @@ -155,6 +160,7 @@ const clonePropertyValue = (value: PropertyValue): PropertyValue => { if (Array.isArray(value)) return [...value] if (!value || typeof value !== 'object') return value if ('position' in value && 'rotation' in value) return { position: { ...value.position }, rotation: { axis: { ...value.rotation.axis }, angle: value.rotation.angle } } + if ('steps' in value && Array.isArray(value.steps)) return { steps: value.steps.map((step) => ({ ...step })) } if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined } return { ...value } } @@ -207,12 +213,12 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => { return document } -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 selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'multi-transform', '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 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', 'mirrored', 'linear-pattern', 'polar-pattern', 'hole']) +const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'multi-transform', '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', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole']) const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape']) -const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer', 'mirrored', 'linear-pattern', 'polar-pattern', 'hole']) +const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole']) const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution']) const shapeTypeIds = new Set([ 'Part::Box', @@ -230,6 +236,7 @@ const shapeTypeIds = new Set([ 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::Mirrored', + 'PartDesign::MultiTransform', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole', @@ -243,6 +250,7 @@ const featureCommands: Record = { fillet: { label: 'Fillet', detail: 'Radius 3 mm' }, chamfer: { label: 'Chamfer', detail: 'Length 2 mm' }, mirrored: { label: 'Mirrored', detail: 'Whole Shape across YZ plane' }, + 'multi-transform': { label: 'Multi-transform', detail: 'Ordered whole-shape transformations' }, 'linear-pattern': { label: 'Linear Pattern', detail: '2 occurrences over 20 mm' }, 'polar-pattern': { label: 'Polar Pattern', detail: '3 occurrences over 360 deg' }, hole: { label: 'Hole', detail: 'Simple 5 mm diameter hole' }, @@ -290,6 +298,7 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope const axis = placement.rotation.axis if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError(`${property.label} rotation axis cannot be zero.`) } + if (property.type === 'App::PropertyMultiTransform') validateMultiTransformValue(value) if (property.type === 'App::PropertyLinkList' || property.type === 'App::PropertyStringList') { if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) throw new TypeError(`${property.label} requires a string list.`) if (property.type === 'App::PropertyLinkList') { @@ -312,6 +321,34 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.') } +function validateMultiTransformValue(value: unknown): asserts value is MultiTransformValue { + if (!value || typeof value !== 'object' || Array.isArray(value) || !('steps' in value) || !Array.isArray(value.steps)) throw new TypeError('Transformations requires an ordered step list.') + if (value.steps.length < 1 || value.steps.length > 6) throw new RangeError('Transformations requires between 1 and 6 steps.') + const ids = new Set() + let instances = 1 + for (const rawStep of value.steps) { + if (!rawStep || typeof rawStep !== 'object' || Array.isArray(rawStep)) throw new TypeError('Each transformation step requires a structured value.') + const step = rawStep as Record + if (typeof step.id !== 'string' || !step.id.trim() || ids.has(step.id)) throw new RangeError('Transformation step IDs must be non-empty and unique.') + ids.add(step.id) + if (step.type === 'linear') { + if (!Number.isSafeInteger(step.occurrences) || (step.occurrences as number) < 2 || (step.occurrences as number) > 10) throw new RangeError('Linear occurrences must be an integer between 2 and 10.') + if (typeof step.length !== 'number' || !Number.isFinite(step.length) || step.length <= 0) throw new RangeError('Linear length must be greater than zero.') + if (!['Horizontal', 'Vertical', 'Normal'].includes(String(step.direction))) throw new RangeError('Linear direction is invalid.') + instances *= step.occurrences as number + } else if (step.type === 'polar') { + if (!Number.isSafeInteger(step.occurrences) || (step.occurrences as number) < 2 || (step.occurrences as number) > 10) throw new RangeError('Polar occurrences must be an integer between 2 and 10.') + if (typeof step.angle !== 'number' || !Number.isFinite(step.angle) || step.angle <= 0 || step.angle > 360) throw new RangeError('Polar angle must be greater than zero and no more than 360 degrees.') + if (!['Horizontal', 'Vertical', 'Normal'].includes(String(step.axis))) throw new RangeError('Polar axis is invalid.') + instances *= step.occurrences as number + } else if (step.type === 'mirrored') { + if (!['XY plane', 'XZ plane', 'YZ plane'].includes(String(step.plane))) throw new RangeError('Mirror plane is invalid.') + instances *= 2 + } else throw new RangeError('Transformation step type is invalid.') + if (instances > 100) throw new RangeError('Multi-transform cannot create more than 100 instances.') + } +} + function validateVectorValue(label: string, value: unknown): asserts value is { x: number; y: number; z: number } { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} requires a Vector value.`) const vector = value as Record @@ -388,7 +425,7 @@ export function createMockFacade(): BitBybitWebCadFacade { const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event)) const emitState = () => emit({ type: 'state.changed', state: getState() }) - const getState = () => ({ ...state, diagnostics: state.diagnostics.map(cloneDiagnostic), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null }) + const getState = () => ({ ...state, diagnostics: state.diagnostics.map(cloneDiagnostic), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: Object.fromEntries(Object.entries(state.task.draft).map(([key, value]) => [key, key === 'transformations' ? clonePropertyValue(value as MultiTransformValue) : value])) } : null }) const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty) autosave.schedule(next.document); emitState() } const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() } const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) } @@ -424,6 +461,7 @@ export function createMockFacade(): BitBybitWebCadFacade { if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Profile' && sourceObject.typeId === 'Sketcher::SketchObject') return { ...property, value: sourceObject.id } if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Base' && shapeTypeIds.has(sourceObject.typeId)) return { ...property, value: sourceObject.id } if (property.type === 'App::PropertyBool' && typeof candidate === 'boolean') return { ...property, value: candidate } + if (property.type === 'App::PropertyMultiTransform' && candidate && typeof candidate === 'object') return { ...property, value: clonePropertyValue(candidate as MultiTransformValue) } if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') && 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 diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index f3b6057..51a39ca 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -11,6 +11,7 @@ const clonePropertyValue = (value: PropertyValue): PropertyValue => { if (Array.isArray(value)) return [...value] if (!value || typeof value !== 'object') return value if ('position' in value && 'rotation' in value) return { position: { ...value.position }, rotation: { axis: { ...value.rotation.axis }, angle: value.rotation.angle } } + if ('steps' in value && Array.isArray(value.steps)) return { steps: value.steps.map((step) => ({ ...step })) } if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined } return { ...value } } diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts index 7075168..c4d65d3 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 { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, MirrorInput, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types' +import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, MirrorInput, MultiTransformStep, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types' import { captureSignatureTopologyHistory } from './topologyHistory' import { migrateTopoRefs } from './topologyReferences' @@ -369,6 +369,40 @@ const sketchProfile = (sketch: DocumentObjectSnapshot['sketch']): { profile?: Pl const geometryFailure = (objectId: string, code: string, message: string): RecomputeNodeResult => ({ status: 'failed', errors: [{ objectId, code, message }] }) +const multiTransformSteps = (object: DocumentObjectSnapshot): { steps: MultiTransformStep[] } | { error: string } => { + const value = propertyValue(object, 'Transformations') + if (!structuredValue(value)) return { error: 'Transformations requires an ordered step list.' } + const record = value as Record + if (!Array.isArray(record.steps)) return { error: 'Transformations requires an ordered step list.' } + if (record.steps.length < 1 || record.steps.length > 6) return { error: 'Transformations requires between 1 and 6 steps.' } + const ids = new Set() + const steps: MultiTransformStep[] = [] + let instances = 1 + for (const rawStep of record.steps) { + if (!structuredValue(rawStep) || typeof rawStep.id !== 'string' || !rawStep.id.trim() || ids.has(rawStep.id)) return { error: 'Transformation step IDs must be non-empty and unique.' } + ids.add(rawStep.id) + if (rawStep.type === 'linear') { + if (!Number.isSafeInteger(rawStep.occurrences) || (rawStep.occurrences as number) < 2 || (rawStep.occurrences as number) > 10) return { error: 'Linear occurrences must be an integer between 2 and 10.' } + if (typeof rawStep.length !== 'number' || !Number.isFinite(rawStep.length) || rawStep.length <= 0) return { error: 'Linear length must be greater than zero.' } + if (!['Horizontal', 'Vertical', 'Normal'].includes(String(rawStep.direction))) return { error: 'Linear direction is invalid.' } + instances *= rawStep.occurrences as number + steps.push({ id: rawStep.id, type: 'linear', occurrences: rawStep.occurrences as number, length: rawStep.length, direction: rawStep.direction as 'Horizontal' | 'Vertical' | 'Normal' }) + } else if (rawStep.type === 'polar') { + if (!Number.isSafeInteger(rawStep.occurrences) || (rawStep.occurrences as number) < 2 || (rawStep.occurrences as number) > 10) return { error: 'Polar occurrences must be an integer between 2 and 10.' } + if (typeof rawStep.angle !== 'number' || !Number.isFinite(rawStep.angle) || rawStep.angle <= 0 || rawStep.angle > 360) return { error: 'Polar angle must be greater than zero and no more than 360 degrees.' } + if (!['Horizontal', 'Vertical', 'Normal'].includes(String(rawStep.axis))) return { error: 'Polar axis is invalid.' } + instances *= rawStep.occurrences as number + steps.push({ id: rawStep.id, type: 'polar', occurrences: rawStep.occurrences as number, angle: rawStep.angle, axis: rawStep.axis as 'Horizontal' | 'Vertical' | 'Normal' }) + } else if (rawStep.type === 'mirrored') { + if (!['XY plane', 'XZ plane', 'YZ plane'].includes(String(rawStep.plane))) return { error: 'Mirror plane is invalid.' } + instances *= 2 + steps.push({ id: rawStep.id, type: 'mirrored', plane: rawStep.plane as 'XY plane' | 'XZ plane' | 'YZ plane' }) + } else return { error: 'Transformation step type is invalid.' } + if (instances > 100) return { error: 'Multi-transform cannot create more than 100 instances.' } + } + return { steps } +} + /** * Adds real OCCT feature execution without putting transient ShapeHandles in the * persisted document snapshot. The map is deliberately owned by the Facade and @@ -386,7 +420,7 @@ export const createFacadeGeometryRecomputeExecutor = ( 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::Mirrored', '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::MultiTransform', '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 profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined @@ -456,6 +490,40 @@ export const createFacadeGeometryRecomputeExecutor = ( try { result = await geometry.union({ ...documentContext, shapes: [baseShape, mirrored] }) } finally { await Promise.allSettled([geometry.release(mirrored)]) } } + } else if (object.typeId === 'PartDesign::MultiTransform') { + const baseObject = linkedObject(object, 'Base', document) + const baseShape = baseObject ? shapes.get(baseObject.id) : undefined + if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Multi-transform base has no valid recomputed Shape.') + const parsed = multiTransformSteps(object) + if ('error' in parsed) return geometryFailure(object.id, 'MULTITRANSFORM_INVALID', parsed.error) + if (parsed.steps.some((step) => step.type === 'mirrored') && !geometry.mirror) return geometryFailure(object.id, 'MULTITRANSFORM_MIRROR_UNAVAILABLE', 'The geometry runtime does not provide plane mirroring.') + let instances: ShapeHandle[] = [baseShape] + const owned = new Map() + const own = (shape: ShapeHandle) => { if (shape.id !== baseShape.id) owned.set(shape.id, shape); return shape } + try { + for (const step of parsed.steps) { + const parents = [...instances] + const created: ShapeHandle[] = [] + if (step.type === 'mirrored') { + const normal: [number, number, number] = step.plane === 'XZ plane' ? [0, 1, 0] : step.plane === 'YZ plane' ? [1, 0, 0] : [0, 0, 1] + for (const parent of parents) created.push(own(await geometry.mirror!({ ...documentContext, shape: parent, origin: [0, 0, 0], normal }))) + } else if (step.type === 'linear') { + const axis: [number, number, number] = step.direction === 'Vertical' ? [0, 1, 0] : step.direction === 'Normal' ? [0, 0, 1] : [1, 0, 0] + for (const parent of parents) for (let index = 1; index < step.occurrences; index += 1) { + const offset = step.length * index / (step.occurrences - 1) + created.push(own(await geometry.applyPlacement({ ...documentContext, shape: parent, placement: { translation: [axis[0] * offset, axis[1] * offset, axis[2] * offset], rotationAxis: [0, 0, 1], rotationAngle: 0 } }))) + } + } else { + const axis: [number, number, number] = step.axis === 'Horizontal' ? [1, 0, 0] : step.axis === 'Vertical' ? [0, 1, 0] : [0, 0, 1] + const angleStep = step.angle === 360 ? step.angle / step.occurrences : step.angle / (step.occurrences - 1) + for (const parent of parents) for (let index = 1; index < step.occurrences; index += 1) created.push(own(await geometry.applyPlacement({ ...documentContext, shape: parent, placement: { translation: [0, 0, 0], rotationAxis: axis, rotationAngle: angleStep * index } }))) + } + instances = [...parents, ...created] + } + result = await geometry.union({ ...documentContext, shapes: instances }) + } finally { + await Promise.allSettled([...owned.values()].map((shape) => geometry.release(shape))) + } } else if (object.typeId === 'PartDesign::LinearPattern') { 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 63d7f16..c6633b7 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -33,14 +33,21 @@ export type PlacementValue = { rotation: { axis: VectorValue; angle: number } } -export type PropertyValue = string | number | boolean | string[] | VectorValue | PlacementValue | TopoRefValue | null +export type MultiTransformStep = + | { id: string; type: 'linear'; occurrences: number; length: number; direction: 'Horizontal' | 'Vertical' | 'Normal' } + | { id: string; type: 'polar'; occurrences: number; angle: number; axis: 'Horizontal' | 'Vertical' | 'Normal' } + | { id: string; type: 'mirrored'; plane: 'XY plane' | 'XZ plane' | 'YZ plane' } + +export type MultiTransformValue = { steps: MultiTransformStep[] } + +export type PropertyValue = string | number | boolean | string[] | VectorValue | PlacementValue | MultiTransformValue | TopoRefValue | null export type ObjectPropertySnapshot = { name: string label: string group: string scope: 'data' | 'view' - type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger' + type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyMultiTransform' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger' value: PropertyValue unit?: string readOnly?: boolean diff --git a/src/styles.css b/src/styles.css index bc55a00..330dea5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -406,4 +406,17 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s .property-placement-grid { display: grid; grid-template-columns: 42px repeat(3, minmax(0, 1fr)); gap: 4px; margin-top: 6px; align-items: center; } .property-placement-grid span { color: var(--text-muted); font-size: 10px; } .property-placement-grid input, .property-vector input { min-width: 0; width: 100%; height: 24px; border: 1px solid var(--line); background: var(--bg-soft); color: var(--text); padding: 2px 4px; } +.multi-transform-editor { width: 100%; display: grid; gap: 7px; margin-bottom: 12px; } +.multi-transform-step { width: 100%; border: 1px solid var(--line); border-radius: 4px; background: var(--bg-soft); padding: 7px; } +.multi-transform-step-head { display: grid; grid-template-columns: minmax(42px, 1fr) minmax(82px, 1.4fr) 24px; gap: 5px; align-items: center; color: var(--text-muted); font-size: 10px; } +.multi-transform-step-head select, .multi-transform-fields input, .multi-transform-fields select { min-width: 0; width: 100%; height: 25px; border: 1px solid var(--line); border-radius: 3px; background: var(--bg-raised); color: var(--text); padding: 0 5px; font-size: 10px; } +.multi-transform-remove { width: 24px; height: 24px; display: grid; place-items: center; border: 0; border-radius: 3px; background: transparent; color: var(--text-muted); cursor: pointer; } +.multi-transform-remove:hover:not(:disabled) { color: var(--red); background: var(--red-soft); } +.multi-transform-remove:disabled { opacity: .35; cursor: default; } +.multi-transform-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 7px; } +.multi-transform-fields-single { grid-template-columns: minmax(0, 1fr); } +.multi-transform-fields label { min-width: 0; display: grid; gap: 3px; color: var(--text-muted); font-size: 9px; } +.multi-transform-add { width: 100%; height: 28px; display: flex; align-items: center; justify-content: center; gap: 5px; border: 1px dashed var(--line); border-radius: 3px; background: transparent; color: var(--text-soft); font-size: 10px; cursor: pointer; } +.multi-transform-add:hover:not(:disabled) { border-color: var(--cyan); color: var(--cyan); } +.multi-transform-add:disabled { opacity: .4; cursor: default; } .property-vector { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; width: 100%; } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 372204c..98c8f18 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -15,7 +15,7 @@ import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolver import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine' import { inspectFcstdArchive } from '../src/facade/fcstd' 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, MultiTransformValue, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types' const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({ id: 'doc-recompute', @@ -745,6 +745,69 @@ test('Mirrored recompute mirrors a whole Shape across a document plane and relea assert.equal(calls.includes('release:mirror-2'), false) }) +test('MultiTransform composes ordered whole-shape steps and releases every transient instance', async () => { + const calls: string[] = [] + const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-multi-transform', documentVersion: 5 }) + let copySequence = 0 + let mirrorSequence = 0 + let unionSequence = 0 + let failPlacementAt = 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) => { + calls.push(`placement:${input.shape.id}:${input.placement.translation.join(',')}:${input.placement.rotationAngle}`) + if (failPlacementAt > 0 && calls.filter((call) => call.startsWith('placement:')).length === failPlacementAt) throw new Error('multi placement failed') + return shape(`copy-${++copySequence}`) + }, + mirror: async (input) => { calls.push(`mirror:${input.shape.id}:${input.normal.join(',')}`); return shape(`mirror-${++mirrorSequence}`) }, + union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape(`multi-result-${++unionSequence}`) }, + 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 transformations = { steps: [ + { id: 'mirror-1', type: 'mirrored' as const, plane: 'YZ plane' as const }, + { id: 'linear-1', type: 'linear' as const, occurrences: 3, length: 20, direction: 'Vertical' as const }, + ] } + const multi: DocumentObjectSnapshot = { id: 'multi-transform', typeId: 'PartDesign::MultiTransform', properties: [ + { name: 'Base', label: 'Base', group: 'Multi-transform', scope: 'data', type: 'App::PropertyLink', value: 'box' }, + { name: 'Transformations', label: 'Transformations', group: 'Multi-transform', scope: 'data', type: 'App::PropertyMultiTransform', value: transformations }, + ] } + const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-multi-transform', version: 5, tree: [{ id: 'box', label: 'Box', type: 'feature' }, { id: 'multi-transform', label: 'Multi-transform', type: 'feature' }], objects: [base, multi], dependencies: [{ sourceId: multi.id, targetId: base.id, relation: 'link' }] } + const shapes = new Map() + const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes) + const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal } + await executor(base, document, context) + const completed = await executor(multi, document, context) + assert.equal(completed.status, 'success') + assert.equal(shapes.get(multi.id)?.id, 'multi-result-1') + assert.ok(calls.includes('mirror:box-shape:1,0,0')) + assert.ok(calls.includes('union:box-shape,mirror-1,copy-1,copy-2,copy-3,copy-4')) + for (const id of ['mirror-1', 'copy-1', 'copy-2', 'copy-3', 'copy-4']) assert.ok(calls.includes(`release:${id}`)) + + const placementCalls = calls.filter((call) => call.startsWith('placement:')).length + failPlacementAt = placementCalls + 2 + const failed = await executor(multi, document, { ...context, generation: 2 }) + assert.equal(failed.status, 'failed') + assert.match(failed.errors?.[0].message ?? '', /multi placement failed/) + assert.equal(shapes.get(multi.id)?.id, 'multi-result-1') + assert.ok(calls.includes('release:mirror-2')) + assert.ok(calls.includes('release:copy-5')) + assert.equal(calls.includes('release:multi-result-1'), false) + + const excessive = { ...multi, properties: multi.properties.map((property) => property.name === 'Transformations' ? { ...property, value: { steps: [ + { id: 'linear-a', type: 'linear' as const, occurrences: 10, length: 1, direction: 'Horizontal' as const }, + { id: 'linear-b', type: 'linear' as const, occurrences: 10, length: 1, direction: 'Vertical' as const }, + { id: 'mirror-a', type: 'mirrored' as const, plane: 'XY plane' as const }, + ] } } : property) } + const invalid = await executor(excessive, { ...document, objects: [base, excessive] }, { ...context, generation: 3 }) + assert.equal(invalid.status, 'failed') + assert.equal(invalid.errors?.[0].code, 'MULTITRANSFORM_INVALID') + assert.match(invalid.errors?.[0].message ?? '', /more than 100/) +}) + 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 calls: string[] = [] @@ -1153,10 +1216,10 @@ test('disabled commands return a reason instead of mutating the document', () => test('manifest commands without a Bitbybit executor are explicitly unsupported', () => { const facade = createMockFacade() const before = facade.getState().document.version - const state = facade.gui.command.getState('multi-transform') + const state = facade.gui.command.getState('additive-loft') assert.equal(state.status, 'disabled') assert.match(state.reason || '', /business executor/) - facade.gui.command.execute({ commandId: 'multi-transform' }) + facade.gui.command.execute({ commandId: 'additive-loft' }) assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED') assert.equal(facade.getState().document.version, before) }) @@ -1229,6 +1292,46 @@ test('Placement properties are deeply cloned, validated, undoable and persisted' await persistence.dispose() }) +test('MultiTransform tasks validate, deeply clone, undo and persist ordered steps', async () => { + const facade = createMockFacade() + assert.equal(facade.gui.command.getState('multi-transform').status, 'enabled') + facade.gui.command.execute({ commandId: 'multi-transform' }) + const transformations: MultiTransformValue = { steps: [ + { id: 'mirror-1', type: 'mirrored', plane: 'XZ plane' }, + { id: 'polar-1', type: 'polar', occurrences: 3, angle: 180, axis: 'Normal' }, + ] } + facade.task.update({ transformations }) + facade.task.apply() + transformations.steps[1] = { id: 'polar-1', type: 'polar', occurrences: 9, angle: 90, axis: 'Horizontal' } + const created = facade.app.document.getObject('multi-transform') + assert.equal(created?.typeId, 'PartDesign::MultiTransform') + assert.equal(created?.properties.find((property) => property.name === 'Base')?.value, 'pad') + assert.deepEqual(created?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [ + { id: 'mirror-1', type: 'mirrored', plane: 'XZ plane' }, + { id: 'polar-1', type: 'polar', occurrences: 3, angle: 180, axis: 'Normal' }, + ] }) + assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'multi-transform') + + const revised: MultiTransformValue = { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] } + facade.app.document.setProperty({ objectId: 'multi-transform', propertyName: 'Transformations', value: revised }) + revised.steps[0] = { id: 'linear-1', type: 'linear', occurrences: 2, length: 1, direction: 'Horizontal' } + assert.deepEqual(facade.app.document.getObject('multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] }) + assert.throws(() => facade.app.document.setProperty({ objectId: 'multi-transform', propertyName: 'Transformations', value: { steps: [ + { id: 'a', type: 'linear', occurrences: 10, length: 1, direction: 'Horizontal' }, + { id: 'b', type: 'polar', occurrences: 10, angle: 360, axis: 'Normal' }, + { id: 'c', type: 'mirrored', plane: 'YZ plane' }, + ] } }), /more than 100/) + facade.history.undo() + assert.equal((facade.app.document.getObject('multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value as MultiTransformValue).steps[1].type, 'polar') + facade.history.redo() + + const persistence = createSqliteProjectPersistence() + await persistence.save(facade.app.document.getActive()) + const loaded = await persistence.load('doc-pump-housing') + assert.deepEqual(loaded?.objects.find((object) => object.id === 'multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] }) + await persistence.dispose() +}) + test('LinkSub properties preserve versioned TopoRefs and create topology dependencies', async () => { const facade = createMockFacade() const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }