feat: complete production naming and reference lifecycle gates
This commit is contained in:
181
src/facade/externalPartDesignLinks.ts
Normal file
181
src/facade/externalPartDesignLinks.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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('')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user