Build and verify the candidate-only FreeCAD naming bridge and OCCT worker path, including three-stage StringHasher restoration. Add Datum, ShapeBinder, attachment-mode, and PartDesign structure oracles plus offline SDK build plans and CI boundary checks.
150 lines
7.9 KiB
TypeScript
150 lines
7.9 KiB
TypeScript
import { pathToFileURL } from 'node:url'
|
|
import { createHash } from 'node:crypto'
|
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
import { dirname } from 'node:path'
|
|
import { resolve } from 'node:path'
|
|
import {
|
|
captureFreeCadPrivateNamingEvidence,
|
|
createFreeCadPrivateNamingAbiRequest,
|
|
probeFreeCadPrivateNamingAbi,
|
|
type NativeFreeCadNamingAbiModule,
|
|
} from '../../src/facade/nativeNamingAbi'
|
|
|
|
type CandidateModule = NativeFreeCadNamingAbiModule & {
|
|
makeBox(x: number, y: number, z: number): unknown
|
|
shapeToStep(shape: unknown): string
|
|
booleanHistoryFromStep(objectStep: string, toolStep: string, operation: 'cut'): {
|
|
provider: 'occt-native'
|
|
occtVersion: string
|
|
hasModified: boolean
|
|
hasGenerated: boolean
|
|
hasDeleted: boolean
|
|
resultStep: string
|
|
records: Array<{
|
|
relation: 'modified' | 'generated' | 'deleted'
|
|
source: string
|
|
kind: 'face' | 'edge' | 'vertex'
|
|
resultKind?: 'face' | 'edge' | 'vertex'
|
|
sourceIndex: number
|
|
resultIndex?: number
|
|
resultIndexes?: number[]
|
|
}>
|
|
}
|
|
}
|
|
|
|
const dist = resolve(process.env.FREECAD_NAMING_WORKER_DIST ?? '.cache/candidates/freecad-naming-worker')
|
|
const moduleUrl = pathToFileURL(resolve(dist, 'bitbybit-occt-history.js')).href
|
|
const createCandidate = (await import(moduleUrl)).default as (options: { locateFile(path: string): string }) => Promise<CandidateModule>
|
|
const candidate = await createCandidate({ locateFile: (path) => resolve(dist, path) })
|
|
const probe = probeFreeCadPrivateNamingAbi(candidate)
|
|
if (probe.availability !== 'available') throw new Error(`Candidate Worker naming ABI probe failed: ${probe.reason}`)
|
|
|
|
const object = candidate.makeBox(10, 10, 10)
|
|
const tool = candidate.makeBox(5, 5, 5)
|
|
const objectStep = candidate.shapeToStep(object)
|
|
const toolStep = candidate.shapeToStep(tool)
|
|
const history = candidate.booleanHistoryFromStep(objectStep, toolStep, 'cut')
|
|
const record = history.records.find((entry) => entry.relation !== 'deleted'
|
|
&& Number.isSafeInteger(entry.sourceIndex)
|
|
&& (Number.isSafeInteger(entry.resultIndex) || entry.resultIndexes?.some(Number.isSafeInteger)))
|
|
if (!record) throw new Error('Candidate Worker OCCT cut returned no usable native history record.')
|
|
const resultIndex = Number.isSafeInteger(record.resultIndex) ? record.resultIndex! : record.resultIndexes!.find(Number.isSafeInteger)!
|
|
const selectedRecord = { ...record, resultIndex, resultIndexes: undefined }
|
|
const evidence = captureFreeCadPrivateNamingEvidence(candidate, createFreeCadPrivateNamingAbiRequest({
|
|
requestId: 'candidate-worker-cut-1',
|
|
documentId: 'candidate-worker-document',
|
|
documentVersion: 1,
|
|
operationId: 'candidate-worker-cut',
|
|
operation: 'cut',
|
|
stageId: 'candidate-worker:stage:1',
|
|
resultObjectId: 'candidate-worker:result:1',
|
|
resultObjectTag: 100,
|
|
inputs: [
|
|
{ inputId: 'object', objectId: 'candidate-object', role: 'object', objectTag: 42, step: objectStep },
|
|
{ inputId: 'tool', objectId: 'candidate-tool', role: 'tool', objectTag: 43, step: toolStep },
|
|
],
|
|
stages: [{ stageId: 'candidate-worker:stage:1', operation: 'cut', inputIds: ['object', 'tool'], ordinal: 0 }],
|
|
resultStep: history.resultStep,
|
|
history: { ...history, records: [selectedRecord] },
|
|
}), probe)
|
|
if (!evidence?.elementMap2 || !evidence.stringHasher || evidence.mappedNames?.length !== 1) throw new Error('Candidate Worker returned incomplete FreeCAD naming evidence for OCCT cut history.')
|
|
const secondEvidence = captureFreeCadPrivateNamingEvidence(candidate, createFreeCadPrivateNamingAbiRequest({
|
|
requestId: 'candidate-worker-cut-2',
|
|
documentId: 'candidate-worker-document',
|
|
documentVersion: 2,
|
|
operationId: 'candidate-worker-cut-2',
|
|
operation: 'cut',
|
|
stageId: 'candidate-worker:stage:2',
|
|
resultObjectId: 'candidate-worker:result:2',
|
|
resultObjectTag: 101,
|
|
inputs: [{ inputId: 'object', objectId: 'candidate-worker:result:1', role: 'object', stageId: 'candidate-worker:stage:1', objectTag: 100, step: history.resultStep, namingEvidence: evidence }],
|
|
stages: [{ stageId: 'candidate-worker:stage:2', operation: 'cut', inputIds: ['object'], ordinal: 0 }],
|
|
resultStep: history.resultStep,
|
|
history: { ...history, records: [{ relation: 'modified', source: 'object', kind: record.resultKind ?? record.kind, sourceIndex: resultIndex, resultIndex: resultIndex + 1 }] },
|
|
}), probe)
|
|
const thirdEvidence = secondEvidence && captureFreeCadPrivateNamingEvidence(candidate, createFreeCadPrivateNamingAbiRequest({
|
|
requestId: 'candidate-worker-cut-3',
|
|
documentId: 'candidate-worker-document',
|
|
documentVersion: 3,
|
|
operationId: 'candidate-worker-cut-3',
|
|
operation: 'cut',
|
|
stageId: 'candidate-worker:stage:3',
|
|
resultObjectId: 'candidate-worker:result:3',
|
|
resultObjectTag: 102,
|
|
inputs: [{ inputId: 'object', objectId: 'candidate-worker:result:2', role: 'object', stageId: 'candidate-worker:stage:2', objectTag: 101, step: history.resultStep, namingEvidence: secondEvidence }],
|
|
stages: [{ stageId: 'candidate-worker:stage:3', operation: 'cut', inputIds: ['object'], ordinal: 0 }],
|
|
resultStep: history.resultStep,
|
|
history: { ...history, records: [{ relation: 'modified', source: 'object', kind: record.resultKind ?? record.kind, sourceIndex: resultIndex + 1, resultIndex: resultIndex + 2 }] },
|
|
}), probe)
|
|
if (!secondEvidence?.stringHasher || !thirdEvidence?.stringHasher || secondEvidence.stringHasher.entries.length < 1 || thirdEvidence.stringHasher.entries.length <= secondEvidence.stringHasher.entries.length) throw new Error('Candidate Worker did not restore non-empty StringHasher evidence across three stages.')
|
|
|
|
let rejected = false
|
|
try {
|
|
captureFreeCadPrivateNamingEvidence(candidate, createFreeCadPrivateNamingAbiRequest({
|
|
requestId: 'candidate-worker-invalid',
|
|
documentId: 'candidate-worker-document',
|
|
documentVersion: 2,
|
|
operationId: 'candidate-worker-invalid',
|
|
operation: 'cut',
|
|
stageId: 'candidate-worker:invalid',
|
|
resultObjectId: 'candidate-worker:invalid-result',
|
|
resultObjectTag: 101,
|
|
inputs: [{ inputId: 'object', objectId: 'candidate-object', role: 'object', objectTag: 42, step: objectStep }],
|
|
stages: [{ stageId: 'candidate-worker:invalid', operation: 'cut', inputIds: ['object'], ordinal: 0 }],
|
|
history: { ...history, records: [] },
|
|
}), probe)
|
|
} catch (error) {
|
|
if (!(error instanceof Error) || !error.message.includes('requires inputs and native history records')) throw error
|
|
rejected = true
|
|
}
|
|
if (!rejected) throw new Error('Candidate Worker accepted incomplete native history.')
|
|
|
|
const result = {
|
|
status: 'freecad-naming-candidate-worker-pass',
|
|
occtVersion: history.occtVersion,
|
|
callbacks: ['freecadNamingAbiVersion', 'freecadNamingCapabilitiesJson', 'freecadNamingEvidenceJson'],
|
|
namingEvidence: {
|
|
mappedNames: evidence.mappedNames.length,
|
|
stringHasherEntries: evidence.stringHasher.entries.length,
|
|
elementMaps: evidence.elementMap2.maps.length,
|
|
},
|
|
chainedStage: { mappedNames: secondEvidence.mappedNames.length, stringHasherEntries: secondEvidence.stringHasher.entries.length },
|
|
thirdStage: { mappedNames: thirdEvidence.mappedNames.length, stringHasherEntries: thirdEvidence.stringHasher.entries.length },
|
|
invalidHistoryRejected: true,
|
|
candidateOnly: true,
|
|
productionPublication: false,
|
|
productionWorkerLinked: false,
|
|
}
|
|
const reportPath = process.env.FREECAD_NAMING_WORKER_REPORT
|
|
if (reportPath) {
|
|
const artifacts = await Promise.all(['bitbybit-occt-history.js', 'bitbybit-occt-history.wasm', 'bitbybit-occt-history.data'].map(async (name) => {
|
|
const path = resolve(dist, name)
|
|
const [bytes, content] = await Promise.all([stat(path).then(({ size }) => size), readFile(path)])
|
|
return { name, bytes, sha256: createHash('sha256').update(content).digest('hex') }
|
|
}))
|
|
const absoluteReportPath = resolve(reportPath)
|
|
await mkdir(dirname(absoluteReportPath), { recursive: true })
|
|
await writeFile(absoluteReportPath, `${JSON.stringify({ schemaVersion: 1, ...result, artifacts }, null, 2)}\n`)
|
|
}
|
|
console.log(JSON.stringify(result, null, 2))
|