Files
Web_FreeCAD_Bitbybit/scripts/run-freecad-cam-path-oracle.ts

224 lines
14 KiB
TypeScript

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))