import { spawnSync } from 'node:child_process' import { readFile, writeFile } from 'node:fs/promises' import { createHash } from 'node:crypto' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { loadGoldenManifest } from './freecad-golden-contract.mjs' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const manifest = await loadGoldenManifest(resolve(root, 'fixtures/freecad-golden/manifest.json')) const booleanScenarios = manifest.scenarios.filter(({ scenario }) => ['fuse', 'cut', 'common'].includes(scenario.operation.type)) const freecadCommand = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-native/bin/FreeCADCmd') const nativeFactory = (await import('../native/occt-history/dist/bitbybit-occt-history.js')).default const native = await nativeFactory() const bitbybitFactory = (await import('@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt.js')).default const bitbybit = await bitbybitFactory({ locateFile: (path) => new URL(`../node_modules/@bitbybit-dev/occt/bitbybit-dev-occt/${path}`, import.meta.url).href }) const translation = (operation) => operation.placement?.translation || [0, 0, 0] const buildShape = (module, operation) => { const [x, y, z] = translation(operation) if (operation.type === 'box') return module.MakeBoxFromPntAndDims(new module.gp_Pnt(x, y, z), operation.length, operation.width, operation.height) if (operation.type === 'sphere') return new module.BRepPrimAPI_MakeSphere(new module.gp_Pnt(x, y, z), operation.radius).Shape() const axis = new module.gp_Ax2(new module.gp_Pnt(x, y, z), new module.gp_Dir(0, 0, 1)) if (operation.type === 'cylinder') return new module.BRepPrimAPI_MakeCylinder(axis, operation.radius, operation.height).Shape() if (operation.type === 'cone') return new module.BRepPrimAPI_MakeCone(axis, operation.radius1, operation.radius2, operation.height, (operation.angle || 360) * Math.PI / 180).Shape() const base = buildShape(module, operation.base) const tool = buildShape(module, operation.tool) const algorithm = operation.type === 'fuse' ? new module.BRepAlgoAPI_Fuse(base, tool) : operation.type === 'cut' ? new module.BRepAlgoAPI_Cut(base, tool) : new module.BRepAlgoAPI_Common(base, tool) algorithm.Build() if (!algorithm.IsDone()) throw new Error(`Bitbybit Boolean ${operation.type} did not complete.`) return algorithm.Shape() } const bitbybitSummary = (shape) => { const box = bitbybit.GetBoundingBox(shape) const validation = JSON.parse(bitbybit.BRepGraphValidate(shape)) const graph = JSON.parse(bitbybit.BRepGraphAnalyze(shape)) return { shapeType: graph.compounds > 0 ? 'Compound' : graph.solids === 1 ? 'Solid' : 'Shape', isNull: shape.IsNull(), isValid: validation.valid === true, solids: graph.solids, faces: graph.faces, edges: graph.edges, vertices: graph.vertices, volume: bitbybit.ComputeVolumeProperties(shape).Mass, area: bitbybit.ComputeSurfaceProperties(shape).Mass, boundingBox: { min: [box.XMin, box.YMin, box.ZMin], max: [box.XMax, box.YMax, box.ZMax] }, } } const runFreecad = (entry) => { const execution = spawnSync(freecadCommand, [resolve(root, manifest.driver)], { cwd: root, encoding: 'utf8', timeout: 120000, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, FREECAD_GOLDEN_SCENARIO: entry.file } }) const line = `${execution.stdout || ''}\n${execution.stderr || ''}`.split(/\r?\n/).find((candidate) => candidate.startsWith('FREECAD_GOLDEN_RESULT=')) if (execution.error || execution.status !== 0 || !line) throw new Error(`FreeCAD oracle failed for ${entry.id}: ${execution.error?.message || execution.stderr || execution.stdout}`) return JSON.parse(line.slice('FREECAD_GOLDEN_RESULT='.length)) } const compare = (expected, actual, tolerance) => { const differences = [] const known = [] const scalar = (path, received, target, epsilon) => { if (!Number.isFinite(received) || Math.abs(received - target) > epsilon) differences.push(`${path}: expected ${target} +/- ${epsilon}, received ${received}`) } for (const key of ['isNull', 'isValid', 'solids']) if (actual[key] !== expected[key]) differences.push(`${key}: expected ${expected[key]}, received ${actual[key]}`) if (actual.shapeType !== expected.shapeType) known.push(`shapeType differs (${expected.shapeType} vs ${actual.shapeType})`) for (const key of ['faces', 'edges', 'vertices']) if (actual[key] !== expected[key]) differences.push(`${key}: expected ${expected[key]}, received ${actual[key]}`) scalar('volume', actual.volume, expected.volume, tolerance.scalar * 10) scalar('area', actual.area, expected.area, tolerance.scalar * 10) for (const axis of ['min', 'max']) for (let i = 0; i < 3; i += 1) scalar(`boundingBox.${axis}[${i}]`, actual.boundingBox[axis][i], expected.boundingBox[axis][i], tolerance.linear * 10) return { differences, known } } const reports = [] for (const entry of booleanScenarios) { const scenario = entry.scenario const oracle = runFreecad(entry) const bitbybitResult = bitbybitSummary(buildShape(bitbybit, scenario.operation)) const nativeShape = (() => { const build = (operation) => { const [x, y, z] = translation(operation) if (operation.type === 'box') return native.makeBoxPlaced(operation.length, operation.width, operation.height, x, y, z) if (operation.type === 'cylinder') return native.makeCylinder(operation.radius, operation.height, x, y, z) if (operation.type === 'sphere') return native.makeSphere(operation.radius, x, y, z) if (operation.type === 'cone') return native.makeCone(operation.radius1, operation.radius2, operation.height, x, y, z) const base = build(operation.base); const tool = build(operation.tool) return native.booleanHistory(base, tool, operation.type) } return build(scenario.operation) })() const nativeResponse = nativeShape?.summary ? nativeShape : null const nativeSummary = nativeResponse ? nativeResponse.summary : native.shapeSummary(nativeShape) const bitbybitDiff = compare(oracle, bitbybitResult, scenario.tolerance) const nativeDiff = compare(oracle, nativeSummary, scenario.tolerance) reports.push({ id: entry.id, operation: scenario.operation.type, oracle: { freecadVersion: oracle.freecadVersion, expected: scenario.expected }, bitbybit: { summary: bitbybitResult, differences: bitbybitDiff }, native: { summary: nativeSummary, historyRecordCount: nativeResponse?.records?.length || 0, differences: nativeDiff } }) } const report = { schemaVersion: 1, baselineId: manifest.baselineId, freecadCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d', nativeOcctVersion: native.occtVersion(), scenarioCount: reports.length, unknownDifferencesFail: true, reports } const output = resolve(root, 'config/freecad-bitbybit-native-threeway.json') await writeFile(output, `${JSON.stringify(report, null, 2)}\n`) console.log(JSON.stringify({ status: reports.some((item) => item.bitbybit.differences.differences.length || item.native.differences.differences.length) ? 'differences-found' : 'pass', scenarioCount: reports.length, output, sha256: createHash('sha256').update(JSON.stringify(report)).digest('hex') }, null, 2)) if (reports.some((item) => item.bitbybit.differences.differences.length || item.native.differences.differences.length)) process.exit(1)