feat: establish reproducible FreeCAD web compatibility baseline
This commit is contained in:
223
scripts/run-freecad-cam-path-oracle.ts
Normal file
223
scripts/run-freecad-cam-path-oracle.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { decodeFcstdPathProperty, inspectFcstdArchive, rewriteFcstdMetadataArchive, rewriteFcstdPathProperty } from '../src/facade/fcstd'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const executable = resolve(root, '.cache/freecad/install-desktop/bin/FreeCAD')
|
||||
const probe = resolve(root, 'scripts/freecad-cam-path-oracle.py')
|
||||
const sysroot = resolve(root, '.cache/freecad/sysroot')
|
||||
const outputDirectory = resolve(root, '.cache/freecad/cam-path-oracle')
|
||||
const sourcePath = resolve(outputDirectory, 'native-path-source.FCStd')
|
||||
const mutatedPath = resolve(outputDirectory, 'native-path-mutated.FCStd')
|
||||
const preservedPath = resolve(outputDirectory, 'web-preserved.FCStd')
|
||||
const webEditedPath = resolve(outputDirectory, 'web-edited-path.FCStd')
|
||||
const webEditedPythonPath = resolve(outputDirectory, 'web-edited-python-path.FCStd')
|
||||
const reportPath = resolve(root, 'config/freecad-cam-path-oracle.json')
|
||||
const marker = 'FREECAD_CAM_PATH_RESULT='
|
||||
let probeCounter = 0
|
||||
const fail = (message: string): never => { throw new Error(`FreeCAD CAM Path oracle: ${message}`) }
|
||||
const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
||||
const semanticSha256 = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true })
|
||||
|
||||
const runtimeEnv = {
|
||||
...process.env,
|
||||
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 runProbe = (extraEnv: NodeJS.ProcessEnv) => {
|
||||
const resultFile = resolve(outputDirectory, `native-probe-result-${probeCounter++}.json`)
|
||||
const execution = spawnSync('xvfb-run', ['-a', executable, '--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), probe], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: 120_000,
|
||||
env: { ...runtimeEnv, ...extraEnv, FREECAD_CAM_PATH_RESULT_FILE: resultFile },
|
||||
})
|
||||
const output = `${execution.stdout ?? ''}\n${execution.stderr ?? ''}`
|
||||
const line = output.split(/\r?\n/).find((candidate) => candidate.startsWith(marker))
|
||||
const resultFileAvailable = (() => { try { return readFileSync(resultFile, 'utf8') } catch { return '' } })()
|
||||
const qtTeardownSignal = execution.status !== 0 && /SIGSEGV|Segmentation fault|Shiboken::BindingManager/.test(output) && Boolean(line || resultFileAvailable)
|
||||
if (execution.error || (execution.status !== 0 && !qtTeardownSignal) || (!line && !resultFileAvailable)) fail(`native probe exited with ${execution.status}: ${execution.error?.message ?? output.trim()}`)
|
||||
const result = resultFileAvailable
|
||||
? JSON.parse(resultFileAvailable) as Record<string, any>
|
||||
: JSON.parse(line!.slice(marker.length)) as Record<string, any>
|
||||
if (qtTeardownSignal && result.nativeRuntime) result.nativeRuntime.qtTaskPanelTeardown = 'isolated-pyside-wrapper-release-after-complete-marker'
|
||||
return result
|
||||
}
|
||||
|
||||
const native = runProbe({
|
||||
FREECAD_CAM_PATH_SOURCE: sourcePath,
|
||||
FREECAD_CAM_PATH_MUTATED: mutatedPath,
|
||||
})
|
||||
if (native.freecadVersion !== '1.1.1' || native.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d') fail('native runtime does not match the locked FreeCAD 1.1.1 oracle')
|
||||
if (native.nativeRuntime?.guiUp !== true || native.qtDynamicOracle?.activeWorkbench !== 'CAMWorkbench') fail('CAM Qt workbench did not activate in the GUI oracle')
|
||||
if (native.profileAlgorithm?.pathPropertyType !== 'Path::PropertyPath' || native.profileAlgorithm?.cuttingCommandCount !== 20 || native.profileAlgorithm?.commandCount < 20) fail('native Profile operation did not reproduce the locked algorithm result')
|
||||
if (native.profileAlgorithm?.variants?.length !== 2 || native.profileAlgorithm.variants.some((variant: any) => variant.commandCount !== 32 || variant.cuttingCommandCount !== 20 || !/^[0-9a-f]{64}$/.test(variant.commandSha256))) fail('native Profile compensation variants are incomplete')
|
||||
const expectedHelixDirections: Record<string, [string, string]> = {
|
||||
'inside-conventional': ['CW', 'G2'],
|
||||
'outside-climb': ['CW', 'G2'],
|
||||
'inside-climb': ['CCW', 'G3'],
|
||||
'outside-conventional': ['CCW', 'G3'],
|
||||
}
|
||||
if (native.helixAlgorithm?.fixture !== 'Mod/CAM/CAMTests/test_holes00.fcstd' || native.helixAlgorithm?.pathPropertyType !== 'Path::PropertyPath' || native.helixAlgorithm?.toolDiameterMm !== 0.9 || native.helixAlgorithm?.baseSubElementCount !== 9 || native.helixAlgorithm?.scenarioCount !== 4) fail('native Helix fixture contract is incomplete')
|
||||
if (native.helixAlgorithm.scenarios.some((scenario: any) => {
|
||||
const expected = expectedHelixDirections[scenario.id]
|
||||
return !expected || scenario.direction !== expected[0] || scenario.expectedDirection !== expected[0] || scenario.expectedArc !== expected[1] || JSON.stringify(scenario.arcNames) !== JSON.stringify([expected[1]]) || scenario.arcCommandCount !== 1260 || scenario.commandCount < 1400 || !/^[0-9a-f]{64}$/.test(scenario.commandSha256)
|
||||
})) fail('native Helix direction or command evidence differs from the locked oracle')
|
||||
const incompleteActions = native.qtDynamicOracle?.requiredCommands?.filter((command: any) => command.emptySelection?.registered !== true || command.selectedModel?.registered !== true || command.selectedJob?.registered !== true || command.selectedOperation?.registered !== true || command.emptyDocument?.registered !== true || command.selectedModel?.actionCount < 1) ?? []
|
||||
if (incompleteActions.length > 0) fail(`required CAM QAction registration evidence is incomplete: ${JSON.stringify(incompleteActions)}`)
|
||||
|
||||
const original = new Uint8Array(await readFile(sourcePath))
|
||||
const inspection = inspectFcstdArchive(original)
|
||||
const nativePath = inspection.objects.find((object) => object.name === 'NativePath') ?? fail('NativePath is missing from Web FCStd inspection')
|
||||
const pythonPath = inspection.objects.find((object) => object.name === 'PythonPath') ?? fail('PythonPath is missing from Web FCStd inspection')
|
||||
const pathProperties = [nativePath, pythonPath].map((object) => object.properties.find((property) => property.name === 'Path'))
|
||||
if (nativePath.typeId !== 'Path::Feature' || pythonPath.typeId !== 'Path::FeaturePython') fail('Web inspection did not preserve Path object TypeIds')
|
||||
if (pathProperties.some((property) => property?.typeId !== 'Path::PropertyPath')) fail('Web inspection did not preserve Path::PropertyPath metadata')
|
||||
if (!inspection.proxyDocument.readOnly || inspection.compatibility.blockedObjects < 1) fail('FeaturePython archive must remain a read-only proxy at the current Web codec boundary')
|
||||
|
||||
const preserved = rewriteFcstdMetadataArchive(original, inspection.proxyDocument)
|
||||
if (sha256(original) !== sha256(preserved) || !Buffer.from(original).equals(Buffer.from(preserved))) fail('read-only Web rewrite was not byte-for-byte transparent')
|
||||
await writeFile(preservedPath, preserved)
|
||||
const reopened = runProbe({
|
||||
FREECAD_CAM_PATH_VERIFY_ONLY: '1',
|
||||
FREECAD_CAM_PATH_VERIFY_ARCHIVE: preservedPath,
|
||||
})
|
||||
if (reopened.verified !== true || reopened.objects?.some((object: any) => object.commandCount !== 5 || object.pathPropertyType !== 'Path::PropertyPath')) fail('FreeCAD could not reopen the Web-preserved Path archive')
|
||||
|
||||
const decodedPath = decodeFcstdPathProperty(original, 'NativePath')
|
||||
if (decodedPath.commands.length !== 5 || decodedPath.resourcePath !== 'NativePath.nc') fail('Web Path::PropertyPath codec did not decode the native command resource')
|
||||
const edited = rewriteFcstdPathProperty(original, {
|
||||
objectName: 'NativePath',
|
||||
value: { ...decodedPath, commands: [...decodedPath.commands, { name: 'M3', parameters: { S: 12000 } }] },
|
||||
})
|
||||
const editedDecoded = decodeFcstdPathProperty(edited, 'NativePath')
|
||||
if (editedDecoded.commands.length !== 6 || editedDecoded.commands.at(-1)?.name !== 'M3' || editedDecoded.commands.at(-1)?.parameters.S !== 12000) fail('Web Path::PropertyPath codec did not persist the edited command')
|
||||
await writeFile(webEditedPath, edited)
|
||||
const editedReopened = runProbe({
|
||||
FREECAD_CAM_PATH_VERIFY_ONLY: '1',
|
||||
FREECAD_CAM_PATH_VERIFY_ARCHIVE: webEditedPath,
|
||||
FREECAD_CAM_PATH_EXPECT_NATIVE_COUNT: '6',
|
||||
FREECAD_CAM_PATH_EXPECT_PYTHON_COUNT: '5',
|
||||
FREECAD_CAM_PATH_EXPECT_NATIVE_LAST: 'M3',
|
||||
})
|
||||
if (editedReopened.verified !== true) fail('FreeCAD could not reopen the Web-edited native Path command resource')
|
||||
|
||||
const decodedPythonPath = decodeFcstdPathProperty(original, 'PythonPath', 'Path', { allowFeaturePython: true })
|
||||
if (decodedPythonPath.commands.length !== 5 || decodedPythonPath.resourcePath !== 'PythonPath.nc') fail('Web FeaturePython Path::PropertyPath opt-in codec did not decode the native command resource')
|
||||
const editedPython = rewriteFcstdPathProperty(original, {
|
||||
objectName: 'PythonPath',
|
||||
propertyName: 'Path',
|
||||
allowFeaturePython: true,
|
||||
value: { ...decodedPythonPath, commands: [...decodedPythonPath.commands, { name: 'M2', parameters: {} }] },
|
||||
})
|
||||
const editedPythonDecoded = decodeFcstdPathProperty(editedPython, 'PythonPath', 'Path', { allowFeaturePython: true })
|
||||
if (editedPythonDecoded.commands.length !== 6 || editedPythonDecoded.commands.at(-1)?.name !== 'M2') fail('Web FeaturePython Path::PropertyPath codec did not persist the edited command')
|
||||
await writeFile(webEditedPythonPath, editedPython)
|
||||
const editedPythonReopened = runProbe({
|
||||
FREECAD_CAM_PATH_VERIFY_ONLY: '1',
|
||||
FREECAD_CAM_PATH_VERIFY_ARCHIVE: webEditedPythonPath,
|
||||
FREECAD_CAM_PATH_EXPECT_NATIVE_COUNT: '5',
|
||||
FREECAD_CAM_PATH_EXPECT_PYTHON_COUNT: '6',
|
||||
FREECAD_CAM_PATH_EXPECT_PYTHON_LAST: 'M2',
|
||||
})
|
||||
if (editedPythonReopened.verified !== true) fail('FreeCAD could not reopen the Web-edited FeaturePython Path command resource')
|
||||
|
||||
const mutated = new Uint8Array(await readFile(mutatedPath))
|
||||
inspectFcstdArchive(mutated)
|
||||
native.fcstdRoundTrip.source.semanticSha256 = semanticSha256(native.fcstdRoundTrip.sourceReopened)
|
||||
native.fcstdRoundTrip.mutated.semanticSha256 = semanticSha256(native.fcstdRoundTrip.mutatedReopened)
|
||||
reopened.archive.semanticSha256 = semanticSha256(reopened.objects)
|
||||
delete native.fcstdRoundTrip.source.sha256
|
||||
delete native.fcstdRoundTrip.mutated.sha256
|
||||
delete reopened.archive.sha256
|
||||
delete native.fcstdRoundTrip.source.byteLength
|
||||
delete native.fcstdRoundTrip.mutated.byteLength
|
||||
delete reopened.archive.byteLength
|
||||
|
||||
const relativePath = (path: string) => relative(root, path).replaceAll('\\', '/')
|
||||
for (const archive of [native.fcstdRoundTrip.source, native.fcstdRoundTrip.mutated, reopened.archive]) archive.path = relativePath(archive.path)
|
||||
native.nativeRuntime.pathModule = native.nativeRuntime.pathModule.startsWith(root) ? relativePath(native.nativeRuntime.pathModule) : native.nativeRuntime.pathModule
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
baselineId: 'freecad-1.1.1',
|
||||
sourceCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d',
|
||||
generatedBy: './npmw run probe:freecad-cam-path',
|
||||
checkedBy: './npmw run check:freecad-cam-path',
|
||||
runtime: native.nativeRuntime,
|
||||
profileAlgorithm: native.profileAlgorithm,
|
||||
helixAlgorithm: native.helixAlgorithm,
|
||||
qtDynamicOracle: native.qtDynamicOracle,
|
||||
fcstdRoundTrip: {
|
||||
...native.fcstdRoundTrip,
|
||||
webInspection: {
|
||||
objectTypes: [nativePath.typeId, pythonPath.typeId],
|
||||
pathPropertyTypes: pathProperties.map((property) => property?.typeId),
|
||||
compatibility: inspection.compatibility,
|
||||
proxyReadOnly: inspection.proxyDocument.readOnly,
|
||||
},
|
||||
webTransparentPreservation: {
|
||||
archive: reopened.archive,
|
||||
byteExact: true,
|
||||
nativeReopenVerified: reopened.verified,
|
||||
objects: reopened.objects,
|
||||
},
|
||||
webEditablePathProperty: {
|
||||
objectName: 'NativePath',
|
||||
resourcePath: editedDecoded.resourcePath,
|
||||
sourceCommandCount: decodedPath.commands.length,
|
||||
editedCommandCount: editedDecoded.commands.length,
|
||||
lastCommand: editedDecoded.commands.at(-1),
|
||||
nativeReopenVerified: editedReopened.verified,
|
||||
featurePythonExecutionBlocked: true,
|
||||
},
|
||||
webEditableFeaturePythonPathProperty: {
|
||||
objectName: 'PythonPath',
|
||||
resourcePath: editedPythonDecoded.resourcePath,
|
||||
sourceCommandCount: decodedPythonPath.commands.length,
|
||||
editedCommandCount: editedPythonDecoded.commands.length,
|
||||
lastCommand: editedPythonDecoded.commands.at(-1),
|
||||
nativeReopenVerified: editedPythonReopened.verified,
|
||||
scriptExecution: 'blocked',
|
||||
optInRequired: true,
|
||||
},
|
||||
},
|
||||
claims: {
|
||||
nativeProfilePathAlgorithm: 'verified',
|
||||
nativeHelixPathAlgorithm: 'verified-four-direction-cases',
|
||||
qtCamWorkbenchDynamicOracle: 'verified',
|
||||
nativePathFeatureFcstdSaveOpenMutateSaveOpen: 'verified',
|
||||
nativeWebNativePathFcstdTransparentRoundTrip: 'verified',
|
||||
webEditablePathPropertyCodec: 'verified-native-path-feature',
|
||||
webEditableFeaturePythonPathResourceCodec: 'verified-safe-resource-only-opt-in',
|
||||
},
|
||||
boundary: {
|
||||
exactEvidence: 'FreeCAD 1.1.1 executes compensated/uncompensated Profile and four Helix direction cases, exposes live Qt CAM actions, and reopens both Path object types after native and byte-transparent Web round-trips.',
|
||||
remainingGap: 'FeaturePython Path::PropertyPath resource editing is now an explicit safe opt-in that never executes Python; FeaturePython operation metadata, recompute behavior and algorithms beyond the locked Profile/Helix scenarios remain unclaimed.',
|
||||
exactParityClaim: false,
|
||||
},
|
||||
status: 'verified-with-native-path-editable-subset',
|
||||
}
|
||||
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(JSON.stringify({
|
||||
status: report.status,
|
||||
profileCommands: report.profileAlgorithm.commandCount,
|
||||
cuttingCommands: report.profileAlgorithm.cuttingCommandCount,
|
||||
helixScenarios: report.helixAlgorithm.scenarioCount,
|
||||
camCommands: report.qtDynamicOracle.camCommandCount,
|
||||
qtVersion: report.qtDynamicOracle.qtVersion,
|
||||
fcstdObjects: report.fcstdRoundTrip.webInspection.objectTypes,
|
||||
byteExact: report.fcstdRoundTrip.webTransparentPreservation.byteExact,
|
||||
report: relativePath(reportPath),
|
||||
}, null, 2))
|
||||
Reference in New Issue
Block a user