feat: propagate feature suppression in recompute
This commit is contained in:
@@ -325,7 +325,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
|
||||
| 任务 | 实施措施 | 验证结果 | 剩余边界 |
|
||||
|---|---|---|---|
|
||||
| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation;后发运行取消前发;节点完成后校验 AbortSignal、文档 ID/版本和当前 generation;结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度;可选的 `createFacadeGeometryRecomputeExecutor` 在 OCCT Worker ready 时执行真实 Pad/Pocket/Revolution/Fillet/Chamfer | 取消、过期版本、依赖失败传播、独立节点并行、闭合 Sketch 特征链、Revolution 角度/反向和 Facade 提交测试通过 | 默认数据模型仍不持久化 ShapeHandle;Up to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape |
|
||||
| DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 |
|
||||
| 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-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 未完成 |
|
||||
@@ -338,7 +338,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
|
||||
|
||||
后续按以下顺序连续开发:
|
||||
|
||||
1. `P4-06/DAG-08`:同步/异步重算统一状态机,增加抑制、恢复上游后的最小闭包重算和诊断树;最近有效 Shape 缓存已进入 Facade,但尚未持久化。
|
||||
1. `P4-06/DAG-08`:同步/异步重算已共享抑制传播语义,并支持解除抑制后的最小闭包重算;继续补齐多配置抑制、Body Tip 重定向和诊断树。最近有效 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` 已通过 44 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
|
||||
| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 46 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
|
||||
|
||||
### 16.3 阶段门和交付节奏
|
||||
|
||||
@@ -1589,7 +1589,7 @@ P0 基线/治理
|
||||
| P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 |
|
||||
| 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;文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、容器规则和原子崩溃恢复仍待实现 |
|
||||
| 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-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 +1603,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` 已覆盖 44 个场景,包括 Part 命令、基本体/布尔重算、async generation 重算、DAG 分层并行、OCCT 特征执行器、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;E2E/黄金几何待补齐 |
|
||||
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 46 个场景,包括 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 构建验证。
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export type RecomputePlan = {
|
||||
cycles: string[][]
|
||||
}
|
||||
|
||||
export type RecomputeState = 'up-to-date' | 'touched' | 'recomputing' | 'error' | 'upstream-failed'
|
||||
export type RecomputeState = 'up-to-date' | 'touched' | 'recomputing' | 'suppressed' | 'upstream-suppressed' | 'error' | 'upstream-failed'
|
||||
|
||||
export type RecomputeSnapshot = {
|
||||
generation: number
|
||||
|
||||
@@ -45,6 +45,7 @@ const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [
|
||||
{ name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label },
|
||||
{ name: 'TypeId', label: 'Type', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: typeIdForItem(item), readOnly: true },
|
||||
{ name: 'Status', label: 'Status', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.state === 'warning' ? 'Warning' : 'Valid', readOnly: true },
|
||||
...(item.type === 'feature' ? [{ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: false, recompute: true }] : []),
|
||||
]
|
||||
|
||||
const viewProperties = (): ObjectPropertySnapshot[] => [
|
||||
@@ -286,6 +287,11 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
createFacadeGeometryRecomputeExecutor(geometryRuntime, featureShapes),
|
||||
(documentId) => state.document.id === documentId ? state.document.version : null,
|
||||
)
|
||||
const releaseFeatureShape = (objectId: string) => {
|
||||
const shape = featureShapes.get(objectId)
|
||||
featureShapes.delete(objectId)
|
||||
if (shape) void geometryRuntime.release(shape)
|
||||
}
|
||||
const clearFeatureShapes = () => {
|
||||
const retained = [...featureShapes.values()]
|
||||
featureShapes.clear()
|
||||
@@ -414,20 +420,21 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const message = `Dependency cycle: ${cycle.join(' -> ')}`
|
||||
for (const objectId of cycle) { objectStates[objectId] = 'error'; errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message }) }
|
||||
}
|
||||
if (errors.length === 0) {
|
||||
for (const objectId of plan.order) {
|
||||
objectStates[objectId] = 'up-to-date'
|
||||
const item = document.tree.find((candidate) => candidate.id === objectId)
|
||||
if (item?.state === 'dirty') item.state = item.type === 'body' ? 'active' : 'valid'
|
||||
const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status')
|
||||
if (status?.value === 'Touched') status.value = 'Valid'
|
||||
}
|
||||
const dependencies = graph.dependenciesOf(objectId)
|
||||
if (dependencies.some((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')) {
|
||||
objectStates[objectId] = 'upstream-failed'
|
||||
} else if (dependencies.some((dependencyId) => objectStates[dependencyId] === 'suppressed' || objectStates[dependencyId] === 'upstream-suppressed')) {
|
||||
objectStates[objectId] = 'upstream-suppressed'
|
||||
} else {
|
||||
for (const objectId of plan.affected) if (objectStates[objectId] !== 'error') objectStates[objectId] = 'upstream-failed'
|
||||
for (const objectId of plan.affected) {
|
||||
const item = document.tree.find((candidate) => candidate.id === objectId)
|
||||
if (item && item.state !== 'active') item.state = 'warning'
|
||||
const object = document.objects.find((candidate) => candidate.id === objectId)
|
||||
objectStates[objectId] = object?.properties.some((property) => property.name === 'Suppressed' && property.value === true) ? 'suppressed' : 'up-to-date'
|
||||
}
|
||||
if (objectStates[objectId] === 'suppressed' || objectStates[objectId] === 'upstream-suppressed') releaseFeatureShape(objectId)
|
||||
const item = document.tree.find((candidate) => candidate.id === objectId)
|
||||
if (item && item.state !== 'readonly') item.state = objectStates[objectId] === 'upstream-failed' ? 'warning' : item.type === 'body' ? 'active' : 'valid'
|
||||
const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status')
|
||||
if (status) status.value = objectStates[objectId] === 'suppressed' ? 'Suppressed' : objectStates[objectId] === 'upstream-suppressed' ? 'Upstream suppressed' : objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning'
|
||||
}
|
||||
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
|
||||
@@ -445,12 +452,13 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const updates = new Map(result.objectUpdates.map((object) => [object.id, object]))
|
||||
document.objects = document.objects.map((object) => updates.has(object.id) ? updates.get(object.id) as DocumentObjectSnapshot : object)
|
||||
for (const objectId of result.affected) {
|
||||
if (result.objectStates[objectId] === 'suppressed' || result.objectStates[objectId] === 'upstream-suppressed') releaseFeatureShape(objectId)
|
||||
const item = document.tree.find((candidate) => candidate.id === objectId)
|
||||
if (!item || item.state === 'readonly') continue
|
||||
if (result.objectStates[objectId] === 'up-to-date') item.state = item.type === 'body' ? 'active' : 'valid'
|
||||
if (result.objectStates[objectId] === 'up-to-date' || result.objectStates[objectId] === 'suppressed' || result.objectStates[objectId] === 'upstream-suppressed') item.state = item.type === 'body' ? 'active' : 'valid'
|
||||
else if (result.objectStates[objectId] === 'error' || result.objectStates[objectId] === 'upstream-failed') item.state = 'warning'
|
||||
const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status')
|
||||
if (status) status.value = result.objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning'
|
||||
if (status) status.value = result.objectStates[objectId] === 'suppressed' ? 'Suppressed' : result.objectStates[objectId] === 'upstream-suppressed' ? 'Upstream suppressed' : result.objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning'
|
||||
}
|
||||
document.recompute = {
|
||||
generation: result.generation,
|
||||
|
||||
@@ -18,7 +18,7 @@ export type RecomputeNodeContext = {
|
||||
}
|
||||
|
||||
export type RecomputeNodeResult = {
|
||||
status: 'success' | 'failed'
|
||||
status: 'success' | 'suppressed' | 'failed'
|
||||
errors?: RecomputeExecutionError[]
|
||||
updatedObject?: DocumentObjectSnapshot
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export type RecomputeExecutionResult = {
|
||||
order: string[]
|
||||
levels: string[][]
|
||||
completed: string[]
|
||||
suppressed: string[]
|
||||
failed: string[]
|
||||
skipped: string[]
|
||||
dirtyObjects: string[]
|
||||
@@ -108,6 +109,7 @@ export class RecomputeCoordinator {
|
||||
for (const objectId of plan.affected) objectStates[objectId] = 'recomputing'
|
||||
|
||||
const completed: string[] = []
|
||||
const suppressed: string[] = []
|
||||
const failed: string[] = []
|
||||
const skipped: string[] = []
|
||||
const errors: RecomputeExecutionError[] = []
|
||||
@@ -125,7 +127,9 @@ export class RecomputeCoordinator {
|
||||
|
||||
const terminalResult = (status: RecomputeExecutionStatus): RecomputeExecutionResult => {
|
||||
if (status === 'cancelled' || status === 'stale') {
|
||||
for (const objectId of plan.affected) if (!completed.includes(objectId)) objectStates[objectId] = 'touched'
|
||||
for (const objectId of plan.affected) {
|
||||
if (objectStates[objectId] !== 'up-to-date' && objectStates[objectId] !== 'suppressed' && objectStates[objectId] !== 'upstream-suppressed') objectStates[objectId] = 'touched'
|
||||
}
|
||||
}
|
||||
if (this.active?.generation === generation) this.active = null
|
||||
return {
|
||||
@@ -136,9 +140,10 @@ export class RecomputeCoordinator {
|
||||
order: plan.order,
|
||||
levels: plan.levels,
|
||||
completed,
|
||||
suppressed,
|
||||
failed,
|
||||
skipped,
|
||||
dirtyObjects: plan.affected.filter((objectId) => objectStates[objectId] !== 'up-to-date'),
|
||||
dirtyObjects: plan.affected.filter((objectId) => objectStates[objectId] === 'touched' || objectStates[objectId] === 'recomputing' || objectStates[objectId] === 'error' || objectStates[objectId] === 'upstream-failed'),
|
||||
objectStates,
|
||||
objectUpdates,
|
||||
errors,
|
||||
@@ -153,8 +158,11 @@ export class RecomputeCoordinator {
|
||||
// Nodes in a level have no dependencies on each other. Execute them together,
|
||||
// then merge outcomes in plan order so persistence and UI events stay deterministic.
|
||||
const outcomes = await Promise.all(level.map(async (objectId) => {
|
||||
const failedDependency = graph.dependenciesOf(objectId).find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
|
||||
const dependencies = graph.dependenciesOf(objectId)
|
||||
const failedDependency = dependencies.find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
|
||||
if (failedDependency) return { objectId, state: 'upstream-failed' as const, error: { objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` } }
|
||||
const suppressedDependency = dependencies.find((dependencyId) => objectStates[dependencyId] === 'suppressed' || objectStates[dependencyId] === 'upstream-suppressed')
|
||||
if (suppressedDependency) return { objectId, state: 'upstream-suppressed' as const }
|
||||
|
||||
const object = objectById.get(objectId)
|
||||
if (!object) return { objectId, state: 'error' as const, error: { objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` } }
|
||||
@@ -173,6 +181,7 @@ export class RecomputeCoordinator {
|
||||
errors: result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }],
|
||||
}
|
||||
}
|
||||
if (result.status === 'suppressed') return { objectId, state: 'suppressed' as const }
|
||||
return { objectId, state: 'up-to-date' as const, updatedObject: result.updatedObject }
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortError(error)) return { objectId, state: 'cancelled' as const }
|
||||
@@ -190,6 +199,11 @@ export class RecomputeCoordinator {
|
||||
if (outcome.state === 'up-to-date') {
|
||||
completed.push(outcome.objectId)
|
||||
if (outcome.updatedObject) objectUpdates.push(outcome.updatedObject)
|
||||
} else if (outcome.state === 'suppressed') {
|
||||
suppressed.push(outcome.objectId)
|
||||
skipped.push(outcome.objectId)
|
||||
} else if (outcome.state === 'upstream-suppressed') {
|
||||
skipped.push(outcome.objectId)
|
||||
} else if (outcome.state === 'upstream-failed') {
|
||||
skipped.push(outcome.objectId)
|
||||
if (outcome.error) errors.push(outcome.error)
|
||||
@@ -209,6 +223,7 @@ export class RecomputeCoordinator {
|
||||
|
||||
export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object, _document, context) => {
|
||||
if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError')
|
||||
if (object.properties.some((property) => property.name === 'Suppressed' && property.value === true)) return { status: 'suppressed' }
|
||||
const expressionError = object.properties.find((property) => property.expressionError)
|
||||
if (expressionError) {
|
||||
return {
|
||||
@@ -279,6 +294,12 @@ export const createFacadeGeometryRecomputeExecutor = (
|
||||
shapes: Map<string, ShapeHandle> = new Map(),
|
||||
): RecomputeNodeExecutor => async (object, document, context) => {
|
||||
const base = await executeFacadeRecomputeNode(object, document, context)
|
||||
if (base.status === 'suppressed') {
|
||||
const previous = shapes.get(object.id)
|
||||
shapes.delete(object.id)
|
||||
if (previous) await geometry.release(previous)
|
||||
return base
|
||||
}
|
||||
if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base
|
||||
if (!['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DependencyGraph } from '../src/facade/dependencyGraph'
|
||||
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
|
||||
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
|
||||
import { createSketch, solveSketch } from '../src/facade/sketcher'
|
||||
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
|
||||
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
|
||||
import { inspectFcstdArchive } from '../src/facade/fcstd'
|
||||
import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
|
||||
import type { DocumentSnapshot, ShapeHandle } from '../src/facade/types'
|
||||
@@ -348,6 +348,25 @@ test('recompute failure marks dependent objects as upstream-failed', async () =>
|
||||
assert.ok(result.errors.some((error) => error.code === 'UPSTREAM_FAILED'))
|
||||
})
|
||||
|
||||
test('recompute suppression skips dependent nodes without reporting a failure', async () => {
|
||||
const document = recomputeDocumentFixture([{ sourceId: 'child', targetId: 'root', relation: 'link' }])
|
||||
document.objects[0].properties.push({ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true })
|
||||
const executed: string[] = []
|
||||
const coordinator = new RecomputeCoordinator(async (object, source, context) => {
|
||||
executed.push(object.id)
|
||||
return executeFacadeRecomputeNode(object, source, context)
|
||||
}, () => 1)
|
||||
const result = await coordinator.run(document)
|
||||
assert.equal(result.status, 'completed')
|
||||
assert.deepEqual(executed, ['root'])
|
||||
assert.deepEqual(result.suppressed, ['root'])
|
||||
assert.deepEqual(result.skipped, ['root', 'child'])
|
||||
assert.equal(result.objectStates.root, 'suppressed')
|
||||
assert.equal(result.objectStates.child, 'upstream-suppressed')
|
||||
assert.deepEqual(result.dirtyObjects, [])
|
||||
assert.deepEqual(result.errors, [])
|
||||
})
|
||||
|
||||
test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains the last valid Shape', async () => {
|
||||
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-feature', documentVersion: 2 })
|
||||
const calls: string[] = []
|
||||
@@ -417,6 +436,12 @@ test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains t
|
||||
assert.equal(revolutionResult.status, 'success')
|
||||
assert.equal(shapes.get('revolution')?.id, 'revolution-shape')
|
||||
assert.ok(calls.includes('revolution:270:-1'))
|
||||
|
||||
const suppressedPad = { ...pad, properties: [...pad.properties, { name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
|
||||
const suppressedResult = await executor(suppressedPad, document, context)
|
||||
assert.equal(suppressedResult.status, 'suppressed')
|
||||
assert.equal(shapes.has('pad'), false)
|
||||
assert.ok(calls.includes('release:pad-shape'))
|
||||
})
|
||||
|
||||
test('Part primitive and boolean nodes execute through the same Shape cache', async () => {
|
||||
@@ -475,6 +500,28 @@ test('facade async recompute commits only an accepted generation', async () => {
|
||||
assert.equal(facade.app.document.getActive().recompute?.dirtyObjects.length, 0)
|
||||
})
|
||||
|
||||
test('feature suppression persists and unsuppress recomputes only its dependent closure', async () => {
|
||||
const facade = createMockFacade()
|
||||
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: true })
|
||||
const suppressed = facade.app.document.recompute()
|
||||
assert.equal(suppressed.status, 'completed')
|
||||
assert.deepEqual(suppressed.order, ['pocket', 'fillet', 'body'])
|
||||
assert.deepEqual(new Set(suppressed.affected), new Set(['pocket', 'fillet', 'body']))
|
||||
assert.equal(facade.app.document.getActive().recompute?.objectStates.pocket, 'suppressed')
|
||||
assert.equal(facade.app.document.getActive().recompute?.objectStates.fillet, 'upstream-suppressed')
|
||||
|
||||
await facade.project.save()
|
||||
assert.equal((await facade.project.load('doc-pump-housing'))?.objects.find((object) => object.id === 'pocket')?.properties.find((property) => property.name === 'Suppressed')?.value, true)
|
||||
|
||||
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: false })
|
||||
const restored = await facade.app.document.recomputeAsync()
|
||||
assert.equal(restored.status, 'completed')
|
||||
assert.deepEqual(restored.order, ['pocket', 'fillet', 'body'])
|
||||
assert.deepEqual(new Set(restored.affected), new Set(['pocket', 'fillet', 'body']))
|
||||
assert.equal(facade.app.document.getActive().recompute?.objectStates.pocket, 'up-to-date')
|
||||
assert.equal(facade.app.document.getActive().recompute?.objectStates.fillet, 'up-to-date')
|
||||
})
|
||||
|
||||
test('persistence writes are serialized and continue after a failed write', async () => {
|
||||
const queue = new PersistenceWriteQueue()
|
||||
let inFlight = 0
|
||||
|
||||
Reference in New Issue
Block a user