224 lines
14 KiB
TypeScript
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))
|