221 lines
14 KiB
JavaScript
221 lines
14 KiB
JavaScript
import { spawnSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import { existsSync } from 'node:fs'
|
|
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { captureFreeCadPrivateNamingEvidence, classifyFreeCadPrivateNamingHistory, createFreeCadPrivateNamingAbiRequest } from '../src/facade/nativeNamingAbi.ts'
|
|
|
|
const root = resolve(new URL('..', import.meta.url).pathname)
|
|
const dist = resolve(root, 'native/occt-history/dist')
|
|
const modulePath = resolve(dist, 'bitbybit-occt-history.js')
|
|
const freecad = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd')
|
|
const sysroot = resolve(root, '.cache/freecad/sysroot')
|
|
const executorPath = resolve(root, 'scripts/run-freecad-ordered-operation-pair-classification.mjs')
|
|
const resaveHarnessPath = resolve(root, 'scripts/freecad-ordered-operation-pair-resave.py')
|
|
const outputPath = resolve(root, 'config/freecad-ordered-operation-pair-classification.json')
|
|
const artifactNames = ['bitbybit-occt-history.js', 'bitbybit-occt-history.wasm', 'bitbybit-occt-history.data']
|
|
const fail = (message) => { throw new Error(`FreeCAD ordered operation pair classification: ${message}`) }
|
|
if (!existsSync(modulePath) || !existsSync(freecad)) fail('native OCCT or FreeCAD executable is missing.')
|
|
|
|
const sha256 = (value) => createHash('sha256').update(value).digest('hex')
|
|
const shapeStep = (module, method, ...args) => {
|
|
const shape = module[method](...args)
|
|
try { return module.shapeToStep(shape) }
|
|
finally { shape.delete?.() }
|
|
}
|
|
const input = (inputId, role, objectId, objectTag, step, namingEvidence) => ({ inputId, role, objectId, objectTag, step, ...(namingEvidence ? { namingEvidence } : {}) })
|
|
const serializableHistory = (response, records) => ({ provider: response.provider, occtVersion: response.occtVersion, records, hasModified: response.hasModified, hasGenerated: response.hasGenerated, hasDeleted: response.hasDeleted, resultStep: response.resultStep, resultBrep: response.resultBrep })
|
|
const namingSemanticSnapshot = (evidence) => (evidence?.mappedNames ?? []).map((entry) => ({
|
|
kind: entry.kind,
|
|
resultIndex: entry.resultIndex,
|
|
resultPersistentId: entry.resultPersistentId,
|
|
relation: entry.relation,
|
|
candidates: (entry.candidates ?? []).map((candidate) => ({ objectId: candidate.objectId, persistentId: candidate.persistentId, stageId: candidate.stageId, relation: candidate.relation })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
|
|
})).sort((left, right) => `${left.kind}:${left.resultIndex}`.localeCompare(`${right.kind}:${right.resultIndex}`))
|
|
const canonicalSummary = (summary) => ({
|
|
isValid: summary.isValid,
|
|
solids: summary.solids,
|
|
faces: summary.faces,
|
|
edges: summary.edges,
|
|
vertices: summary.vertices,
|
|
volume: Number(summary.volume.toFixed(7)),
|
|
area: Number(summary.area.toFixed(7)),
|
|
bounds: [...summary.boundingBox.min, ...summary.boundingBox.max].map((value) => Number(value.toFixed(7))),
|
|
})
|
|
|
|
const operationSpecs = {
|
|
fuse: { builder: 'BRepAlgoAPI_Fuse', nominal: { toolSize: 5, toolOffset: 12 }, edited: { toolSize: 5, toolOffset: 11 }, parameter: 'toolOffsetX', beforeValue: 12, editedValue: 11 },
|
|
cut: { builder: 'BRepAlgoAPI_Cut', nominal: { toolSize: 4, toolOffset: 2 }, edited: { toolSize: 3, toolOffset: 2 }, parameter: 'toolSize', beforeValue: 4, editedValue: 3 },
|
|
common: { builder: 'BRepAlgoAPI_Common', nominal: { toolSize: 5, toolOffset: 10 }, edited: { toolSize: 5, toolOffset: 9 }, parameter: 'toolOffsetX', beforeValue: 10, editedValue: 9 },
|
|
rotate: { builder: 'BRepBuilderAPI_Transform', nominal: { angle: 15 }, edited: { angle: 22.5 }, parameter: 'angle', beforeValue: 15, editedValue: 22.5 },
|
|
}
|
|
|
|
const createOperation = (module, { operation, objectStep, toolSize, toolOffset, angle, stage }) => {
|
|
const baseStep = objectStep ?? shapeStep(module, 'makeBox', 10, 10, 10)
|
|
const objectInput = input('object', 'object', stage.objectId, stage.objectTag, baseStep, stage.namingEvidence)
|
|
if (operation === 'rotate') return { response: module.rotateHistoryFromStep(baseStep, 0, 0, 0, 0, 0, 1, angle), inputs: [objectInput] }
|
|
const toolStep = shapeStep(module, 'makeBoxPlaced', toolSize, toolSize, toolSize, toolOffset, 0, 0)
|
|
const inputs = [
|
|
objectInput,
|
|
input('tool', 'tool', `${stage.resultObjectId}:tool`, stage.objectTag + 1, toolStep),
|
|
]
|
|
return { response: module.booleanHistoryFromStep(baseStep, toolStep, operation), inputs }
|
|
}
|
|
|
|
const captureStage = (module, definition) => {
|
|
const { response, inputs } = createOperation(module, definition)
|
|
try {
|
|
const provenance = classifyFreeCadPrivateNamingHistory(response.records, inputs)
|
|
const stableRecords = provenance.records.filter(({ relation }) => relation !== 'deleted').length
|
|
if (response.provider !== 'occt-native' || !response.resultStep?.startsWith('ISO-10303-21;') || response.summary?.isValid !== true || (stableRecords < 1 && provenance.ambiguities.length < 1)) fail(`${definition.stage.stageId} native ${definition.operation} builder returned no usable result.`)
|
|
const request = createFreeCadPrivateNamingAbiRequest({
|
|
requestId: `${definition.stage.stageId}:request`,
|
|
documentId: 'ordered-operation-pair',
|
|
documentVersion: definition.stage.ordinal + 1,
|
|
operationId: definition.stage.stageId,
|
|
operation: definition.operation,
|
|
stageId: definition.stage.stageId,
|
|
resultObjectId: definition.stage.resultObjectId,
|
|
resultObjectTag: definition.stage.resultObjectTag,
|
|
inputs,
|
|
stages: [{ stageId: definition.stage.stageId, operation: definition.operation, inputIds: inputs.map(({ inputId }) => inputId), ordinal: definition.stage.ordinal }],
|
|
resultStep: response.resultStep,
|
|
resultBrep: response.resultBrep,
|
|
history: serializableHistory(response, response.records),
|
|
})
|
|
const namingEvidence = captureFreeCadPrivateNamingEvidence(module, request)
|
|
const mappedResults = stableRecords + provenance.ambiguities.length
|
|
if (!namingEvidence || !['native-evidence', 'ambiguous'].includes(namingEvidence.status) || namingEvidence.mappedNames?.length !== mappedResults) fail(`${definition.stage.stageId} FreeCAD naming callback evidence is incomplete: ${JSON.stringify(namingEvidence)}`)
|
|
return {
|
|
response,
|
|
namingEvidence,
|
|
report: {
|
|
operation: definition.operation,
|
|
builder: operationSpecs[definition.operation].builder,
|
|
inputCount: inputs.length,
|
|
historyProvider: response.provider,
|
|
namingStatus: namingEvidence.status,
|
|
summary: canonicalSummary(response.summary),
|
|
historyRecords: response.records.length,
|
|
selectedRecords: stableRecords,
|
|
ambiguousResults: provenance.ambiguities.length,
|
|
historySha256: sha256(JSON.stringify(response.records)),
|
|
namingEvidenceSha256: sha256(JSON.stringify(namingEvidence)),
|
|
namingSemanticSha256: sha256(JSON.stringify(namingSemanticSnapshot(namingEvidence))),
|
|
},
|
|
}
|
|
} catch (error) {
|
|
response.result?.delete?.()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const createModule = (await import(pathToFileURL(modulePath).href)).default
|
|
const module = await createModule({ locateFile: (path) => resolve(dist, path) })
|
|
const persistAcceptedPair = async (pair, second) => {
|
|
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'freecad-ordered-pair-'))
|
|
try {
|
|
const stepPath = join(temporaryDirectory, 'pair-result.step')
|
|
const evidencePath = join(temporaryDirectory, 'pair-naming.json')
|
|
await Promise.all([writeFile(stepPath, second.response.resultStep), writeFile(evidencePath, JSON.stringify(second.namingEvidence))])
|
|
const execution = spawnSync(freecad, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), resaveHarnessPath], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
timeout: 180_000,
|
|
maxBuffer: 40 * 1024 * 1024,
|
|
env: {
|
|
...process.env,
|
|
FREECAD_ORDERED_PAIR: pair,
|
|
FREECAD_PAIR_STEP_PATH: stepPath,
|
|
FREECAD_PAIR_EVIDENCE_PATH: evidencePath,
|
|
FREECAD_PAIR_OUTPUT_DIRECTORY: temporaryDirectory,
|
|
PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`,
|
|
LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`,
|
|
MATPLOTLIBRC: resolve(sysroot, 'usr/share/matplotlib/mpl-data/matplotlibrc'),
|
|
MPLBACKEND: 'Agg',
|
|
},
|
|
})
|
|
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
|
|
const marker = 'FREECAD_ORDERED_OPERATION_PAIR_RESAVE_RESULT='
|
|
const markerIndex = output.indexOf(marker)
|
|
const payload = markerIndex >= 0 ? output.slice(markerIndex + marker.length).match(/\{.*\}/s)?.[0] : undefined
|
|
if (execution.error || execution.status !== 0 || !payload) fail(`FreeCAD FCStd replay exited with ${execution.status}: ${execution.error?.message || output.trim()}`)
|
|
const persistence = JSON.parse(payload)
|
|
if (persistence.status !== 'pass' || persistence.freecadVersion !== '1.1.1' || persistence.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || !Object.values(persistence.checks).every(Boolean)) fail(`FreeCAD FCStd replay failed: ${JSON.stringify(persistence)}`)
|
|
return persistence
|
|
} finally {
|
|
await rm(temporaryDirectory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
const classifyFusePair = async (toOperation, pairIndex) => {
|
|
const pair = `fuse->${toOperation}`
|
|
const pairId = pair.replace('->', '-')
|
|
const first = captureStage(module, {
|
|
operation: 'fuse', toolSize: 5, toolOffset: 8,
|
|
stage: { stageId: `pair:${pairId}:first`, ordinal: 0, objectId: `pair:${pairId}:base`, objectTag: 1001 + pairIndex * 100, resultObjectId: `pair:${pairId}:first`, resultObjectTag: 1101 + pairIndex * 100 },
|
|
})
|
|
const spec = operationSpecs[toOperation]
|
|
const secondDefinition = {
|
|
operation: toOperation,
|
|
objectStep: first.response.resultStep,
|
|
...spec.nominal,
|
|
stage: { stageId: `pair:${pairId}:second`, ordinal: 1, objectId: `pair:${pairId}:first`, objectTag: 1101 + pairIndex * 100, resultObjectId: `pair:${pairId}:second`, resultObjectTag: 1201 + pairIndex * 100, namingEvidence: first.namingEvidence },
|
|
}
|
|
const second = captureStage(module, secondDefinition)
|
|
const mutated = captureStage(module, { ...secondDefinition, ...spec.edited })
|
|
const restored = captureStage(module, secondDefinition)
|
|
try {
|
|
const mutationChanged = JSON.stringify(second.report.summary) !== JSON.stringify(mutated.report.summary)
|
|
const restoredExactly = JSON.stringify(second.report.summary) === JSON.stringify(restored.report.summary) && second.report.historySha256 === restored.report.historySha256 && second.report.namingSemanticSha256 === restored.report.namingSemanticSha256
|
|
if (!mutationChanged || !restoredExactly) fail(`${pair} mutation did not change and restore native geometry/history.`)
|
|
const persistence = await persistAcceptedPair(pair, second)
|
|
return {
|
|
taskId: `TSN-PAIR-fuse-${toOperation}`,
|
|
pair,
|
|
classification: 'accepted',
|
|
reasonCode: 'native-builders-mutation-naming-and-fcstd-resave-pass',
|
|
nativeDecision: 'accepted',
|
|
first: first.report,
|
|
second: second.report,
|
|
mutation: { scope: 'second-operation-only', parameter: spec.parameter, trajectory: [spec.beforeValue, spec.editedValue, spec.beforeValue], beforeValue: spec.beforeValue, editedValue: spec.editedValue, restoredValue: spec.beforeValue, changed: mutationChanged, restoredExactly, editedEvidence: mutated.report, restoredEvidence: restored.report },
|
|
naming: { upstreamEvidenceRestored: Boolean(secondDefinition.stage.namingEvidence), downstreamEvidence: Boolean(second.namingEvidence), jsonRoundtripStable: JSON.stringify(JSON.parse(JSON.stringify(second.namingEvidence))) === JSON.stringify(second.namingEvidence) },
|
|
persistence,
|
|
}
|
|
} finally {
|
|
for (const capture of [first, second, mutated, restored]) capture.response.result?.delete?.()
|
|
}
|
|
}
|
|
|
|
const classifications = []
|
|
for (const [index, operation] of ['fuse', 'cut', 'common', 'rotate'].entries()) classifications.push(await classifyFusePair(operation, index))
|
|
|
|
const [artifacts, executorHarness, matrixHarness, resaveHarness] = await Promise.all([
|
|
Promise.all(artifactNames.map(async (name) => {
|
|
const path = resolve(dist, name)
|
|
const [content, bytes] = await Promise.all([readFile(path), stat(path).then(({ size }) => size)])
|
|
return { name, bytes, sha256: sha256(content) }
|
|
})),
|
|
readFile(executorPath),
|
|
readFile(resolve(root, 'scripts/freecad-naming-production-matrix.mjs')),
|
|
readFile(resaveHarnessPath),
|
|
])
|
|
const report = {
|
|
schemaVersion: 1,
|
|
baseline: { freecadVersion: '1.1.1', commit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d', occtVersion: module.occtVersion() },
|
|
generatedBy: './npmw run probe:freecad-ordered-operation-pairs',
|
|
checkedBy: './npmw run check:freecad-ordered-operation-pairs',
|
|
nativeProbe: {
|
|
executor: { path: 'scripts/run-freecad-ordered-operation-pair-classification.mjs', bytes: executorHarness.length, sha256: sha256(executorHarness) },
|
|
matrix: { path: 'scripts/freecad-naming-production-matrix.mjs', bytes: matrixHarness.length, sha256: sha256(matrixHarness) },
|
|
resave: { path: 'scripts/freecad-ordered-operation-pair-resave.py', bytes: resaveHarness.length, sha256: sha256(resaveHarness) },
|
|
artifacts,
|
|
},
|
|
classifications,
|
|
summary: { registeredOperations: 19, orderedPairs: 361, classifiedPairs: classifications.length, accepted: classifications.filter(({ classification }) => classification === 'accepted').length, rejected: classifications.filter(({ classification }) => classification === 'rejected').length, unknown: 361 - classifications.length },
|
|
}
|
|
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`)
|
|
console.log(JSON.stringify({ status: 'freecad-ordered-operation-pair-classification-generated', pairs: classifications.map(({ pair, classification }) => ({ pair, classification })), nativeBuilderRuns: classifications.length * 4, fcstdPhases: classifications.length * 3, output: 'config/freecad-ordered-operation-pair-classification.json' }, null, 2))
|