Files
Web_FreeCAD_Bitbybit/src/chromeAssemblyHarness.ts

144 lines
13 KiB
TypeScript

import { createMockFacade } from './facade/mockFacade'
import { createAssembly } from './facade/assembly'
import { inspectFcstdArchive, serializeFcstdMetadataArchive } from './facade/fcstd'
import type { AssemblySnapshot } from './facade/assembly'
import type { DocumentSnapshot } from './facade/types'
type AssemblyReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
geometry?: { components: number; validShapes: number; releasedShapes: number }
solver?: { status: string; iterations: number; coincident: string; distance: string; shaftX: number; diagnostics: number; workerStatus: string; angleStatus: string; angleYaw: number }
tools?: { bomRows: number; collisionCount: number; explodedMoved: boolean; variantX: number; motionFrames: number; motionEndX: number }
scale?: { components: number; bomRows: number; durationMs: number; budgetMs: number }
fcstd?: { bytes: number; objects: number; links: number; reopenedObjects: number; roundTrip: boolean; released: boolean }
persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; markerComponents: number; markerRemoved: boolean }
afterRelease?: { shapeCount: number; kernelReferenceCount: number }
error?: string
}
const sha256 = async (bytes: Uint8Array) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', Uint8Array.from(bytes).buffer))].map((value) => value.toString(16).padStart(2, '0')).join('')
const solveInWorker = async (snapshot: AssemblySnapshot) => {
const worker = new Worker(new URL('./assemblySolverWorker.ts', import.meta.url), { type: 'module', name: 'assembly-solver' })
try {
return await new Promise<AssemblySnapshot>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Assembly solver Worker timed out.')), 10_000)
worker.addEventListener('message', (event: MessageEvent<{ id: number; snapshot?: AssemblySnapshot; error?: string }>) => { clearTimeout(timer); event.data.snapshot ? resolve(event.data.snapshot) : reject(new Error(event.data.error ?? 'Assembly solver Worker failed.')) }, { once: true })
worker.addEventListener('error', (event) => { clearTimeout(timer); reject(new Error(event.message)) }, { once: true })
worker.postMessage({ id: 1, snapshot })
})
} finally {
worker.terminate()
}
}
const fcstdFixture = (): DocumentSnapshot => ({
id: 'assembly-fcstd', label: 'Assembly FCStd', version: 1, dirty: false, readOnly: false, units: 'mm',
tree: [{ id: 'Assembly', label: 'Assembly', type: 'folder', state: 'valid' }, { id: 'Base', label: 'Base', type: 'feature', state: 'valid' }, { id: 'Shaft', label: 'Shaft', type: 'feature', state: 'valid' }, { id: 'ShaftLink', label: 'Shaft Link', type: 'feature', state: 'valid' }],
objects: [
{ id: 'Assembly', typeId: 'App::Part', properties: [] },
{ id: 'Base', typeId: 'Part::Box', properties: [{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10 }] },
{ id: 'Shaft', typeId: 'Part::Box', properties: [{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 8 }] },
{ id: 'ShaftLink', typeId: 'App::Link', properties: [{ name: 'LinkedObject', label: 'Linked Object', group: 'Link', scope: 'data', type: 'App::PropertyLink', value: 'Shaft' }] },
],
dependencies: [{ sourceId: 'ShaftLink', targetId: 'Shaft', relation: 'link', propertyName: 'LinkedObject' }],
recompute: { generation: 0, status: 'idle', objectStates: { Assembly: 'up-to-date', Base: 'up-to-date', Shaft: 'up-to-date', ShaftLink: 'up-to-date' }, dirtyObjects: [], order: ['Assembly', 'Base', 'Shaft', 'ShaftLink'], errors: [] },
})
const run = async (): Promise<AssemblyReport> => {
const facade = createMockFacade()
const report: AssemblyReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome Assembly evidence requires isolated OPFS.')
await facade.project.list()
const context = { documentId: 'assembly', documentVersion: 1 }
const baseShape = await facade.geometry.createBox({ ...context, width: 10, length: 10, height: 2 })
const shaftShape = await facade.geometry.createBox({ ...context, width: 2, length: 2, height: 8 })
const assembly = createAssembly('motor', 'Motor assembly')
assembly.addComponent({ id: 'base', sourceObjectId: baseShape.id, label: 'Base', grounded: true, bounds: { min: { x: -1, y: -1, z: -1 }, max: { x: 1, y: 1, z: 1 } } })
assembly.addComponent({ id: 'shaft', sourceObjectId: shaftShape.id, label: 'Shaft', grounded: false, placement: { x: 1 }, bounds: { min: { x: -1, y: -1, z: -1 }, max: { x: 1, y: 1, z: 1 } } })
assembly.addConnector({ id: 'base-origin', componentId: 'base', origin: { x: 0, y: 0, z: 0 }, axis: { x: 1, y: 0, z: 0 } })
assembly.addConnector({ id: 'shaft-origin', componentId: 'shaft', origin: { x: 0, y: 0, z: 0 }, axis: { x: 1, y: 0, z: 0 } })
assembly.addJoint({ id: 'coincident', kind: 'coincident', first: 'base-origin', second: 'shaft-origin' })
assembly.addJoint({ id: 'distance', kind: 'distance', first: 'base-origin', second: 'shaft-origin', value: 5 })
const initialCollisionCount = assembly.collisions().length
const workerSolved = await solveInWorker(assembly.snapshot())
const solved = assembly.solve()
const exploded = assembly.exploded(2)
const variant = assembly.variant('inspection', { shaft: { x: 7 } })
const motion = assembly.motion('shaft', { x: 9, yaw: Math.PI / 2 }, 4)
const angled = createAssembly('angle', 'Angle fixture')
angled.addComponent({ id: 'fixed', sourceObjectId: baseShape.id, label: 'Fixed', grounded: true })
angled.addComponent({ id: 'arm', sourceObjectId: shaftShape.id, label: 'Arm', grounded: false })
angled.addConnector({ id: 'fixed-axis', componentId: 'fixed', origin: { x: 0, y: 0, z: 0 }, axis: { x: 1, y: 0, z: 0 } })
angled.addConnector({ id: 'arm-axis', componentId: 'arm', origin: { x: 0, y: 0, z: 0 }, axis: { x: 1, y: 0, z: 0 } })
angled.addJoint({ id: 'angle', kind: 'angle', first: 'fixed-axis', second: 'arm-axis', value: Math.PI / 2 })
const angleSolved = angled.solve()
const scaleStarted = performance.now()
const large = createAssembly('large', 'Large assembly')
for (let index = 0; index < 1000; index += 1) large.addComponent({ id: `component-${index}`, sourceObjectId: `part-${index % 10}`, label: `Part ${index % 10}`, grounded: index === 0, placement: { x: index * 2 } })
const largeBom = large.bom()
const scaleDurationMs = performance.now() - scaleStarted
const fcstdBytes = serializeFcstdMetadataArchive(fcstdFixture(), { guiViews: [{ name: 'Assembly', type: 'orthographic', visibility: 'true' }] })
const inspected = inspectFcstdArchive(fcstdBytes)
const reopenedFcstd = serializeFcstdMetadataArchive(inspected.proxyDocument, { guiViews: [{ name: 'Assembly', type: 'orthographic', visibility: 'true' }] })
const reopenedInspection = inspectFcstdArchive(reopenedFcstd)
const fcstdStored = await facade.project.resource.put(fcstdBytes, 'application/vnd.freecad.fcstd')
const fcstdLoaded = await facade.project.resource.get(fcstdStored.hash)
const fcstdRoundTrip = fcstdLoaded !== null && await sha256(fcstdLoaded) === await sha256(fcstdBytes)
await facade.project.resource.release(fcstdStored.hash)
const fcstdReleased = await facade.project.resource.get(fcstdStored.hash) === null
const payload = new TextEncoder().encode(JSON.stringify(solved))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.assembly+json')
const loaded = await facade.project.resource.get(stored.hash)
const roundTrip = loaded !== null && await sha256(loaded) === await sha256(payload)
await facade.project.resource.release(stored.hash)
const released = await facade.project.resource.get(stored.hash) === null
const directory = await navigator.storage.getDirectory()
const markerName = 'assembly-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'ASM-TOOLS', components: solved.components.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; components: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.geometry = { components: solved.components.length, validShapes: [baseShape, shaftShape].filter((shape) => shape.id.length > 0).length, releasedShapes: 0 }
report.solver = { status: solved.solver.status, iterations: solved.solver.iterations, coincident: solved.joints.find((joint) => joint.id === 'coincident')?.status || 'missing', distance: solved.joints.find((joint) => joint.id === 'distance')?.status || 'missing', shaftX: solved.components.find((component) => component.id === 'shaft')?.placement.x || 0, diagnostics: solved.diagnostics.length, workerStatus: workerSolved.solver.status, angleStatus: angleSolved.solver.status, angleYaw: angleSolved.components.find((component) => component.id === 'arm')?.placement.yaw ?? Number.NaN }
report.tools = { bomRows: assembly.bom().length, collisionCount: initialCollisionCount, explodedMoved: exploded.some((component) => component.placement.x !== solved.components.find((entry) => entry.id === component.id)?.placement.x), variantX: variant.placements.find((component) => component.id === 'shaft')?.placement.x || 0, motionFrames: motion.length, motionEndX: motion.at(-1)?.placement.x ?? Number.NaN }
report.scale = { components: large.snapshot().components.length, bomRows: largeBom.length, durationMs: scaleDurationMs, budgetMs: 1000 }
const linkProperties = inspected.objects.flatMap((object) => object.properties.filter((property) => property.typeId === 'App::PropertyLink' && property.value === 'Shaft'))
report.fcstd = { bytes: fcstdBytes.byteLength, objects: inspected.objects.length, links: (inspected.proxyDocument?.dependencies ?? []).filter((dependency) => dependency.relation === 'link').length + linkProperties.length, reopenedObjects: reopenedInspection.objects.length, roundTrip: fcstdRoundTrip, released: fcstdReleased }
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, markerComponents: markerPayload.components, markerRemoved }
report.status = report.crossOriginIsolated && report.geometry.validShapes === 2 && report.solver.status === 'solved' && report.solver.iterations === 2 && report.solver.coincident === 'solved' && report.solver.distance === 'solved' && report.solver.shaftX === 5 && report.solver.diagnostics === 0 && report.solver.workerStatus === 'solved' && report.solver.angleStatus === 'solved' && Math.abs(report.solver.angleYaw - Math.PI / 2) < 1e-12 && report.tools.bomRows === 2 && report.tools.collisionCount === 1 && report.tools.explodedMoved && report.tools.variantX === 7 && report.tools.motionFrames === 5 && report.tools.motionEndX === 9 && report.scale.components === 1000 && report.scale.bomRows === 10 && report.scale.durationMs <= report.scale.budgetMs && report.fcstd.bytes > 0 && report.fcstd.objects === 4 && report.fcstd.links === 1 && report.fcstd.reopenedObjects === 4 && report.fcstd.roundTrip && report.fcstd.released && report.persistence.mode === 'sqlite-opfs' && report.persistence.byteLength > 0 && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'ASM-TOOLS' && report.opfs.markerComponents === 2 && report.opfs.markerRemoved ? 'pass' : 'failed'
} catch (error) {
report.error = error instanceof Error ? error.stack || error.message : String(error)
} finally {
facade.geometry.dispose()
await new Promise((resolve) => setTimeout(resolve, 0))
const capabilities = facade.geometry.capabilities()
report.afterRelease = { shapeCount: capabilities.shapeCount, kernelReferenceCount: capabilities.kernelReferenceCount }
if (capabilities.shapeCount !== 0 || capabilities.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed.` }
}
return report
}
run().then((report) => {
;(window as Window & { __bitbybitAssemblyReport?: AssemblyReport }).__bitbybitAssemblyReport = report
document.documentElement.dataset.status = report.status
document.querySelector('#result')!.textContent = JSON.stringify(report, null, 2)
}).catch((error) => {
document.documentElement.dataset.status = 'failed'
document.querySelector('#result')!.textContent = error instanceof Error ? error.stack || error.message : String(error)
})
export {}