feat: expose recompute diagnostic repair tree

This commit is contained in:
2026-08-02 22:56:38 -04:00
parent bc7adb500b
commit aeafc6c607
10 changed files with 265 additions and 18 deletions

View File

@@ -18,7 +18,7 @@
}, },
"facadeCapabilities": { "facadeCapabilities": {
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "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", "shape-check", "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", "root-cause-diagnostics", "repair-branch-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"] }
}, },

View File

@@ -325,8 +325,8 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
| 任务 | 实施措施 | 验证结果 | 剩余边界 | | 任务 | 实施措施 | 验证结果 | 剩余边界 |
|---|---|---|---| |---|---|---|---|
| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation后发运行取消前发节点完成后校验 AbortSignal、文档 ID/版本和当前 generation结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度;可选的 `createFacadeGeometryRecomputeExecutor` 在 OCCT Worker ready 时执行真实 Pad/Pocket/Revolution/Fillet/Chamfer | 取消、过期版本、依赖失败传播、独立节点并行、闭合 Sketch 特征链、Revolution 角度/反向和 Facade 提交测试通过 | 默认数据模型仍不持久化 ShapeHandleUp to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape | | P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation后发运行取消前发节点完成后校验 AbortSignal、文档 ID/版本和当前 generation结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度;可选的 `createFacadeGeometryRecomputeExecutor` 在 OCCT Worker ready 时执行真实 Pad/Pocket/Revolution/Fillet/Chamfer | 取消、过期版本、依赖失败传播、独立节点并行、闭合 Sketch 特征链、Revolution 角度/反向和 Facade 提交测试通过 | 默认数据模型仍不持久化 ShapeHandleUp to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape |
| DAG-08 失败/抑制传播 | 上游 `error` 将下游置为 `upstream-failed`Feature 的可持久化 `Suppressed` 属性将自身置为 `suppressed`、下游置为 `upstream-suppressed`,不误报重算失败;抑制时释放缓存 Shape解除后只重算依赖闭包 | 上游失败、抑制跳过、Shape 释放、保存恢复和解除抑制最小闭包测试通过 | 多配置抑制、Body Tip 重定向、完整 FreeCAD 状态机和 Report 诊断树仍待实现 | | DAG-08 失败/抑制传播 | 上游 `error` 将下游置为 `upstream-failed`Feature 的可持久化 `Suppressed` 属性将自身置为 `suppressed`、下游置为 `upstream-suppressed`,不误报重算失败;抑制时释放缓存 Shape解除后只重算依赖闭包重算错误映射为带版本、generation、根因、依赖路径和修复动作的诊断树 | 上游失败、抑制跳过、Shape 释放、保存恢复、根因树、对象定位和解除错误后的最小闭包修复测试通过 | 多配置抑制、Body Tip 重定向、最近有效 Shape 显式回滚和完整 FreeCAD 状态机仍待实现 |
| P4-06 任务提交与重算闭包 | Task 草稿在 Facade 事务内写入 Boolean Base/Tool 等链接;新对象和依赖闭包进入 `touched/dirty`React OK 通过 `recomputeAsync()` 提交并反馈结果 | Boolean Task 链接/依赖测试、Task dirty 快照和完整验证通过 | Apply 预览仍未持久化临时 Shape多选选择集、抑制对象和完整 FreeCAD Task 校验仍待实现 | | P4-06 任务提交与重算闭包 | Task 草稿在 Facade 事务内写入 Boolean Base/Tool 等链接;新对象和依赖闭包进入 `touched/dirty`React OK 通过 `recomputeAsync()` 提交并反馈结果Facade `diagnostics` 提供根因树和定位/闭包重算/合法抑制修复Diagnostics 页面只做事件投影 | Boolean Task 链接/依赖测试、Task dirty 快照、根因路径和修复后诊断清理通过 | Apply 预览仍未持久化临时 Shape多选选择集、Shape 回滚和完整 FreeCAD Task 校验仍待实现 |
| DAG-07 Body/Tip 与来源链接 | Part Design 特征提交时按来源类型绑定 Profile/Base更新只读 Body.Tip并将 Body 与新特征一并纳入 dirty/recompute 闭包Undo/Redo 恢复 Tip | Pad Task、Body.Tip、Undo/Redo 回归测试通过 | 单实体规则、Tip 抑制/重定向、复杂容器和跨 Body 支持仍待实现 | | DAG-07 Body/Tip 与来源链接 | Part Design 特征提交时按来源类型绑定 Profile/Base更新只读 Body.Tip并将 Body 与新特征一并纳入 dirty/recompute 闭包Undo/Redo 恢复 Tip | Pad Task、Body.Tip、Undo/Redo 回归测试通过 | 单实体规则、Tip 抑制/重定向、复杂容器和跨 Body 支持仍待实现 |
| 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、视图、表达式和参数化对象 |
@@ -338,7 +338,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
后续按以下顺序连续开发: 后续按以下顺序连续开发:
1. `P4-06/DAG-08`:同步/异步重算已共享抑制传播语义,并支持解除抑制后的最小闭包重算继续补齐多配置抑制、Body Tip 重定向和诊断树。最近有效 Shape 缓存已进入 Facade但尚未持久化。 1. `P4-06/DAG-08`:同步/异步重算已共享抑制传播和根因诊断语义,并支持定位、解除错误后的最小闭包重算与合法特征抑制继续补齐多配置抑制、Body Tip 重定向、最近有效 Shape 显式回滚和性能计数器。Shape 缓存已进入 Facade但尚未持久化。
2. `P3-04/P3-05`Pad/Pocket/Revolution/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写Pocket Through all 已接入;继续补齐 Up to face、拓扑历史和 Pattern/Hole未完成前保持 disabled。 2. `P3-04/P3-05`Pad/Pocket/Revolution/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写Pocket Through all 已接入;继续补齐 Up to face、拓扑历史和 Pattern/Hole未完成前保持 disabled。
3. `TSN-03/05/07/08`:补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。 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。 4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。

View File

@@ -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` 已通过 46 个 Facade 测试、入口守卫和构建跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 | | 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 47 个 Facade 测试、入口守卫和构建跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
### 16.3 阶段门和交付节奏 ### 16.3 阶段门和交付节奏
@@ -1590,6 +1590,7 @@ P0 基线/治理
| P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`React 工作区已通过事件投影工作台、选择、文档树和通知 | | P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`React 工作区已通过事件投影工作台、选择、文档树和通知 |
| P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker | | P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker |
| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body新增 Part Design 特征会原子更新 Body.Tip并按 Sketch/实体来源写入 Profile/BaseFeature 支持可持久化抑制、下游跳过、Shape 释放和解除后的最小闭包重算文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、完整容器/Tip 重定向规则和原子崩溃恢复仍待实现 | | P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body新增 Part Design 特征会原子更新 Body.Tip并按 Sketch/实体来源写入 Profile/BaseFeature 支持可持久化抑制、下游跳过、Shape 释放和解除后的最小闭包重算文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、完整容器/Tip 重定向规则和原子崩溃恢复仍待实现 |
| P4-06 诊断树与修复动作 | `PASS (基础闭环)` | 同步/异步重算错误生成带文档版本、generation、根因对象和依赖路径的结构化诊断Facade 提供诊断树以及定位对象、合并当前 dirty 集后重算根因闭包、合法特征抑制动作循环不会错误提供抑制修复Diagnostics 页面通过 Facade 投影并执行动作;最近有效 Shape 显式回滚、复杂修复建议和性能计数器仍待实现 |
| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;基础表达式/单位/Link DAG 和常用数学函数已落地locale、多选 mixed 和持久引用迁移仍待实现 | | P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;基础表达式/单位/Link DAG 和常用数学函数已落地locale、多选 mixed 和持久引用迁移仍待实现 |
| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v4`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写1000 次压力、迁移回滚和恢复演练仍待补齐;跨标签写入已由 P2-05 通过 Web Locks/降级能力标记覆盖 | | P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v4`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写1000 次压力、迁移回滚和恢复演练仍待补齐;跨标签写入已由 P2-05 通过 Web Locks/降级能力标记覆盖 |
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
@@ -1603,7 +1604,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/RevolutionRevolution Angle/Reversed 已接入Fillet/Chamfer 也有 Shape 缓存回写Up to face、FCStd Shape 持久化仍待完成 | | P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/RevolutionRevolution Angle/Reversed 已接入Fillet/Chamfer 也有 Shape 缓存回写Up to face、FCStd Shape 持久化仍待完成 |
| 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` 已覆盖 46 个场景,包括 Part 命令、基本体/布尔重算、async generation 重算、DAG 分层并行、抑制传播/恢复、OCCT 特征执行器、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式E2E/黄金几何待补齐 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 47 个场景,包括 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 构建验证。

View File

@@ -54,7 +54,7 @@ import {
ZoomOut, ZoomOut,
} from 'lucide-react' } from 'lucide-react'
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade' import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId type Workbench = WorkbenchId
@@ -586,6 +586,7 @@ function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page
const [geometry, setGeometry] = useState(() => facade.geometry.capabilities()) const [geometry, setGeometry] = useState(() => facade.geometry.capabilities())
const document = facade.getState().document const document = facade.getState().document
const persistence = facade.project.capabilities() const persistence = facade.project.capabilities()
const diagnosticTree = facade.diagnostics.tree()
const geometryTone = geometry.status === 'ready' ? 'green' : geometry.status === 'failed' || geometry.status === 'unavailable' ? 'amber' : 'cyan' const geometryTone = geometry.status === 'ready' ? 'green' : geometry.status === 'failed' || geometry.status === 'unavailable' ? 'amber' : 'cyan'
const geometryValue = geometry.status === 'ready' ? 'Ready' : geometry.status === 'initializing' ? 'Loading' : geometry.status const geometryValue = geometry.status === 'ready' ? 'Ready' : geometry.status === 'initializing' ? 'Loading' : geometry.status
useEffect(() => { void facade.project.recovery(facade.getState().document.id).then(setRecovery).catch((error: unknown) => showNotice(`Recovery report unavailable: ${error instanceof Error ? error.message : String(error)}`)) }, [facade, showNotice]) useEffect(() => { void facade.project.recovery(facade.getState().document.id).then(setRecovery).catch((error: unknown) => showNotice(`Recovery report unavailable: ${error instanceof Error ? error.message : String(error)}`)) }, [facade, showNotice])
@@ -594,7 +595,24 @@ function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page
void facade.geometry.initialize().then((capabilities) => { if (!disposed) setGeometry(capabilities) }).catch((error: unknown) => { if (!disposed) showNotice(`Geometry capability probe failed: ${error instanceof Error ? error.message : String(error)}`) }) void facade.geometry.initialize().then((capabilities) => { if (!disposed) setGeometry(capabilities) }).catch((error: unknown) => { if (!disposed) showNotice(`Geometry capability probe failed: ${error instanceof Error ? error.message : String(error)}`) })
return () => { disposed = true } return () => { disposed = true }
}, [facade, showNotice]) }, [facade, showNotice])
return <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value={geometryValue} detail={geometry.reason || 'BitBybit OCCT package loads on demand'} tone={geometryTone} icon={Code2} /><HealthCard label="Local storage" value={persistence.mode === 'sqlite-opfs' ? 'SQLite + OPFS' : persistence.mode === 'sqlite-memory' ? 'Memory fallback' : 'Unavailable'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.crossTabWriteLock || 'local queue'}`} tone={persistence.mode === 'unavailable' ? 'amber' : 'cyan'} icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="Three.js 0.185.1" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value={`${document.recompute?.errors.length || 0} warnings`} detail={`${document.label} · v${document.version}`} tone={document.recompute?.errors.length ? 'amber' : 'green'} icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · single public entry" tone="green" /><DiagnosticRow name="Geometry Worker" value={geometryValue} detail={`${geometry.provider} ${geometry.version} · Box, Boolean, feature and export boundaries`} tone={geometryTone} /><DiagnosticRow name="SQLite WASM" value={persistence.sqliteWasm ? 'Worker ready' : 'Memory fallback'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.reason || 'SQLite worker configured'}`} tone={persistence.sqliteWasm ? 'green' : 'cyan'} /><DiagnosticRow name="OPFS" value={persistence.opfs ? 'Available' : 'Fallback'} detail="Cross-origin isolation and browser OPFS capability" tone={persistence.opfs ? 'green' : 'cyan'} /><DiagnosticRow name="Recovery" value={recovery?.integrity || 'Checking'} detail={recovery ? `Last saved v${recovery.lastSavedVersion ?? 'none'}${recovery.warnings.length ? ` · ${recovery.warnings.length} warning(s)` : ''}` : 'Checking SQLite integrity and saved snapshot'} tone={recovery?.integrity === 'ok' ? 'green' : 'cyan'} /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /><DiagnosticRow name="Document warnings" value={String(document.recompute?.errors.length || 0)} detail="Recompute diagnostics from the current document" tone={document.recompute?.errors.length ? 'amber' : 'green'} onClick={() => showNotice('Document diagnostics opened')} /></section></div> return <div className="diagnostics-page">
<PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} />
<div className="health-grid"><HealthCard label="WebAssembly" value={geometryValue} detail={geometry.reason || 'BitBybit OCCT package loads on demand'} tone={geometryTone} icon={Code2} /><HealthCard label="Local storage" value={persistence.mode === 'sqlite-opfs' ? 'SQLite + OPFS' : persistence.mode === 'sqlite-memory' ? 'Memory fallback' : 'Unavailable'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.crossTabWriteLock || 'local queue'}`} tone={persistence.mode === 'unavailable' ? 'amber' : 'cyan'} icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="Three.js 0.185.1" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value={`${document.recompute?.errors.length || 0} warnings`} detail={`${document.label} · v${document.version}`} tone={document.recompute?.errors.length ? 'amber' : 'green'} icon={AlertTriangle} /></div>
<section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · single public entry" tone="green" /><DiagnosticRow name="Geometry Worker" value={geometryValue} detail={`${geometry.provider} ${geometry.version} · Box, Boolean, feature and export boundaries`} tone={geometryTone} /><DiagnosticRow name="SQLite WASM" value={persistence.sqliteWasm ? 'Worker ready' : 'Memory fallback'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.reason || 'SQLite worker configured'}`} tone={persistence.sqliteWasm ? 'green' : 'cyan'} /><DiagnosticRow name="OPFS" value={persistence.opfs ? 'Available' : 'Fallback'} detail="Cross-origin isolation and browser OPFS capability" tone={persistence.opfs ? 'green' : 'cyan'} /><DiagnosticRow name="Recovery" value={recovery?.integrity || 'Checking'} detail={recovery ? `Last saved v${recovery.lastSavedVersion ?? 'none'}${recovery.warnings.length ? ` · ${recovery.warnings.length} warning(s)` : ''}` : 'Checking SQLite integrity and saved snapshot'} tone={recovery?.integrity === 'ok' ? 'green' : 'cyan'} /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /></section>
<section className="diagnostic-tree-panel panel-surface"><div className="section-title"><div><span className="section-kicker">Document</span><h3>Diagnostic tree</h3></div><strong className={diagnosticTree.length ? 'icon-amber' : 'icon-green'}>{diagnosticTree.length} root causes</strong></div>{diagnosticTree.length === 0 ? <div className="diagnostic-empty"><CheckCircle2 size={16} />No active document diagnostics</div> : <div className="diagnostic-tree">{diagnosticTree.map((node) => <DocumentDiagnosticNode key={node.diagnostic.id} node={node} facade={facade} showNotice={showNotice} />)}</div>}</section>
</div>
}
function DocumentDiagnosticNode({ node, facade, showNotice, child = false }: { node: DiagnosticTreeNode; facade: BitBybitWebCadFacade; showNotice: (message: string) => void; child?: boolean }) {
const diagnostic = node.diagnostic
const runRepair = (actionId: 'select-object' | 'recompute-root' | 'suppress-root') => {
void facade.diagnostics.repair(diagnostic.id, actionId).then((result) => showNotice(result.message))
}
return <div className={`document-diagnostic ${child ? 'is-child' : ''}`}>
<div className="document-diagnostic-main"><span className={`diagnostic-severity ${diagnostic.severity}`}><AlertTriangle size={14} /></span><div><strong>{diagnostic.code}</strong><span>{diagnostic.message}</span><small>{diagnostic.objectId || diagnostic.source}{diagnostic.dependencyPath && diagnostic.dependencyPath.length > 1 ? ` · ${diagnostic.dependencyPath.join(' → ')}` : ''}{diagnostic.generation ? ` · generation ${diagnostic.generation}` : ''}</small></div></div>
{diagnostic.repairActions?.length ? <div className="diagnostic-actions">{diagnostic.repairActions.map((action) => <button key={action.id} className="button button-quiet" disabled={!action.enabled} title={action.reason || action.label} onClick={() => runRepair(action.id)}>{action.id === 'select-object' ? <Search size={13} /> : action.id === 'recompute-root' ? <RefreshCw size={13} /> : <Pause size={13} />}{action.label}</button>)}</div> : null}
{node.children.length > 0 ? <div className="diagnostic-children">{node.children.map((entry) => <DocumentDiagnosticNode key={entry.diagnostic.id} node={entry} facade={facade} showNotice={showNotice} child />)}</div> : null}
</div>
} }
function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) { function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {

120
src/facade/diagnostics.ts Normal file
View File

@@ -0,0 +1,120 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import type { Diagnostic, DiagnosticRepairAction, DiagnosticTreeNode, DocumentSnapshot } from './types'
type RecomputeDiagnosticInput = {
document: DocumentSnapshot
generation: number
affected: string[]
objectStates: Record<string, RecomputeState>
errors: Array<{ objectId: string; code: string; message: string }>
}
const cloneAction = (action: DiagnosticRepairAction): DiagnosticRepairAction => ({ ...action })
export const cloneDiagnostic = (diagnostic: Diagnostic): Diagnostic => ({
...diagnostic,
dependencyPath: diagnostic.dependencyPath ? [...diagnostic.dependencyPath] : undefined,
repairActions: diagnostic.repairActions?.map(cloneAction),
})
const rootCausePath = (objectId: string, graph: DependencyGraph, states: Record<string, RecomputeState>) => {
const path = [objectId]
const visited = new Set(path)
let current = objectId
while (states[current] === 'upstream-failed') {
const dependency = graph.dependenciesOf(current).find((candidate) => states[candidate] === 'error' || states[candidate] === 'upstream-failed')
if (!dependency || visited.has(dependency)) break
path.push(dependency)
visited.add(dependency)
current = dependency
}
return path
}
const repairActions = (document: DocumentSnapshot, targetObjectId: string, rootCode?: string): DiagnosticRepairAction[] => {
const target = document.objects.find((object) => object.id === targetObjectId)
const suppressibleFeature = target?.properties.some((property) => property.name === 'Suppressed' && property.type === 'App::PropertyBool') ?? false
const canSuppress = suppressibleFeature && rootCode !== 'DEPENDENCY_CYCLE'
return [
{ id: 'select-object', label: 'Select root object', targetObjectId, enabled: Boolean(target) },
{ id: 'recompute-root', label: 'Recompute affected branch', targetObjectId, enabled: Boolean(target) },
{ id: 'suppress-root', label: 'Suppress failing feature', targetObjectId, enabled: canSuppress, reason: canSuppress ? undefined : rootCode === 'DEPENDENCY_CYCLE' ? 'Dependency cycles must be repaired by changing a reference or expression.' : 'The root object is not a suppressible feature.' },
]
}
export const buildRecomputeDiagnostics = ({ document, generation, affected, objectStates, errors }: RecomputeDiagnosticInput): Diagnostic[] => {
const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id))
const errorsByObject = new Map<string, Array<{ code: string; message: string }>>()
for (const error of errors) {
const entries = errorsByObject.get(error.objectId) ?? []
entries.push({ code: error.code, message: error.message })
errorsByObject.set(error.objectId, entries)
}
const diagnostics: Diagnostic[] = []
for (const objectId of affected) {
const state = objectStates[objectId]
if (state !== 'error' && state !== 'upstream-failed') continue
const path = rootCausePath(objectId, graph, objectStates)
const rootCauseObjectId = path.at(-1) as string
const rootCode = errorsByObject.get(rootCauseObjectId)?.[0]?.code
const objectErrors = errorsByObject.get(objectId)
const entries = objectErrors?.length ? objectErrors : [{ code: 'UPSTREAM_FAILED', message: `Skipped because ${rootCauseObjectId} failed to recompute.` }]
for (const [index, error] of entries.entries()) {
diagnostics.push({
id: `recompute:${document.id}:${generation}:${objectId}:${error.code}:${index}`,
source: 'recompute',
severity: state === 'error' ? 'error' : 'warning',
code: error.code,
message: error.message,
objectId,
documentId: document.id,
documentVersion: document.version,
generation,
rootCauseObjectId,
dependencyPath: path,
repairActions: repairActions(document, rootCauseObjectId, rootCode),
})
}
}
return diagnostics
}
export const buildDiagnosticTree = (diagnostics: Diagnostic[]): DiagnosticTreeNode[] => {
const active = diagnostics.filter((diagnostic) => !diagnostic.resolved).map(cloneDiagnostic)
const childrenByRoot = new Map<string, Diagnostic[]>()
const roots: Diagnostic[] = []
const rootKeys = new Set<string>()
const keyFor = (diagnostic: Diagnostic, objectId = diagnostic.objectId) => `${diagnostic.source}:${diagnostic.documentId ?? ''}:${diagnostic.generation ?? ''}:${objectId ?? diagnostic.id}`
for (const diagnostic of active.filter((candidate) => !candidate.rootCauseObjectId || candidate.objectId === candidate.rootCauseObjectId)) {
const key = keyFor(diagnostic)
if (rootKeys.has(key)) {
const children = childrenByRoot.get(key) ?? []
children.push(diagnostic)
childrenByRoot.set(key, children)
} else {
rootKeys.add(key)
roots.push(diagnostic)
}
}
for (const diagnostic of active.filter((candidate) => candidate.rootCauseObjectId && candidate.objectId !== candidate.rootCauseObjectId)) {
const key = keyFor(diagnostic, diagnostic.rootCauseObjectId)
if (!rootKeys.has(key)) {
roots.push(diagnostic)
rootKeys.add(keyFor(diagnostic))
continue
}
const children = childrenByRoot.get(key) ?? []
children.push(diagnostic)
childrenByRoot.set(key, children)
}
return roots.map((diagnostic) => ({
diagnostic,
children: childrenByRoot.get(keyFor(diagnostic))?.map((child) => ({ diagnostic: child, children: [] })) ?? [],
}))
}
export const replaceRecomputeDiagnostics = (existing: Diagnostic[], documentId: string, affected: string[], next: Diagnostic[]) => {
const affectedIds = new Set(affected)
return [...existing.filter((diagnostic) => diagnostic.source !== 'recompute' || diagnostic.documentId !== documentId || !diagnostic.objectId || !affectedIds.has(diagnostic.objectId)), ...next].map(cloneDiagnostic)
}

View File

@@ -1,9 +1,10 @@
export { createMockFacade } from './mockFacade' export { createMockFacade } from './mockFacade'
export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore' export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export { ThreeViewportAdapter } from './threeViewport' export { ThreeViewportAdapter } from './threeViewport'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime' export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types' export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types'
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming' export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher' export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher' export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'

View File

@@ -4,6 +4,8 @@ import type {
CommandState, CommandState,
DocumentSnapshot, DocumentSnapshot,
Diagnostic, Diagnostic,
DiagnosticRepairAction,
DiagnosticRepairResult,
ExecuteCommandInput, ExecuteCommandInput,
FacadeEvent, FacadeEvent,
FacadeListener, FacadeListener,
@@ -28,6 +30,7 @@ import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimension
import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher' import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine' import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
import { inspectFcstdArchive } from './fcstd' import { inspectFcstdArchive } from './fcstd'
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
const initialTree: ModelTreeItem[] = [ const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] }, { id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
@@ -304,7 +307,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event)) const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
const emitState = () => emit({ type: 'state.changed', state: getState() }) const emitState = () => emit({ type: 'state.changed', state: getState() })
const getState = () => ({ ...state, diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null }) const getState = () => ({ ...state, diagnostics: state.diagnostics.map(cloneDiagnostic), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null })
const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty) autosave.schedule(next.document); emitState() } const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty) autosave.schedule(next.document); emitState() }
const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() } const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() }
const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) } const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
@@ -438,7 +441,11 @@ export function createMockFacade(): BitBybitWebCadFacade {
} }
const nextRecompute = { generation, status: errors.length === 0 ? 'completed' as const : 'failed' as const, objectStates, dirtyObjects: errors.length === 0 ? [] : plan.affected, order: plan.order, errors } const nextRecompute = { generation, status: errors.length === 0 ? 'completed' as const : 'failed' as const, objectStates, dirtyObjects: errors.length === 0 ? [] : plan.affected, order: plan.order, errors }
document.recompute = nextRecompute document.recompute = nextRecompute
state = { ...state, document } const nextDiagnostics = buildRecomputeDiagnostics({ document, generation, affected: plan.affected, objectStates, errors })
const diagnostics = replaceRecomputeDiagnostics(state.diagnostics, document.id, plan.affected, nextDiagnostics)
state = { ...state, document, diagnostics }
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId: `recompute-${generation}`, documentId: document.id, documentVersion: document.version, workbench: state.activeWorkbench }
for (const diagnostic of nextDiagnostics) emit({ type: 'diagnostic.added', diagnostic, context })
if (document.dirty) autosave.schedule(document) if (document.dirty) autosave.schedule(document)
emitState() emitState()
return { ...plan, generation, status: nextRecompute.status, errors } return { ...plan, generation, status: nextRecompute.status, errors }
@@ -464,15 +471,35 @@ export function createMockFacade(): BitBybitWebCadFacade {
generation: result.generation, generation: result.generation,
status: result.status, status: result.status,
objectStates: result.objectStates, objectStates: result.objectStates,
dirtyObjects: result.dirtyObjects, dirtyObjects: [...new Set([...(source.recompute?.dirtyObjects ?? []).filter((objectId) => !result.affected.includes(objectId)), ...result.dirtyObjects])],
order: result.order, order: result.order,
errors: result.errors, errors: result.errors,
} }
state = { ...state, document } const nextDiagnostics = buildRecomputeDiagnostics({ document, generation: result.generation, affected: result.affected, objectStates: result.objectStates, errors: result.errors })
const diagnostics = replaceRecomputeDiagnostics(state.diagnostics, document.id, result.affected, nextDiagnostics)
state = { ...state, document, diagnostics }
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId: `recompute-${result.generation}`, documentId: document.id, documentVersion: document.version, workbench: state.activeWorkbench }
for (const diagnostic of nextDiagnostics) emit({ type: 'diagnostic.added', diagnostic, context })
if (document.dirty) autosave.schedule(document) if (document.dirty) autosave.schedule(document)
emitState() emitState()
return result return result
} }
const repairDiagnostic = async (diagnosticId: string, actionId: DiagnosticRepairAction['id']): Promise<DiagnosticRepairResult> => {
const diagnostic = state.diagnostics.find((candidate) => candidate.id === diagnosticId && !candidate.resolved)
const action = diagnostic?.repairActions?.find((candidate) => candidate.id === actionId)
if (!diagnostic || !action || !action.enabled) return { diagnosticId, actionId, status: 'unavailable', message: action?.reason || 'The diagnostic or repair action is no longer available.' }
if (!state.document.objects.some((object) => object.id === action.targetObjectId)) return { diagnosticId, actionId, status: 'unavailable', message: 'The repair target no longer exists in the active document.' }
if (actionId === 'select-object') {
select(action.targetObjectId)
notify(`Selected diagnostic root ${action.targetObjectId}`)
return { diagnosticId, actionId, status: 'completed', message: 'Root object selected.' }
}
if (actionId === 'suppress-root') setProperty({ objectId: action.targetObjectId, propertyName: 'Suppressed', value: true })
const result = await recomputeDocumentAsync({ dirtyObjectIds: [...new Set([...(state.document.recompute?.dirtyObjects ?? []), action.targetObjectId])] })
const completed = result.status === 'completed'
notify(completed ? 'Diagnostic branch repaired' : 'Diagnostic repair did not complete')
return { diagnosticId, actionId, status: completed ? 'completed' : 'failed', message: completed ? 'The affected dependency branch recomputed successfully.' : `Recompute finished with status ${result.status}.` }
}
const loadDocument = async (documentId: string) => { const loadDocument = async (documentId: string) => {
recomputeCoordinator.cancel() recomputeCoordinator.cancel()
const loaded = await projectPersistence.load(documentId) const loaded = await projectPersistence.load(documentId)
@@ -549,7 +576,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId, selectedTypeId) const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId, selectedTypeId)
if (status.status === 'disabled') { if (status.status === 'disabled') {
const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED' const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED'
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId } const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'command', severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId, documentId: state.document.id, documentVersion: state.document.version }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] } state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
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: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
} }
@@ -559,7 +586,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 } const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 }
commit({ ...state, document: savedDocument }) commit({ ...state, document: savedDocument })
void projectPersistence.save(savedDocument).catch((error: unknown) => { void projectPersistence.save(savedDocument).catch((error: unknown) => {
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'error', code: 'PERSISTENCE_SAVE_FAILED', message: error instanceof Error ? error.message : String(error), requestId } const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'persistence', severity: 'error', code: 'PERSISTENCE_SAVE_FAILED', message: error instanceof Error ? error.message : String(error), requestId, documentId: state.document.id, documentVersion: state.document.version }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] } state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context }); notify('Save failed; export a recovery package') emit({ type: 'diagnostic.added', diagnostic, context }); notify('Save failed; export a recovery package')
}) })
@@ -569,7 +596,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const objectId = state.selectedObjectId const objectId = state.selectedObjectId
const shape = featureShapes.get(objectId) const shape = featureShapes.get(objectId)
const reportFailure = (code: string, message: string) => { const reportFailure = (code: string, message: string) => {
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message, objectId, requestId } const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'geometry', severity: 'warning', code, message, objectId, requestId, documentId: state.document.id, documentVersion: state.document.version }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] } state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context }) emit({ type: 'diagnostic.added', diagnostic, context })
notify(message) notify(message)
@@ -579,7 +606,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const count = topology.faces.length + topology.edges.length + topology.vertices.length const count = topology.faces.length + topology.edges.length + topology.vertices.length
if (count === 0) throw new Error('Shape topology is empty.') if (count === 0) throw new Error('Shape topology is empty.')
const message = `Shape check passed: ${topology.faces.length} faces, ${topology.edges.length} edges, ${topology.vertices.length} vertices` const message = `Shape check passed: ${topology.faces.length} faces, ${topology.edges.length} edges, ${topology.vertices.length} vertices`
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'info', code: 'SHAPE_CHECK_PASSED', message, objectId, requestId } const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'geometry', severity: 'info', code: 'SHAPE_CHECK_PASSED', message, objectId, requestId, documentId: state.document.id, documentVersion: state.document.version }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] } state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context }) emit({ type: 'diagnostic.added', diagnostic, context })
notify(message) notify(message)
@@ -597,11 +624,12 @@ export function createMockFacade(): BitBybitWebCadFacade {
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, 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() } }, task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
diagnostics: { list: () => state.diagnostics.map(cloneDiagnostic), tree: () => buildDiagnosticTree(state.diagnostics), repair: repairDiagnostic },
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), 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 }, project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), 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), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } }, geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
viewport: { createAdapter: () => new ThreeViewportAdapter() }, viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
} }
void projectPersistence.initialize().then((nextCapabilities) => { state = { ...state, persistence: nextCapabilities }; emitState() }).catch((error: unknown) => { const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'PERSISTENCE_INIT_FAILED', message: error instanceof Error ? error.message : String(error) }; state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }; emit({ type: 'diagnostic.added', diagnostic, context: { apiVersion: state.apiVersion, requestId: `req-${++requestSequence}`, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } }) }) void projectPersistence.initialize().then((nextCapabilities) => { state = { ...state, persistence: nextCapabilities }; emitState() }).catch((error: unknown) => { const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'persistence', severity: 'warning', code: 'PERSISTENCE_INIT_FAILED', message: error instanceof Error ? error.message : String(error), documentId: state.document.id, documentVersion: state.document.version }; state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }; emit({ type: 'diagnostic.added', diagnostic, context: { apiVersion: state.apiVersion, requestId: `req-${++requestSequence}`, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } }) })
return facade return facade
} }

View File

@@ -293,11 +293,39 @@ export type FacadeRequestContext = {
export type Diagnostic = { export type Diagnostic = {
id: string id: string
source: 'command' | 'recompute' | 'geometry' | 'persistence' | 'system'
severity: 'info' | 'warning' | 'error' severity: 'info' | 'warning' | 'error'
code: string code: string
message: string message: string
objectId?: string objectId?: string
requestId?: string requestId?: string
documentId?: string
documentVersion?: number
generation?: number
rootCauseObjectId?: string
dependencyPath?: string[]
repairActions?: DiagnosticRepairAction[]
resolved?: boolean
}
export type DiagnosticRepairAction = {
id: 'select-object' | 'recompute-root' | 'suppress-root'
label: string
targetObjectId: string
enabled: boolean
reason?: string
}
export type DiagnosticTreeNode = {
diagnostic: Diagnostic
children: DiagnosticTreeNode[]
}
export type DiagnosticRepairResult = {
diagnosticId: string
actionId: DiagnosticRepairAction['id']
status: 'completed' | 'failed' | 'unavailable'
message: string
} }
export type TaskSnapshot = { export type TaskSnapshot = {
@@ -400,6 +428,11 @@ export interface BitBybitWebCadFacade {
apply(): void apply(): void
cancel(): void cancel(): void
} }
readonly diagnostics: {
list(): Diagnostic[]
tree(): DiagnosticTreeNode[]
repair(diagnosticId: string, actionId: DiagnosticRepairAction['id']): Promise<DiagnosticRepairResult>
}
readonly project: { readonly project: {
capabilities(): PersistenceCapabilities capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe

View File

@@ -269,6 +269,23 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.diagnostic-value { font-size: 10px; text-align: right; } .diagnostic-value { font-size: 10px; text-align: right; }
.diagnostic-value.green { color: var(--green); }.diagnostic-value.cyan { color: var(--cyan); }.diagnostic-value.amber { color: var(--amber); } .diagnostic-value.green { color: var(--green); }.diagnostic-value.cyan { color: var(--cyan); }.diagnostic-value.amber { color: var(--amber); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }.status-dot.green { background: var(--green); }.status-dot.cyan { background: var(--cyan); }.status-dot.amber { background: var(--amber); } .status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }.status-dot.green { background: var(--green); }.status-dot.cyan { background: var(--cyan); }.status-dot.amber { background: var(--amber); }
.diagnostic-tree-panel { margin-top: 16px; padding: 20px; }
.diagnostic-tree-panel .section-title > strong { font-size: 10px; }
.diagnostic-empty { min-height: 64px; display: flex; align-items: center; justify-content: center; gap: 8px; border-top: 1px solid var(--line-soft); color: var(--text-muted); font-size: 11px; }
.diagnostic-tree { border-top: 1px solid var(--line-soft); }
.document-diagnostic { padding: 12px 0; border-bottom: 1px solid var(--line-soft); }
.document-diagnostic.is-child { padding: 10px 0 10px 22px; border-bottom: 0; border-top: 1px solid var(--line-soft); }
.document-diagnostic-main { display: flex; align-items: flex-start; gap: 9px; min-width: 0; }
.document-diagnostic-main > div { min-width: 0; }
.document-diagnostic-main strong, .document-diagnostic-main span, .document-diagnostic-main small { display: block; }
.document-diagnostic-main strong { font-size: 11px; }
.document-diagnostic-main span { margin-top: 3px; color: var(--text-soft); font-size: 10px; line-height: 1.45; }
.document-diagnostic-main small { margin-top: 5px; color: var(--text-muted); font-size: 9px; overflow-wrap: anywhere; }
.diagnostic-severity { width: 24px; height: 24px; flex: 0 0 24px; display: grid; place-items: center; border-radius: 3px; }
.diagnostic-severity.error { color: var(--red); background: var(--red-soft); }.diagnostic-severity.warning { color: var(--amber); background: var(--amber-soft); }.diagnostic-severity.info { color: var(--cyan); background: var(--cyan-soft); }
.diagnostic-actions { display: flex; flex-wrap: wrap; gap: 5px; margin: 9px 0 0 33px; }
.diagnostic-actions .button { min-height: 25px; padding: 0 8px; font-size: 9px; }
.diagnostic-children { margin: 10px 0 -12px 33px; border-left: 1px solid var(--line); }
.sync-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 13px; } .sync-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 13px; }
.sync-card { padding: 23px; } .sync-card { padding: 23px; }
.sync-card-header { display: flex; align-items: center; gap: 11px; } .sync-card-header { display: flex; align-items: center; gap: 11px; }

View File

@@ -707,6 +707,35 @@ test('expression cycles fail recompute without silently accepting a result', ()
assert.equal(facade.getState().document.recompute?.objectStates.pocket, 'error') assert.equal(facade.getState().document.recompute?.objectStates.pocket, 'error')
}) })
test('recompute diagnostics expose root causes and repair only the affected branch', async () => {
const facade = createMockFacade()
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: 'pocket.Length' })
facade.app.document.setExpression({ objectId: 'pocket', propertyName: 'Length', expression: 'pad.Length' })
const failed = facade.app.document.recompute()
assert.equal(failed.status, 'failed')
const diagnostics = facade.diagnostics.list().filter((diagnostic) => diagnostic.source === 'recompute')
const pocketRoot = diagnostics.find((diagnostic) => diagnostic.objectId === 'pocket' && diagnostic.code === 'DEPENDENCY_CYCLE')
const fillet = diagnostics.find((diagnostic) => diagnostic.objectId === 'fillet')
assert.ok(pocketRoot)
assert.equal(pocketRoot.generation, failed.generation)
assert.equal(pocketRoot.documentVersion, facade.app.document.getActive().version)
assert.equal(pocketRoot.repairActions?.find((action) => action.id === 'suppress-root')?.enabled, false)
assert.deepEqual(fillet?.dependencyPath, ['fillet', 'pocket'])
assert.equal(fillet?.rootCauseObjectId, 'pocket')
const tree = facade.diagnostics.tree()
assert.ok(tree.find((node) => node.diagnostic.id === pocketRoot.id)?.children.some((node) => node.diagnostic.objectId === 'fillet'))
const selected = await facade.diagnostics.repair(pocketRoot.id, 'select-object')
assert.equal(selected.status, 'completed')
assert.equal(facade.selection.getObjectId(), 'pocket')
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: '25 mm' })
const repaired = await facade.diagnostics.repair(pocketRoot.id, 'recompute-root')
assert.equal(repaired.status, 'completed')
assert.equal(facade.diagnostics.list().some((diagnostic) => diagnostic.source === 'recompute'), false)
})
test('view properties do not mark the geometry object touched', () => { test('view properties do not mark the geometry object touched', () => {
const facade = createMockFacade() const facade = createMockFacade()
facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Visibility', value: false }) facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Visibility', value: false })