P4: add expressions dependency recompute and topology signatures

This commit is contained in:
2026-08-02 09:40:26 -04:00
parent a9f4ff752d
commit 6a0bd534ea
14 changed files with 758 additions and 26 deletions

View 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: [],
})

View File

@@ -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> {

View File

@@ -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'

View File

@@ -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,
}

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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>

View 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
}

View File

@@ -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
View 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}`
}