P4: add expressions dependency recompute and topology signatures
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user