diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 76f8a98..8a7dd5d 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -324,7 +324,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 | 任务 | 实施措施 | 验证结果 | 剩余边界 | |---|---|---|---| -| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation;后发运行取消前发;节点完成后校验 AbortSignal、文档 ID/版本和当前 generation;结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度 | 取消、过期版本、依赖失败传播、独立节点并行和 Facade 提交测试通过 | 每个 PartDesign 特征尚未绑定真实 OCCT 输入/输出 Shape;同层并行目前是调度层并发,不等同于每节点独立 Worker;尚未实现最近有效 Shape 保留 | +| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation;后发运行取消前发;节点完成后校验 AbortSignal、文档 ID/版本和当前 generation;结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度;可选的 `createFacadeGeometryRecomputeExecutor` 在 OCCT Worker ready 时执行真实 Pad/Pocket/Fillet/Chamfer | 取消、过期版本、依赖失败传播、独立节点并行、闭合 Sketch 特征链和 Facade 提交测试通过 | 默认数据模型仍不持久化 ShapeHandle;Through all/Up to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape | | DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 | | SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 | | FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 | @@ -333,8 +333,8 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 后续按以下顺序连续开发: -1. `P4-06/DAG-08`:同步/异步重算统一状态机,增加失败保留最近有效 Shape、抑制、恢复上游后的最小闭包重算和诊断树。 -2. `P3-04/P3-05`:建立 Pad/Pocket/Revolution/Fillet/Chamfer 参数到 OCCT Worker 的输入适配和 Shape 资源回写;Pattern/Hole 继续保持 disabled,直到真实 executor 可复现。 +1. `P4-06/DAG-08`:同步/异步重算统一状态机,增加抑制、恢复上游后的最小闭包重算和诊断树;最近有效 Shape 缓存已进入 Facade,但尚未持久化。 +2. `P3-04/P3-05`:Pad/Pocket/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写;继续补齐 Through all/Up to face、Revolution 参数化、拓扑历史和 Pattern/Hole,未完成前保持 disabled。 3. `TSN-03/05/07/08`:补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。 4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM,按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。 5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index c0fb2af..0822cb1 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1409,7 +1409,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 | 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 | | Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts`、`scripts/check-facade-boundary.mjs` | -| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 35 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 | +| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 42 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 | ### 16.3 阶段门和交付节奏 @@ -1466,7 +1466,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 | P3-01 | 复现 FreeCAD/OCCT/BitBybit 构建 | WASM 构建脚本、资源清单 | G0/G1 | 干净环境生成同一 hash | | P3-02 | 定义 ShapeHandle、SubshapeRef、MeshAsset | 句柄/拓扑/三角化类型 | P1-04 | 不暴露底层指针或临时索引 | | P3-03 | 实现基本体和变换 | Box、Cylinder、Sphere、Cone、Placement | P3-01 | 尺寸和包围盒误差在预算内 | -| P3-04 | 实现 Boolean、Pad/Pocket、Revolution | Kernel Adapter、诊断 | P3-03 | 可取消,失败保留有效结果 | +| P3-04 | 实现 Boolean、Pad/Pocket、Revolution | Kernel Adapter、诊断;Pocket `throughAll` 基体范围计算 | P3-03 | 可取消,失败保留有效结果;`Up to face` 未实现时必须返回明确诊断 | | P3-05 | 实现 Fillet、Chamfer、Pattern、Hole | 特征执行器 | P3-04 | 错误包含输入和子形状上下文 | | P3-06 | 建立性能、内存和资源回收基准 | benchmark 报告 | P3-03 | 达到预算或形成降级决策 | @@ -1509,7 +1509,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 |---|---|---|---|---| | P7-01 | STEP/IGES/网格导入 | importer、进度、取消 | P3-03/P4-01 | 样例树、单位和形状可检查 | | P7-02 | STEP/IGES/网格/GLB 导出 | exporter、单位/材质策略 | P3-02/P5-02 | 目标工具可重新打开 | -| P7-03 | Web CAD 项目包导入导出 | manifest、hash、schema version | P2-04 | 干净浏览器可恢复 | +| P7-03 | Web CAD 项目包导入导出 | manifest、hash、schema version;Facade Shape 的 STEP/STL 下载 | P2-04 | 干净浏览器可恢复;没有有效 Shape 时导出按钮必须给出重计算诊断 | | P7-04 | FCStd A 档对象映射 | compatibility importer | P4-01/P4-02 | 支持对象有等级,其余有 proxy 报告 | | P7-05 | FCStd B/C 档差异评估 | golden samples、差异报告 | P7-04 | 无静默丢失 | @@ -1599,9 +1599,9 @@ P0 基线/治理 | P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | | P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;`geometry.topology()` 已从 BitBybit 网格生成面/边/点基础签名,OCCT 历史命名仍未实现 | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI | -| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 generation/cancel 协议已完成,真实特征参数执行、Through all/Up to face 和几何结果回写尚未完成 | -| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口预览链会释放 Pad/Pocket 全部 Shape 句柄,对象缓存与文档增量同步尚未实现 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 41 个场景,包括 async generation 重算、DAG 分层并行、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;E2E/黄金几何待补齐 | +| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket,Fillet/Chamfer 也有 Shape 缓存回写;Through all/Up to face、Revolution 参数化和 FCStd Shape 持久化仍待完成 | +| 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/黄金几何待补齐 | 本迭代验证命令:`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 构建验证。 @@ -1619,7 +1619,7 @@ P0 基线/治理 | Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 | | 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter;不会假称 OPFS 已可用 | -仍未通过 G4/G5:自动保存恢复报告、1000 次压力报告、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。 +仍未通过 G4/G5:自动保存恢复报告、1000 次压力报告、崩溃恢复、迁移回滚、完整 FCStd Shape 资源持久化和完整 FreeCAD/OCCT 计算尚未完成;当前 Facade 已有内存 Shape 缓存和 Pad/Pocket/Fillet/Chamfer executor,但这些仍由 P2-02 至 P4-06 的剩余任务负责。 ### 16.11 P3 Bitbybit OCCT 几何运行时验证记录 @@ -1681,7 +1681,7 @@ OCCT Boolean 结果可能与源形状共享底层拓扑。1.1.1 的 `deleteShape | X `[1,2]`、Y `[-1,1]` 面绕 Y 轴 360° Revolution | 212 顶点、208 三角形 | X `[-2,2]`、Y `[-1,1]`、Z 约 `[-1.9854,1.9854]` | | 释放全部特征结果后访问 Pocket 基体 | `PASS` | 基体仍为 24 顶点、12 三角形,包围盒不变 | -Pad/Pocket 的方向向量在 Facade 内归一化,`length` 单独控制尺寸;`reversed` 改变方向,`symmetricToPlane` 将起始面移动到长度中点。Revolution Worker 插件支持任意轴原点和方向,弥补 Bitbybit 1.1.1 高层 `revolve` 固定绕原点轴的限制。当前 Pocket 是长度模式;`Through all`、`Up to face`、双向长度和轮廓支持面映射继续由 P4-02/P4-05 与 P3-04 后续任务实现。 +Pad/Pocket 的方向向量在 Facade 内归一化,`length` 单独控制尺寸;`reversed` 改变方向,`symmetricToPlane` 将起始面移动到长度中点。Revolution Worker 插件支持任意轴原点和方向,弥补 Bitbybit 1.1.1 高层 `revolve` 固定绕原点轴的限制。Pocket 的 `Through all` 现在读取基体网格投影范围,按范围加余量生成对称切削工具;`Up to face`、双向长度和轮廓支持面映射继续由 P4-02/P4-05 与 P3-04 后续任务实现,未支持时返回 `UP_TO_FACE_UNSUPPORTED`,不会退化为固定长度。 ### 16.15 P4-02 Property 元数据与编辑器验证记录 diff --git a/src/App.tsx b/src/App.tsx index 98351cd..b3f30d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -289,7 +289,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set {leftTab === 'model' ?
Property view

{document.tree.find((item) => item.id === selectedObject)?.label || 'No selection'}

:
Combo View task tab

Use the right Task panel for command parameters. This tab stays available for selection and dependency context.

}
- +
Perspective
Grid 10 mm
XYZ
@@ -382,7 +382,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi return <>
{property.label}
{editor}
{property.expression &&
{property.expression}
} } -function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) { +function Viewport({ selectedObject, setSelectedObject, workbench, facade, documentVersion, recomputeGeneration, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; documentVersion: number; recomputeGeneration: number; showNotice: (message: string) => void }) { const hostRef = useRef(null) const adapterRef = useRef(null) useEffect(() => { @@ -392,6 +392,7 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo adapterRef.current = adapter let cancelled = false const shapes: ShapeHandle[] = [] + let ownsShapes = false try { adapter.mount(host) } catch (error) { @@ -404,6 +405,14 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo const capabilities = await facade.geometry.initialize() if (capabilities.status !== 'ready') throw new Error(capabilities.reason || 'OCCT geometry runtime unavailable') const document = facade.app.document.getActive() + const featureIds = [selectedObject, 'fillet', 'pocket', 'pad'].filter(Boolean) + const storedShape = featureIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((shape): shape is ShapeHandle => Boolean(shape)) + if (storedShape) { + const mesh = await facade.geometry.mesh(storedShape, 0.05) + if (!cancelled) adapter.setMesh(mesh) + return + } + ownsShapes = true const profile = { outer: [[-1.35, -0.7, 0], [1.35, -0.7, 0], [1.35, 0.7, 0], [-1.35, 0.7, 0]] as [number, number, number][] } const pad = await facade.geometry.pad({ profile, length: 1.6, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version }) shapes.push(pad) @@ -422,11 +431,11 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo }) return () => { cancelled = true - void Promise.all(shapes.map((entry) => facade.geometry.release(entry))) + if (ownsShapes) void Promise.all(shapes.map((entry) => facade.geometry.release(entry))) adapter.dispose() adapterRef.current = null } - }, [facade, showNotice]) + }, [facade, showNotice, selectedObject, documentVersion, recomputeGeneration]) useEffect(() => adapterRef.current?.setSelection(selectedObject), [selectedObject]) return
{workbench}Body / Fillet
setSelectedObject('')}>
SelectedEdgesDatum
@@ -474,6 +483,8 @@ function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate: function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { const isImport = mode === 'import' const [fcstdReport, setFcstdReport] = useState(null) + const [selectedFormat, setSelectedFormat] = useState<'STEP' | 'STL' | 'GLB / GLTF' | 'Web CAD package'>('STEP') + const [exporting, setExporting] = useState(false) const fileInputRef = useRef(null) const inspectFile = async (file: File) => { if (!file.name.toLowerCase().endsWith('.fcstd')) { showNotice('Only FCStd inspection is connected to this import boundary'); return } @@ -483,7 +494,27 @@ function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' showNotice(`FCStd inspected: ${report.compatibility.level}`) } catch (error) { setFcstdReport(null); showNotice(`FCStd rejected: ${error instanceof Error ? error.message : String(error)}`) } } - return
onNavigate('start')} />
Step 01 / 03

{isImport ? 'Select a CAD file' : 'Select an export format'}

Local only
{isImport ? <>
Drop a model hereor browse from your device { const file = event.target.files?.[0]; if (file) void inspectFile(file); event.currentTarget.value = '' }} />STEP · IGES · STL · OBJ · PLY · FCStd
FCStd is currently a read-only metadata inspection boundary. Python/macros are isolated and never executed.
{fcstdReport &&
{fcstdReport.label} · {fcstdReport.objects.length} objects · {fcstdReport.compatibility.level}; {fcstdReport.compatibility.warnings.join(' ') || 'No compatibility warnings.'}
} :
{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => )}
}
+ const exportModel = async () => { + if (selectedFormat !== 'STEP' && selectedFormat !== 'STL') { showNotice(`${selectedFormat} export is not implemented yet`); return } + const objectIds = ['fillet', 'chamfer', 'pocket', 'pad'] + const shape = objectIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((candidate): candidate is ShapeHandle => Boolean(candidate)) + if (!shape) { showNotice('Recompute a valid Part Design feature before exporting'); return } + setExporting(true) + try { + const result = selectedFormat === 'STEP' ? await facade.geometry.exportStep(shape, `${facade.app.document.getActive().label}.step`) : await facade.geometry.exportStl(shape, `${facade.app.document.getActive().label}.stl`) + const blob = new Blob([result.text], { type: result.mediaType }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = result.fileName + anchor.click() + URL.revokeObjectURL(url) + showNotice(`${result.format.toUpperCase()} export downloaded`) + } catch (error) { + showNotice(`Export failed: ${error instanceof Error ? error.message : String(error)}`) + } finally { setExporting(false) } + } + return
onNavigate('start')} />
Step 01 / 03

{isImport ? 'Select a CAD file' : 'Select an export format'}

Local only
{isImport ? <>
Drop a model hereor browse from your device { const file = event.target.files?.[0]; if (file) void inspectFile(file); event.currentTarget.value = '' }} />STEP · IGES · STL · OBJ · PLY · FCStd
FCStd is currently a read-only metadata inspection boundary. Python/macros are isolated and never executed.
{fcstdReport &&
{fcstdReport.label} · {fcstdReport.objects.length} objects · {fcstdReport.compatibility.level}; {fcstdReport.compatibility.warnings.join(' ') || 'No compatibility warnings.'}
} :
{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => )}
}
} function FlowStep({ index, title, active, complete }: { index: string; title: string; active: boolean; complete: boolean }) { diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts index 67cf589..444dc41 100644 --- a/src/facade/geometryRuntime.ts +++ b/src/facade/geometryRuntime.ts @@ -408,7 +408,11 @@ export class BitbybitGeometryRuntime { validatePocketInput(input) const base = this.resolveShape(input.base).reference const client = await this.readyClient() - const tool = await this.createExtrusion(client, input) + const direction = input.direction ?? [0, 1, 0] + const featureInput = input.throughAll + ? { ...input, length: await this.throughAllLength(client, base, direction), symmetricToPlane: true } + : input + const tool = await this.createExtrusion(client, featureInput) const kernelShape = await client.occt.booleans.difference({ shape: base, shapes: [tool], keepEdges: false }) return this.registerShape(kernelShape, input.documentId, input.documentVersion) } @@ -502,6 +506,25 @@ export class BitbybitGeometryRuntime { return client.occt.operations.extrude({ shape: face, direction: extrusion }) } + private async throughAllLength(client: BitByBitOCCT, shape: KernelShapeReference, direction: Point3) { + const mesh = await client.occt.shapeToMesh({ shape, precision: 0.1, adjustYtoZ: false }) + const directionLength = magnitude(direction) + const normalized = direction.map((coordinate) => coordinate / directionLength) as Point3 + let minimum = Infinity + let maximum = -Infinity + for (const face of mesh.faceList) { + for (let index = 0; index < face.vertexCoord.length; index += 3) { + const point: Point3 = [face.vertexCoord[index], face.vertexCoord[index + 1], face.vertexCoord[index + 2]] + const projection = dot(point, normalized) + minimum = Math.min(minimum, projection) + maximum = Math.max(maximum, projection) + } + } + if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) throw new Error('Through-all pocket cannot determine the base Shape extent.') + const span = Math.max(maximum - minimum, 1) + return span + Math.max(1, span * 0.05) + } + private registerShape(reference: KernelShapeReference, documentId: string, documentVersion: number) { const handle: ShapeHandle = { id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`, diff --git a/src/facade/index.ts b/src/facade/index.ts index 9e0e881..0b7fb93 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -7,7 +7,7 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming' export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher' export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher' -export { executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine' -export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine' +export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine' +export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeGeometryRuntime, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine' export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd' export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } from './fcstd' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index e0b2000..e8ba974 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -16,6 +16,7 @@ import type { SetPropertyInput, SetExpressionInput, RecomputeResult, + ShapeHandle, TaskSnapshot, Unsubscribe, } from './types' @@ -25,7 +26,7 @@ import { ThreeViewportAdapter } from './threeViewport' import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph' import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units' import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher' -import { executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine' +import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine' import { inspectFcstdArchive } from './fcstd' const initialTree: ModelTreeItem[] = [ @@ -38,7 +39,7 @@ const initialTree: ModelTreeItem[] = [ { id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] }, ] -const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' +const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [ { name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label }, @@ -74,6 +75,10 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { { name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true }, { name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true }, ] + if (item.id.startsWith('chamfer')) return [ + { name: 'Distance', label: 'Distance', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2, unit: 'mm', recompute: true }, + { name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true }, + ] if (item.type === 'sketch') return [ { name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: 'XY_Plane', recompute: true }, { name: 'ConstraintStatus', label: 'Solver state', group: 'Constraints', scope: 'data', type: 'App::PropertyString', value: 'Fully constrained', readOnly: true }, @@ -216,12 +221,18 @@ const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable() const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document)) let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] } const recomputeCoordinator = new RecomputeCoordinator( - executeFacadeRecomputeNode, + createFacadeGeometryRecomputeExecutor(geometryRuntime, featureShapes), (documentId) => state.document.id === documentId ? state.document.version : null, ) + const clearFeatureShapes = () => { + const retained = [...featureShapes.values()] + featureShapes.clear() + void Promise.all(retained.map((shape) => geometryRuntime.release(shape))) + } const listeners = new Set() const undoStack: FacadeState[] = [] const redoStack: FacadeState[] = [] @@ -440,7 +451,7 @@ export function createMockFacade(): BitBybitWebCadFacade { emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId } emit({ type: 'command.started', commandId, context }) - if (commandId === 'new-document') commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) + if (commandId === 'new-document') { clearFeatureShapes(); commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) } else if (commandId === 'save') { const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 } commit({ ...state, document: savedDocument }) @@ -458,13 +469,13 @@ export function createMockFacade(): BitBybitWebCadFacade { } const facade: BitBybitWebCadFacade = { - app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, - history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } }, + app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, + history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } }, gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource }, - geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, + geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, } diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts index 317a545..1dfed78 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 { DocumentObjectSnapshot, DocumentSnapshot } from './types' +import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, ShapeHandle } from './types' export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale' @@ -29,6 +29,15 @@ export type RecomputeNodeExecutor = ( context: RecomputeNodeContext, ) => Promise +export type RecomputeGeometryRuntime = { + capabilities(): { status: string } + pad(input: PadInput): Promise + pocket(input: PocketInput): Promise + fillet(input: FilletInput): Promise + chamfer(input: ChamferInput): Promise + release(shape: ShapeHandle): Promise +} + export type RecomputeProgress = { generation: number documentVersion: number @@ -217,3 +226,97 @@ export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object, } return { status: 'success', updatedObject } } + +const propertyValue = (object: DocumentObjectSnapshot, name: string) => object.properties.find((property) => property.name === name)?.value +const linkedObject = (object: DocumentObjectSnapshot, name: string, document: DocumentSnapshot) => { + const value = propertyValue(object, name) + return typeof value === 'string' ? document.objects.find((candidate) => candidate.id === value) : undefined +} + +const pointsEqual = (left: [number, number, number], right: [number, number, number], tolerance = 1e-7) => left.every((value, index) => Math.abs(value - right[index]) <= tolerance) + +const sketchProfile = (sketch: DocumentObjectSnapshot['sketch']): { profile?: PlanarProfile; code?: string; message?: string } => { + if (!sketch) return { code: 'PROFILE_MISSING', message: 'Feature profile does not reference a Sketcher object.' } + const geometry = sketch.geometry.filter((candidate) => !candidate.construction) + if (geometry.some((candidate) => candidate.type !== 'line')) return { code: 'PROFILE_UNSUPPORTED', message: 'OCCT feature recompute currently requires a closed line-loop sketch profile.' } + const segments = geometry.filter((candidate): candidate is Extract => candidate.type === 'line') + if (segments.length < 3) return { code: 'PROFILE_OPEN', message: 'Feature profile requires at least three connected line segments.' } + + const first = segments[0] + const ring: [number, number, number][] = [[first.start.x, first.start.y, 0]] + let current: [number, number, number] = [first.end.x, first.end.y, 0] + const remaining = segments.slice(1) + while (remaining.length > 0 && !pointsEqual(current, ring[0])) { + const index = remaining.findIndex((segment) => pointsEqual([segment.start.x, segment.start.y, 0], current) || pointsEqual([segment.end.x, segment.end.y, 0], current)) + if (index < 0) return { code: 'PROFILE_OPEN', message: 'Feature profile line segments do not form a closed loop.' } + const segment = remaining.splice(index, 1)[0] + if (pointsEqual([segment.start.x, segment.start.y, 0], current)) current = [segment.end.x, segment.end.y, 0] + else current = [segment.start.x, segment.start.y, 0] + ring.push(current) + } + if (!pointsEqual(current, ring[0]) || remaining.length > 0) return { code: 'PROFILE_OPEN', message: 'Feature profile line segments do not form one closed loop.' } + ring.pop() + return { profile: { outer: ring } } +} + +const geometryFailure = (objectId: string, code: string, message: string): RecomputeNodeResult => ({ status: 'failed', errors: [{ objectId, code, message }] }) + +/** + * Adds real OCCT feature execution without putting transient ShapeHandles in the + * persisted document snapshot. The map is deliberately owned by the Facade and + * keeps the last successful shape when a later feature fails. + */ +export const createFacadeGeometryRecomputeExecutor = ( + geometry: RecomputeGeometryRuntime, + shapes: Map = new Map(), +): RecomputeNodeExecutor => async (object, document, context) => { + const base = await executeFacadeRecomputeNode(object, document, context) + if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base + if (!['PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base + + const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' + const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined + const profile = requiresProfile ? sketchProfile(profileObject?.sketch) : { profile: undefined } + if (requiresProfile && !profile.profile) return geometryFailure(object.id, profile.code || 'PROFILE_INVALID', profile.message || 'Feature profile is invalid.') + if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError') + + const numberProperty = (name: string, fallback: number) => { + const value = propertyValue(object, name) + return typeof value === 'number' && Number.isFinite(value) ? value : fallback + } + const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion } + try { + let result: ShapeHandle + 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 }) + } else if (object.typeId === 'PartDesign::Pocket') { + const pocketType = propertyValue(object, 'Type') + if (pocketType === 'Up to face') return geometryFailure(object.id, 'UP_TO_FACE_UNSUPPORTED', 'Pocket Up to face requires a persistent support face and is not implemented yet.') + const baseObject = linkedObject(object, 'Base', document) + const baseShape = baseObject ? shapes.get(baseObject.id) : undefined + if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Pocket base has no valid recomputed Shape.') + result = await geometry.pocket({ ...documentContext, base: baseShape, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, throughAll: pocketType === 'Through all' }) + } else if (object.typeId === 'PartDesign::Fillet') { + const baseObject = linkedObject(object, 'Base', document) + const baseShape = baseObject ? shapes.get(baseObject.id) : undefined + if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Fillet base has no valid recomputed Shape.') + result = await geometry.fillet({ ...documentContext, base: baseShape, radius: numberProperty('Radius', 1) }) + } else { + const baseObject = linkedObject(object, 'Base', document) + const baseShape = baseObject ? shapes.get(baseObject.id) : undefined + if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Chamfer base has no valid recomputed Shape.') + result = await geometry.chamfer({ ...documentContext, base: baseShape, distance: numberProperty('Distance', 1) }) + } + if (context.signal.aborted) { + await geometry.release(result) + throw new DOMException('Recompute cancelled.', 'AbortError') + } + const previous = shapes.get(object.id) + shapes.set(object.id, result) + if (previous && previous.id !== result.id) await geometry.release(previous) + return base + } catch (error) { + if (context.signal.aborted || isAbortError(error)) throw error + return geometryFailure(object.id, 'GEOMETRY_EXECUTION_FAILED', error instanceof Error ? error.message : String(error)) + } +} diff --git a/src/facade/types.ts b/src/facade/types.ts index 6e95231..4280126 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -257,6 +257,7 @@ export type PadInput = GeometryDocumentContext & LinearFeatureParameters export type PocketInput = GeometryDocumentContext & LinearFeatureParameters & { base: ShapeHandle + throughAll?: boolean } export type RevolutionInput = GeometryDocumentContext & { @@ -424,6 +425,7 @@ export interface BitBybitWebCadFacade { mesh(shape: ShapeHandle, precision?: number): Promise subshapes(shape: ShapeHandle, precision?: number): Promise topology(shape: ShapeHandle, precision?: number): Promise + getObjectShape(objectId: string): ShapeHandle | null release(shape: ShapeHandle): Promise dispose(): void } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 7085465..c548b73 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -8,7 +8,7 @@ import { DependencyGraph } from '../src/facade/dependencyGraph' import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units' import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming' import { createSketch, solveSketch } from '../src/facade/sketcher' -import { RecomputeCoordinator } from '../src/facade/recomputeEngine' +import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine' import { inspectFcstdArchive } from '../src/facade/fcstd' import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' import type { DocumentSnapshot, ShapeHandle } from '../src/facade/types' @@ -345,6 +345,59 @@ test('recompute failure marks dependent objects as upstream-failed', async () => assert.ok(result.errors.some((error) => error.code === 'UPSTREAM_FAILED')) }) +test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains the last valid Shape', async () => { + const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-feature', documentVersion: 2 }) + const calls: string[] = [] + let padShouldFail = false + const runtime: RecomputeGeometryRuntime = { + capabilities: () => ({ status: 'ready' }), + pad: async (input) => { calls.push(`pad:${input.profile.outer.length}`); if (padShouldFail) throw new Error('OCCT pad failed'); return shape('pad-shape') }, + pocket: async (input) => { calls.push(`pocket:${input.base.id}:${String(input.throughAll)}`); return shape('pocket-shape') }, + fillet: async () => shape('fillet-shape'), + chamfer: async () => shape('chamfer-shape'), + release: async (released) => { calls.push(`release:${released.id}`) }, + } + const sketch = { id: 'sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch('sketch', [ + { id: 'a', type: 'line' as const, start: { x: -1, y: -1 }, end: { x: 1, y: -1 } }, + { id: 'b', type: 'line' as const, start: { x: 1, y: -1 }, end: { x: 1, y: 1 } }, + { id: 'c', type: 'line' as const, start: { x: 1, y: 1 }, end: { x: -1, y: 1 } }, + { id: 'd', type: 'line' as const, start: { x: -1, y: 1 }, end: { x: -1, y: -1 } }, + ]) } + const pad = { id: 'pad', typeId: 'PartDesign::Pad', properties: [ + { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 10, unit: 'mm' }, + { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' }, + ] } + const pocket = { id: 'pocket', typeId: 'PartDesign::Pocket', properties: [ + { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Through all', options: ['Dimension', 'Through all', 'Up to face'] }, + { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5, unit: 'mm' }, + { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' }, + { name: 'Base', label: 'Base', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' }, + ] } + const document: DocumentSnapshot = { ...recomputeDocumentFixture(), version: 2, objects: [sketch, pad, pocket], dependencies: [ + { sourceId: 'pad', targetId: 'sketch', relation: 'link' }, + { sourceId: 'pocket', targetId: 'pad', relation: 'link' }, + { sourceId: 'pocket', targetId: 'sketch', relation: 'link' }, + ], recompute: { generation: 0, status: 'idle', objectStates: { sketch: 'touched', pad: 'touched', pocket: 'touched' }, dirtyObjects: ['sketch'], order: [], errors: [] } } + const shapes = new Map() + const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes) + const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal } + const result = await new RecomputeCoordinator(executor, () => 2).run(document) + assert.equal(result.status, 'completed') + assert.deepEqual(calls.slice(0, 2), ['pad:3', 'pocket:pad-shape:true']) + assert.equal(shapes.get('pad')?.id, 'pad-shape') + assert.equal(shapes.get('pocket')?.id, 'pocket-shape') + + padShouldFail = true + const failed = await executor(pad, document, context) + assert.equal(failed.status, 'failed') + assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED') + assert.equal(shapes.get('pad')?.id, 'pad-shape') + const upToFace = { ...pocket, properties: pocket.properties.map((property) => property.name === 'Type' ? { ...property, value: 'Up to face' } : property) } + const unsupported = await executor(upToFace, document, context) + assert.equal(unsupported.status, 'failed') + assert.equal(unsupported.errors?.[0].code, 'UP_TO_FACE_UNSUPPORTED') +}) + test('facade async recompute commits only an accepted generation', async () => { const facade = createMockFacade() facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })