From d3d88b297b733b1cc15f6df332820762258cd3e9 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 23:41:16 -0400 Subject: [PATCH] feat: persist LinkSub topology references --- config/compatibility-matrix.json | 2 +- docs/freecad-full-parity-plan.zh-CN.md | 3 ++- docs/web-cad-implementation-plan.zh-CN.md | 4 ++-- src/App.tsx | 8 ++++++-- src/facade/index.ts | 2 +- src/facade/mockFacade.ts | 18 +++++++++++++++--- src/facade/projectStore.ts | 5 +++-- src/facade/topologyReferences.ts | 14 ++------------ src/facade/types.ts | 16 ++++++++++++++-- src/styles.css | 3 +++ tests/facade.test.ts | 20 ++++++++++++++++++++ 11 files changed, 69 insertions(+), 26 deletions(-) diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 46664fa..d27e2a0 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -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", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "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", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "undo-redo"] }, + "document": { "level": "experimental", "operations": ["typed-properties", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "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"] } }, diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 63d799c..a655a1a 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -337,7 +337,8 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 | P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;命令状态同时检查工作台和对象类型选择谓词;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`mirrored` 未实现状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 | | P3-05 Pattern 实验切片 | `linear-pattern` 和 `polar-pattern` 通过 Part Design 选择谓词、Task 和类型化属性创建对应对象;Linear 按总 Length 等距平移,Polar 在小于 360° 时覆盖首末角、360° 时不复制重合端点;两者均由 OCCT union 合并并释放临时副本 | 线性间距/方向、圆周完整/部分角度分布、联合输入、句柄释放、非法参数诊断、Base 链接、Body.Tip、属性校验和任务提交回归测试通过 | 当前为 FreeCAD 1.0+ 的 Transform body 近似,只复制整个 Base Shape;未实现 Transform tool shapes、连接单实体过滤、稳定 TopoRef、历史映射、反向轴和引用轴,因此保持 `experimental` | | P3-05 Hole 基础切片 | `hole` 创建 `PartDesign::Hole`,用 32 边圆形 Profile 调用现有 OCCT Pocket;支持 Diameter、Depth 以及 Dimension/Through all,校验正尺寸并纳入 Base 链接和 Body.Tip | 圆孔 Profile、有限深度调用、非法直径诊断、Task 属性提交和 Tip 更新测试通过 | 仅为基体原点 XY 面、+Z 法向的直孔;草图圆/面定位、沉孔、锪孔、钻尖、螺纹、尺寸标准和稳定支撑面引用仍待实现,保持 `experimental` | -| TSN-02/05/06/08 TopoRef 基础迁移 | `topologyReferences` 提供 schema v1 创建、序列化、安全解析、解析和跨版本迁移;重复几何候选 ID 唯一,一旧多新不会错误继承;匹配按 kind 隔离并用形状尺度归一化几何评分支持纯平移 | round-trip、瞬时索引拒绝、resolved/ambiguous/deleted、重复拆分和纯平移回归测试通过 | 仍是网格签名启发式;没有 OCCT Generated/Modified/Deleted、邻接/曲率签名、布尔历史、数据库对象属性接线和黄金模型规模验证,不得提升为 compatible | +| TSN-02/05/06/08 TopoRef 基础迁移 | `topologyReferences` 提供 schema v1 创建、序列化、安全解析、解析和跨版本迁移;重复几何候选 ID 唯一,一旧多新不会错误继承;匹配按 kind 隔离并用形状尺度归一化几何评分支持纯平移 | round-trip、瞬时索引拒绝、resolved/ambiguous/deleted、重复拆分和纯平移回归测试通过 | 仍是网格签名启发式;没有 OCCT Generated/Modified/Deleted、邻接/曲率签名、布尔历史和黄金模型规模验证,不得提升为 compatible | +| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef,`App::PropertyLinkSub` 经 Facade 解析/校验和深克隆;依赖图产生带 persistentId 的 `topo-ref` 边;SQLite/内存 Project 以结构化 JSON 保存;Pocket 暴露 `UpToFace` 槽,Property 面板显示状态并可清除 | 非法瞬时索引/缺失对象拒绝、DAG 边、保存加载 round-trip、49 项 Facade 测试、桌面属性面板无横向溢出 | 尚未从 3D 选择创建引用,也未在重算后自动写回迁移结果;Pocket Up to face executor 仍保持结构化 unsupported,等待 OCCT 面历史/解析接线 | 后续按以下顺序连续开发: diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 603adb2..92d2dbf 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1591,7 +1591,7 @@ P0 基线/治理 | 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 和持久引用迁移仍待实现 | +| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite;基础表达式/单位/Link DAG 已落地;`App::PropertyLinkSub` 使用版本化 TopoRef,生成 `topo-ref` DAG 边并可保存加载。3D 子形状拾取、重算迁移写回、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` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | Worker memory resource fallback | `PASS` | Worker 在 SQLite WASM 无 OPFS 时使用 transient content-addressed resource map,保持 `put/get/release` 引用计数语义;不再把资源 API 错误地绑定到 OPFS | @@ -1605,7 +1605,7 @@ P0 基线/治理 | 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 持久化仍待完成 | | P3-05 Fillet/Chamfer/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存;Linear/Polar Pattern 已贯通选择谓词、Task、Placement/union 和清理;基础 Hole 用圆形 Profile 复用 Pocket,支持 Diameter、Depth、Dimension/Through all。Pattern 当前为整 Shape,Hole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 48 个场景,包括 Part/PartDesign 命令、Pattern/Hole、TopoRef round-trip/迁移/歧义、基本体/布尔重算、async generation、DAG、诊断修复、FCStd 安全、持久化、资源、任务、历史和表达式;E2E/黄金几何待补齐 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 49 个场景,包括 Part/PartDesign、Pattern/Hole、TopoRef round-trip/迁移/歧义、LinkSub DAG/持久化、几何重算、async generation、诊断修复、FCStd 安全、资源、任务、历史和表达式;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 构建验证。 diff --git a/src/App.tsx b/src/App.tsx index 452a56b..76eacf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -365,6 +365,7 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa const activeDocument = facade.app.document.getActive() const booleanCommand = activeCommand === 'union' || activeCommand === 'cut' || activeCommand === 'intersection' const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].includes(object.typeId)) + const objectLabel = (object: DocumentSnapshot['objects'][number]) => { const value = object.properties.find((property) => property.name === 'Label')?.value; return typeof value === 'string' || typeof value === 'number' ? String(value) : object.id } const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : '' const acceptTask = () => { if (!activeTask || activeTask.status !== 'preview') { showNotice('No active task'); return } @@ -389,8 +390,8 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa {primitiveFields.map((field) => )} } {booleanCommand && <> - - + + } {currentFeatureField && } {activeCommand === 'linear-pattern' && <> @@ -449,6 +450,9 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi const document = facade.app.document.getActive() const ids = [...new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))] editor = + } else if (property.type === 'App::PropertyLinkSub') { + const ref = property.value && typeof property.value === 'object' ? property.value : null + editor = ref ?
{ref.objectId} / {ref.kind} · {ref.status} commit(null)} />
: None } else if (property.type === 'App::PropertyColor') editor = else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') editor = else editor = { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /> diff --git a/src/facade/index.ts b/src/facade/index.ts index 3d38c44..ec5eeb1 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -4,7 +4,7 @@ export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS 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, 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 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, TopoRefValue } from './types' export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming' export { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences' export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index eecb2ec..2e73181 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -31,6 +31,7 @@ import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type Ske import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine' import { inspectFcstdArchive } from './fcstd' import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics' +import { parseTopoRef } from './topologyReferences' const initialTree: ModelTreeItem[] = [ { id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] }, @@ -96,6 +97,7 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 18, unit: 'mm', recompute: true }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, { name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true }, + { name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, ] if (item.id.startsWith('revolution')) return [ @@ -141,10 +143,12 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()], sketch: item.type === 'sketch' ? createSketch(item.id) : undefined }) +const clonePropertyValue = (value: PropertyValue): PropertyValue => value && typeof value === 'object' ? { ...value, candidates: value.candidates ? [...value.candidates] : undefined } : value + const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), - objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), + objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined, }) @@ -155,6 +159,7 @@ const collectDependencyEdges = (document: Pick): De for (const object of document.objects) { for (const property of object.properties) { if (property.type === 'App::PropertyLink' && typeof property.value === 'string' && objectIds.has(property.value)) edges.push({ sourceId: object.id, targetId: property.value, relation: 'link', propertyName: property.name }) + if (property.type === 'App::PropertyLinkSub' && property.value && typeof property.value === 'object' && objectIds.has(property.value.objectId)) edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'topo-ref', propertyName: property.name, reference: property.value.persistentId }) if (property.expression) { for (const reference of expressionReferences(property.expression)) { const separator = reference.lastIndexOf('.') @@ -260,6 +265,13 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])])) if (value !== null && !knownIds.has(value)) throw new RangeError(`${property.label} target does not exist in this document.`) } + if (property.type === 'App::PropertyLinkSub') { + if (value !== null && (typeof value !== 'object' || Array.isArray(value))) throw new TypeError(`${property.label} requires a TopoRef value.`) + if (value !== null) { + const topoRef = parseTopoRef(JSON.stringify(value)) + if (!document.objects.some((object) => object.id === topoRef.objectId)) throw new RangeError(`${property.label} target does not exist in this document.`) + } + } if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.') } @@ -383,7 +395,7 @@ export function createMockFacade(): BitBybitWebCadFacade { if (Number(objectSnapshot.properties.find((property) => property.name === 'Diameter')?.value) <= 0) throw new RangeError('Hole diameter must be greater than zero.') if (Number(objectSnapshot.properties.find((property) => property.name === 'Depth')?.value) <= 0) throw new RangeError('Hole depth must be greater than zero.') } - const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot] + const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value) })), 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') @@ -407,7 +419,7 @@ export function createMockFacade(): BitBybitWebCadFacade { const document = cloneDocumentSnapshot(state.document) const object = document.objects[objectIndex] - object.properties[propertyIndex] = { ...object.properties[propertyIndex], value, expression: undefined, expressionError: undefined } + object.properties[propertyIndex] = { ...object.properties[propertyIndex], value: clonePropertyValue(value), expression: undefined, expressionError: undefined } const treeItem = document.tree.find((item) => item.id === objectId) if (propertyName === 'Label' && treeItem) treeItem.label = String(value) document.dependencies = collectDependencyEdges(document) diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index 3f0559c..662600d 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -1,4 +1,4 @@ -import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, Unsubscribe } from './types' +import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types' import { cloneSketch } from './sketcher' type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string } @@ -6,7 +6,8 @@ type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' } | { type type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string } const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' } -const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined }) +const clonePropertyValue = (value: PropertyValue): PropertyValue => value && typeof value === 'object' ? { ...value, candidates: value.candidates ? [...value.candidates] : undefined } : value +const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined }) export interface ProjectPersistenceClient { initialize(): Promise diff --git a/src/facade/topologyReferences.ts b/src/facade/topologyReferences.ts index 0f909e9..1a45f5b 100644 --- a/src/facade/topologyReferences.ts +++ b/src/facade/topologyReferences.ts @@ -1,17 +1,7 @@ -import type { SubshapeRef } from './types' +import type { SubshapeRef, TopoRefValue } from './types' import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming' -export type PersistedTopoRef = { - schemaVersion: 1 - objectId: string - kind: SubshapeRef['kind'] - persistentId: string - topologyVersion: number - generation: number - status: NonNullable - signature?: string - candidates?: string[] -} +export type PersistedTopoRef = TopoRefValue export type TopoRefResolution = { status: 'resolved' | 'ambiguous' | 'deleted' diff --git a/src/facade/types.ts b/src/facade/types.ts index 3a05c26..670da21 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -14,14 +14,26 @@ export type ModelTreeItem = { children?: string[] } -export type PropertyValue = string | number | boolean | null +export type TopoRefValue = { + schemaVersion: 1 + objectId: string + kind: 'face' | 'edge' | 'vertex' + persistentId: string + topologyVersion: number + generation: number + status: 'stable' | 'ambiguous' | 'new' | 'deleted' + signature?: string + candidates?: string[] +} + +export type PropertyValue = string | number | boolean | TopoRefValue | null export type ObjectPropertySnapshot = { name: string label: string group: string scope: 'data' | 'view' - type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger' + type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger' value: PropertyValue unit?: string readOnly?: boolean diff --git a/src/styles.css b/src/styles.css index 11c4fe0..c0879f6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -377,6 +377,9 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s .property-number { width: 100%; display: flex; align-items: center; gap: 4px; color: var(--text-muted); font-size: 9px; } .property-number .property-control { min-width: 0; } .property-link-control { color: var(--cyan); } +.property-link-sub { display: flex; min-width: 0; align-items: center; gap: 4px; color: var(--cyan); font-size: 10px; } +.property-link-sub > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.property-link-sub .icon-button { width: 24px; height: 24px; flex: 0 0 24px; } .property-color { display: flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; } .property-color input { width: 22px; height: 18px; padding: 0; border: 1px solid var(--line); border-radius: 2px; background: transparent; } .property-expression { min-height: 20px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; color: var(--cyan); font-size: 9px; } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 5c7c8b3..73acb86 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -760,6 +760,26 @@ test('typed properties are validated, versioned and undoable through the documen assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 50) }) +test('LinkSub properties preserve versioned TopoRefs and create topology dependencies', async () => { + const facade = createMockFacade() + const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] } + const topology = createSubshapeRefs('pad-shape', 18, [face]) + const topoRef = createPersistedTopoRef('pad', topology.refs[0], 4) + facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: topoRef }) + const stored = facade.app.document.getObject('pocket')?.properties.find((property) => property.name === 'UpToFace')?.value + assert.deepEqual(stored, topoRef) + assert.notEqual(stored, topoRef) + const dependency = facade.app.document.getDependencies().find((edge) => edge.sourceId === 'pocket' && edge.relation === 'topo-ref') + assert.equal(dependency?.targetId, 'pad') + assert.equal(dependency?.reference, topoRef.persistentId) + assert.throws(() => facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: { ...topoRef, faceIndex: 1 } as never }), /must not persist transient faceIndex/) + assert.throws(() => facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: { ...topoRef, objectId: 'missing' } }), /target does not exist/) + + await facade.project.save() + const loaded = await facade.project.load('doc-pump-housing') + assert.deepEqual(loaded?.objects.find((object) => object.id === 'pocket')?.properties.find((property) => property.name === 'UpToFace')?.value, topoRef) +}) + test('expressions are evaluated in the facade and create document dependencies', () => { const facade = createMockFacade() facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: '1 in + 2 mm' })