97 lines
7.2 KiB
JavaScript
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)
|