From 6a0bd534ea60dead4e1068a235597b64e5b64e80 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 09:40:26 -0400 Subject: [PATCH] P4: add expressions dependency recompute and topology signatures --- config/runtime-baseline.json | 4 +- docs/freecad-full-parity-plan.zh-CN.md | 6 + docs/web-cad-implementation-plan.zh-CN.md | 15 +- src/facade/dependencyGraph.ts | 145 +++++++++++++++++ src/facade/geometryRuntime.ts | 13 +- src/facade/index.ts | 3 +- src/facade/mockFacade.ts | 168 +++++++++++++++++-- src/facade/persistenceWorker.ts | 9 +- src/facade/projectSchema.ts | 8 +- src/facade/projectStore.ts | 2 +- src/facade/topologyNaming.ts | 111 +++++++++++++ src/facade/types.ts | 29 ++++ src/facade/units.ts | 189 ++++++++++++++++++++++ tests/facade.test.ts | 82 +++++++++- 14 files changed, 758 insertions(+), 26 deletions(-) create mode 100644 src/facade/dependencyGraph.ts create mode 100644 src/facade/topologyNaming.ts create mode 100644 src/facade/units.ts diff --git a/config/runtime-baseline.json b/config/runtime-baseline.json index d69188b..412c028 100644 --- a/config/runtime-baseline.json +++ b/config/runtime-baseline.json @@ -5,7 +5,7 @@ "frontendFramework": "React 18 + Vite 6 + TypeScript 5", "geometry": { "provider": "@bitbybit-dev/occt-worker", "kernel": "OCCT WASM", "version": "1.1.1", "execution": "dedicated worker", "wasmSha256": "1b6a8fc7b83d222854f73b66af8d0e45ad4fdee025904b447e885d4e18ae8656", "status": "core-feature-integration" }, "viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "occt-mesh-integration" }, - "persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "schema-v1-resource-autosave-and-properties" }, + "persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "schema-v3-resource-autosave-properties-dependencies" }, "targetBrowsers": "Capability matrix to be locked in P0-02", - "status": "core-feature-and-property-integration" + "status": "expression-dag-and-subshape-signature-integration" } diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 575fa16..f80f959 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -303,3 +303,9 @@ ## 15. 完成定义 “完整 FreeCAD 对标”只有在 F0-F9 全部阶段门通过、核心与扩展工作台均有兼容等级、稳定子形状命名/Sketcher/Expression/单位/DAG/重算/FCStd 均有黄金报告、跨浏览器和安全门禁通过,并且发布文档列出所有差异后才能使用。当前仓库仍处于核心 Facade、OCCT 几何和属性编辑的进行中阶段,下一工作目标按第 14 节执行。 + +## 16. 已实现切片与剩余边界 + +本轮已完成 `EXU-02..EXU-06` 的基础版本:Quantity 以 mm/deg 等规范单位保存,表达式支持四则运算、对象属性引用和维度错误;已完成 `DAG-01..DAG-06` 的领域版本:Link/Expression 边、下游传播、SCC 循环检测、稳定拓扑计划、generation 和 recompute 状态已进入 Facade 与 SQLite schema v3。还未把每个 DAG 节点接入真实 OCCT/Sketcher 计算 Worker,因此当前是可验证的业务调度层,不是完整几何重算。 + +TSN 已完成面级量化签名、唯一/重复候选判定和匹配 API,并挂接到 Bitbybit `MeshAsset`。签名刻意不使用 transient `faceIndex`;但是它还没有 OCCT 历史 `Generated/Modified/Deleted` 信息,也没有边/顶点和跨特征布尔历史映射。TSN-03、TSN-05、TSN-07、TSN-08 仍是完成 FreeCAD 稳定子形状命名的必要任务。 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 42472c4..caa0a9d 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1698,7 +1698,20 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值 持久化复用 schema v1 已有 `object_properties` 表:`value_json` 保存完整 Property snapshot,`property_type` 保留可查询类型;删除/重写对象时依赖外键级联清理旧属性。内存降级、autosave、Undo/Redo 和 Facade state 均使用深拷贝,避免 options/property 数组共享引用。 -当前限制:Expression 只展示 metadata,尚无解析/依赖求值;Length 以文档 mm 基值保存,尚无 Quantity 单位换算;PropertyLink 只校验本 Document 目标存在,未实现循环和删除传播;多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。属性变更只做轻量 Touched 标记,不在 UI 线程同步执行 OCCT 重计算。 +当前限制:Expression/Quantity 已完成基础词法、四则运算、长度/角度/百分比换算、对象属性引用和维度校验;函数库、locale 规则、Spreadsheet alias 和完整 FreeCAD 表达式兼容仍待 EXU。PropertyLink、Expression 和下游对象现在进入统一依赖 DAG,支持 SCC 循环诊断、拓扑重算计划和 schema v3 持久化;当前重算执行器仍是 Facade 领域切片,尚未把每个节点调度到 OCCT/Sketcher Worker。多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。 + +### 16.17 EXU/DAG/TSN 基础切片验证记录 + +| 检查 | 结果 | 证据 | +|---|---|---| +| Quantity 基础运算 | `PASS` | `1 in + 2 mm = 27.4 mm`;长度/角度相加、除零会结构化失败 | +| Property Expression | `PASS` | Pad Length 可保存 `1 in + 2 mm`;Pocket 可引用 `pad.Length` 并生成 expression dependency | +| 依赖 DAG | `PASS` | `pad → pocket → fillet → body` 下游传播和稳定拓扑顺序均有测试 | +| 循环检测 | `PASS` | `pad.Length ↔ pocket.Length` 返回 `DEPENDENCY_CYCLE`,不提交伪造重算结果 | +| SQLite schema | `PASS` | schema v1→v2 增加 `recompute_json`,v2→v3 增加 dependency property/reference 字段 | +| 子形状签名 | `PASS` | 面签名不使用 transient `faceIndex`;重复签名标记 `ambiguous`,唯一候选可匹配 | + +尚未完成的 TSN 工作:OCCT `Generated/Modified/Deleted` 历史捕获、跨布尔/特征的真实拓扑映射、面/边/顶点统一命名、附着和 `TopoRef` 文件迁移。当前签名是可审计的基础候选层,不能单独宣称 FreeCAD 稳定拓扑命名已完成。 ### 16.16 引擎版本修复与完整 FreeCAD 对标专项 diff --git a/src/facade/dependencyGraph.ts b/src/facade/dependencyGraph.ts new file mode 100644 index 0000000..e249239 --- /dev/null +++ b/src/facade/dependencyGraph.ts @@ -0,0 +1,145 @@ +export type DependencyRelation = 'link' | 'expression' | 'topo-ref' | 'container' | 'view' + +export type DependencyEdge = { + sourceId: string + targetId: string + relation: DependencyRelation + propertyName?: string + reference?: string +} + +export type RecomputePlan = { + affected: string[] + order: string[] + cycles: string[][] +} + +export type RecomputeState = 'up-to-date' | 'touched' | 'recomputing' | 'error' | 'upstream-failed' + +export type RecomputeSnapshot = { + generation: number + status: 'idle' | 'recomputing' | 'completed' | 'failed' + objectStates: Record + dirtyObjects: string[] + order: string[] + errors: Array<{ objectId: string; code: string; message: string }> +} + +export class DependencyGraph { + private readonly nodes = new Set() + private readonly outgoing = new Map>() + private readonly incoming = new Map>() + private readonly edgeList: DependencyEdge[] = [] + + constructor(edges: DependencyEdge[] = [], nodeIds: string[] = []) { + nodeIds.forEach((nodeId) => this.addNode(nodeId)) + edges.forEach((edge) => this.addEdge(edge)) + } + + addNode(nodeId: string) { + this.nodes.add(nodeId) + if (!this.outgoing.has(nodeId)) this.outgoing.set(nodeId, new Set()) + if (!this.incoming.has(nodeId)) this.incoming.set(nodeId, new Set()) + } + + addEdge(edge: DependencyEdge) { + if (!edge.sourceId || !edge.targetId) throw new RangeError('Dependency edge requires sourceId and targetId.') + this.addNode(edge.sourceId) + this.addNode(edge.targetId) + if (this.edgeList.some((candidate) => candidate.sourceId === edge.sourceId && candidate.targetId === edge.targetId && candidate.relation === edge.relation && candidate.propertyName === edge.propertyName)) return + this.edgeList.push({ ...edge }) + this.outgoing.get(edge.sourceId)?.add(edge.targetId) + this.incoming.get(edge.targetId)?.add(edge.sourceId) + } + + removeEdgesForProperty(sourceId: string, propertyName: string) { + const retained = this.edgeList.filter((edge) => edge.sourceId !== sourceId || edge.propertyName !== propertyName) + this.edgeList.length = 0 + retained.forEach((edge) => this.edgeList.push(edge)) + this.rebuildIndexes() + } + + edges(): DependencyEdge[] { return this.edgeList.map((edge) => ({ ...edge })) } + + dependenciesOf(sourceId: string): string[] { return [...(this.outgoing.get(sourceId) ?? [])] } + + dependentsOf(targetId: string): string[] { return [...(this.incoming.get(targetId) ?? [])] } + + plan(dirtyIds: Iterable): RecomputePlan { + const affected = new Set() + const visitDependents = (nodeId: string) => { + if (affected.has(nodeId)) return + affected.add(nodeId) + for (const dependent of this.dependentsOf(nodeId)) visitDependents(dependent) + } + for (const dirtyId of dirtyIds) if (this.nodes.has(dirtyId)) visitDependents(dirtyId) + + const cycles = this.findCycles(affected) + const cyclic = new Set(cycles.flat()) + const order: string[] = [] + const visited = new Set() + const visitDependencies = (nodeId: string) => { + if (visited.has(nodeId)) return + visited.add(nodeId) + for (const dependency of this.dependenciesOf(nodeId)) if (affected.has(dependency) && !cyclic.has(dependency)) visitDependencies(dependency) + if (!cyclic.has(nodeId)) order.push(nodeId) + } + for (const nodeId of affected) visitDependencies(nodeId) + return { affected: [...affected], order, cycles } + } + + findCycles(scope: Set = this.nodes): string[][] { + const cycles: string[][] = [] + const indexByNode = new Map() + const lowLinkByNode = new Map() + const stack: string[] = [] + const onStack = new Set() + let index = 0 + const strongConnect = (nodeId: string) => { + indexByNode.set(nodeId, index) + lowLinkByNode.set(nodeId, index) + index += 1 + stack.push(nodeId) + onStack.add(nodeId) + for (const dependency of this.dependenciesOf(nodeId)) { + if (!scope.has(dependency)) continue + if (!indexByNode.has(dependency)) { + strongConnect(dependency) + lowLinkByNode.set(nodeId, Math.min(lowLinkByNode.get(nodeId) as number, lowLinkByNode.get(dependency) as number)) + } else if (onStack.has(dependency)) { + lowLinkByNode.set(nodeId, Math.min(lowLinkByNode.get(nodeId) as number, indexByNode.get(dependency) as number)) + } + } + if (lowLinkByNode.get(nodeId) !== indexByNode.get(nodeId)) return + const component: string[] = [] + let candidate = '' + do { + candidate = stack.pop() as string + onStack.delete(candidate) + component.push(candidate) + } while (candidate !== nodeId) + if (component.length > 1 || this.dependenciesOf(nodeId).includes(nodeId)) { + const members = new Set(component) + cycles.push([...scope].filter((candidate) => members.has(candidate))) + } + } + for (const nodeId of scope) if (!indexByNode.has(nodeId)) strongConnect(nodeId) + return cycles + } + + private rebuildIndexes() { + this.outgoing.clear() + this.incoming.clear() + for (const nodeId of this.nodes) { this.outgoing.set(nodeId, new Set()); this.incoming.set(nodeId, new Set()) } + for (const edge of this.edgeList) { this.outgoing.get(edge.sourceId)?.add(edge.targetId); this.incoming.get(edge.targetId)?.add(edge.sourceId) } + } +} + +export const createRecomputeSnapshot = (objectIds: string[], generation = 0): RecomputeSnapshot => ({ + generation, + status: 'idle', + objectStates: Object.fromEntries(objectIds.map((objectId) => [objectId, 'up-to-date'])), + dirtyObjects: [], + order: [], + errors: [], +}) diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts index 35c933b..f7d2dce 100644 --- a/src/facade/geometryRuntime.ts +++ b/src/facade/geometryRuntime.ts @@ -1,6 +1,7 @@ import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker' import type { Inputs } from '@bitbybit-dev/occt' -import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle } from './types' +import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef } from './types' +import { createSubshapeRefs } from './topologyNaming' type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer type KernelMesh = Inputs.OCCT.DecomposedMeshDto @@ -176,7 +177,7 @@ const appendFace = (face: Inputs.OCCT.DecomposedFaceDto, positions: number[], no for (const index of face.triIndexes) indices.push(vertexOffset + index) } -export const normalizeBitbybitMesh = (shape: ShapeHandle, mesh: KernelMesh): MeshAsset => { +export const normalizeBitbybitMesh = (shape: ShapeHandle, mesh: KernelMesh, tolerance = 1e-5): MeshAsset => { const positions: number[] = [] const normals: number[] = [] const indices: number[] = [] @@ -191,12 +192,14 @@ export const normalizeBitbybitMesh = (shape: ShapeHandle, mesh: KernelMesh): Mes max[axis] = Math.max(max[axis], positions[index + axis]) } } + const subshapes: SubshapeRef[] = createSubshapeRefs(shape.id, shape.documentVersion, mesh.faceList.map((face) => ({ vertexCoord: face.vertexCoord, normalCoord: face.normalCoord, triIndexes: face.triIndexes })), tolerance).refs return { shapeId: shape.id, topologyVersion: shape.documentVersion, positions: new Float32Array(positions), normals: new Float32Array(normals), indices: new Uint32Array(indices), + subshapes, bounds: { min, max }, } } @@ -385,7 +388,11 @@ export class BitbybitGeometryRuntime { const entry = this.resolveShape(shape) const client = await this.readyClient() const mesh = await client.occt.shapeToMesh({ shape: entry.reference, precision, adjustYtoZ: false }) - return normalizeBitbybitMesh(entry.handle, mesh) + return normalizeBitbybitMesh(entry.handle, mesh, Math.max(1e-5, precision * 0.1)) + } + + async subshapes(shape: ShapeHandle, precision = 0.05): Promise { + return (await this.mesh(shape, precision)).subshapes ?? [] } async release(shape: ShapeHandle): Promise { diff --git a/src/facade/index.ts b/src/facade/index.ts index d6984b7..5051282 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -3,4 +3,5 @@ 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, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, PropertyValue, RevolutionInput, SetPropertyInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' +export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' +export { createSubshapeRefs, matchSubshapes, signatureForFace } from './topologyNaming' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 376554c..de2a2d7 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -14,12 +14,16 @@ import type { ObjectPropertySnapshot, PropertyValue, SetPropertyInput, + SetExpressionInput, + RecomputeResult, TaskSnapshot, Unsubscribe, } from './types' import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore' import { BitbybitGeometryRuntime } from './geometryRuntime' import { ThreeViewportAdapter } from './threeViewport' +import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph' +import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units' const initialTree: ModelTreeItem[] = [ { id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] }, @@ -60,6 +64,7 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Through all', options: ['Dimension', 'Through all', 'Up to face'], recompute: true }, { 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: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, ] if (item.id.startsWith('fillet')) return [ @@ -82,11 +87,45 @@ 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 })) })), + 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 createDocument = (label = 'Pump Housing'): DocumentSnapshot => ({ - id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: initialTree.map(createObjectSnapshot), -}) +const collectDependencyEdges = (document: Pick): DependencyEdge[] => { + const objectIds = new Set(document.objects.map((object) => object.id)) + const edges: DependencyEdge[] = [] + 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.expression) { + for (const reference of expressionReferences(property.expression)) { + const separator = reference.lastIndexOf('.') + const targetId = separator > 0 ? reference.slice(0, separator) : '' + if (objectIds.has(targetId)) edges.push({ sourceId: object.id, targetId, relation: 'expression', propertyName: property.name, reference }) + } + } + } + } + return edges +} + +const expressionReferences = (expression: string): string[] => { + try { + return evaluateQuantityExpression(expression).references + } catch { + const references = new Set() + for (const match of expression.matchAll(/[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*/g)) references.add(match[0]) + return [...references] + } +} + +const createDocument = (label = 'Pump Housing'): DocumentSnapshot => { + const objects = initialTree.map(createObjectSnapshot) + const document: DocumentSnapshot = { id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects } + document.dependencies = collectDependencyEdges(document) + document.recompute = createRecomputeSnapshot(objects.map((object) => object.id)) + return document +} const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area']) const systemCommands = new Set(['new-document', 'save', 'select-object']) @@ -124,6 +163,49 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.') } +const expressionVariables = (document: DocumentSnapshot): ReadonlyMap => { + const variables = new Map() + for (const object of document.objects) for (const property of object.properties) { + if (typeof property.value !== 'number' || !Number.isFinite(property.value)) continue + const quantity = property.unit && getUnit(property.unit) ? quantityFromUnit(property.value, property.unit) : quantityFromNumber(property.value) + variables.set(`${object.id}.${property.name}`, quantity) + } + return variables +} + +const expectedExpressionValue = (property: ObjectPropertySnapshot, expression: string, document: DocumentSnapshot) => { + const result = evaluateQuantityExpression(expression, expressionVariables(document)) + const expectedDimension = quantityDimensionForUnit(property.unit) + let quantity = result.value + if (quantity.dimension === 'dimensionless' && expectedDimension !== 'dimensionless') { + const unit = property.unit ? getUnit(property.unit) : undefined + if (!unit) throw new TypeError(`${property.label} requires a registered unit.`) + quantity = { value: quantity.value * unit.factor, dimension: expectedDimension } + } + if (quantity.dimension !== expectedDimension) throw new TypeError(`${property.label} expression has dimension ${quantity.dimension}; expected ${expectedDimension}.`) + const value = property.unit ? convertQuantity(quantity, property.unit) : quantity.value + if (!Number.isFinite(value)) throw new RangeError(`${property.label} expression result must be finite.`) + return { value, references: result.references } +} + +const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable) => { + const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id)) + const plan = graph.plan(objectIds) + const recompute = document.recompute ?? createRecomputeSnapshot(document.objects.map((object) => object.id)) + const objectStates = { ...recompute.objectStates } + for (const objectId of plan.affected) objectStates[objectId] = 'touched' + const dirtyObjects = Object.entries(objectStates).filter(([, status]) => status === 'touched' || status === 'error' || status === 'upstream-failed').map(([objectId]) => objectId) + document.recompute = { ...recompute, status: 'idle', objectStates, dirtyObjects, order: [], errors: [] } + for (const objectId of plan.affected) { + const item = document.tree.find((candidate) => candidate.id === objectId) + if (item && item.state !== 'active' && item.state !== 'readonly') item.state = 'dirty' + const object = document.objects.find((candidate) => candidate.id === objectId) + const status = object?.properties.find((property) => property.name === 'Status') + if (status && status.value !== 'Warning') status.value = 'Touched' + } + return plan +} + export function createMockFacade(): BitBybitWebCadFacade { const projectPersistence = createSqliteProjectPersistence() const geometryRuntime = new BitbybitGeometryRuntime() @@ -160,7 +242,11 @@ export function createMockFacade(): BitBybitWebCadFacade { if (body) body.children = [...(body.children || []), objectId] tree.push(item) } - return { document: { ...document, version: document.version + 1, dirty: true, tree, objects: [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })) })), createObjectSnapshot(item)] }, objectId } + const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })) })), createObjectSnapshot(item)] + 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) + return { document: nextDocument, objectId } } const setProperty = ({ objectId, propertyName, value }: SetPropertyInput) => { const objectIndex = state.document.objects.findIndex((object) => object.id === objectId) @@ -174,19 +260,77 @@ export function createMockFacade(): BitBybitWebCadFacade { const document = cloneDocumentSnapshot(state.document) const object = document.objects[objectIndex] - object.properties[propertyIndex] = { ...object.properties[propertyIndex], value } + object.properties[propertyIndex] = { ...object.properties[propertyIndex], value, expression: undefined, expressionError: undefined } const treeItem = document.tree.find((item) => item.id === objectId) if (propertyName === 'Label' && treeItem) treeItem.label = String(value) - if (sourceProperty.recompute && treeItem && treeItem.state !== 'active') { - treeItem.state = 'dirty' - const status = object.properties.find((property) => property.name === 'Status') - if (status) status.value = 'Touched' - } + document.dependencies = collectDependencyEdges(document) + if (sourceProperty.recompute) markDocumentTouched(document, [objectId]) document.version += 1 document.dirty = true commit({ ...state, document }) notify(`${sourceProperty.label} updated`) } + const setExpression = ({ objectId, propertyName, expression }: SetExpressionInput) => { + const objectIndex = state.document.objects.findIndex((object) => object.id === objectId) + if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`) + const sourceObject = state.document.objects[objectIndex] + const propertyIndex = sourceObject.properties.findIndex((property) => property.name === propertyName) + if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`) + const sourceProperty = sourceObject.properties[propertyIndex] + if (sourceProperty.readOnly) throw new Error(`${sourceProperty.label} is read-only.`) + if (!['App::PropertyLength', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`) + const evaluated = expectedExpressionValue(sourceProperty, expression, state.document) + const references = expressionReferences(expression) + const variables = expressionVariables(state.document) + for (const reference of references) if (!variables.has(reference)) throw new ReferenceError(`Unknown expression reference: ${reference}.`) + const document = cloneDocumentSnapshot(state.document) + const property = document.objects[objectIndex].properties[propertyIndex] + property.value = evaluated.value + property.expression = expression.trim() + property.expressionError = undefined + document.dependencies = collectDependencyEdges(document) + if (sourceProperty.recompute) markDocumentTouched(document, [objectId]) + document.version += 1 + document.dirty = true + commit({ ...state, document }) + notify(`${sourceProperty.label} expression updated`) + } + const recomputeDocument = (): RecomputeResult => { + const document = cloneDocumentSnapshot(state.document) + const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id)) + const recompute = document.recompute ?? createRecomputeSnapshot(document.objects.map((object) => object.id)) + const dirtyIds = recompute.dirtyObjects.length > 0 ? recompute.dirtyObjects : document.objects.filter((object) => object.properties.some((property) => property.recompute && property.expressionError)).map((object) => object.id) + const plan = graph.plan(dirtyIds) + const generation = recompute.generation + 1 + const errors: RecomputeResult['errors'] = [] + const objectStates = { ...recompute.objectStates } + for (const objectId of plan.affected) objectStates[objectId] = 'recomputing' + for (const cycle of plan.cycles) { + 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' + } + } 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 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 } + if (document.dirty) autosave.schedule(document) + emitState() + return { ...plan, generation, status: nextRecompute.status, errors } + } const applyTask = () => { const task = state.task if (!task || task.status !== 'preview') return @@ -225,13 +369,13 @@ export function createMockFacade(): BitBybitWebCadFacade { } const facade: BitBybitWebCadFacade = { - app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })) } : null }, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty } }, + app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })) } : null }, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit } }, history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } }, gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), 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() } }, project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), 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), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, + geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), 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), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, } diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts index b50d569..2a9e147 100644 --- a/src/facade/persistenceWorker.ts +++ b/src/facade/persistenceWorker.ts @@ -82,12 +82,14 @@ const saveDocument = (document: DocumentSnapshot) => { database.exec('BEGIN;') try { database.exec({ sql: 'INSERT INTO projects(id, name, schema_version, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, schema_version=excluded.schema_version, updated_at=excluded.updated_at', bind: [document.id, document.label, PROJECT_SCHEMA_VERSION, now, now] }) - database.exec({ sql: 'INSERT INTO documents(id, project_id, label, version, dirty, read_only, units) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET label=excluded.label, version=excluded.version, dirty=excluded.dirty, read_only=excluded.read_only, units=excluded.units', bind: [document.id, document.id, document.label, document.version, document.dirty ? 1 : 0, document.readOnly ? 1 : 0, document.units] }) + database.exec({ sql: 'INSERT INTO documents(id, project_id, label, version, dirty, read_only, units, recompute_json) VALUES(?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET label=excluded.label, version=excluded.version, dirty=excluded.dirty, read_only=excluded.read_only, units=excluded.units, recompute_json=excluded.recompute_json', bind: [document.id, document.id, document.label, document.version, document.dirty ? 1 : 0, document.readOnly ? 1 : 0, document.units, JSON.stringify(document.recompute ?? null)] }) database.exec({ sql: 'DELETE FROM objects WHERE document_id = ?', bind: [document.id] }) const parentByChild = new Map() for (const item of document.tree) for (const childId of item.children || []) parentByChild.set(childId, item.id) document.tree.forEach((item, ordinal) => database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal] })) for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] }) + database.exec({ sql: 'DELETE FROM dependencies WHERE document_id = ?', bind: [document.id] }) + for (const edge of document.dependencies ?? []) database.exec({ sql: 'INSERT INTO dependencies(document_id, source_id, target_id, relation, property_name, reference) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, edge.sourceId, edge.targetId, edge.relation, edge.propertyName ?? null, edge.reference ?? null] }) database.exec('COMMIT;') } catch (error) { database.exec('ROLLBACK;') @@ -98,11 +100,12 @@ const saveDocument = (document: DocumentSnapshot) => { const loadDocument = (documentId: string): DocumentSnapshot | null => { if (!database) throw new Error('Persistence database is not initialized.') - const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> + const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const row = documents[0] if (!row) return null const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const propertyRows = database.exec({ sql: 'SELECT object_id, value_json FROM object_properties WHERE document_id = ? ORDER BY object_id, name', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> + const dependencyRows = database.exec({ sql: 'SELECT source_id, target_id, relation, property_name, reference FROM dependencies WHERE document_id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const propertiesByObject = new Map() for (const propertyRow of propertyRows) { const properties = propertiesByObject.get(String(propertyRow.object_id)) ?? [] @@ -124,6 +127,8 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => { units: String(row.units), tree, objects: objectSnapshots, + dependencies: dependencyRows.map((dependency) => ({ sourceId: String(dependency.source_id), targetId: String(dependency.target_id), relation: String(dependency.relation) as 'link' | 'expression' | 'topo-ref' | 'container' | 'view', propertyName: dependency.property_name ? String(dependency.property_name) : undefined, reference: dependency.reference ? String(dependency.reference) : undefined })), + recompute: row.recompute_json ? JSON.parse(String(row.recompute_json)) : undefined, } } diff --git a/src/facade/projectSchema.ts b/src/facade/projectSchema.ts index 0028115..79c24cf 100644 --- a/src/facade/projectSchema.ts +++ b/src/facade/projectSchema.ts @@ -1,4 +1,4 @@ -export const PROJECT_SCHEMA_VERSION = 1 +export const PROJECT_SCHEMA_VERSION = 3 export const PROJECT_SCHEMA_SQL = ` PRAGMA foreign_keys = ON; @@ -84,4 +84,8 @@ CREATE TABLE IF NOT EXISTS resources ( ); ` -export const PROJECT_SCHEMA_MIGRATIONS = [{ version: PROJECT_SCHEMA_VERSION, sql: PROJECT_SCHEMA_SQL }] as const +export const PROJECT_SCHEMA_MIGRATIONS = [ + { version: 1, sql: PROJECT_SCHEMA_SQL }, + { version: 2, sql: `ALTER TABLE documents ADD COLUMN recompute_json TEXT NOT NULL DEFAULT '{"generation":0,"status":"idle","objectStates":{},"dirtyObjects":[],"order":[],"errors":[]}';` }, + { version: 3, sql: 'ALTER TABLE dependencies ADD COLUMN property_name TEXT; ALTER TABLE dependencies ADD COLUMN reference TEXT;' }, +] as const diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index de2cc6f..6c9fcb3 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -5,7 +5,7 @@ type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { 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: '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 })) })) }) +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 })) })), 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/topologyNaming.ts b/src/facade/topologyNaming.ts new file mode 100644 index 0000000..faa7036 --- /dev/null +++ b/src/facade/topologyNaming.ts @@ -0,0 +1,111 @@ +import type { SubshapeRef } from './types' + +export type FaceMeshInput = { + vertexCoord: number[] + normalCoord: number[] + triIndexes: number[] +} + +export type SubshapeSignature = { + kind: 'face' + canonical: string + hash: string + centroid: [number, number, number] + bounds: { min: [number, number, number]; max: [number, number, number] } + area: number + normal: [number, number, number] +} + +export type SubshapeMatch = { + current: SubshapeRef + previousId?: string + score: number + status: 'stable' | 'ambiguous' | 'new' | 'deleted' +} + +const quantize = (value: number, tolerance: number) => Math.round(value / tolerance) + +const hashString = (value: string) => { + let hash = 1469598103934665603n + for (let index = 0; index < value.length; index += 1) { + hash ^= BigInt(value.charCodeAt(index)) + hash = BigInt.asUintN(64, hash * 1099511628211n) + } + return hash.toString(16).padStart(16, '0') +} + +const cross = (left: [number, number, number], right: [number, number, number]): [number, number, number] => [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]] +const magnitude = (value: [number, number, number]) => Math.hypot(value[0], value[1], value[2]) + +const facePoint = (coordinates: number[], index: number): [number, number, number] => [coordinates[index * 3], coordinates[index * 3 + 1], coordinates[index * 3 + 2]] + +export const signatureForFace = (face: FaceMeshInput, tolerance = 1e-5): SubshapeSignature => { + if (face.vertexCoord.length === 0 || face.vertexCoord.length % 3 !== 0) throw new RangeError('A face signature requires vertex coordinates in triples.') + const pointCount = face.vertexCoord.length / 3 + const min: [number, number, number] = [Infinity, Infinity, Infinity] + const max: [number, number, number] = [-Infinity, -Infinity, -Infinity] + const sum: [number, number, number] = [0, 0, 0] + for (let index = 0; index < pointCount; index += 1) { + const point = facePoint(face.vertexCoord, index) + for (let axis = 0; axis < 3; axis += 1) { min[axis] = Math.min(min[axis], point[axis]); max[axis] = Math.max(max[axis], point[axis]); sum[axis] += point[axis] } + } + const centroid: [number, number, number] = sum.map((value) => value / pointCount) as [number, number, number] + let area = 0 + const normalSum: [number, number, number] = [0, 0, 0] + for (let index = 0; index + 2 < face.triIndexes.length; index += 3) { + const a = facePoint(face.vertexCoord, face.triIndexes[index]) + const b = facePoint(face.vertexCoord, face.triIndexes[index + 1]) + const c = facePoint(face.vertexCoord, face.triIndexes[index + 2]) + const normal = cross([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]) + area += magnitude(normal) / 2 + normalSum[0] += normal[0]; normalSum[1] += normal[1]; normalSum[2] += normal[2] + } + const normalLength = magnitude(normalSum) + const normal: [number, number, number] = normalLength > tolerance ? normalSum.map((value) => value / normalLength) as [number, number, number] : [0, 0, 0] + const canonical = [ + 'face', + `bounds=${min.map((value) => quantize(value, tolerance)).join(',')}:${max.map((value) => quantize(value, tolerance)).join(',')}`, + `centroid=${centroid.map((value) => quantize(value, tolerance)).join(',')}`, + `area=${quantize(area, tolerance)}`, + `normal=${normal.map((value) => quantize(value, tolerance)).join(',')}`, + ].join('|') + return { kind: 'face', canonical, hash: hashString(canonical), centroid, bounds: { min, max }, area, normal } +} + +export const createSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => { + const signatures = faces.map((face) => signatureForFace(face, tolerance)) + const occurrences = new Map() + signatures.forEach((signature) => occurrences.set(signature.hash, (occurrences.get(signature.hash) ?? 0) + 1)) + const refs = signatures.map((signature) => { + const duplicate = (occurrences.get(signature.hash) ?? 0) > 1 + return { shapeId, kind: 'face' as const, persistentId: `topo-face-${signature.hash}`, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? signatures.filter((candidate) => candidate.hash === signature.hash).map((candidate) => `topo-face-${candidate.hash}`) : undefined } + }) + return { refs, signatures } +} + +const signatureScore = (left: SubshapeSignature, right: SubshapeSignature, tolerance: number) => { + const distance = Math.hypot(...left.centroid.map((value, axis) => value - right.centroid[axis])) + const areaDelta = Math.abs(left.area - right.area) + const boundsDelta = Math.hypot(...left.bounds.min.map((value, axis) => value - right.bounds.min[axis]), ...left.bounds.max.map((value, axis) => value - right.bounds.max[axis])) + const normalDelta = Math.hypot(...left.normal.map((value, axis) => value - right.normal[axis])) + return Math.max(0, 1 - (distance + areaDelta + boundsDelta + normalDelta) / Math.max(tolerance, 1e-9)) +} + +export const matchSubshapes = (previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>, current: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>, tolerance = 1e-4): SubshapeMatch[] => { + const matches: SubshapeMatch[] = [] + const used = new Set() + for (const candidate of current) { + const exact = previous.filter((entry) => entry.signature.hash === candidate.signature.hash && !used.has(entry.ref.persistentId)) + if (exact.length === 1) { used.add(exact[0].ref.persistentId); matches.push({ current: { ...candidate.ref, persistentId: exact[0].ref.persistentId, status: 'stable' }, previousId: exact[0].ref.persistentId, score: 1, status: 'stable' }); continue } + const scored = previous.map((entry) => ({ entry, score: signatureScore(entry.signature, candidate.signature, tolerance) })).filter((entry) => !used.has(entry.entry.ref.persistentId)).sort((left, right) => right.score - left.score) + const best = scored[0] + const second = scored[1] + if (best && best.score > 0.9 && (!second || best.score - second.score > 0.05)) { + used.add(best.entry.ref.persistentId) + matches.push({ current: { ...candidate.ref, persistentId: best.entry.ref.persistentId, status: 'stable' }, previousId: best.entry.ref.persistentId, score: best.score, status: 'stable' }) + } else if (best && best.score > 0.5 && second && Math.abs(best.score - second.score) <= 0.05) { + matches.push({ current: { ...candidate.ref, status: 'ambiguous', candidates: [best.entry.ref.persistentId, second.entry.ref.persistentId] }, score: best.score, status: 'ambiguous' }) + } else matches.push({ current: { ...candidate.ref, status: 'new' }, score: best?.score ?? 0, status: 'new' }) + } + return matches +} diff --git a/src/facade/types.ts b/src/facade/types.ts index 0ad1a4a..6624393 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -1,4 +1,6 @@ import type { CommandDefinition, WorkbenchId } from '../freecadManifest' +import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph' +import type { Quantity, QuantityDimension } from './units' export type ModelTreeItem = { id: string @@ -24,6 +26,7 @@ export type ObjectPropertySnapshot = { recompute?: boolean options?: string[] expression?: string + expressionError?: string } export type DocumentObjectSnapshot = { @@ -41,6 +44,20 @@ export type DocumentSnapshot = { units: string tree: ModelTreeItem[] objects: DocumentObjectSnapshot[] + dependencies?: DependencyEdge[] + recompute?: RecomputeSnapshot +} + +export type SetExpressionInput = { + objectId: string + propertyName: string + expression: string +} + +export type RecomputeResult = RecomputePlan & { + generation: number + status: RecomputeSnapshot['status'] + errors: Array<{ objectId: string; code: string; message: string }> } export type SetPropertyInput = { @@ -93,6 +110,9 @@ export type SubshapeRef = { kind: 'face' | 'edge' | 'vertex' persistentId: string topologyVersion: number + status?: 'stable' | 'ambiguous' | 'new' | 'deleted' + signature?: string + candidates?: string[] } export type MeshAsset = { @@ -101,6 +121,7 @@ export type MeshAsset = { positions: Float32Array normals: Float32Array indices: Uint32Array + subshapes?: SubshapeRef[] bounds: { min: [number, number, number] max: [number, number, number] @@ -273,6 +294,13 @@ export interface BitBybitWebCadFacade { create(label?: string): DocumentSnapshot markDirty(): void setProperty(input: SetPropertyInput): void + setExpression(input: SetExpressionInput): void + recompute(): RecomputeResult + getDependencies(): DependencyEdge[] + } + expression: { + evaluate(expression: string, variables?: Record): { value: Quantity; references: string[] } + dimensionForUnit(unit?: string): QuantityDimension } } readonly history: { @@ -330,6 +358,7 @@ export interface BitBybitWebCadFacade { pocket(input: PocketInput): Promise revolution(input: RevolutionInput): Promise mesh(shape: ShapeHandle, precision?: number): Promise + subshapes(shape: ShapeHandle, precision?: number): Promise release(shape: ShapeHandle): Promise dispose(): void } diff --git a/src/facade/units.ts b/src/facade/units.ts new file mode 100644 index 0000000..fd5a291 --- /dev/null +++ b/src/facade/units.ts @@ -0,0 +1,189 @@ +export type QuantityDimension = 'dimensionless' | 'length' | 'angle' | 'area' | 'volume' | 'percent' + +export type Quantity = { + value: number + dimension: QuantityDimension +} + +export type UnitDefinition = { + id: string + symbol: string + dimension: QuantityDimension + factor: number +} + +const definitions: UnitDefinition[] = [ + { id: 'mm', symbol: 'mm', dimension: 'length', factor: 1 }, + { id: 'cm', symbol: 'cm', dimension: 'length', factor: 10 }, + { id: 'm', symbol: 'm', dimension: 'length', factor: 1000 }, + { id: 'in', symbol: 'in', dimension: 'length', factor: 25.4 }, + { id: 'ft', symbol: 'ft', dimension: 'length', factor: 304.8 }, + { id: 'deg', symbol: 'deg', dimension: 'angle', factor: 1 }, + { id: 'rad', symbol: 'rad', dimension: 'angle', factor: 180 / Math.PI }, + { id: '%', symbol: '%', dimension: 'percent', factor: 1 }, +] + +const bySymbol = new Map(definitions.map((definition) => [definition.symbol.toLowerCase(), definition])) + +export const listUnits = (): UnitDefinition[] => definitions.map((definition) => ({ ...definition })) + +export const getUnit = (symbol: string): UnitDefinition | undefined => bySymbol.get(symbol.toLowerCase()) + +export const quantityDimensionForUnit = (unit: string | undefined): QuantityDimension => { + if (!unit) return 'dimensionless' + const definition = getUnit(unit) + if (!definition) throw new RangeError(`Unknown unit: ${unit}`) + return definition.dimension +} + +export const quantityFromNumber = (value: number, dimension: QuantityDimension = 'dimensionless'): Quantity => { + if (!Number.isFinite(value)) throw new RangeError('Quantity value must be finite.') + return { value, dimension } +} + +export const quantityFromUnit = (value: number, unit: string): Quantity => { + if (!Number.isFinite(value)) throw new RangeError('Quantity value must be finite.') + const definition = getUnit(unit) + if (!definition) throw new RangeError(`Unknown unit: ${unit}`) + return { value: value * definition.factor, dimension: definition.dimension } +} + +export const convertQuantity = (quantity: Quantity, unit: string): number => { + const definition = getUnit(unit) + if (!definition) throw new RangeError(`Unknown unit: ${unit}`) + if (quantity.dimension !== definition.dimension) throw new TypeError(`Cannot convert ${quantity.dimension} to ${definition.dimension}.`) + return quantity.value / definition.factor +} + +type Token = { kind: 'number' | 'identifier' | 'operator' | 'eof'; text: string; position: number } + +const tokenize = (source: string): Token[] => { + const tokens: Token[] = [] + let index = 0 + while (index < source.length) { + const character = source[index] + if (/\s/.test(character)) { index += 1; continue } + if ('+-*/()'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue } + if (character === '%') { tokens.push({ kind: 'identifier', text: character, position: index }); index += 1; continue } + const number = source.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/) + if (number) { tokens.push({ kind: 'number', text: number[0], position: index }); index += number[0].length; continue } + const identifier = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_.:]*/) + if (identifier) { tokens.push({ kind: 'identifier', text: identifier[0], position: index }); index += identifier[0].length; continue } + throw new SyntaxError(`Unexpected character '${character}' at position ${index}.`) + } + tokens.push({ kind: 'eof', text: '', position: source.length }) + return tokens +} + +const sameDimension = (left: Quantity, right: Quantity) => left.dimension === right.dimension + +const add = (left: Quantity, right: Quantity, operator: '+' | '-') => { + if (!sameDimension(left, right)) throw new TypeError(`Cannot ${operator === '+' ? 'add' : 'subtract'} ${left.dimension} and ${right.dimension}.`) + return { value: operator === '+' ? left.value + right.value : left.value - right.value, dimension: left.dimension } +} + +const multiply = (left: Quantity, right: Quantity): Quantity => { + if (left.dimension === 'dimensionless') return { value: left.value * right.value, dimension: right.dimension } + if (right.dimension === 'dimensionless') return { value: left.value * right.value, dimension: left.dimension } + throw new TypeError(`Cannot multiply ${left.dimension} by ${right.dimension}.`) +} + +const divide = (left: Quantity, right: Quantity): Quantity => { + if (right.value === 0) throw new RangeError('Division by zero.') + if (right.dimension === 'dimensionless') return { value: left.value / right.value, dimension: left.dimension } + if (left.dimension === right.dimension) return { value: left.value / right.value, dimension: 'dimensionless' } + throw new TypeError(`Cannot divide ${left.dimension} by ${right.dimension}.`) +} + +class QuantityParser { + private index = 0 + readonly references = new Set() + + constructor(private readonly tokens: Token[], private readonly variables: ReadonlyMap) {} + + parse(): Quantity { + const result = this.parseAddSub() + const token = this.peek() + if (token.kind !== 'eof') throw new SyntaxError(`Unexpected token '${token.text}' at position ${token.position}.`) + return result + } + + private peek() { return this.tokens[this.index] } + + private take() { return this.tokens[this.index++] } + + private match(text: string) { + if (this.peek().text !== text) return false + this.index += 1 + return true + } + + private parseAddSub(): Quantity { + let result = this.parseMulDiv() + while (this.peek().text === '+' || this.peek().text === '-') { + const operator = this.take().text as '+' | '-' + result = add(result, this.parseMulDiv(), operator) + } + return result + } + + private parseMulDiv(): Quantity { + let result = this.parseUnary() + while (this.peek().text === '*' || this.peek().text === '/') { + const operator = this.take().text + result = operator === '*' ? multiply(result, this.parseUnary()) : divide(result, this.parseUnary()) + } + return result + } + + private parseUnary(): Quantity { + if (this.match('+')) return this.parseUnary() + if (this.match('-')) { const value = this.parseUnary(); return { ...value, value: -value.value } } + return this.parsePrimary() + } + + private parsePrimary(): Quantity { + const token = this.take() + if (token.text === '(') { + const result = this.parseAddSub() + if (!this.match(')')) throw new SyntaxError(`Missing ')' at position ${this.peek().position}.`) + return result + } + if (token.kind === 'number') { + const value = Number(token.text) + const next = this.peek() + if (next.kind === 'identifier' && getUnit(next.text)) return quantityFromUnit(value, this.take().text) + return quantityFromNumber(value) + } + if (token.kind === 'identifier') { + if (token.text.toLowerCase() === 'pi') return quantityFromNumber(Math.PI) + const value = this.variables.get(token.text) + if (!value) throw new ReferenceError(`Unknown expression reference: ${token.text}.`) + this.references.add(token.text) + return { ...value } + } + throw new SyntaxError(`Expected a quantity at position ${token.position}.`) + } +} + +export const evaluateQuantityExpression = (expression: string, variables: ReadonlyMap = new Map()) => { + if (!expression.trim()) throw new SyntaxError('Expression cannot be empty.') + const parser = new QuantityParser(tokenize(expression), variables) + const value = parser.parse() + if (!Number.isFinite(value.value)) throw new RangeError('Expression result must be finite.') + return { value, references: [...parser.references] } +} + +export const extractExpressionReferences = (expression: string): string[] => { + const references = new Set() + for (const token of tokenize(expression)) { + if (token.kind !== 'identifier' || token.text.toLowerCase() === 'pi' || getUnit(token.text)) continue + if (token.text.includes('.')) references.add(token.text) + } + return [...references] +} + +export const formatQuantity = (quantity: Quantity, unit: string, fractionDigits = 6): string => { + const value = convertQuantity(quantity, unit) + return `${Number(value.toFixed(fractionDigits))} ${unit}` +} diff --git a/tests/facade.test.ts b/tests/facade.test.ts index ce5a5a3..5b6054f 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -3,6 +3,9 @@ import assert from 'node:assert/strict' import { createMockFacade } from '../src/facade/mockFacade' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema' import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' +import { DependencyGraph } from '../src/facade/dependencyGraph' +import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units' +import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming' import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' import type { ShapeHandle } from '../src/facade/types' @@ -62,6 +65,8 @@ test('Bitbybit face meshes are normalized into a facade-owned indexed asset', () assert.deepEqual([...mesh.indices], [0, 1, 2]) assert.deepEqual(mesh.bounds, { min: [0, 0, 0], max: [2, 3, 0] }) assert.equal(mesh.topologyVersion, 7) + assert.equal(mesh.subshapes?.length, 1) + assert.equal(mesh.subshapes?.[0].status, 'stable') assert.throws(() => normalizeBitbybitMesh(shape, { faceList: [{ faceIndex: 2, vertexCoord: [0, 0, 0], normalCoord: [], triIndexes: [0, 1, 0], numberOfTriangles: 1 }], edgeList: [], pointsList: [], @@ -69,12 +74,56 @@ test('Bitbybit face meshes are normalized into a facade-owned indexed asset', () }) test('project schema is versioned and covers the FreeCAD document graph', () => { - assert.equal(PROJECT_SCHEMA_VERSION, 1) - assert.equal(PROJECT_SCHEMA_MIGRATIONS[0].version, PROJECT_SCHEMA_VERSION) + assert.equal(PROJECT_SCHEMA_VERSION, 3) + assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3]) for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)) assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/) }) +test('quantity expressions convert units and reject incompatible dimensions', () => { + const result = evaluateQuantityExpression('1 in + 2 mm') + assert.equal(result.value.dimension, 'length') + assert.ok(Math.abs(result.value.value - 27.4) < 1e-9) + const variables = new Map([['pad.Length', { value: 42, dimension: 'length' as const }]]) + const reference = evaluateQuantityExpression('pad.Length * 2', variables) + assert.deepEqual(reference.references, ['pad.Length']) + assert.equal(reference.value.value, 84) + assert.throws(() => evaluateQuantityExpression('1 in + 90 deg'), /Cannot add/) + assert.throws(() => evaluateQuantityExpression('1 mm / 0'), /Division by zero/) + assert.equal(evaluateQuantityExpression('2 + 3').value.value, 5) + assert.equal(quantityFromNumber(7).dimension, 'dimensionless') + assert.equal(evaluateQuantityExpression('50%').value.dimension, 'percent') +}) + +test('topology signatures are independent of transient face indexes and flag ambiguity', () => { + const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 0, 2, 0, 0, 2], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] } + const signature = signatureForFace(square) + const sameSignature = signatureForFace({ ...square, triIndexes: [3, 0, 1, 1, 2, 3] }) + assert.equal(signature.hash, sameSignature.hash) + const first = createSubshapeRefs('shape-a', 1, [square]) + assert.equal(first.refs[0].status, 'stable') + const duplicate = createSubshapeRefs('shape-b', 2, [square, square]) + assert.equal(duplicate.refs[0].status, 'ambiguous') + assert.deepEqual(duplicate.refs[0].candidates, [duplicate.refs[0].persistentId, duplicate.refs[1].persistentId]) + const matches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: duplicate.refs[0], signature: duplicate.signatures[0] }]) + assert.equal(matches[0].status, 'stable') + assert.equal(matches[0].previousId, first.refs[0].persistentId) +}) + +test('dependency graph propagates dirty state and orders dependencies', () => { + const graph = new DependencyGraph([ + { sourceId: 'pocket', targetId: 'pad', relation: 'link' }, + { sourceId: 'fillet', targetId: 'pocket', relation: 'link' }, + { sourceId: 'body', targetId: 'fillet', relation: 'link' }, + ], ['pad', 'pocket', 'fillet', 'body']) + const plan = graph.plan(['pad']) + assert.deepEqual(plan.cycles, []) + assert.deepEqual(plan.order, ['pad', 'pocket', 'fillet', 'body']) + assert.deepEqual(plan.affected, ['pad', 'pocket', 'fillet', 'body']) + graph.addEdge({ sourceId: 'pad', targetId: 'body', relation: 'expression' }) + assert.deepEqual(graph.findCycles(new Set(['pad', 'pocket', 'fillet', 'body'])), [['pad', 'pocket', 'fillet', 'body']]) +}) + test('persistence writes are serialized and continue after a failed write', async () => { const queue = new PersistenceWriteQueue() let inFlight = 0 @@ -188,6 +237,35 @@ 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('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' }) + const property = facade.app.document.getObject('pad')?.properties.find((candidate) => candidate.name === 'Length') + assert.equal(property?.expression, '1 in + 2 mm') + assert.ok(Math.abs(Number(property?.value) - 27.4) < 1e-9) + assert.equal(facade.getState().document.recompute?.objectStates.pad, 'touched') + + facade.app.document.setExpression({ objectId: 'pocket', propertyName: 'Length', expression: 'pad.Length * 2' }) + const edge = facade.app.document.getDependencies().find((candidate) => candidate.sourceId === 'pocket' && candidate.targetId === 'pad' && candidate.relation === 'expression') + assert.equal(edge?.propertyName, 'Length') + const result = facade.app.document.recompute() + assert.equal(result.status, 'completed') + assert.deepEqual(result.order, ['pad', 'pocket', 'fillet', 'body']) + assert.equal(facade.getState().document.recompute?.dirtyObjects.length, 0) + assert.equal(facade.app.document.getObject('pocket')?.properties.find((candidate) => candidate.name === 'Length')?.value, 54.8) +}) + +test('expression cycles fail recompute without silently accepting a result', () => { + 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 result = facade.app.document.recompute() + assert.equal(result.status, 'failed') + assert.ok(result.errors.every((error) => error.code === 'DEPENDENCY_CYCLE')) + assert.equal(facade.getState().document.recompute?.objectStates.pad, 'error') + assert.equal(facade.getState().document.recompute?.objectStates.pocket, 'error') +}) + test('view properties do not mark the geometry object touched', () => { const facade = createMockFacade() facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Visibility', value: false })