feat: complete production naming and reference lifecycle gates
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

This commit is contained in:
2026-08-14 10:10:30 -04:00
parent f97eae4153
commit f64b78865c
75 changed files with 57290 additions and 15675 deletions

View File

@@ -0,0 +1,181 @@
export type ExternalDocumentStatus = 'open' | 'closed' | 'missing'
export type ExternalSourceObjectEvidence = {
objectId: string
length: number
width: number
height: number
volume: number
surfaceArea: number
shapeId: string
}
export type ExternalSourceEvidence = {
documentId: string
path: string
revision: number
objects: {
SourceBox: ExternalSourceObjectEvidence
SecondBox: ExternalSourceObjectEvidence
}
}
export type ExternalLinkTarget = {
documentId: string
objectId: string
subElements?: string[]
}
type ExternalRegistryState = {
status: ExternalDocumentStatus
source: ExternalSourceEvidence
}
type ExternalRegistryArchive = {
schemaVersion: 1
current: ExternalRegistryState
undo: ExternalRegistryState[]
redo: ExternalRegistryState[]
}
export type ExternalPartDesignLinkSnapshot = {
schemaVersion: 1
source: { documentId: string; path: string; revision: number; status: ExternalDocumentStatus }
properties: {
XLink: ExternalLinkTarget | null
XLinkSub: ExternalLinkTarget | null
XLinkList: ExternalLinkTarget[]
XLinkSubList: ExternalLinkTarget[]
}
propertyStatus: Record<'XLink' | 'XLinkSub' | 'XLinkList' | 'XLinkSubList', ['21']>
dimensions: {
xLinkLength: number | null
xLinkSubWidth: number | null
xLinkListHeights: number[]
xLinkSubListAreas: number[]
}
binder: {
support: ExternalLinkTarget[]
area: number
shapeId: string
state: ['Up-to-date']
status: 'Valid'
cacheState: 'linked' | 'cached'
}
history: { undo: number; redo: number }
}
const finitePositive = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value > 0
const clone = <T>(value: T): T => structuredClone(value)
const validateObject = (value: ExternalSourceObjectEvidence, name: string) => {
if (!value || value.objectId !== name || !finitePositive(value.length) || !finitePositive(value.width) || !finitePositive(value.height) || !finitePositive(value.volume) || !finitePositive(value.surfaceArea) || typeof value.shapeId !== 'string' || !value.shapeId) throw new TypeError(`External source object ${name} is invalid.`)
const expectedVolume = value.length * value.width * value.height
const expectedArea = 2 * (value.length * value.width + value.length * value.height + value.width * value.height)
if (Math.abs(value.volume - expectedVolume) > 1e-7 || Math.abs(value.surfaceArea - expectedArea) > 1e-7) throw new RangeError(`External source object ${name} geometry evidence does not match its dimensions.`)
}
const validateSource = (value: ExternalSourceEvidence) => {
if (!value || value.documentId !== 'XLinkSource' || value.path !== '/projects/XLinkSource.FCStd' || !Number.isSafeInteger(value.revision) || value.revision < 0) throw new TypeError('External source document identity is invalid.')
validateObject(value.objects?.SourceBox, 'SourceBox')
validateObject(value.objects?.SecondBox, 'SecondBox')
}
const validateState = (value: ExternalRegistryState) => {
if (!value || !['open', 'closed', 'missing'].includes(value.status)) throw new TypeError('External source status is invalid.')
validateSource(value.source)
}
const objectTarget = (objectId: string): ExternalLinkTarget => ({ documentId: 'XLinkSource', objectId })
const subTarget = (objectId: string, subElements: string[]): ExternalLinkTarget => ({ documentId: 'XLinkSource', objectId, subElements: [...subElements] })
export class ExternalPartDesignLinkRegistry {
private current: ExternalRegistryState
private readonly undoStack: ExternalRegistryState[]
private readonly redoStack: ExternalRegistryState[]
constructor(source: ExternalSourceEvidence, archive?: Pick<ExternalRegistryArchive, 'current' | 'undo' | 'redo'>) {
validateSource(source)
this.current = archive ? clone(archive.current) : { status: 'open', source: clone(source) }
this.undoStack = archive ? clone(archive.undo) : []
this.redoStack = archive ? clone(archive.redo) : []
validateState(this.current)
this.undoStack.forEach(validateState)
this.redoStack.forEach(validateState)
}
closeSource() {
if (this.current.status !== 'open') throw new Error('Only an open external source can be closed.')
this.current = { ...this.current, status: 'closed' }
}
markSourceMissing() {
if (this.current.status === 'open') throw new Error('An open external source cannot become missing before it is closed.')
this.current = { ...this.current, status: 'missing' }
}
relinkSource(source: ExternalSourceEvidence) {
validateSource(source)
if (source.documentId !== this.current.source.documentId || source.path !== this.current.source.path) throw new Error('External source relink requires the original document id and path.')
this.current = { status: 'open', source: clone(source) }
}
editSource(source: ExternalSourceEvidence) {
validateSource(source)
if (this.current.status !== 'open') throw new Error('External source edits require an open and resolved document.')
if (source.documentId !== this.current.source.documentId || source.path !== this.current.source.path || source.revision <= this.current.source.revision) throw new Error('External source edits require the same document and a newer revision.')
this.undoStack.push(clone(this.current))
this.redoStack.length = 0
this.current = { status: 'open', source: clone(source) }
}
undo() {
const previous = this.undoStack.pop()
if (!previous) throw new Error('External link history has nothing to undo.')
this.redoStack.push(clone(this.current))
this.current = previous
}
redo() {
const next = this.redoStack.pop()
if (!next) throw new Error('External link history has nothing to redo.')
this.undoStack.push(clone(this.current))
this.current = next
}
snapshot(): ExternalPartDesignLinkSnapshot {
const resolved = this.current.status === 'open'
const source = this.current.source.objects.SourceBox
const second = this.current.source.objects.SecondBox
const XLink = objectTarget('SourceBox')
const XLinkSub = subTarget('SourceBox', ['Face1'])
const XLinkList = [objectTarget('SourceBox'), objectTarget('SecondBox')]
const XLinkSubList = [subTarget('SourceBox', ['Face1']), subTarget('SecondBox', ['Face1', 'Face2'])]
return {
schemaVersion: 1,
source: { documentId: this.current.source.documentId, path: this.current.source.path, revision: this.current.source.revision, status: this.current.status },
properties: { XLink: resolved ? XLink : null, XLinkSub: resolved ? XLinkSub : null, XLinkList: resolved ? XLinkList : [], XLinkSubList: resolved ? XLinkSubList : [] },
propertyStatus: { XLink: ['21'], XLinkSub: ['21'], XLinkList: ['21'], XLinkSubList: ['21'] },
dimensions: {
xLinkLength: resolved ? source.length : null,
xLinkSubWidth: resolved ? source.width : null,
xLinkListHeights: resolved ? [source.height, second.height] : [],
xLinkSubListAreas: resolved ? [source.width * source.height, second.width * second.height, second.width * second.height] : [],
},
binder: { support: resolved ? [XLinkSub] : [], area: source.width * source.height, shapeId: source.shapeId, state: ['Up-to-date'], status: 'Valid', cacheState: resolved ? 'linked' : 'cached' },
history: { undo: this.undoStack.length, redo: this.redoStack.length },
}
}
serialize() {
const archive: ExternalRegistryArchive = { schemaVersion: 1, current: clone(this.current), undo: clone(this.undoStack), redo: clone(this.redoStack) }
return JSON.stringify(archive)
}
static deserialize(text: string) {
const archive = JSON.parse(text) as ExternalRegistryArchive
if (archive?.schemaVersion !== 1 || !Array.isArray(archive.undo) || !Array.isArray(archive.redo)) throw new TypeError('External link registry archive is invalid.')
validateState(archive.current)
return new ExternalPartDesignLinkRegistry(archive.current.source, archive)
}
}

View File

@@ -926,6 +926,15 @@ const pathPropertyValue = (value: unknown, context: string): PathPropertyValue =
return { schemaVersion: 1, commands, ...(resourcePath === undefined ? {} : { resourcePath }), version, ...(center === undefined ? {} : { center: center as PathPropertyValue['center'] }) }
}
const nativeLinkTopoRefName = (value: TopoRefValue, context: string, expectedObjectId: string) => {
if (value.schemaVersion !== 1 || value.objectId !== expectedObjectId || !value.persistentId.trim()) throw new TypeError(`${context} TopoRef is invalid or targets a different object.`)
if (!Number.isSafeInteger(value.topologyVersion) || value.topologyVersion < 0 || !Number.isSafeInteger(value.generation) || value.generation < 0) throw new TypeError(`${context} TopoRef requires non-negative integer topology versions.`)
if (value.status !== 'stable') throw new Error(`${context} '${value.persistentId}' must have stable topology status.`)
const prefix = value.kind === 'face' ? 'Face' : value.kind === 'edge' ? 'Edge' : 'Vertex'
if (!new RegExp(`^${prefix}[1-9]\\d*$`).test(value.persistentId)) throw new Error(`${context} '${value.persistentId}' is not a lossless native ${prefix}N reference.`)
return value.persistentId
}
const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId?: string, topologyElementMap?: ElementMapSnapshot) => {
const attributes = native ? property.type === 'App::PropertyPlacement' ? ' status="8388608"' : '' : ` group="${xmlEscape(property.group ?? '')}" doc="${xmlEscape(property.label ?? property.name)}" attr="0" ro="${property.readOnly ? 1 : 0}" hide="${property.hidden ? 1 : 0}"`
if (property.type === 'Path::PropertyPath') {
@@ -979,8 +988,7 @@ const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId
if (!property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('entries' in property.value) || property.value.schemaVersion !== 1 || !Array.isArray(property.value.entries)) throw new TypeError(`FCStd LinkSubList property ${property.name} requires a structured value.`)
const links = property.value.entries.map((entry) => {
if (!entry || typeof entry.objectId !== 'string' || !entry.objectId.trim()) throw new TypeError(`FCStd LinkSubList property ${property.name} requires non-empty object ids.`)
const subElement = entry.subElement === null ? '' : typeof entry.subElement === 'string' ? entry.subElement : entry.subElement.persistentId
if (entry.subElement && typeof entry.subElement === 'object' && entry.subElement.objectId !== entry.objectId) throw new RangeError(`FCStd LinkSubList property ${property.name} TopoRef targets a different object.`)
const subElement = entry.subElement === null ? '' : typeof entry.subElement === 'string' ? entry.subElement : nativeLinkTopoRefName(entry.subElement, `FCStd LinkSubList property ${property.name}`, entry.objectId)
if (entry.subElement !== null && !subElement.trim()) throw new TypeError(`FCStd LinkSubList property ${property.name} contains an empty sub-element.`)
return `<Link obj="${xmlEscape(entry.objectId)}" sub="${xmlEscape(subElement)}"/>`
}).join('')
@@ -1013,9 +1021,9 @@ const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId
const value = property.value
if (value !== null && typeof value === 'object' && !Array.isArray(value) && 'objectId' in value && typeof value.objectId === 'string') {
objectId = value.objectId
if ('subElements' in value && Array.isArray(value.subElements)) subElements = value.subElements.map((entry) => typeof entry === 'string' ? entry : entry.persistentId)
else if ('persistentId' in value && typeof value.persistentId === 'string') subElements = [value.persistentId]
else if ('subElement' in value && value.subElement) subElements = [typeof value.subElement === 'string' ? value.subElement : value.subElement.persistentId]
if ('subElements' in value && Array.isArray(value.subElements)) subElements = value.subElements.map((entry) => typeof entry === 'string' ? entry : nativeLinkTopoRefName(entry, `FCStd LinkSub property ${property.name}`, objectId))
else if ('persistentId' in value && typeof value.persistentId === 'string') subElements = [nativeLinkTopoRefName(value as TopoRefValue, `FCStd LinkSub property ${property.name}`, objectId)]
else if ('subElement' in value && value.subElement) subElements = [typeof value.subElement === 'string' ? value.subElement : nativeLinkTopoRefName(value.subElement, `FCStd LinkSub property ${property.name}`, objectId)]
} else if (value !== null) throw new TypeError(`FCStd LinkSub property ${property.name} requires a structured value.`)
if (subElements.some((entry) => !entry.trim())) throw new TypeError(`FCStd LinkSub property ${property.name} contains an empty sub-element.`)
const subs = subElements.map((entry) => `<Sub value="${xmlEscape(entry)}"/>`).join('')

View File

@@ -79,8 +79,8 @@ export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOc
export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from './nativeHistoryProtocol'
export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities, nativeNamingCapabilitiesForModule } from './nativeHistoryProtocol'
export type { NativeNamingAbiCapabilities, NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryInputTransport, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest, NativeOcctHistoryStageTransport } from './nativeHistoryProtocol'
export { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, FREECAD_PRIVATE_NAMING_ABI_VERSION, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi'
export type { FreeCadPrivateNamingAbiDescriptor, FreeCadPrivateNamingAbiProbe, FreeCadPrivateNamingAbiRequest, NativeFreeCadNamingAbiModule } from './nativeNamingAbi'
export { captureFreeCadPrivateNamingEvidence, classifyFreeCadPrivateNamingHistory, createFreeCadPrivateNamingAbiRequest, FREECAD_PRIVATE_NAMING_ABI_VERSION, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi'
export type { FreeCadPrivateNamingAbiDescriptor, FreeCadPrivateNamingAbiProbe, FreeCadPrivateNamingAbiRequest, FreeCadPrivateNamingHistoryAmbiguity, FreeCadPrivateNamingHistoryCandidate, FreeCadPrivateNamingHistoryClassification, NativeFreeCadNamingAbiModule } from './nativeNamingAbi'
export { assertNativeNamingEvidence, cloneNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence'
export type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence'
export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient'

View File

@@ -1,5 +1,5 @@
import { migrateElementMap2Schema, validateElementMap2 } from './elementMap2'
import { assertNativeNamingEvidence, cloneNativeNamingEvidence, type NativeStageNamingEvidence } from './nativeNamingEvidence'
import { assertNativeNamingEvidence, cloneNativeNamingEvidence, type NativeMappedNameRef, type NativeStageNamingEvidence } from './nativeNamingEvidence'
import { migrateStringHasherSchema, validateElementMap2StringHasherEvidence, validateStringHasherTable } from './stringHasher'
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse } from './nativeHistoryProvider'
@@ -54,15 +54,197 @@ export type FreeCadPrivateNamingAbiRequest = {
resultStep?: string
resultBrep?: string
resultStepByStage?: Record<string, string>
/** Results with more than one native source stay explicit and never enter the private-name builder. */
historyAmbiguities?: FreeCadPrivateNamingHistoryAmbiguity[]
history: Pick<NativeOcctHistoryResponse, 'provider' | 'occtVersion' | 'records' | 'hasModified' | 'hasGenerated' | 'hasDeleted' | 'resultStep' | 'resultBrep'>
}
export type FreeCadPrivateNamingHistoryCandidate = {
inputId: string
objectId: string
persistentId: string
stageId?: string
relation: 'modified' | 'generated'
sourceKind: 'face' | 'edge' | 'vertex'
sourceIndex: number
}
export type FreeCadPrivateNamingHistoryAmbiguity = {
resultKind: 'face' | 'edge' | 'vertex'
resultIndex: number
candidates: FreeCadPrivateNamingHistoryCandidate[]
}
export type FreeCadPrivateNamingHistoryClassification = {
records: NativeOcctHistoryResponse['records']
ambiguities: FreeCadPrivateNamingHistoryAmbiguity[]
externalRecordCount: number
internalRecordCount: number
duplicateRecordCount: number
relationConflictCount: number
}
const encoder = new TextEncoder()
const lockedCommit = '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d'
const supportedOperations = new Set<NativeOcctHistoryOperation>(['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'])
const boundedLimit = (value: unknown, maximum: number) => Number.isSafeInteger(value) && (value as number) > 0 ? Math.min(value as number, maximum) : maximum
type FreeCadPrivateNamingInputIdentity = Pick<FreeCadPrivateNamingAbiRequest['inputs'][number], 'inputId' | 'role'>
type FreeCadPrivateNamingInput = FreeCadPrivateNamingAbiRequest['inputs'][number]
const namingKinds = new Set(['face', 'edge', 'vertex'])
const titleKind = (kind: FreeCadPrivateNamingHistoryAmbiguity['resultKind']) => `${kind[0].toUpperCase()}${kind.slice(1)}`
const effectiveSource = (record: NativeOcctHistoryResponse['records'][number]) => record.sourceId ?? record.source
const resolveNamingInput = (record: NativeOcctHistoryResponse['records'][number], inputs: FreeCadPrivateNamingInput[]): FreeCadPrivateNamingInput | undefined => {
const source = effectiveSource(record)
return inputs.find((input) => input.inputId === source)
?? inputs.find((input) => input.role === source)
?? (source === 'object' ? inputs[0] : undefined)
?? (source === 'tool' ? inputs[1] : undefined)
}
const sourceCandidates = (record: NativeOcctHistoryResponse['records'][number], input: FreeCadPrivateNamingInput, kind: FreeCadPrivateNamingHistoryCandidate['sourceKind']): FreeCadPrivateNamingHistoryCandidate[] => {
const prior = input.namingEvidence?.mappedNames?.find((mapped) => mapped.kind === kind && mapped.resultIndex === record.sourceIndex)
if (prior?.candidates && prior.candidates.length > 1) return prior.candidates.map((candidate) => ({
inputId: input.inputId,
objectId: candidate.objectId,
persistentId: candidate.persistentId,
...(candidate.stageId ? { stageId: candidate.stageId } : {}),
relation: record.relation as 'modified' | 'generated',
sourceKind: kind,
sourceIndex: record.sourceIndex,
}))
return [{
inputId: input.inputId,
objectId: input.objectId ?? input.inputId,
persistentId: prior?.resultPersistentId ?? `${titleKind(kind)}${record.sourceIndex + 1}`,
...(input.stageId ? { stageId: input.stageId } : {}),
relation: record.relation as 'modified' | 'generated',
sourceKind: kind,
sourceIndex: record.sourceIndex,
}]
}
export const selectFreeCadPrivateNamingHistoryRecords = (
records: NativeOcctHistoryResponse['records'],
inputs: FreeCadPrivateNamingInputIdentity[],
): NativeOcctHistoryResponse['records'] => {
const sources = new Set<string>()
for (const input of inputs) {
if (input.inputId.trim()) sources.add(input.inputId)
if (input.role?.trim()) sources.add(input.role)
}
if (inputs.length > 0) sources.add('object')
if (inputs.length > 1) sources.add('tool')
return records.filter((record) => sources.has(record.sourceId ?? record.source))
}
/**
* Classifies the native graph before the FreeCAD private-name ABI is called.
* A result is stable only when every contributing native relation resolves to
* one source object/subshape/stage. Isomorphic multi-source results retain all
* candidates and are deliberately excluded from private token generation.
*/
export const classifyFreeCadPrivateNamingHistory = (
records: NativeOcctHistoryResponse['records'],
inputs: FreeCadPrivateNamingInput[],
): FreeCadPrivateNamingHistoryClassification => {
const externalRecords = selectFreeCadPrivateNamingHistoryRecords(records, inputs)
const deletedRecords = externalRecords.filter((record) => record.relation === 'deleted').map((record) => ({ ...record, resultIndexes: record.resultIndexes ? [...record.resultIndexes] : undefined }))
const grouped = new Map<string, Array<{ record: NativeOcctHistoryResponse['records'][number]; input: FreeCadPrivateNamingInput; resultKind: FreeCadPrivateNamingHistoryAmbiguity['resultKind']; resultIndex: number; candidate: FreeCadPrivateNamingHistoryCandidate }>>()
for (const record of externalRecords) {
if (record.relation === 'deleted') continue
const resultKind = record.resultKind ?? record.kind
if (!namingKinds.has(record.kind) || !namingKinds.has(resultKind)) throw new TypeError(`FreeCAD naming history contains unsupported kind '${record.kind}' -> '${resultKind}'.`)
if (!Number.isSafeInteger(record.sourceIndex) || record.sourceIndex < 0) throw new RangeError('FreeCAD naming history sourceIndex must be a non-negative safe integer.')
const input = resolveNamingInput(record, inputs)
if (!input) throw new Error(`FreeCAD naming history source '${effectiveSource(record)}' has no transport input.`)
const indexes = record.resultIndexes ?? (record.resultIndex === undefined ? [] : [record.resultIndex])
for (const resultIndex of indexes) {
if (!Number.isSafeInteger(resultIndex) || resultIndex < 0) throw new RangeError('FreeCAD naming history resultIndex must be a non-negative safe integer.')
const sourceKind = record.kind as FreeCadPrivateNamingHistoryCandidate['sourceKind']
const kind = resultKind as FreeCadPrivateNamingHistoryAmbiguity['resultKind']
const key = `${kind}:${resultIndex}`
const entries = grouped.get(key) ?? []
for (const candidate of sourceCandidates(record, input, sourceKind)) entries.push({ record, input, resultKind: kind, resultIndex, candidate })
grouped.set(key, entries)
}
}
const selected: NativeOcctHistoryResponse['records'] = [...deletedRecords]
const ambiguities: FreeCadPrivateNamingHistoryAmbiguity[] = []
let duplicateRecordCount = 0
let relationConflictCount = 0
for (const entries of grouped.values()) {
const unique = new Map<string, typeof entries[number]>()
for (const entry of entries) {
const candidate = entry.candidate
const key = `${candidate.objectId}\u0000${candidate.persistentId}\u0000${candidate.stageId ?? ''}\u0000${candidate.relation}`
if (!unique.has(key)) unique.set(key, entry)
}
const distinctSources = new Set([...unique.values()].map(({ candidate }) => `${candidate.objectId}\u0000${candidate.persistentId}\u0000${candidate.stageId ?? ''}`))
if (distinctSources.size > 1) {
const first = entries[0]
ambiguities.push({ resultKind: first.resultKind, resultIndex: first.resultIndex, candidates: [...unique.values()].map(({ candidate }) => ({ ...candidate })) })
continue
}
const relations = new Set([...unique.values()].map(({ candidate }) => candidate.relation))
if (relations.size > 1) relationConflictCount += 1
// OCCT may expose one unchanged result through both Modified() and Generated().
// Modified preserves identity and therefore has deterministic precedence.
const stable = [...unique.values()].find(({ candidate }) => candidate.relation === 'modified') ?? unique.values().next().value as typeof entries[number]
selected.push({ ...stable.record, resultKind: stable.resultKind, resultIndex: stable.resultIndex, resultIndexes: undefined })
duplicateRecordCount += entries.length - unique.size
}
return {
records: selected,
ambiguities,
externalRecordCount: externalRecords.length,
internalRecordCount: records.length - externalRecords.length,
duplicateRecordCount,
relationConflictCount,
}
}
const ambiguousMappedNames = (ambiguities: FreeCadPrivateNamingHistoryAmbiguity[]): NativeMappedNameRef[] => ambiguities.map((ambiguity) => {
const name = `${titleKind(ambiguity.resultKind)}${ambiguity.resultIndex + 1}`
return {
kind: ambiguity.resultKind,
resultIndex: ambiguity.resultIndex,
resultPersistentId: name,
reference: { name },
relation: 'ambiguous',
candidates: ambiguity.candidates.map(({ objectId, persistentId, stageId }) => ({ objectId, persistentId, ...(stageId ? { stageId } : {}) })),
}
})
const withIndexedSourceFallbacks = (inputs: FreeCadPrivateNamingInput[], records: NativeOcctHistoryResponse['records']): FreeCadPrivateNamingInput[] => {
const cloned = inputs.map((input) => ({ ...input, ...(input.namingEvidence ? { namingEvidence: cloneNativeNamingEvidence(input.namingEvidence) } : {}) }))
for (const record of records) {
if (record.relation === 'deleted' || !namingKinds.has(record.kind)) continue
const input = resolveNamingInput(record, cloned)
const evidence = input?.namingEvidence
if (!input || !evidence || evidence.status === 'final-shape-only' || evidence.status === 'missing' || evidence.mappedNames?.some((mapped) => mapped.kind === record.kind && mapped.resultIndex === record.sourceIndex)) continue
const kind = record.kind as NativeMappedNameRef['kind']
const name = `${titleKind(kind)}${record.sourceIndex + 1}`
evidence.mappedNames = [...(evidence.mappedNames ?? []), {
kind,
resultIndex: record.sourceIndex,
resultPersistentId: name,
reference: { name },
relation: 'preserved',
sourceRefs: [{ objectId: input.objectId ?? input.inputId, persistentId: name, ...(input.stageId ? { stageId: input.stageId } : {}) }],
}]
assertNativeNamingEvidence(evidence)
}
return cloned
}
export const probeFreeCadPrivateNamingAbi = (module: NativeFreeCadNamingAbiModule): FreeCadPrivateNamingAbiProbe => {
if (typeof module.freecadNamingAbiVersion !== 'function' || typeof module.freecadNamingCapabilitiesJson !== 'function' || typeof module.freecadNamingEvidenceJson !== 'function') return {
availability: 'unavailable',
@@ -99,18 +281,22 @@ export const createFreeCadPrivateNamingAbiRequest = (input: Omit<FreeCadPrivateN
if (!input.requestId.trim() || !input.documentId.trim() || !input.operationId.trim() || !input.stageId.trim() || !input.resultObjectId.trim()) throw new TypeError('FreeCAD naming ABI request IDs must be non-empty.')
if (!Number.isSafeInteger(input.documentVersion) || input.documentVersion < 0) throw new RangeError('FreeCAD naming ABI documentVersion must be a non-negative integer.')
if (input.resultObjectTag !== undefined && (!Number.isSafeInteger(input.resultObjectTag) || input.resultObjectTag <= 0)) throw new RangeError('FreeCAD naming ABI resultObjectTag must be a positive safe integer.')
const inputs = input.inputs.map((entry) => {
if (!entry.inputId.trim() || !entry.step.trim()) throw new TypeError('FreeCAD naming ABI inputs require non-empty inputId and STEP text.')
if (entry.objectId !== undefined && !entry.objectId.trim()) throw new TypeError(`FreeCAD naming ABI input ${entry.inputId} objectId must be non-empty when provided.`)
if (entry.objectTag !== undefined && (!Number.isSafeInteger(entry.objectTag) || entry.objectTag <= 0)) throw new RangeError(`FreeCAD naming ABI input ${entry.inputId} objectTag must be a positive safe integer.`)
return { ...entry, ...(entry.namingEvidence ? { namingEvidence: cloneNativeNamingEvidence(assertNativeNamingEvidence(entry.namingEvidence)) } : {}) }
})
const classification = classifyFreeCadPrivateNamingHistory(input.history.records, inputs)
const abiInputs = withIndexedSourceFallbacks(inputs, classification.records)
return {
schemaVersion: 1,
...input,
inputs: input.inputs.map((entry) => {
if (!entry.inputId.trim() || !entry.step.trim()) throw new TypeError('FreeCAD naming ABI inputs require non-empty inputId and STEP text.')
if (entry.objectId !== undefined && !entry.objectId.trim()) throw new TypeError(`FreeCAD naming ABI input ${entry.inputId} objectId must be non-empty when provided.`)
if (entry.objectTag !== undefined && (!Number.isSafeInteger(entry.objectTag) || entry.objectTag <= 0)) throw new RangeError(`FreeCAD naming ABI input ${entry.inputId} objectTag must be a positive safe integer.`)
return { ...entry, ...(entry.namingEvidence ? { namingEvidence: cloneNativeNamingEvidence(assertNativeNamingEvidence(entry.namingEvidence)) } : {}) }
}),
inputs: abiInputs,
stages: input.stages.map((entry) => ({ ...entry, inputIds: [...entry.inputIds] })),
...(input.resultStepByStage ? { resultStepByStage: { ...input.resultStepByStage } } : {}),
history: { ...input.history, records: input.history.records.map((record) => ({ ...record, resultIndexes: record.resultIndexes ? [...record.resultIndexes] : undefined })) },
...(classification.ambiguities.length > 0 ? { historyAmbiguities: classification.ambiguities.map((ambiguity) => ({ ...ambiguity, candidates: ambiguity.candidates.map((candidate) => ({ ...candidate })) })) } : {}),
history: { ...input.history, records: classification.records.map((record) => ({ ...record, resultIndexes: record.resultIndexes ? [...record.resultIndexes] : undefined })) },
}
}
@@ -121,6 +307,16 @@ export const captureFreeCadPrivateNamingEvidence = (
): NativeStageNamingEvidence | undefined => {
if (probe.availability !== 'available' || !probe.descriptor || typeof module.freecadNamingEvidenceJson !== 'function') return undefined
if (!probe.descriptor.operations.includes(request.operation)) return undefined
const ambiguities = request.historyAmbiguities ?? []
const stableResultCount = request.history.records.filter((record) => record.relation !== 'deleted').reduce((count, record) => count + (record.resultIndexes?.length ?? (record.resultIndex === undefined ? 0 : 1)), 0)
if (stableResultCount === 0 && ambiguities.length > 0) return assertNativeNamingEvidence({
schemaVersion: 1,
stageId: request.stageId,
resultObjectId: request.resultObjectId,
status: 'ambiguous',
mappedNames: ambiguousMappedNames(ambiguities),
reason: 'Native topology has more than one source provenance; private FreeCAD names were not minted.',
})
const requestJson = JSON.stringify(request)
const requestBytes = encoder.encode(requestJson).byteLength
const requestLimit = boundedLimit(probe.descriptor.maxRequestBytes, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES)
@@ -147,5 +343,12 @@ export const captureFreeCadPrivateNamingEvidence = (
if (!elementMap2Report.valid) throw new Error(`FreeCAD naming ABI ElementMap2 is invalid: ${elementMap2Report.issues[0].path}: ${elementMap2Report.issues[0].message}`)
const closureIssues = validateElementMap2StringHasherEvidence(elementMap2, stringHasher)
if (closureIssues.length > 0) throw new Error(`FreeCAD naming ABI StringHasher closure is invalid: ${closureIssues[0].path}: ${closureIssues[0].message}`)
return { ...evidence, stringHasher, elementMap2 }
const normalized = { ...evidence, stringHasher, elementMap2 }
if (ambiguities.length === 0) return normalized
return assertNativeNamingEvidence({
...normalized,
status: 'ambiguous',
mappedNames: [...(normalized.mappedNames ?? []), ...ambiguousMappedNames(ambiguities)],
reason: 'Unique native sources use FreeCAD private names; non-unique isomorphic sources retain explicit candidates.',
})
}