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

@@ -22,11 +22,19 @@ export type FcstdEntryMetadata = {
export type FcstdObjectSupport = 'recognized' | 'proxy' | 'blocked'
export type FcstdPropertySummary = {
name: string
typeId: string
element: string
value: string
}
export type FcstdObjectSummary = {
name: string
label: string
typeId: string
propertyCount: number
properties: FcstdPropertySummary[]
support: FcstdObjectSupport
}
@@ -179,6 +187,18 @@ const propertyValue = (property: Record<string, unknown>): string => {
return ''
}
const propertyElement = (property: Record<string, unknown>): string => {
const entry = Object.entries(property).find(([name, value]) => !name.startsWith('@_') && Boolean(value && typeof value === 'object'))
return entry?.[0] || ''
}
const propertySummaries = (properties: Record<string, unknown>[]): FcstdPropertySummary[] => properties.map((property) => ({
name: attribute(property, 'name') || '<unnamed>',
typeId: attribute(property, 'type') || propertyElement(property) || 'unknown',
element: propertyElement(property),
value: propertyValue(property),
}))
const parseDocumentXml = (bytes: Uint8Array) => {
const xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error('FCStd Document.xml declarations and entities are not allowed.')
@@ -202,7 +222,7 @@ const parseDocumentXml = (bytes: Uint8Array) => {
const properties = asArray((((data?.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label')
const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy'
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, support }
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, properties: propertySummaries(properties), support }
})
return {
schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown',

View File

@@ -1,6 +1,8 @@
import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker'
import type { Inputs } from '@bitbybit-dev/occt'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef, SubshapeTopology } from './types'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef, SubshapeTopology } from './types'
import { mapNativeOcctHistoryRecords } from './nativeHistoryProvider'
import { NativeOcctHistoryCoordinator, type NativeOcctHistoryProvider } from './nativeHistoryProtocol'
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs } from './topologyNaming'
type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer
@@ -230,6 +232,34 @@ export class BitbybitGeometryRuntime {
private sequence = 0
private readonly shapes = new Map<string, ShapeEntry>()
private readonly kernelReferences = new Map<number, KernelReferenceEntry>()
private nativeHistory: { provider: NativeOcctHistoryProvider; coordinator: NativeOcctHistoryCoordinator } | null = null
private readonly nativeHistoryDocumentVersions = new Map<string, number>()
configureNativeHistory(provider: NativeOcctHistoryProvider | null, timeoutMs = 120_000) {
this.nativeHistory?.coordinator.cancel()
if (provider === null) {
this.nativeHistory = null
this.nativeHistoryDocumentVersions.clear()
return
}
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new RangeError('Native history timeoutMs must be a positive safe integer.')
this.nativeHistory = {
provider,
coordinator: new NativeOcctHistoryCoordinator((documentId) => this.nativeHistoryDocumentVersions.get(documentId) ?? null, timeoutMs),
}
}
nativeHistoryCapabilities() {
return this.nativeHistory?.provider.capabilities() ?? {
providerId: 'occt-native.history-step',
providerVersion: 'unconfigured',
occtVersion: 'unknown',
availability: 'unavailable' as const,
operations: [],
transport: 'step-text' as const,
reason: 'Native OCCT history provider is not configured.',
}
}
capabilities(): GeometryCapabilities { return { ...this.capabilitiesState } }
@@ -471,6 +501,30 @@ export class BitbybitGeometryRuntime {
return { faces: faceTopology.refs, edges: edgeTopology.refs, vertices: vertexTopology.refs, entries }
}
async topologyHistory(input: NativeTopologyHistoryInput): Promise<NativeTopologyHistoryRecord[]> {
const nativeHistory = this.nativeHistory
if (!nativeHistory) throw new Error('Native OCCT history provider is not configured.')
if (!input.operation) throw new Error('Native OCCT history requires a supported Boolean operation.')
if (input.inputs.length !== 2) throw new Error('Native OCCT history requires exactly an object and a tool input.')
validateDocumentContext(input)
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
const [objectInput, toolInput] = input.inputs
const [objectStep, toolStep] = await Promise.all([
this.exportStep(objectInput.shape, `${objectInput.objectId}.step`),
this.exportStep(toolInput.shape, `${toolInput.objectId}.step`),
])
const execution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
documentId: input.documentId,
documentVersion: input.documentVersion,
operationId: input.operationId,
operation: input.operation,
objectStep: objectStep.text,
toolStep: toolStep.text,
})
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
return mapNativeOcctHistoryRecords(execution.response.history, { object: objectInput.objectId, tool: toolInput.objectId })
}
async release(shape: ShapeHandle): Promise<void> {
const entry = this.shapes.get(shape.id)
if (!entry) return
@@ -487,6 +541,7 @@ export class BitbybitGeometryRuntime {
}
dispose() {
this.nativeHistory?.coordinator.cancel()
this.cancelInitialization?.()
this.shapes.clear()
this.kernelReferences.clear()
@@ -496,6 +551,8 @@ export class BitbybitGeometryRuntime {
this.worker = null
this.initialization = null
this.cancelInitialization = null
this.nativeHistoryDocumentVersions.clear()
this.nativeHistory = null
this.capabilitiesState = unavailableCapabilities()
}

View File

@@ -11,8 +11,15 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences'
export { captureSignatureTopologyHistory } from './topologyHistory'
export { captureNativeTopologyHistory, captureSignatureTopologyHistory } from './topologyHistory'
export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory'
export { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from './nativeHistoryProvider'
export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol'
export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from './nativeHistoryProtocol'
export type { NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest } from './nativeHistoryProtocol'
export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient'
export type { NativeOcctHistoryWorkerOptions } from './nativeHistoryWorkerClient'
export { BasicSketchSolverAdapter, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchExternalGeometry, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
export { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay } from './sketchSolverProtocol'
@@ -20,4 +27,4 @@ export type { SketchSolverCapabilities, SketchSolverCompatibility, SketchSolverE
export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeGeometryRuntime, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine'
export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd'
export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } from './fcstd'
export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport, FcstdPropertySummary } from './fcstd'

View File

@@ -809,7 +809,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
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() } },
diagnostics: { list: () => state.diagnostics.map(cloneDiagnostic), tree: () => buildDiagnosticTree(state.diagnostics), repair: repairDiagnostic },
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), loadCheckpoint: (documentId, version) => projectPersistence.loadCheckpoint(documentId, version), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, 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), mirror: (input) => geometryRuntime.mirror(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), 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), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
geometry: { capabilities: () => geometryRuntime.capabilities(), configureNativeHistory: (provider, timeoutMs) => geometryRuntime.configureNativeHistory(provider, timeoutMs), nativeHistoryCapabilities: () => geometryRuntime.nativeHistoryCapabilities(), 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), mirror: (input) => geometryRuntime.mirror(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), 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), topology: (shape, precision) => geometryRuntime.topology(shape, precision), topologyHistory: (input) => geometryRuntime.topologyHistory(input), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
}

View File

@@ -0,0 +1,138 @@
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
export const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const
export type NativeOcctHistoryCapabilities = {
providerId: string
providerVersion: string
occtVersion: string
availability: 'available' | 'unavailable'
operations: NativeOcctHistoryOperation[]
transport: 'step-text'
reason?: string
}
export type NativeOcctHistoryRequest = {
protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION
requestId: string
documentId: string
documentVersion: number
operationId: string
operation: NativeOcctHistoryOperation
objectStep: string
toolStep: string
}
export type NativeOcctHistoryProtocolResponse = {
protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION
requestId: string
documentId: string
documentVersion: number
operationId: string
provider: NativeOcctHistoryCapabilities
history: NativeOcctHistoryResponse
}
export interface NativeOcctHistoryProvider {
capabilities(): NativeOcctHistoryCapabilities
capture(request: NativeOcctHistoryRequest, signal: AbortSignal): Promise<NativeOcctHistoryProtocolResponse>
}
export class NativeOcctHistoryUnavailableError extends Error {
readonly code = 'NATIVE_OCCT_HISTORY_UNAVAILABLE'
constructor(readonly provider: NativeOcctHistoryCapabilities) {
super(provider.reason || `Native OCCT history provider '${provider.providerId}' is unavailable.`)
this.name = 'NativeOcctHistoryUnavailableError'
}
}
const abortError = () => new DOMException('Native OCCT history request cancelled.', 'AbortError')
const assertRequest = (request: NativeOcctHistoryRequest) => {
if (request.protocolVersion !== NATIVE_OCCT_HISTORY_PROTOCOL_VERSION) throw new RangeError(`Unsupported native OCCT history protocol version: ${request.protocolVersion}.`)
if (!request.requestId.trim() || !request.documentId.trim() || !request.operationId.trim()) throw new TypeError('Native OCCT history request IDs must be non-empty strings.')
if (!Number.isSafeInteger(request.documentVersion) || request.documentVersion < 0) throw new RangeError('Native OCCT history documentVersion must be a non-negative integer.')
if (!request.objectStep.startsWith('ISO-10303-21;') || !request.toolStep.startsWith('ISO-10303-21;')) throw new TypeError('Native OCCT history transport requires ISO-10303-21 STEP text.')
}
const assertResponse = (request: NativeOcctHistoryRequest, response: NativeOcctHistoryProtocolResponse) => {
if (response.protocolVersion !== request.protocolVersion || response.requestId !== request.requestId || response.documentId !== request.documentId || response.documentVersion !== request.documentVersion || response.operationId !== request.operationId) throw new Error('Native OCCT history response does not match its request context.')
if (response.history.provider !== 'occt-native' || response.history.occtVersion !== response.provider.occtVersion) throw new Error('Native OCCT history response provider metadata is inconsistent.')
return response
}
export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvider {
constructor(private readonly module: NativeOcctHistoryStepProvider, private readonly providerVersion = '8.0.0-embind') {}
capabilities(): NativeOcctHistoryCapabilities {
const occtVersion = this.module.occtVersion()
return {
providerId: 'occt-native.history-step',
providerVersion: this.providerVersion,
occtVersion,
availability: 'available',
operations: ['fuse', 'cut', 'common'],
transport: 'step-text',
}
}
async capture(request: NativeOcctHistoryRequest, signal: AbortSignal): Promise<NativeOcctHistoryProtocolResponse> {
assertRequest(request)
if (signal.aborted) throw abortError()
const provider = this.capabilities()
const history = await Promise.resolve().then(() => this.module.booleanHistoryFromStep(request.objectStep, request.toolStep, request.operation))
if (signal.aborted) throw abortError()
return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history })
}
}
export class UnavailableNativeOcctHistoryProvider implements NativeOcctHistoryProvider {
constructor(private readonly message = 'No native OCCT history WASM artifact is installed.') {}
capabilities(): NativeOcctHistoryCapabilities {
return { providerId: 'occt-native.history-step', providerVersion: 'unavailable', occtVersion: 'unknown', availability: 'unavailable', operations: [], transport: 'step-text', reason: this.message }
}
capture(): Promise<NativeOcctHistoryProtocolResponse> {
return Promise.reject(new NativeOcctHistoryUnavailableError(this.capabilities()))
}
}
export type NativeOcctHistoryExecution = {
status: 'completed' | 'cancelled' | 'stale' | 'timed-out'
requestId: string
response?: NativeOcctHistoryProtocolResponse
}
export class NativeOcctHistoryCoordinator {
private active: { requestId: string; controller: AbortController } | null = null
private sequence = 0
constructor(private readonly currentDocumentVersion: (documentId: string) => number | null, private readonly timeoutMs = 120_000) {}
cancel() { this.active?.controller.abort() }
async capture(provider: NativeOcctHistoryProvider, input: Omit<NativeOcctHistoryRequest, 'protocolVersion' | 'requestId'>): Promise<NativeOcctHistoryExecution> {
this.active?.controller.abort()
const requestId = `native-history-${++this.sequence}`
const controller = new AbortController()
this.active = { requestId, controller }
const request: NativeOcctHistoryRequest = { ...input, protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId }
let timeout: ReturnType<typeof setTimeout> | undefined
try {
const timeoutPromise = new Promise<never>((_, reject) => { timeout = setTimeout(() => { controller.abort(); reject(new DOMException('Native OCCT history request timed out.', 'TimeoutError')) }, this.timeoutMs) })
const response = await Promise.race([provider.capture(request, controller.signal), timeoutPromise])
if (controller.signal.aborted || this.active?.requestId !== requestId) return { status: 'cancelled', requestId }
if (this.currentDocumentVersion(input.documentId) !== input.documentVersion) return { status: 'stale', requestId }
return { status: 'completed', requestId, response }
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') return { status: 'timed-out', requestId }
if (controller.signal.aborted || (error instanceof Error && error.name === 'AbortError')) return { status: 'cancelled', requestId }
throw error
} finally {
if (timeout) clearTimeout(timeout)
if (this.active?.requestId === requestId) this.active = null
}
}
}

View File

@@ -0,0 +1,80 @@
import type { NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types'
export type NativeOcctHistoryRecord = {
relation: 'modified' | 'generated' | 'deleted'
source: 'object' | 'tool'
kind: string
sourceIndex: number
resultIndex: number
}
export type NativeOcctHistoryResponse = {
provider: 'occt-native'
occtVersion: string
resultStep?: string
records: NativeOcctHistoryRecord[]
hasModified: boolean
hasGenerated: boolean
hasDeleted: boolean
}
export type NativeOcctHistoryOperation = 'fuse' | 'cut' | 'common'
export type NativeOcctHistoryStepProvider = {
occtVersion(): string
booleanHistoryFromStep(objectStep: string, toolStep: string, operation: NativeOcctHistoryOperation): NativeOcctHistoryResponse
}
export type NativeOcctHistoryStepGeometry = {
exportStep(shape: ShapeHandle, fileName?: string): Promise<{ format: 'step'; fileName: string; mediaType: string; text: string }>
}
const kinds: SubshapeRef['kind'][] = ['vertex', 'edge', 'face']
const isKind = (value: string): value is SubshapeRef['kind'] => kinds.includes(value as SubshapeRef['kind'])
export const mapNativeOcctHistoryRecords = (
response: NativeOcctHistoryResponse,
sourceObjectIds: { object: string; tool: string },
): NativeTopologyHistoryRecord[] => {
if (response.provider !== 'occt-native' || !/^8\./.test(response.occtVersion)) throw new Error('Unsupported native OCCT history provider response.')
return response.records.map((record) => {
if (!isKind(record.kind)) throw new Error(`Unsupported native OCCT subshape kind: ${record.kind}`)
if (!Number.isSafeInteger(record.sourceIndex) || record.sourceIndex < 0) throw new RangeError('Native OCCT sourceIndex must be a non-negative safe integer.')
const sourceObjectId = sourceObjectIds[record.source]
if (!sourceObjectId) throw new Error(`Missing native OCCT source object ID for ${record.source}.`)
if (record.relation === 'deleted') {
return { sourceObjectId, sourceKind: record.kind, sourceIndex: record.sourceIndex, relation: 'deleted' }
}
if (!Number.isSafeInteger(record.resultIndex) || record.resultIndex < 0) throw new RangeError('Native OCCT resultIndex must be a non-negative safe integer.')
return {
sourceObjectId,
sourceKind: record.kind,
sourceIndex: record.sourceIndex,
relation: record.relation,
resultKind: record.kind,
resultIndexes: [record.resultIndex],
}
})
}
/**
* Adapts Bitbybit STEP exports to the standalone OCCT history WASM module.
* ShapeHandle values never cross this boundary; only versioned STEP text does.
*/
export const createNativeOcctStepHistoryBridge = (
geometry: NativeOcctHistoryStepGeometry,
provider: NativeOcctHistoryStepProvider,
) => async (input: NativeTopologyHistoryInput): Promise<NativeTopologyHistoryRecord[]> => {
if (!input.operation) throw new Error('Native OCCT STEP history requires a supported Boolean operation.')
if (input.inputs.length !== 2) throw new Error('Native OCCT STEP history requires exactly an object and a tool input.')
const [objectInput, toolInput] = input.inputs
const [objectStep, toolStep] = await Promise.all([
geometry.exportStep(objectInput.shape, `${objectInput.objectId}.step`),
geometry.exportStep(toolInput.shape, `${toolInput.objectId}.step`),
])
if (objectStep.format !== 'step' || toolStep.format !== 'step' || typeof objectStep.text !== 'string' || typeof toolStep.text !== 'string') {
throw new Error('Bitbybit STEP export returned an invalid transport payload.')
}
const response = provider.booleanHistoryFromStep(objectStep.text, toolStep.text, input.operation)
return mapNativeOcctHistoryRecords(response, { object: objectInput.objectId, tool: toolInput.objectId })
}

View File

@@ -0,0 +1,111 @@
import type { NativeOcctHistoryResponse, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
import { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, type NativeOcctHistoryCapabilities, type NativeOcctHistoryProvider, type NativeOcctHistoryProtocolResponse, type NativeOcctHistoryRequest } from './nativeHistoryProtocol'
type WorkerLike = Pick<Worker, 'postMessage' | 'terminate'> & {
addEventListener(type: 'message', listener: (event: MessageEvent) => void): void
addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void
removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void
removeEventListener(type: 'error', listener: (event: ErrorEvent) => void): void
}
type WorkerResponse = { type: 'ready'; capabilities: NativeOcctHistoryCapabilities } | { type: 'response'; response: NativeOcctHistoryProtocolResponse } | { type: 'error'; requestId?: string; error: string }
const abortError = () => new DOMException('Native OCCT history Worker request cancelled.', 'AbortError')
export type NativeOcctHistoryWorkerOptions = {
moduleUrl?: string
initializationTimeoutMs?: number
workerFactory?: () => WorkerLike
}
const defaultWorkerFactory = () => new Worker(new URL('./nativeHistoryWorkerEntry.ts', import.meta.url), { type: 'module', name: 'occt-native-history' })
export class NativeOcctHistoryWorkerProvider implements NativeOcctHistoryProvider {
private readonly worker: WorkerLike
private readonly options: Required<Pick<NativeOcctHistoryWorkerOptions, 'moduleUrl' | 'initializationTimeoutMs'>>
private readonly pending = new Map<string, { resolve: (response: NativeOcctHistoryProtocolResponse) => void; reject: (error: Error) => void }>()
private initialization: Promise<NativeOcctHistoryCapabilities> | null = null
private initializationReject: ((error: Error) => void) | null = null
private current: NativeOcctHistoryCapabilities = { providerId: 'occt-native.history-step', providerVersion: 'unavailable', occtVersion: 'unknown', availability: 'unavailable', operations: [], transport: 'step-text', reason: 'Native OCCT history Worker has not been initialized.' }
private readonly onMessage = (event: MessageEvent<WorkerResponse>) => {
if (event.data.type === 'ready') {
this.current = event.data.capabilities
return
}
if (event.data.type === 'error') {
const requestId = event.data.requestId
const pending = requestId ? this.pending.get(requestId) : null
if (pending && requestId) { this.pending.delete(requestId); pending.reject(new Error(event.data.error)) }
if (!requestId) this.initializationReject?.(new Error(event.data.error))
return
}
const pending = this.pending.get(event.data.response.requestId)
if (!pending) return
this.pending.delete(event.data.response.requestId)
pending.resolve(event.data.response)
}
private readonly onError = (event: ErrorEvent) => {
const error = new Error(event.message || 'Native OCCT history Worker failed.')
for (const pending of this.pending.values()) pending.reject(error)
this.pending.clear()
this.initialization = null
this.initializationReject?.(error)
this.initializationReject = null
this.current = { ...this.current, availability: 'unavailable', reason: error.message }
}
constructor(options: NativeOcctHistoryWorkerOptions = {}) {
this.worker = (options.workerFactory || defaultWorkerFactory)()
this.options = {
moduleUrl: options.moduleUrl || '/native/occt-history/bitbybit-occt-history.js',
initializationTimeoutMs: options.initializationTimeoutMs ?? 120_000,
}
this.worker.addEventListener('message', this.onMessage)
this.worker.addEventListener('error', this.onError)
}
capabilities(): NativeOcctHistoryCapabilities { return { ...this.current, operations: [...this.current.operations] } }
initialize(): Promise<NativeOcctHistoryCapabilities> {
if (this.current.availability === 'available') return Promise.resolve(this.capabilities())
if (this.initialization) return this.initialization
this.initialization = new Promise<NativeOcctHistoryCapabilities>((resolve, reject) => {
let settled = false
const timer = setTimeout(() => fail(new Error('Native OCCT history Worker initialization timed out.')), this.options.initializationTimeoutMs)
const fail = (error: Error) => { if (settled) return; settled = true; clearTimeout(timer); this.worker.removeEventListener('message', waitForReady); this.initialization = null; this.initializationReject = null; this.current = { ...this.current, reason: error.message }; reject(error) }
this.initializationReject = fail
const waitForReady = (event: MessageEvent<WorkerResponse>) => { if (event.data.type === 'error') return fail(new Error(event.data.error)); if (event.data.type !== 'ready') return; settled = true; clearTimeout(timer); this.worker.removeEventListener('message', waitForReady); this.initializationReject = null; resolve(this.capabilities()) }
this.worker.addEventListener('message', waitForReady)
this.worker.postMessage({ type: 'initialize', moduleUrl: this.options.moduleUrl })
})
return this.initialization
}
async capture(request: NativeOcctHistoryRequest, signal: AbortSignal): Promise<NativeOcctHistoryProtocolResponse> {
if (request.protocolVersion !== NATIVE_OCCT_HISTORY_PROTOCOL_VERSION) throw new RangeError(`Unsupported native OCCT history protocol version: ${request.protocolVersion}.`)
await this.initialize()
if (signal.aborted) throw abortError()
const response = new Promise<NativeOcctHistoryProtocolResponse>((resolve, reject) => {
this.pending.set(request.requestId, { resolve, reject })
this.worker.postMessage({ type: 'capture', request })
})
const cancel = () => { this.worker.postMessage({ type: 'cancel', requestId: request.requestId }); this.pending.get(request.requestId)?.reject(abortError()); this.pending.delete(request.requestId) }
signal.addEventListener('abort', cancel, { once: true })
try { return await response } finally { signal.removeEventListener('abort', cancel) }
}
dispose() {
this.worker.postMessage({ type: 'dispose' })
this.worker.removeEventListener('message', this.onMessage)
this.worker.removeEventListener('error', this.onError)
this.worker.terminate()
this.initializationReject?.(new Error('Native OCCT history Worker disposed.'))
this.initializationReject = null
for (const pending of this.pending.values()) pending.reject(new Error('Native OCCT history Worker disposed.'))
this.pending.clear()
this.initialization = null
}
}
export type NativeOcctHistoryWorkerModule = NativeOcctHistoryStepProvider & { default?: never }
export type NativeOcctHistoryWorkerResponse = NativeOcctHistoryResponse

View File

@@ -0,0 +1,49 @@
/// <reference lib="webworker" />
import type { NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
import type { NativeOcctHistoryCapabilities, NativeOcctHistoryRequest, NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol'
type WorkerRequest = { type: 'initialize'; moduleUrl: string } | { type: 'capture'; request: NativeOcctHistoryRequest } | { type: 'cancel'; requestId: string } | { type: 'dispose' }
type WorkerResponse = { type: 'ready'; capabilities: NativeOcctHistoryCapabilities } | { type: 'response'; response: NativeOcctHistoryProtocolResponse } | { type: 'error'; requestId?: string; error: string }
const scope = self as DedicatedWorkerGlobalScope
let provider: NativeOcctHistoryStepProvider | null = null
const cancelled = new Set<string>()
const send = (message: WorkerResponse) => scope.postMessage(message)
const initialize = async (moduleUrl: string) => {
const imported = await import(/* @vite-ignore */ moduleUrl) as { default?: () => Promise<NativeOcctHistoryStepProvider> }
if (typeof imported.default !== 'function') throw new Error('Native OCCT history module has no default Emscripten factory export.')
provider = await imported.default()
send({
type: 'ready',
capabilities: {
providerId: 'occt-native.history-step',
providerVersion: '8.0.0-embind',
occtVersion: provider.occtVersion(),
availability: 'available',
operations: ['fuse', 'cut', 'common'],
transport: 'step-text',
},
})
}
scope.onmessage = ({ data }: MessageEvent<WorkerRequest>) => {
void (async () => {
try {
if (data.type === 'initialize') return await initialize(data.moduleUrl)
if (data.type === 'dispose') { provider = null; scope.close(); return }
if (data.type === 'cancel') { cancelled.add(data.requestId); return }
if (!provider) throw new Error('Native OCCT history Worker is not initialized.')
if (cancelled.delete(data.request.requestId)) return
const history = provider.booleanHistoryFromStep(data.request.objectStep, data.request.toolStep, data.request.operation)
if (cancelled.delete(data.request.requestId)) return
send({ type: 'response', response: { protocolVersion: data.request.protocolVersion, requestId: data.request.requestId, documentId: data.request.documentId, documentVersion: data.request.documentVersion, operationId: data.request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' }, history } })
} catch (error) {
send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) })
}
})()
}
export {}

View File

@@ -1,7 +1,7 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, MirrorInput, MultiTransformStep, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types'
import { captureSignatureTopologyHistory } from './topologyHistory'
import { captureNativeTopologyHistory, captureSignatureTopologyHistory } from './topologyHistory'
import { migrateTopoRefs } from './topologyReferences'
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
@@ -49,6 +49,7 @@ export type RecomputeGeometryRuntime = {
fillet(input: FilletInput): Promise<ShapeHandle>
chamfer(input: ChamferInput): Promise<ShapeHandle>
topology?(shape: ShapeHandle, precision?: number): Promise<SubshapeTopology>
topologyHistory?(input: { documentId: string; documentVersion: number; operationId: string; operation?: 'fuse' | 'cut' | 'common'; inputs: Array<{ objectId: string; shape: ShapeHandle }>; result: ShapeHandle }): Promise<import('./types').NativeTopologyHistoryRecord[]>
release(shape: ShapeHandle): Promise<void>
}
@@ -303,6 +304,8 @@ const placementForObject = (object: DocumentObjectSnapshot) => {
const topologyForObject = async (
geometry: RecomputeGeometryRuntime,
object: DocumentObjectSnapshot,
document: DocumentSnapshot,
shapes: Map<string, ShapeHandle>,
shape: ShapeHandle,
context: RecomputeNodeContext,
): Promise<ObjectTopologySnapshot | undefined> => {
@@ -319,11 +322,28 @@ const topologyForObject = async (
},
signature: { ...entry.signature, centroid: [...entry.signature.centroid], bounds: { min: [...entry.signature.bounds.min], max: [...entry.signature.bounds.max] }, normal: [...entry.signature.normal] },
})) as ObjectTopologySnapshot['entries']
const history = captureSignatureTopologyHistory(
`${object.id}:generation:${context.generation}`,
previous.length > 0 ? [{ objectId: object.id, entries: previous }] : [],
entries,
)
const operationId = `${object.id}:generation:${context.generation}`
const linkedSources = ['Base', 'Tool', 'Profile']
.map((propertyName) => linkedObject(object, propertyName, document))
.filter((candidate): candidate is DocumentObjectSnapshot => Boolean(candidate))
const sourceObjects = [...new Map(linkedSources.map((source) => [source.id, source])).values()]
const nativeInputs = sourceObjects
.map((source) => ({ source, shape: shapes.get(source.id) }))
.filter((entry): entry is { source: DocumentObjectSnapshot; shape: ShapeHandle } => Boolean(entry.shape && entry.source.topology?.entries.length))
const nativeOperation = object.typeId === 'Part::Fuse' ? 'fuse' : object.typeId === 'Part::Cut' ? 'cut' : object.typeId === 'Part::Common' ? 'common' : undefined
const nativeRecords = geometry.topologyHistory && nativeOperation && nativeInputs.length > 0
? await geometry.topologyHistory({
documentId: context.documentId,
documentVersion: context.documentVersion,
operationId,
operation: nativeOperation,
inputs: nativeInputs.map(({ source, shape: inputShape }) => ({ objectId: source.id, shape: inputShape })),
result: shape,
})
: undefined
const history = nativeRecords
? captureNativeTopologyHistory(operationId, nativeInputs.map(({ source }) => ({ objectId: source.id, entries: source.topology!.entries })), entries, nativeRecords)
: captureSignatureTopologyHistory(operationId, previous.length > 0 ? [{ objectId: object.id, entries: previous }] : [], entries)
return {
shapeId: shape.id,
documentVersion: context.documentVersion,
@@ -597,7 +617,7 @@ export const createFacadeGeometryRecomputeExecutor = (
}
let topology: ObjectTopologySnapshot | undefined
try {
topology = await topologyForObject(geometry, object, result, context)
topology = await topologyForObject(geometry, object, document, shapes, result, context)
} catch (error) {
await Promise.allSettled([geometry.release(result)])
throw error

View File

@@ -1,4 +1,4 @@
import type { SubshapeRef, TopologyHistoryRelation as StoredTopologyHistoryRelation, TopologyHistoryResult as StoredTopologyHistoryResult } from './types'
import type { NativeTopologyHistoryRecord, SubshapeRef, TopologyHistoryRelation as StoredTopologyHistoryRelation, TopologyHistoryResult as StoredTopologyHistoryResult } from './types'
import { matchSubshapes, type SubshapeSignature } from './topologyNaming'
export type TopologyHistoryEntry = { ref: SubshapeRef; signature: SubshapeSignature }
@@ -7,6 +7,53 @@ export type TopologyHistoryRelation = StoredTopologyHistoryRelation
export type TopologyHistoryResult = StoredTopologyHistoryResult
const sourceKey = (objectId: string, persistentId: string) => `${encodeURIComponent(objectId)}::${persistentId}`
const emptyCounts = (): TopologyHistoryResult['counts'] => ({ preserved: 0, modified: 0, generated: 0, deleted: 0, ambiguous: 0 })
const entriesOfKind = (entries: TopologyHistoryEntry[], kind: SubshapeRef['kind']) => entries.filter((entry) => entry.ref.kind === kind)
export const captureNativeTopologyHistory = (
operationId: string,
inputs: Array<{ objectId: string; entries: TopologyHistoryEntry[] }>,
output: TopologyHistoryEntry[],
records: NativeTopologyHistoryRecord[],
): TopologyHistoryResult => {
if (!operationId.trim()) throw new RangeError('Topology history operationId is required.')
const inputById = new Map(inputs.map((input) => [input.objectId, input]))
if (inputById.size !== inputs.length) throw new RangeError('Native topology history input object IDs must be unique.')
const counts = emptyCounts()
const relations: TopologyHistoryRelation[] = []
const seen = new Set<string>()
for (const record of records) {
const input = inputById.get(record.sourceObjectId)
if (!input) throw new RangeError(`Native topology history references unknown input object ${record.sourceObjectId}.`)
const sources = entriesOfKind(input.entries, record.sourceKind)
if (!Number.isSafeInteger(record.sourceIndex) || record.sourceIndex < 0 || record.sourceIndex >= sources.length) throw new RangeError(`Native topology history source index is out of range for ${record.sourceObjectId} ${record.sourceKind}.`)
const source = sources[record.sourceIndex]
if (record.relation === 'deleted') {
if (record.resultIndexes?.length) throw new RangeError('Deleted native topology history records cannot reference result indexes.')
const key = `${record.sourceObjectId}:${source.ref.persistentId}:deleted`
if (!seen.has(key)) {
seen.add(key)
relations.push({ relation: 'deleted', sourceObjectId: record.sourceObjectId, sourcePersistentId: source.ref.persistentId, score: 1 })
counts.deleted += 1
}
continue
}
const resultKind = record.resultKind ?? record.sourceKind
const results = entriesOfKind(output, resultKind)
if (!record.resultIndexes?.length) throw new RangeError(`${record.relation} native topology history records require result indexes.`)
for (const resultIndex of record.resultIndexes) {
if (!Number.isSafeInteger(resultIndex) || resultIndex < 0 || resultIndex >= results.length) throw new RangeError(`Native topology history result index is out of range for ${resultKind}.`)
const result = results[resultIndex]
const key = `${record.sourceObjectId}:${source.ref.persistentId}:${record.relation}:${result.ref.persistentId}`
if (seen.has(key)) continue
seen.add(key)
relations.push({ relation: record.relation, sourceObjectId: record.sourceObjectId, sourcePersistentId: source.ref.persistentId, resultPersistentId: result.ref.persistentId, score: 1 })
counts[record.relation] += 1
}
}
return { operationId, provider: 'occt-native', relations, counts }
}
export const captureSignatureTopologyHistory = (
operationId: string,
@@ -21,7 +68,7 @@ export const captureSignatureTopologyHistory = (
return { ref: { ...entry.ref, persistentId: key }, signature: entry.signature }
}))
const matches = matchSubshapes(previous, output)
const counts: TopologyHistoryResult['counts'] = { preserved: 0, modified: 0, generated: 0, deleted: 0, ambiguous: 0 }
const counts = emptyCounts()
const relations = matches.map((match): TopologyHistoryRelation => {
if (match.status === 'new') return { relation: 'generated', resultPersistentId: match.current.persistentId, score: match.score }
if (match.status === 'deleted') {

View File

@@ -4,6 +4,7 @@ import type { Quantity, QuantityDimension } from './units'
import type { SketchConstraint, SketchExternalGeometry, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher'
import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine'
import type { FcstdArchiveLimits, FcstdInspection } from './fcstd'
import type { NativeOcctHistoryCapabilities, NativeOcctHistoryProvider } from './nativeHistoryProtocol'
export type ModelTreeItem = {
id: string
@@ -221,11 +222,27 @@ export type TopologyHistoryRelation = {
export type TopologyHistoryResult = {
operationId: string
provider: 'signature-fallback'
provider: 'occt-native' | 'signature-fallback'
relations: TopologyHistoryRelation[]
counts: Record<TopologyHistoryRelation['relation'], number>
}
export type NativeTopologyHistoryRecord = {
sourceObjectId: string
sourceKind: SubshapeRef['kind']
sourceIndex: number
relation: 'preserved' | 'modified' | 'generated' | 'deleted'
resultKind?: SubshapeRef['kind']
resultIndexes?: number[]
}
export type NativeTopologyHistoryInput = GeometryDocumentContext & {
operationId: string
operation?: 'fuse' | 'cut' | 'common'
inputs: Array<{ objectId: string; shape: ShapeHandle }>
result: ShapeHandle
}
export type ObjectTopologySnapshot = {
shapeId: string
documentVersion: number
@@ -555,6 +572,8 @@ export interface BitBybitWebCadFacade {
}
readonly geometry: {
capabilities(): GeometryCapabilities
configureNativeHistory?(provider: NativeOcctHistoryProvider | null, timeoutMs?: number): void
nativeHistoryCapabilities?(): NativeOcctHistoryCapabilities
initialize(): Promise<GeometryCapabilities>
createBox(input: CreateBoxInput): Promise<ShapeHandle>
createCylinder(input: CreateCylinderInput): Promise<ShapeHandle>
@@ -575,6 +594,7 @@ export interface BitBybitWebCadFacade {
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
subshapes(shape: ShapeHandle, precision?: number): Promise<SubshapeRef[]>
topology(shape: ShapeHandle, precision?: number): Promise<SubshapeTopology>
topologyHistory?(input: NativeTopologyHistoryInput): Promise<NativeTopologyHistoryRecord[]>
getObjectShape(objectId: string): ShapeHandle | null
release(shape: ShapeHandle): Promise<void>
dispose(): void