feat: maintain Part Design body tip

This commit is contained in:
2026-08-02 21:40:44 -04:00
parent 2a0b14b26d
commit 10921a086e
4 changed files with 16 additions and 3 deletions

View File

@@ -327,6 +327,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 提交测试通过 | 默认数据模型仍不持久化 ShapeHandleUp to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape |
| DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 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 未完成 |
| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
| FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型未知类型为 proxyPython 类型为 blocked脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip |

View File

@@ -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文档版本、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文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 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` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |

View File

@@ -112,7 +112,7 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
{ name: 'ConstraintStatus', label: 'Solver state', group: 'Constraints', scope: 'data', type: 'App::PropertyString', value: 'Fully constrained', readOnly: true },
]
if (item.type === 'body') return [
{ name: 'Tip', label: 'Tip', group: 'Part Design', scope: 'data', type: 'App::PropertyLink', value: 'fillet', recompute: true },
{ name: 'Tip', label: 'Tip', group: 'Part Design', scope: 'data', type: 'App::PropertyLink', value: 'fillet', readOnly: true, recompute: true },
]
return []
}
@@ -325,10 +325,14 @@ export function createMockFacade(): BitBybitWebCadFacade {
tree.push(item)
}
const objectSnapshot = createObjectSnapshot(item)
const sourceId = typeof draft.source === 'string' && document.objects.some((object) => object.id === draft.source) ? draft.source : ''
const sourceObject = sourceId ? document.objects.find((object) => object.id === sourceId) : undefined
objectSnapshot.properties = objectSnapshot.properties.map((property) => {
if (!property.recompute) return property
const candidate = draft[property.name.toLowerCase()]
if (property.type === 'App::PropertyLink' && typeof candidate === 'string' && candidate.length > 0) return { ...property, value: candidate }
if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Profile' && sourceObject.typeId === 'Sketcher::SketchObject') return { ...property, value: sourceObject.id }
if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Base' && shapeTypeIds.has(sourceObject.typeId)) return { ...property, value: sourceObject.id }
if (property.type === 'App::PropertyBool' && typeof candidate === 'boolean') return { ...property, value: candidate }
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && typeof candidate === 'number' && Number.isFinite(candidate)) return { ...property, value: candidate }
if (property.type === 'App::PropertyEnumeration' && typeof candidate === 'string' && property.options?.includes(candidate)) return { ...property, value: candidate }
@@ -338,10 +342,15 @@ export function createMockFacade(): BitBybitWebCadFacade {
objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && typeof draft.base !== 'string' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property)
}
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot]
const tipObject = type === 'feature' && partDesignCommands.has(commandId) && commandId !== 'create-sketch' ? objects.find((object) => object.id === 'body') : undefined
if (tipObject) {
const tip = tipObject.properties.find((property) => property.name === 'Tip')
if (tip) tip.value = objectId
}
const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects }
nextDocument.dependencies = collectDependencyEdges(nextDocument)
nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0)
markDocumentTouched(nextDocument, [objectId])
markDocumentTouched(nextDocument, tipObject ? [objectId, tipObject.id] : [objectId])
return { document: nextDocument, objectId }
}
const setProperty = ({ objectId, propertyName, value }: SetPropertyInput) => {

View File

@@ -783,12 +783,15 @@ test('Part Design feature tasks commit a document object and remain undoable', (
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Length')?.value, 55)
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Reversed')?.value, true)
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Midplane')?.value, true)
assert.equal(committed.objects.find((object) => object.id === 'body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad001')
assert.ok(committed.recompute?.dirtyObjects.includes('pad001'))
facade.history.undo()
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), false)
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
facade.history.redo()
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), true)
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad001')
facade.gui.command.execute({ commandId: 'revolution' })
assert.equal(facade.task.getActive()?.commandId, 'revolution')