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 tabUse the right Task panel for command parameters. This tab stays available for selection and dependency context.
}
@@ -382,7 +382,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
return <>{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
@@ -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 ? <>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 ? <>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 })