Files
Web_FreeCAD_Bitbybit/scripts/run-freecad-threeway-boolean.mjs

97 lines
7.2 KiB
JavaScript

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)