feat: advance FreeCAD parity and native OCCT history

This commit is contained in:
2026-08-03 08:04:10 -04:00
parent 2ce261b982
commit e5a5d74dbc
32 changed files with 1883 additions and 30 deletions

View File

@@ -8,13 +8,16 @@ import { DependencyGraph } from '../src/facade/dependencyGraph'
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
import { createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
import { captureSignatureTopologyHistory } from '../src/facade/topologyHistory'
import { captureNativeTopologyHistory, captureSignatureTopologyHistory } from '../src/facade/topologyHistory'
import { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '../src/facade/nativeHistoryProvider'
import { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, type NativeOcctHistoryProvider } from '../src/facade/nativeHistoryProtocol'
import { NativeOcctHistoryWorkerProvider } from '../src/facade/nativeHistoryWorkerClient'
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy'
import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher'
import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol'
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
import { inspectFcstdArchive } from '../src/facade/fcstd'
import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateMirrorInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
import { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateMirrorInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
import type { DocumentObjectSnapshot, DocumentSnapshot, MultiTransformValue, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types'
const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({
@@ -226,6 +229,194 @@ test('signature topology history conservatively classifies boolean fallback rela
assert.deepEqual(new Set(ambiguous.relations[0].candidates?.map((candidate) => candidate.sourceObjectId)), new Set(['base', 'tool']))
})
test('native topology history maps OCCT relation indexes without transient references', () => {
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
const secondFace = { vertexCoord: [0, 0, 1, 2, 0, 1, 2, 2, 1, 0, 2, 1], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
const input = createSubshapeRefs('native-input', 1, [face, secondFace])
const output = createSubshapeRefs('native-output', 2, [face])
const result = captureNativeTopologyHistory(
'boolean-native',
[{ objectId: 'base', entries: input.refs.map((ref, index) => ({ ref, signature: input.signatures[index] })) }],
output.refs.map((ref, index) => ({ ref, signature: output.signatures[index] })),
[
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultIndexes: [0] },
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 1, relation: 'deleted' },
],
)
assert.equal(result.provider, 'occt-native')
assert.equal(result.counts.modified, 1)
assert.equal(result.counts.deleted, 1)
assert.equal(result.relations[0].sourcePersistentId, input.refs[0].persistentId)
assert.equal(result.relations[0].resultPersistentId, output.refs[0].persistentId)
assert.equal(result.relations[1].sourcePersistentId, input.refs[1].persistentId)
assert.throws(() => captureNativeTopologyHistory('boolean-native', [{ objectId: 'base', entries: [] }], [], [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'deleted' }]), /out of range/)
})
test('native OCCT provider protocol maps worker records to facade source IDs', () => {
const records = mapNativeOcctHistoryRecords({
provider: 'occt-native',
occtVersion: '8.0.0',
hasModified: true,
hasGenerated: false,
hasDeleted: true,
records: [
{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 1 },
{ relation: 'deleted', source: 'tool', kind: 'face', sourceIndex: 2, resultIndex: -1 },
],
}, { object: 'base', tool: 'tool' })
assert.deepEqual(records, [
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [1] },
{ sourceObjectId: 'tool', sourceKind: 'face', sourceIndex: 2, relation: 'deleted' },
])
})
test('native OCCT STEP bridge serializes handles before invoking the provider', async () => {
const calls: string[] = []
const bridge = createNativeOcctStepHistoryBridge(
{ exportStep: async (shape) => { calls.push(shape.id); return { format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` } } },
{
occtVersion: () => '8.0.0',
booleanHistoryFromStep: (objectStep, toolStep, operation) => {
assert.match(objectStep, /base-shape/)
assert.match(toolStep, /tool-shape/)
assert.equal(operation, 'cut')
return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] }
},
},
)
const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'cut-1', operation: 'cut', inputs: [
{ objectId: 'base', shape: { id: 'base-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } },
{ objectId: 'tool', shape: { id: 'tool-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } },
], result: { id: 'result-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } })
assert.deepEqual(calls, ['base-shape', 'tool-shape'])
assert.deepEqual(records, [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }])
})
test('native OCCT history protocol validates STEP context and isolates stale generations', async () => {
const provider = new DirectNativeOcctHistoryProvider({
occtVersion: () => '8.0.0',
booleanHistoryFromStep: (objectStep, toolStep, operation) => ({
provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false,
resultStep: objectStep, records: [{ relation: operation === 'cut' ? 'modified' : 'generated', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }],
}),
})
assert.deepEqual(provider.capabilities(), { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' })
const coordinator = new NativeOcctHistoryCoordinator(() => 2)
const execution = await coordinator.capture(provider, { documentId: 'doc', documentVersion: 2, operationId: 'cut-1', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
assert.equal(execution.status, 'completed')
assert.equal(execution.response?.protocolVersion, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION)
assert.equal(execution.response?.history.records[0].relation, 'modified')
await assert.rejects(() => provider.capture({ protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'bad', documentId: 'doc', documentVersion: 2, operationId: 'bad', operation: 'cut', objectStep: 'not-step', toolStep: 'ISO-10303-21; tool' }, new AbortController().signal), /STEP text/)
const stale = new NativeOcctHistoryCoordinator(() => 3)
const staleExecution = await stale.capture(provider, { documentId: 'doc', documentVersion: 2, operationId: 'cut-2', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
assert.equal(staleExecution.status, 'stale')
})
test('native OCCT history coordinator reports timeout and cancellation', async () => {
const hanging: NativeOcctHistoryProvider = { capabilities: () => ({ providerId: 'test', providerVersion: '1', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' }), capture: async (_request, signal) => await new Promise<never>((_, reject) => { signal.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true }) }) }
const timedOut = await new NativeOcctHistoryCoordinator(() => 1, 5).capture(hanging, { documentId: 'doc', documentVersion: 1, operationId: 'cut-timeout', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
assert.equal(timedOut.status, 'timed-out')
const coordinator = new NativeOcctHistoryCoordinator(() => 1, 120_000)
const pending = coordinator.capture(hanging, { documentId: 'doc', documentVersion: 1, operationId: 'cut-cancel', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
coordinator.cancel()
assert.equal((await pending).status, 'cancelled')
})
test('native OCCT Worker provider preserves versioned request and response context', async () => {
const messageListeners = new Set<(event: MessageEvent) => void>()
const errorListeners = new Set<(event: ErrorEvent) => void>()
const fakeWorker = {
postMessage(message: { type: string; request?: { requestId: string; protocolVersion: number; documentId: string; documentVersion: number; operationId: string; objectStep: string; toolStep: string; operation: 'cut' } }) {
if (message.type === 'initialize') {
queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'ready', capabilities: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' } } } as MessageEvent)))
} else if (message.type === 'capture' && message.request) {
const request = message.request
const response = {
protocolVersion: request.protocolVersion,
requestId: request.requestId,
documentId: request.documentId,
documentVersion: request.documentVersion,
operationId: request.operationId,
provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' },
history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [] },
}
queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'response', response } } as MessageEvent)))
}
},
terminate() {},
addEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { (type === 'message' ? messageListeners : errorListeners).add(listener as never) },
removeEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { (type === 'message' ? messageListeners : errorListeners).delete(listener as never) },
}
const provider = new NativeOcctHistoryWorkerProvider({ workerFactory: () => fakeWorker })
const request = { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'worker-1', documentId: 'doc', documentVersion: 4, operationId: 'cut-worker', operation: 'cut' as const, objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }
const response = await provider.capture(request, new AbortController().signal)
assert.equal(response.requestId, request.requestId)
assert.equal(response.documentVersion, request.documentVersion)
assert.equal(provider.capabilities().availability, 'available')
provider.dispose()
})
test('native OCCT Worker provider propagates AbortSignal cancellation to the worker', async () => {
const messageListeners = new Set<(event: MessageEvent) => void>()
const cancelRequests: string[] = []
const fakeWorker = {
postMessage(message: { type: string; requestId?: string }) {
if (message.type === 'initialize') {
queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'ready', capabilities: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' } } } as MessageEvent)))
} else if (message.type === 'cancel' && message.requestId) cancelRequests.push(message.requestId)
},
terminate() {},
addEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { if (type === 'message') messageListeners.add(listener as never) },
removeEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { if (type === 'message') messageListeners.delete(listener as never) },
}
const provider = new NativeOcctHistoryWorkerProvider({ workerFactory: () => fakeWorker })
const request = { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'worker-cancel-1', documentId: 'doc', documentVersion: 4, operationId: 'cut-worker-cancel', operation: 'cut' as const, objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }
const controller = new AbortController()
const pending = provider.capture(request, controller.signal)
await new Promise<void>((resolve) => queueMicrotask(resolve))
controller.abort()
await assert.rejects(pending, (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.deepEqual(cancelRequests, [request.requestId])
provider.dispose()
})
test('Bitbybit geometry runtime routes configured native history through STEP and the facade mapper', async () => {
const runtime = new BitbybitGeometryRuntime()
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
let capturedRequestId = ''
const provider: NativeOcctHistoryProvider = {
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' }),
capture: async (request) => {
capturedRequestId = request.requestId
assert.match(request.objectStep, /base/)
assert.match(request.toolStep, /tool/)
return {
protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION,
requestId: request.requestId,
documentId: request.documentId,
documentVersion: request.documentVersion,
operationId: request.operationId,
provider: provider.capabilities(),
history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: true, records: [
{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 },
{ relation: 'deleted', source: 'tool', kind: 'edge', sourceIndex: 1 },
] },
}
},
}
runtime.configureNativeHistory(provider, 1000)
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'cut-runtime', operation: 'cut', inputs: [{ objectId: 'base', shape: handle('base') }, { objectId: 'tool', shape: handle('tool') }], result: handle('result') })
assert.match(capturedRequestId, /^native-history-/)
assert.deepEqual(records, [
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] },
{ sourceObjectId: 'tool', sourceKind: 'edge', sourceIndex: 1, relation: 'deleted' },
])
runtime.configureNativeHistory(null)
assert.equal(runtime.nativeHistoryCapabilities().availability, 'unavailable')
runtime.dispose()
})
test('edge and vertex topology signatures are orientation/index independent', () => {
const edge = signatureForEdge([0, 0, 0], [2, 0, 0])
assert.equal(edge.hash, signatureForEdge([2, 0, 0], [0, 0, 0]).hash)
@@ -1489,6 +1680,7 @@ test('FCStd inspection reports recognized, proxy, and Python-backed objects with
assert.equal(inspection.label, 'Imported assembly')
assert.equal(inspection.schemaVersion, '4')
assert.equal(inspection.objects.find((object) => object.name === 'Body')?.label, 'Main body')
assert.deepEqual(inspection.objects.find((object) => object.name === 'Body')?.properties, [{ name: 'Label', typeId: 'App::PropertyString', element: 'String', value: 'Main body' }])
assert.equal(inspection.compatibility.recognizedObjects, 1)
assert.equal(inspection.compatibility.proxyObjects, 1)
assert.equal(inspection.compatibility.blockedObjects, 1)