348 lines
19 KiB
JavaScript
348 lines
19 KiB
JavaScript
export const FREECAD_NAMING_CALLBACKS = [
|
|
'freecadNamingAbiVersion',
|
|
'freecadNamingCapabilitiesJson',
|
|
'freecadNamingEvidenceJson',
|
|
]
|
|
|
|
export const FREECAD_NAMING_OPERATIONS = [
|
|
'fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe',
|
|
'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness',
|
|
'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform',
|
|
]
|
|
|
|
const shapeStep = (module, method, ...args) => {
|
|
const shape = module[method](...args)
|
|
try {
|
|
return module.shapeToStep(shape)
|
|
} finally {
|
|
shape.delete?.()
|
|
}
|
|
}
|
|
|
|
const box = (module, ...args) => shapeStep(module, 'makeBox', ...args)
|
|
const placedBox = (module, ...args) => shapeStep(module, 'makeBoxPlaced', ...args)
|
|
const rectangle = (module, ...args) => shapeStep(module, 'makeRectangleFace', ...args)
|
|
const placedRectangle = (module, ...args) => shapeStep(module, 'makeRectangleFacePlaced', ...args)
|
|
const line = (module, ...args) => shapeStep(module, 'makeLineWire', ...args)
|
|
|
|
const input = (inputId, role, objectTag, step) => ({
|
|
inputId,
|
|
objectId: `production-${inputId}`,
|
|
role,
|
|
objectTag,
|
|
step,
|
|
})
|
|
|
|
const fixtures = {
|
|
fuse(module) {
|
|
const objectStep = box(module, 10, 10, 10)
|
|
const toolStep = placedBox(module, 5, 5, 5, 8, 0, 0)
|
|
return { response: module.booleanHistoryFromStep(objectStep, toolStep, 'fuse'), inputs: [input('object', 'object', 101, objectStep), input('tool', 'tool', 102, toolStep)] }
|
|
},
|
|
cut(module) {
|
|
const objectStep = box(module, 10, 10, 10)
|
|
const toolStep = box(module, 5, 5, 5)
|
|
return { response: module.booleanHistoryFromStep(objectStep, toolStep, 'cut'), inputs: [input('object', 'object', 111, objectStep), input('tool', 'tool', 112, toolStep)] }
|
|
},
|
|
common(module) {
|
|
const objectStep = box(module, 10, 10, 10)
|
|
const toolStep = placedBox(module, 5, 5, 5, 8, 2, 2)
|
|
return { response: module.booleanHistoryFromStep(objectStep, toolStep, 'common'), inputs: [input('object', 'object', 121, objectStep), input('tool', 'tool', 122, toolStep)] }
|
|
},
|
|
rotate(module) {
|
|
const objectStep = placedBox(module, 2, 1, 1, 1, 0, 0)
|
|
return { response: module.rotateHistoryFromStep(objectStep, 0, 0, 0, 0, 0, 1, 30), inputs: [input('object', 'object', 131, objectStep)] }
|
|
},
|
|
pad(module) {
|
|
const objectStep = rectangle(module, 2, 3)
|
|
return { response: module.prismHistoryFromStep(objectStep, 0, 0, 5), inputs: [input('object', 'object', 141, objectStep)] }
|
|
},
|
|
pocket(module) {
|
|
const objectStep = box(module, 10, 10, 10)
|
|
const toolStep = rectangle(module, 2, 3)
|
|
return { response: module.pocketHistoryFromStep(objectStep, toolStep, 0, 0, 5), inputs: [input('object', 'object', 151, objectStep), input('tool', 'tool', 152, toolStep)] }
|
|
},
|
|
loft(module) {
|
|
const objectStep = placedRectangle(module, 2, 2, 0, 0, 0)
|
|
const toolStep = placedRectangle(module, 2, 2, 0, 0, 5)
|
|
return { response: module.loftHistoryFromStep(objectStep, toolStep, false), inputs: [input('object', 'object', 161, objectStep), input('tool', 'tool', 162, toolStep)] }
|
|
},
|
|
pipe(module) {
|
|
const objectStep = rectangle(module, 2, 2)
|
|
const toolStep = line(module, 0, 0, 0, 0, 0, 5)
|
|
return { response: module.pipeHistoryFromStep(objectStep, toolStep), inputs: [input('object', 'object', 171, objectStep), input('tool', 'tool', 172, toolStep)] }
|
|
},
|
|
revolution(module) {
|
|
const objectStep = rectangle(module, 2, 3)
|
|
return { response: module.revolutionHistoryFromStep(objectStep, -1, 0, 0, 0, 1, 0, 360), inputs: [input('object', 'object', 181, objectStep)] }
|
|
},
|
|
groove(module) {
|
|
const objectStep = placedBox(module, 10, 10, 8, -5, 0, -4)
|
|
const toolStep = placedRectangle(module, 1, 3, 0.5, 2, 0)
|
|
return { response: module.grooveHistoryFromStep(objectStep, toolStep, 0, 0, 0, 0, 1, 0, 360), inputs: [input('object', 'object', 191, objectStep), input('tool', 'tool', 192, toolStep)] }
|
|
},
|
|
fillet(module) {
|
|
const objectStep = box(module, 6, 6, 6)
|
|
return { response: module.filletHistoryFromStep(objectStep, 0.4), inputs: [input('object', 'object', 201, objectStep)] }
|
|
},
|
|
chamfer(module) {
|
|
const objectStep = box(module, 6, 6, 6)
|
|
return { response: module.chamferHistoryFromStep(objectStep, 0.4), inputs: [input('object', 'object', 211, objectStep)] }
|
|
},
|
|
hole(module) {
|
|
const objectStep = box(module, 10, 10, 10)
|
|
return { response: module.holeHistoryFromStep(objectStep, 1, 10, 0, 0, -5, 0, 0, 1), inputs: [input('object', 'object', 221, objectStep)] }
|
|
},
|
|
draft(module) {
|
|
const objectStep = box(module, 10, 10, 10)
|
|
return { response: module.draftHistoryFromStep(objectStep, 0, 5, 0, 0, 1, 0, 0, 0, 0, 0, 1, false), inputs: [input('object', 'object', 231, objectStep)] }
|
|
},
|
|
thickness(module) {
|
|
const objectStep = box(module, 6, 6, 6)
|
|
return { response: module.thicknessHistoryFromStep(objectStep, 1, -0.4, false), inputs: [input('object', 'object', 241, objectStep)] }
|
|
},
|
|
'linear-pattern'(module) {
|
|
const objectStep = box(module, 2, 2, 2)
|
|
return { response: module.linearPatternHistoryFromStep(objectStep, 2, 0, 0), inputs: [input('object', 'object', 251, objectStep)] }
|
|
},
|
|
'polar-pattern'(module) {
|
|
const objectStep = placedBox(module, 2, 1, 1, -1, -0.5, -0.5)
|
|
return { response: module.polarPatternHistoryFromStep(objectStep, 0, 0, 0, 0, 0, 1, 90), inputs: [input('object', 'object', 261, objectStep)] }
|
|
},
|
|
mirrored(module) {
|
|
const objectStep = placedBox(module, 2, 1, 1, -0.5, -0.5, -0.5)
|
|
return { response: module.mirroredHistoryFromStep(objectStep, 0, 0, 0, 1, 0, 0), inputs: [input('object', 'object', 271, objectStep)] }
|
|
},
|
|
'multi-transform'(module) {
|
|
const objectStep = placedBox(module, 2, 1, 1, -0.5, -0.5, -0.5)
|
|
return {
|
|
response: module.multiTransformHistoryFromStep(objectStep, [
|
|
{ type: 'linear', direction: [1, 0, 0] },
|
|
{ type: 'mirrored', axisOrigin: [0, 0, 0], direction: [1, 0, 0] },
|
|
]),
|
|
inputs: [input('object', 'object', 281, objectStep)],
|
|
}
|
|
},
|
|
}
|
|
|
|
const serializableHistory = (response) => ({
|
|
provider: response.provider,
|
|
occtVersion: response.occtVersion,
|
|
records: response.records,
|
|
hasModified: response.hasModified,
|
|
hasGenerated: response.hasGenerated,
|
|
hasDeleted: response.hasDeleted,
|
|
resultStep: response.resultStep,
|
|
resultBrep: response.resultBrep,
|
|
})
|
|
|
|
const externallySourcedRecords = (records, inputs) => {
|
|
const sources = new Set(inputs.flatMap(({ inputId, role }) => [inputId, role]).filter(Boolean))
|
|
if (inputs.length > 0) sources.add('object')
|
|
if (inputs.length > 1) sources.add('tool')
|
|
return records.filter((record) => sources.has(record.sourceId ?? record.source))
|
|
}
|
|
|
|
const resolveInput = (record, inputs) => {
|
|
const source = record.sourceId ?? record.source
|
|
return inputs.find((entry) => entry.inputId === source)
|
|
?? inputs.find((entry) => entry.role === source)
|
|
?? (source === 'object' ? inputs[0] : undefined)
|
|
?? (source === 'tool' ? inputs[1] : undefined)
|
|
}
|
|
|
|
const persistentSourceName = (record, sourceInput) => {
|
|
const prior = sourceInput.namingEvidence?.mappedNames?.find((mapped) => mapped.kind === record.kind && mapped.resultIndex === record.sourceIndex)
|
|
const title = `${record.kind[0].toUpperCase()}${record.kind.slice(1)}`
|
|
return prior?.resultPersistentId ?? `${title}${record.sourceIndex + 1}`
|
|
}
|
|
|
|
export const classifyFreeCadNamingProvenance = (records, inputs) => {
|
|
const externalRecords = externallySourcedRecords(records, inputs)
|
|
const grouped = new Map()
|
|
for (const record of externalRecords) {
|
|
if (record.relation === 'deleted') continue
|
|
const sourceInput = resolveInput(record, inputs)
|
|
if (!sourceInput) throw new Error(`Native source ${record.sourceId ?? record.source} has no transport input.`)
|
|
const resultKind = record.resultKind || record.kind
|
|
const indexes = Array.isArray(record.resultIndexes) ? record.resultIndexes : [record.resultIndex]
|
|
for (const resultIndex of indexes) {
|
|
if (!Number.isSafeInteger(resultIndex) || resultIndex < 0) throw new Error(`Native result ${resultKind}:${String(resultIndex)} is invalid.`)
|
|
const key = `${resultKind}:${resultIndex}`
|
|
const entries = grouped.get(key) ?? []
|
|
entries.push({
|
|
record: { ...record, resultKind, resultIndex, resultIndexes: undefined },
|
|
resultKind,
|
|
resultIndex,
|
|
candidate: {
|
|
inputId: sourceInput.inputId,
|
|
objectId: sourceInput.objectId || sourceInput.inputId,
|
|
persistentId: persistentSourceName(record, sourceInput),
|
|
...(sourceInput.stageId ? { stageId: sourceInput.stageId } : {}),
|
|
relation: record.relation,
|
|
sourceKind: record.kind,
|
|
sourceIndex: record.sourceIndex,
|
|
},
|
|
})
|
|
grouped.set(key, entries)
|
|
}
|
|
}
|
|
const selected = externalRecords.filter((record) => record.relation === 'deleted').map((record) => ({ ...record }))
|
|
const ambiguities = []
|
|
let duplicateRecordCount = 0
|
|
let relationConflictCount = 0
|
|
for (const entries of grouped.values()) {
|
|
const unique = new Map()
|
|
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)
|
|
}
|
|
duplicateRecordCount += entries.length - unique.size
|
|
const distinctSources = new Set([...unique.values()].map(({ candidate }) => `${candidate.objectId}\u0000${candidate.persistentId}\u0000${candidate.stageId || ''}`))
|
|
if (distinctSources.size > 1) {
|
|
ambiguities.push({ resultKind: entries[0].resultKind, resultIndex: entries[0].resultIndex, candidates: [...unique.values()].map(({ candidate }) => candidate) })
|
|
continue
|
|
}
|
|
const relations = new Set([...unique.values()].map(({ candidate }) => candidate.relation))
|
|
if (relations.size > 1) relationConflictCount += 1
|
|
const stable = [...unique.values()].find(({ candidate }) => candidate.relation === 'modified') ?? [...unique.values()][0]
|
|
selected.push(stable.record)
|
|
}
|
|
return {
|
|
records: selected,
|
|
ambiguities,
|
|
externalRecordCount: externalRecords.length,
|
|
internalRecordCount: records.length - externalRecords.length,
|
|
duplicateRecordCount,
|
|
relationConflictCount,
|
|
}
|
|
}
|
|
|
|
const validateEvidence = (operation, evidence, recordCount) => {
|
|
if (evidence.status !== 'native-evidence') throw new Error(`${operation}: callback did not return native evidence.`)
|
|
if (evidence.mappedNames?.length !== recordCount || recordCount < 1) throw new Error(`${operation}: callback did not map every selected native history result.`)
|
|
// A first-generation mapping from plain Face1/Edge1 names legitimately has
|
|
// no interned strings. Chained mappings below must restore and grow the table.
|
|
if (!Array.isArray(evidence.stringHasher?.entries)) throw new Error(`${operation}: StringHasher evidence is absent.`)
|
|
if (!Array.isArray(evidence.elementMap2?.maps) || evidence.elementMap2.maps.length < 1) throw new Error(`${operation}: ElementMap2 evidence is empty.`)
|
|
const hasherIds = new Set(evidence.stringHasher.entries.map((entry) => entry.id))
|
|
const tokens = evidence.elementMap2.maps.flatMap((map) => map.sections.flatMap((section) => section.names.flatMap((name) => name.tokens)))
|
|
for (const mapped of evidence.mappedNames) {
|
|
const reference = mapped.reference
|
|
if (typeof reference?.name !== 'string' || !reference.name) throw new Error(`${operation}: MappedNameRef is malformed.`)
|
|
if (reference.name.startsWith('#')) {
|
|
if (!Number.isSafeInteger(reference.prefixStringId) || !reference.stringIds?.includes(reference.prefixStringId) || !reference.stringIds.every((id) => hasherIds.has(id))) throw new Error(`${operation}: MappedNameRef is not closed over StringHasher.`)
|
|
if (!tokens.some((token) => token.marker === '$' && token.name === reference.name)) throw new Error(`${operation}: MappedNameRef is absent from ElementMap2.`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const runFreeCadNamingProductionMatrix = async (module) => {
|
|
if (!FREECAD_NAMING_CALLBACKS.every((name) => typeof module[name] === 'function')) throw new Error('Production Worker omits a required FreeCAD naming callback.')
|
|
if (module.freecadNamingAbiVersion() !== 1) throw new Error('Production Worker FreeCAD naming ABI version is not 1.')
|
|
const descriptor = JSON.parse(module.freecadNamingCapabilitiesJson())
|
|
if (descriptor.freecadVersion !== '1.1.1' || descriptor.sourceCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d') throw new Error('Production Worker naming descriptor is not source locked.')
|
|
if (descriptor.operations?.length !== FREECAD_NAMING_OPERATIONS.length || FREECAD_NAMING_OPERATIONS.some((operation) => !descriptor.operations.includes(operation))) throw new Error('Production Worker naming descriptor does not cover all native builders.')
|
|
|
|
const cases = []
|
|
let chainedSource
|
|
for (let index = 0; index < FREECAD_NAMING_OPERATIONS.length; index += 1) {
|
|
const operation = FREECAD_NAMING_OPERATIONS[index]
|
|
const { response, inputs } = fixtures[operation](module)
|
|
try {
|
|
if (response.provider !== 'occt-native' || !/^8\./.test(response.occtVersion) || !response.resultStep?.startsWith('ISO-10303-21;')) throw new Error(`${operation}: native builder response is invalid.`)
|
|
const provenance = classifyFreeCadNamingProvenance(response.records, inputs)
|
|
const records = provenance.records
|
|
const stableResultCount = records.filter((record) => record.relation !== 'deleted').length
|
|
if (stableResultCount < 1 && provenance.ambiguities.length < 1) throw new Error(`${operation}: native builder returned no usable result history.`)
|
|
const request = {
|
|
schemaVersion: 1,
|
|
requestId: `production-${operation}-request`,
|
|
documentId: 'production-naming-matrix',
|
|
documentVersion: index + 1,
|
|
operationId: `production-${operation}`,
|
|
operation,
|
|
stageId: `production:${operation}:stage`,
|
|
resultObjectId: `production:${operation}:result`,
|
|
resultObjectTag: 1000 + index,
|
|
inputs,
|
|
stages: [{ stageId: `production:${operation}:stage`, operation, inputIds: inputs.map(({ inputId }) => inputId), ordinal: 0 }],
|
|
resultStep: response.resultStep,
|
|
resultBrep: response.resultBrep,
|
|
...(provenance.ambiguities.length > 0 ? { historyAmbiguities: provenance.ambiguities } : {}),
|
|
history: { ...serializableHistory(response), records },
|
|
}
|
|
const evidence = stableResultCount > 0 ? JSON.parse(module.freecadNamingEvidenceJson(JSON.stringify(request))) : undefined
|
|
if (evidence) validateEvidence(operation, evidence, stableResultCount)
|
|
cases.push({ operation, status: 'pass', namingStatus: evidence ? (provenance.ambiguities.length > 0 ? 'mixed-native-and-ambiguous' : 'native-evidence') : 'ambiguous', callbackExecuted: Boolean(evidence), historyRecords: response.records.length, externallySourcedRecords: provenance.externalRecordCount, internalRecordsExcluded: provenance.internalRecordCount, duplicateRecordsCollapsed: provenance.duplicateRecordCount, modifiedRelationPrecedence: provenance.relationConflictCount, selectedRecords: stableResultCount, ambiguousResults: provenance.ambiguities.length, provenanceResults: stableResultCount + provenance.ambiguities.length, mappedNames: evidence?.mappedNames.length ?? 0, stringHasherEntries: evidence?.stringHasher.entries.length ?? 0, elementMaps: evidence?.elementMap2.maps.length ?? 0 })
|
|
if (operation === 'cut' && evidence) chainedSource = { request, evidence, record: records.find((record) => record.relation !== 'deleted') }
|
|
} finally {
|
|
response.result?.delete?.()
|
|
}
|
|
}
|
|
|
|
const symmetricObjectStep = box(module, 10, 10, 10)
|
|
const symmetricToolStep = box(module, 10, 10, 10)
|
|
const symmetricResponse = module.booleanHistoryFromStep(symmetricObjectStep, symmetricToolStep, 'common')
|
|
let isomorphicCorpus
|
|
try {
|
|
const symmetricInputs = [input('symmetric-object', 'object', 301, symmetricObjectStep), input('symmetric-tool', 'tool', 302, symmetricToolStep)]
|
|
const provenance = classifyFreeCadNamingProvenance(symmetricResponse.records, symmetricInputs)
|
|
if (provenance.ambiguities.length < 1 || provenance.ambiguities.some(({ candidates }) => candidates.length < 2)) throw new Error('Symmetric Common did not retain its non-unique native source candidates.')
|
|
const candidateSets = provenance.ambiguities.map(({ resultKind, resultIndex, candidates }) => ({ result: `${resultKind}:${resultIndex}`, candidates: candidates.map(({ objectId, persistentId, stageId }) => `${objectId}:${persistentId}:${stageId || ''}`).sort() }))
|
|
isomorphicCorpus = {
|
|
operation: 'common',
|
|
fixture: 'coincident-equal-boxes',
|
|
status: 'pass',
|
|
historyRecords: symmetricResponse.records.length,
|
|
uniqueResults: provenance.records.filter((record) => record.relation !== 'deleted').length,
|
|
ambiguousResults: provenance.ambiguities.length,
|
|
candidateSets,
|
|
arrayOrderFallbackUsed: false,
|
|
}
|
|
} finally {
|
|
symmetricResponse.result?.delete?.()
|
|
}
|
|
|
|
if (!chainedSource) throw new Error('Production naming matrix lacks its chained cut source.')
|
|
const nextIndex = chainedSource.record.resultIndex + 1
|
|
const chainedRequest = {
|
|
...chainedSource.request,
|
|
requestId: 'production-cut-chained-request',
|
|
documentVersion: 20,
|
|
operationId: 'production-cut-chained',
|
|
stageId: 'production:cut:chained-stage',
|
|
resultObjectId: 'production:cut:chained-result',
|
|
resultObjectTag: 1100,
|
|
inputs: [{ inputId: 'object', objectId: chainedSource.request.resultObjectId, role: 'object', stageId: chainedSource.request.stageId, objectTag: chainedSource.request.resultObjectTag, step: chainedSource.request.resultStep, namingEvidence: chainedSource.evidence }],
|
|
stages: [{ stageId: 'production:cut:chained-stage', operation: 'cut', inputIds: ['object'], ordinal: 0 }],
|
|
history: { ...chainedSource.request.history, records: [{ relation: 'modified', source: 'object', kind: chainedSource.record.resultKind || chainedSource.record.kind, sourceIndex: chainedSource.record.resultIndex, resultIndex: nextIndex }] },
|
|
}
|
|
const chainedEvidence = JSON.parse(module.freecadNamingEvidenceJson(JSON.stringify(chainedRequest)))
|
|
validateEvidence('cut-chained', chainedEvidence, 1)
|
|
if (chainedEvidence.stringHasher.entries.length <= chainedSource.evidence.stringHasher.entries.length) throw new Error('Production Worker did not restore and extend the prior StringHasher table.')
|
|
|
|
const invalid = JSON.parse(module.freecadNamingEvidenceJson(JSON.stringify({ ...chainedSource.request, requestId: 'production-invalid-history', history: { ...chainedSource.request.history, records: [] } })))
|
|
if (invalid.status !== 'error' || !invalid.error?.includes('requires inputs and native history records')) throw new Error('Production Worker did not fail closed for missing native history.')
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
status: 'pass',
|
|
implementation: 'freecad-linked',
|
|
productionWorkerLinked: true,
|
|
occtVersion: module.occtVersion(),
|
|
freecadVersion: descriptor.freecadVersion,
|
|
sourceCommit: descriptor.sourceCommit,
|
|
callbacks: [...FREECAD_NAMING_CALLBACKS],
|
|
operations: [...FREECAD_NAMING_OPERATIONS],
|
|
cases,
|
|
callbackExecutedCases: cases.filter(({ callbackExecuted }) => callbackExecuted).length,
|
|
ambiguityOnlyCases: cases.filter(({ namingStatus }) => namingStatus === 'ambiguous').length,
|
|
isomorphicCorpus,
|
|
chainedStage: { mappedNames: chainedEvidence.mappedNames.length, stringHasherEntries: chainedEvidence.stringHasher.entries.length },
|
|
invalidHistoryRejected: true,
|
|
systemExact: false,
|
|
}
|
|
}
|