P4: add expressions dependency recompute and topology signatures
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 稳定子形状命名的必要任务。
|
||||
|
||||
@@ -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 对标专项
|
||||
|
||||
|
||||
145
src/facade/dependencyGraph.ts
Normal file
145
src/facade/dependencyGraph.ts
Normal file
@@ -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<string, RecomputeState>
|
||||
dirtyObjects: string[]
|
||||
order: string[]
|
||||
errors: Array<{ objectId: string; code: string; message: string }>
|
||||
}
|
||||
|
||||
export class DependencyGraph {
|
||||
private readonly nodes = new Set<string>()
|
||||
private readonly outgoing = new Map<string, Set<string>>()
|
||||
private readonly incoming = new Map<string, Set<string>>()
|
||||
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<string>): RecomputePlan {
|
||||
const affected = new Set<string>()
|
||||
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<string>()
|
||||
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<string> = this.nodes): string[][] {
|
||||
const cycles: string[][] = []
|
||||
const indexByNode = new Map<string, number>()
|
||||
const lowLinkByNode = new Map<string, number>()
|
||||
const stack: string[] = []
|
||||
const onStack = new Set<string>()
|
||||
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: [],
|
||||
})
|
||||
@@ -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<SubshapeRef[]> {
|
||||
return (await this.mesh(shape, precision)).subshapes ?? []
|
||||
}
|
||||
|
||||
async release(shape: ShapeHandle): Promise<void> {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<DocumentSnapshot, 'objects'>): 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<string>()
|
||||
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<string, Quantity> => {
|
||||
const variables = new Map<string, Quantity>()
|
||||
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<string>) => {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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<string, string>()
|
||||
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<Record<string, string | number>>
|
||||
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<Record<string, string | number | null>>
|
||||
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<Record<string, string | number | null>>
|
||||
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<Record<string, string>>
|
||||
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<Record<string, string | null>>
|
||||
const propertiesByObject = new Map<string, ObjectPropertySnapshot[]>()
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PersistenceCapabilities>
|
||||
|
||||
111
src/facade/topologyNaming.ts
Normal file
111
src/facade/topologyNaming.ts
Normal file
@@ -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<string, number>()
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
@@ -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<string, Quantity>): { value: Quantity; references: string[] }
|
||||
dimensionForUnit(unit?: string): QuantityDimension
|
||||
}
|
||||
}
|
||||
readonly history: {
|
||||
@@ -330,6 +358,7 @@ export interface BitBybitWebCadFacade {
|
||||
pocket(input: PocketInput): Promise<ShapeHandle>
|
||||
revolution(input: RevolutionInput): Promise<ShapeHandle>
|
||||
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
|
||||
subshapes(shape: ShapeHandle, precision?: number): Promise<SubshapeRef[]>
|
||||
release(shape: ShapeHandle): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
189
src/facade/units.ts
Normal file
189
src/facade/units.ts
Normal file
@@ -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<string>()
|
||||
|
||||
constructor(private readonly tokens: Token[], private readonly variables: ReadonlyMap<string, Quantity>) {}
|
||||
|
||||
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<string, Quantity> = 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<string>()
|
||||
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}`
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user