feat: connect Part primitives and booleans
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"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", "linear-pattern", "polar-pattern"] },
|
||||||
"Part": { "status": "ui-manifest", "level": "unsupported", "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"] },
|
||||||
"TechDraw": { "status": "ui-prototype", "level": "read-only", "commands": ["new-page", "view-part", "section-view", "dimension", "annotation"] },
|
"TechDraw": { "status": "ui-prototype", "level": "read-only", "commands": ["new-page", "view-part", "section-view", "dimension", "annotation"] },
|
||||||
@@ -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", "union", "cut", "intersection", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "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", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
|
||||||
"document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "undo-redo"] },
|
"document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "undo-redo"] },
|
||||||
"sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "persistence"] },
|
"sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "persistence"] },
|
||||||
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
|
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
|
|||||||
| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 |
|
| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 |
|
||||||
| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
|
| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
|
||||||
| FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型;未知类型为 proxy,Python 类型为 blocked;脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip |
|
| FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型;未知类型为 proxy,Python 类型为 blocked;脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip |
|
||||||
| P1-02 命令准确性 | manifest 仍可完整展示;未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | `linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |
|
| P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、`linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |
|
||||||
|
|
||||||
后续按以下顺序连续开发:
|
后续按以下顺序连续开发:
|
||||||
|
|
||||||
|
|||||||
@@ -1409,7 +1409,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
|
|||||||
| SQLite WASM + OPFS 持久化 | `IN PROGRESS` | schema v4、OPFS/内存降级、单写者队列、内容寻址资源、自动保存已落地;迁移回滚、配额回收、跨标签写者和崩溃恢复仍待 P2 |
|
| SQLite WASM + OPFS 持久化 | `IN PROGRESS` | schema v4、OPFS/内存降级、单写者队列、内容寻址资源、自动保存已落地;迁移回滚、配额回收、跨标签写者和崩溃恢复仍待 P2 |
|
||||||
| 标准格式与 FCStd 兼容 | `IN PROGRESS` | BitBybit STEP/ASCII STL 导出已接入;`src/facade/fcstd.ts` 已完成安全 ZIP 预检和 Document.xml 元数据/代理报告,FCStd 对象映射、BRep 读写和 round-trip 仍待 P7 |
|
| 标准格式与 FCStd 兼容 | `IN PROGRESS` | BitBybit STEP/ASCII STL 导出已接入;`src/facade/fcstd.ts` 已完成安全 ZIP 预检和 Document.xml 元数据/代理报告,FCStd 对象映射、BRep 读写和 round-trip 仍待 P7 |
|
||||||
| Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts`、`scripts/check-facade-boundary.mjs` |
|
| Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts`、`scripts/check-facade-boundary.mjs` |
|
||||||
| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 42 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
|
| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 44 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
|
||||||
|
|
||||||
### 16.3 阶段门和交付节奏
|
### 16.3 阶段门和交付节奏
|
||||||
|
|
||||||
@@ -1601,7 +1601,7 @@ P0 基线/治理
|
|||||||
| P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI |
|
| P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI |
|
||||||
| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/Revolution,Revolution 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/Revolution,Revolution Angle/Reversed 已接入,Fillet/Chamfer 也有 Shape 缓存回写;Up to face、FCStd Shape 持久化仍待完成 |
|
||||||
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
|
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
|
||||||
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 42 个场景,包括 async generation 重算、DAG 分层并行、OCCT 特征执行器、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;E2E/黄金几何待补齐 |
|
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 44 个场景,包括 Part 命令、基本体/布尔重算、async generation 重算、DAG 分层并行、OCCT 特征执行器、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;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 构建验证。
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,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('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
|
const 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.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 },
|
||||||
@@ -58,6 +58,29 @@ const viewProperties = (): ObjectPropertySnapshot[] => [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||||
|
if (item.id.startsWith('box')) return [
|
||||||
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||||
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||||
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||||
|
]
|
||||||
|
if (item.id.startsWith('cylinder')) return [
|
||||||
|
{ name: 'Radius', label: 'Radius', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
|
||||||
|
{ name: 'Height', label: 'Height', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||||
|
{ name: 'Angle', label: 'Angle', group: 'Cylinder', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
|
||||||
|
]
|
||||||
|
if (item.id.startsWith('sphere')) return [
|
||||||
|
{ name: 'Radius', label: 'Radius', group: 'Sphere', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
|
||||||
|
]
|
||||||
|
if (item.id.startsWith('cone')) return [
|
||||||
|
{ name: 'Radius1', label: 'Bottom radius', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
|
||||||
|
{ name: 'Radius2', label: 'Top radius', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true },
|
||||||
|
{ name: 'Height', label: 'Height', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||||
|
{ name: 'Angle', label: 'Angle', group: 'Cone', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
|
||||||
|
]
|
||||||
|
if (item.id.startsWith('union') || item.id.startsWith('cut') || item.id.startsWith('intersection')) return [
|
||||||
|
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
|
||||||
|
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
|
||||||
|
]
|
||||||
if (item.id.startsWith('pad')) return [
|
if (item.id.startsWith('pad')) return [
|
||||||
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 42, unit: 'mm', recompute: true, expression: '42 mm' },
|
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 42, unit: 'mm', recompute: true, expression: '42 mm' },
|
||||||
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
|
||||||
@@ -140,10 +163,11 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
|
|||||||
return document
|
return document
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
|
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'union', 'cut', 'intersection', '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', 'solve-sketch'])
|
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'primitive', 'union', 'cut', 'intersection', 'solve-sketch'])
|
||||||
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer'])
|
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer'])
|
||||||
|
const partCommands = new Set(['primitive', 'union', 'cut', 'intersection'])
|
||||||
const featureCommands: Record<string, { label: string; detail: string }> = {
|
const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||||
'create-body': { label: 'Body', detail: 'Part Design body' },
|
'create-body': { label: 'Body', detail: 'Part Design body' },
|
||||||
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
|
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
|
||||||
@@ -152,6 +176,10 @@ 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' },
|
||||||
|
primitive: { label: 'Box', detail: '10 × 10 × 10 mm' },
|
||||||
|
union: { label: 'Union', detail: 'Boolean fuse' },
|
||||||
|
cut: { label: 'Cut', detail: 'Boolean difference' },
|
||||||
|
intersection: { label: 'Intersection', detail: 'Boolean common' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => {
|
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => {
|
||||||
@@ -159,6 +187,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
|
|||||||
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
|
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
|
||||||
if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' }
|
if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' }
|
||||||
if (partDesignCommands.has(commandId) && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: `Switch to Part Design to use ${commandId}.` }
|
if (partDesignCommands.has(commandId) && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: `Switch to Part Design to use ${commandId}.` }
|
||||||
|
if (partCommands.has(commandId) && activeWorkbench !== 'Part') return { id: commandId, status: 'disabled', reason: `Switch to Part to use ${commandId}.` }
|
||||||
if ((commandId === 'new-sketch' || commandId === 'solve-sketch') && activeWorkbench !== 'Sketcher') return { id: commandId, status: 'disabled', reason: `Switch to Sketcher to use ${commandId}.` }
|
if ((commandId === 'new-sketch' || commandId === 'solve-sketch') && activeWorkbench !== 'Sketcher') return { id: commandId, status: 'disabled', reason: `Switch to Sketcher to use ${commandId}.` }
|
||||||
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
|
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
|
||||||
return { id: commandId, status: 'enabled' }
|
return { id: commandId, status: 'enabled' }
|
||||||
@@ -265,13 +294,18 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
|||||||
const type = commandId === 'create-sketch' ? 'sketch' : commandId === 'create-body' ? 'body' : 'feature'
|
const type = commandId === 'create-sketch' ? 'sketch' : commandId === 'create-body' ? 'body' : 'feature'
|
||||||
const item: ModelTreeItem = { id: objectId, label: definition.label, type, state: type === 'body' ? 'active' : 'valid', detail: definition.detail }
|
const item: ModelTreeItem = { id: objectId, label: definition.label, type, state: type === 'body' ? 'active' : 'valid', detail: definition.detail }
|
||||||
const tree: ModelTreeItem[] = document.tree.map((entry) => ({ ...entry, children: entry.children ? [...entry.children] : undefined }))
|
const tree: ModelTreeItem[] = document.tree.map((entry) => ({ ...entry, children: entry.children ? [...entry.children] : undefined }))
|
||||||
if (type === 'body') tree.push({ ...item, children: [] })
|
const isPartObject = partCommands.has(commandId)
|
||||||
|
if (type === 'body' || isPartObject) tree.push({ ...item, children: type === 'body' ? [] : undefined })
|
||||||
else {
|
else {
|
||||||
const body = tree.find((entry) => entry.type === 'body')
|
const body = tree.find((entry) => entry.type === 'body')
|
||||||
if (body) body.children = [...(body.children || []), objectId]
|
if (body) body.children = [...(body.children || []), objectId]
|
||||||
tree.push(item)
|
tree.push(item)
|
||||||
}
|
}
|
||||||
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), createObjectSnapshot(item)]
|
const objectSnapshot = createObjectSnapshot(item)
|
||||||
|
if (isPartObject && commandId !== 'primitive') {
|
||||||
|
objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property)
|
||||||
|
}
|
||||||
|
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot]
|
||||||
const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects }
|
const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects }
|
||||||
nextDocument.dependencies = collectDependencyEdges(nextDocument)
|
nextDocument.dependencies = collectDependencyEdges(nextDocument)
|
||||||
nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0)
|
nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0)
|
||||||
|
|||||||
@@ -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 { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types'
|
import type { BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types'
|
||||||
|
|
||||||
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
|
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
|
||||||
|
|
||||||
@@ -31,6 +31,13 @@ export type RecomputeNodeExecutor = (
|
|||||||
|
|
||||||
export type RecomputeGeometryRuntime = {
|
export type RecomputeGeometryRuntime = {
|
||||||
capabilities(): { status: string }
|
capabilities(): { status: string }
|
||||||
|
createBox(input: CreateBoxInput): Promise<ShapeHandle>
|
||||||
|
createCylinder(input: CreateCylinderInput): Promise<ShapeHandle>
|
||||||
|
createSphere(input: CreateSphereInput): Promise<ShapeHandle>
|
||||||
|
createCone(input: CreateConeInput): Promise<ShapeHandle>
|
||||||
|
union(input: BooleanUnionInput): Promise<ShapeHandle>
|
||||||
|
cut(input: BooleanCutInput): Promise<ShapeHandle>
|
||||||
|
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>
|
||||||
pad(input: PadInput): Promise<ShapeHandle>
|
pad(input: PadInput): Promise<ShapeHandle>
|
||||||
pocket(input: PocketInput): Promise<ShapeHandle>
|
pocket(input: PocketInput): Promise<ShapeHandle>
|
||||||
revolution(input: RevolutionInput): Promise<ShapeHandle>
|
revolution(input: RevolutionInput): Promise<ShapeHandle>
|
||||||
@@ -273,7 +280,7 @@ export const createFacadeGeometryRecomputeExecutor = (
|
|||||||
): RecomputeNodeExecutor => async (object, document, context) => {
|
): RecomputeNodeExecutor => async (object, document, context) => {
|
||||||
const base = await executeFacadeRecomputeNode(object, document, context)
|
const base = await executeFacadeRecomputeNode(object, document, context)
|
||||||
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 (!['PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].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'].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
|
||||||
@@ -288,7 +295,24 @@ export const createFacadeGeometryRecomputeExecutor = (
|
|||||||
const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion }
|
const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion }
|
||||||
try {
|
try {
|
||||||
let result: ShapeHandle
|
let result: ShapeHandle
|
||||||
if (object.typeId === 'PartDesign::Pad') {
|
if (object.typeId === 'Part::Box') {
|
||||||
|
result = await geometry.createBox({ ...documentContext, width: numberProperty('Width', 10), length: numberProperty('Length', 10), height: numberProperty('Height', 10) })
|
||||||
|
} else if (object.typeId === 'Part::Cylinder') {
|
||||||
|
result = await geometry.createCylinder({ ...documentContext, radius: numberProperty('Radius', 5), height: numberProperty('Height', 10), angle: numberProperty('Angle', 360) })
|
||||||
|
} else if (object.typeId === 'Part::Sphere') {
|
||||||
|
result = await geometry.createSphere({ ...documentContext, radius: numberProperty('Radius', 5) })
|
||||||
|
} else if (object.typeId === 'Part::Cone') {
|
||||||
|
result = await geometry.createCone({ ...documentContext, radius1: numberProperty('Radius1', 5), radius2: numberProperty('Radius2', 0), height: numberProperty('Height', 10), angle: numberProperty('Angle', 360) })
|
||||||
|
} else if (object.typeId === 'Part::Fuse' || object.typeId === 'Part::Cut' || object.typeId === 'Part::Common') {
|
||||||
|
const baseObject = linkedObject(object, 'Base', document)
|
||||||
|
const toolObject = linkedObject(object, 'Tool', document)
|
||||||
|
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
|
||||||
|
const toolShape = toolObject ? shapes.get(toolObject.id) : undefined
|
||||||
|
if (!baseShape || !toolShape) return geometryFailure(object.id, 'BOOLEAN_SHAPE_MISSING', 'Boolean operation requires recomputed Base and Tool shapes.')
|
||||||
|
if (object.typeId === 'Part::Fuse') result = await geometry.union({ ...documentContext, shapes: [baseShape, toolShape] })
|
||||||
|
else if (object.typeId === 'Part::Cut') result = await geometry.cut({ ...documentContext, base: baseShape, tools: [toolShape] })
|
||||||
|
else result = await geometry.intersection({ ...documentContext, shapes: [baseShape, toolShape] })
|
||||||
|
} else if (object.typeId === 'PartDesign::Pad') {
|
||||||
result = await geometry.pad({ ...documentContext, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, symmetricToPlane: propertyValue(object, 'Midplane') === true })
|
result = await geometry.pad({ ...documentContext, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, symmetricToPlane: propertyValue(object, 'Midplane') === true })
|
||||||
} else if (object.typeId === 'PartDesign::Pocket') {
|
} else if (object.typeId === 'PartDesign::Pocket') {
|
||||||
const pocketType = propertyValue(object, 'Type')
|
const pocketType = propertyValue(object, 'Type')
|
||||||
|
|||||||
@@ -351,6 +351,13 @@ test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains t
|
|||||||
let padShouldFail = false
|
let padShouldFail = false
|
||||||
const runtime: RecomputeGeometryRuntime = {
|
const runtime: RecomputeGeometryRuntime = {
|
||||||
capabilities: () => ({ status: 'ready' }),
|
capabilities: () => ({ status: 'ready' }),
|
||||||
|
createBox: async () => shape('box-shape'),
|
||||||
|
createCylinder: async () => shape('cylinder-shape'),
|
||||||
|
createSphere: async () => shape('sphere-shape'),
|
||||||
|
createCone: async () => shape('cone-shape'),
|
||||||
|
union: async () => shape('union-shape'),
|
||||||
|
cut: async () => shape('cut-shape'),
|
||||||
|
intersection: async () => shape('intersection-shape'),
|
||||||
pad: async (input) => { calls.push(`pad:${input.profile.outer.length}`); if (padShouldFail) throw new Error('OCCT pad failed'); return shape('pad-shape') },
|
pad: async (input) => { calls.push(`pad:${input.profile.outer.length}`); if (padShouldFail) throw new Error('OCCT pad failed'); return shape('pad-shape') },
|
||||||
pocket: async (input) => { calls.push(`pocket:${input.base.id}:${String(input.throughAll)}`); return shape('pocket-shape') },
|
pocket: async (input) => { calls.push(`pocket:${input.base.id}:${String(input.throughAll)}`); return shape('pocket-shape') },
|
||||||
revolution: async (input) => { calls.push(`revolution:${input.angle}:${input.axisDirection?.[1]}`); return shape('revolution-shape') },
|
revolution: async (input) => { calls.push(`revolution:${input.angle}:${input.axisDirection?.[1]}`); return shape('revolution-shape') },
|
||||||
@@ -409,6 +416,53 @@ test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains t
|
|||||||
assert.ok(calls.includes('revolution:270:-1'))
|
assert.ok(calls.includes('revolution:270:-1'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Part primitive and boolean nodes execute through the same Shape cache', async () => {
|
||||||
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-part', documentVersion: 2 })
|
||||||
|
const calls: string[] = []
|
||||||
|
const runtime: RecomputeGeometryRuntime = {
|
||||||
|
capabilities: () => ({ status: 'ready' }),
|
||||||
|
createBox: async (input) => { calls.push(`box:${input.width}:${input.length}:${input.height}`); return shape('box-shape') },
|
||||||
|
createCylinder: async () => shape('cylinder-shape'),
|
||||||
|
createSphere: async () => shape('sphere-shape'),
|
||||||
|
createCone: async () => shape('cone-shape'),
|
||||||
|
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('fuse-shape') },
|
||||||
|
cut: async () => shape('cut-shape'),
|
||||||
|
intersection: async () => shape('common-shape'),
|
||||||
|
pad: async () => shape('pad-shape'),
|
||||||
|
pocket: async () => shape('pocket-shape'),
|
||||||
|
revolution: async () => shape('revolution-shape'),
|
||||||
|
fillet: async () => shape('fillet-shape'),
|
||||||
|
chamfer: async () => shape('chamfer-shape'),
|
||||||
|
release: async () => undefined,
|
||||||
|
}
|
||||||
|
const boxProperties = (width: number) => [
|
||||||
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: width },
|
||||||
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 4 },
|
||||||
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5 },
|
||||||
|
]
|
||||||
|
const document: DocumentSnapshot = {
|
||||||
|
id: 'doc-part', label: 'Part fixture', version: 2, dirty: true, readOnly: false, units: 'mm',
|
||||||
|
tree: [{ id: 'boxA', label: 'Box', type: 'feature', state: 'dirty' }, { id: 'boxB', label: 'Box001', type: 'feature', state: 'dirty' }, { id: 'fuse', label: 'Union', type: 'feature', state: 'dirty' }],
|
||||||
|
objects: [
|
||||||
|
{ id: 'boxA', typeId: 'Part::Box', properties: boxProperties(2) },
|
||||||
|
{ id: 'boxB', typeId: 'Part::Box', properties: boxProperties(3) },
|
||||||
|
{ id: 'fuse', typeId: 'Part::Fuse', properties: [
|
||||||
|
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink' as const, value: 'boxA' },
|
||||||
|
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink' as const, value: 'boxB' },
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
dependencies: [{ sourceId: 'fuse', targetId: 'boxA', relation: 'link' }, { sourceId: 'fuse', targetId: 'boxB', relation: 'link' }],
|
||||||
|
recompute: { generation: 0, status: 'idle', objectStates: { boxA: 'touched', boxB: 'touched', fuse: 'touched' }, dirtyObjects: ['boxA', 'boxB'], order: [], errors: [] },
|
||||||
|
}
|
||||||
|
const shapes = new Map<string, ShapeHandle>()
|
||||||
|
const result = await new RecomputeCoordinator(createFacadeGeometryRecomputeExecutor(runtime, shapes), () => 2).run(document)
|
||||||
|
assert.equal(result.status, 'completed')
|
||||||
|
assert.equal(shapes.get('boxA')?.id, 'box-shape')
|
||||||
|
assert.equal(shapes.get('fuse')?.id, 'fuse-shape')
|
||||||
|
assert.ok(calls.includes('box:2:4:5'))
|
||||||
|
assert.ok(calls.includes('union:box-shape,box-shape'))
|
||||||
|
})
|
||||||
|
|
||||||
test('facade async recompute commits only an accepted generation', async () => {
|
test('facade async recompute commits only an accepted generation', async () => {
|
||||||
const facade = createMockFacade()
|
const facade = createMockFacade()
|
||||||
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })
|
||||||
@@ -728,3 +782,23 @@ test('Part Design feature tasks commit a document object and remain undoable', (
|
|||||||
facade.app.document.setProperty({ objectId: 'revolution', propertyName: 'Angle', value: 180 })
|
facade.app.document.setProperty({ objectId: 'revolution', propertyName: 'Angle', value: 180 })
|
||||||
assert.equal(facade.app.document.getObject('revolution')?.properties.find((property) => property.name === 'Angle')?.value, 180)
|
assert.equal(facade.app.document.getObject('revolution')?.properties.find((property) => property.name === 'Angle')?.value, 180)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Part workbench commands create primitive and boolean document objects', () => {
|
||||||
|
const facade = createMockFacade()
|
||||||
|
facade.gui.workbench.setActive('Part')
|
||||||
|
assert.equal(facade.gui.command.getState('primitive').status, 'enabled')
|
||||||
|
facade.gui.command.execute({ commandId: 'primitive' })
|
||||||
|
facade.task.apply()
|
||||||
|
assert.equal(facade.app.document.getObject('box')?.typeId, 'Part::Box')
|
||||||
|
facade.gui.command.execute({ commandId: 'primitive' })
|
||||||
|
facade.task.apply()
|
||||||
|
assert.equal(facade.app.document.getObject('box001')?.typeId, 'Part::Box')
|
||||||
|
|
||||||
|
assert.equal(facade.gui.command.getState('union').status, 'enabled')
|
||||||
|
facade.gui.command.execute({ commandId: 'union' })
|
||||||
|
facade.task.apply()
|
||||||
|
assert.equal(facade.app.document.getObject('union')?.typeId, 'Part::Fuse')
|
||||||
|
facade.app.document.setProperty({ objectId: 'union', propertyName: 'Base', value: 'box' })
|
||||||
|
facade.app.document.setProperty({ objectId: 'union', propertyName: 'Tool', value: 'box001' })
|
||||||
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'union').map((edge) => edge.targetId).sort(), ['box', 'box001'])
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user