feat: expose recompute diagnostic repair tree
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"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"] },
|
||||
"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"] },
|
||||
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
|
||||
},
|
||||
|
||||
@@ -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 提交测试通过 | 默认数据模型仍不持久化 ShapeHandle;Up to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape |
|
||||
| DAG-08 失败/抑制传播 | 上游 `error` 将下游置为 `upstream-failed`;Feature 的可持久化 `Suppressed` 属性将自身置为 `suppressed`、下游置为 `upstream-suppressed`,不误报重算失败;抑制时释放缓存 Shape,解除后只重算依赖闭包 | 上游失败、抑制跳过、Shape 释放、保存恢复和解除抑制最小闭包测试通过 | 多配置抑制、Body Tip 重定向、完整 FreeCAD 状态机和 Report 诊断树仍待实现 |
|
||||
| P4-06 任务提交与重算闭包 | Task 草稿在 Facade 事务内写入 Boolean Base/Tool 等链接;新对象和依赖闭包进入 `touched/dirty`,React OK 通过 `recomputeAsync()` 提交并反馈结果 | Boolean Task 链接/依赖测试、Task dirty 快照和完整验证通过 | Apply 预览仍未持久化临时 Shape;多选选择集、抑制对象和完整 FreeCAD Task 校验仍待实现 |
|
||||
| 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()` 提交并反馈结果;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 支持仍待实现 |
|
||||
| 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、视图、表达式和参数化对象 |
|
||||
@@ -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。
|
||||
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。
|
||||
|
||||
@@ -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` 已通过 46 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
|
||||
| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 47 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
|
||||
|
||||
### 16.3 阶段门和交付节奏
|
||||
|
||||
@@ -1590,6 +1590,7 @@ P0 基线/治理
|
||||
| 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 |
|
||||
| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;新增 Part Design 特征会原子更新 Body.Tip,并按 Sketch/实体来源写入 Profile/Base;Feature 支持可持久化抑制、下游跳过、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 和持久引用迁移仍待实现 |
|
||||
| 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` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
|
||||
@@ -1603,7 +1604,7 @@ P0 基线/治理
|
||||
| P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI |
|
||||
| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/Revolution,Revolution Angle/Reversed 已接入,Fillet/Chamfer 也有 Shape 缓存回写;Up to face、FCStd Shape 持久化仍待完成 |
|
||||
| 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 构建验证。
|
||||
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -54,7 +54,7 @@ import {
|
||||
ZoomOut,
|
||||
} from 'lucide-react'
|
||||
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
|
||||
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type 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 Workbench = WorkbenchId
|
||||
@@ -586,6 +586,7 @@ function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page
|
||||
const [geometry, setGeometry] = useState(() => facade.geometry.capabilities())
|
||||
const document = facade.getState().document
|
||||
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 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])
|
||||
@@ -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)}`) })
|
||||
return () => { disposed = true }
|
||||
}, [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 }) {
|
||||
|
||||
120
src/facade/diagnostics.ts
Normal file
120
src/facade/diagnostics.ts
Normal 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)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
export { createMockFacade } from './mockFacade'
|
||||
export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
|
||||
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
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 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 { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
|
||||
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
CommandState,
|
||||
DocumentSnapshot,
|
||||
Diagnostic,
|
||||
DiagnosticRepairAction,
|
||||
DiagnosticRepairResult,
|
||||
ExecuteCommandInput,
|
||||
FacadeEvent,
|
||||
FacadeListener,
|
||||
@@ -28,6 +30,7 @@ import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimension
|
||||
import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
|
||||
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
|
||||
import { inspectFcstdArchive } from './fcstd'
|
||||
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
|
||||
|
||||
const initialTree: ModelTreeItem[] = [
|
||||
{ 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 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 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`) }
|
||||
@@ -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 }
|
||||
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)
|
||||
emitState()
|
||||
return { ...plan, generation, status: nextRecompute.status, errors }
|
||||
@@ -464,15 +471,35 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
generation: result.generation,
|
||||
status: result.status,
|
||||
objectStates: result.objectStates,
|
||||
dirtyObjects: result.dirtyObjects,
|
||||
dirtyObjects: [...new Set([...(source.recompute?.dirtyObjects ?? []).filter((objectId) => !result.affected.includes(objectId)), ...result.dirtyObjects])],
|
||||
order: result.order,
|
||||
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)
|
||||
emitState()
|
||||
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) => {
|
||||
recomputeCoordinator.cancel()
|
||||
const loaded = await projectPersistence.load(documentId)
|
||||
@@ -549,7 +576,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId, selectedTypeId)
|
||||
if (status.status === '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] }
|
||||
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 }
|
||||
commit({ ...state, document: savedDocument })
|
||||
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] }
|
||||
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 shape = featureShapes.get(objectId)
|
||||
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] }
|
||||
emit({ type: 'diagnostic.added', diagnostic, context })
|
||||
notify(message)
|
||||
@@ -579,7 +606,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const count = topology.faces.length + topology.edges.length + topology.vertices.length
|
||||
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 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] }
|
||||
emit({ type: 'diagnostic.added', diagnostic, context })
|
||||
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 } },
|
||||
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() } },
|
||||
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 },
|
||||
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,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -293,11 +293,39 @@ export type FacadeRequestContext = {
|
||||
|
||||
export type Diagnostic = {
|
||||
id: string
|
||||
source: 'command' | 'recompute' | 'geometry' | 'persistence' | 'system'
|
||||
severity: 'info' | 'warning' | 'error'
|
||||
code: string
|
||||
message: string
|
||||
objectId?: 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 = {
|
||||
@@ -400,6 +428,11 @@ export interface BitBybitWebCadFacade {
|
||||
apply(): void
|
||||
cancel(): void
|
||||
}
|
||||
readonly diagnostics: {
|
||||
list(): Diagnostic[]
|
||||
tree(): DiagnosticTreeNode[]
|
||||
repair(diagnosticId: string, actionId: DiagnosticRepairAction['id']): Promise<DiagnosticRepairResult>
|
||||
}
|
||||
readonly project: {
|
||||
capabilities(): PersistenceCapabilities
|
||||
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
|
||||
|
||||
@@ -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.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); }
|
||||
.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-card { padding: 23px; }
|
||||
.sync-card-header { display: flex; align-items: center; gap: 11px; }
|
||||
|
||||
@@ -707,6 +707,35 @@ test('expression cycles fail recompute without silently accepting a result', ()
|
||||
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', () => {
|
||||
const facade = createMockFacade()
|
||||
facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Visibility', value: false })
|
||||
|
||||
Reference in New Issue
Block a user