144 lines
13 KiB
TypeScript
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 {}
|