P4: add expressions dependency recompute and topology signatures
This commit is contained in:
@@ -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