feat: establish reproducible FreeCAD web compatibility baseline

This commit is contained in:
2026-08-10 16:11:38 -04:00
parent e5a5d74dbc
commit b962a5c3b5
733 changed files with 349081 additions and 647 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
import { createAssembly } from './facade/assembly'
import type { AssemblySnapshot } from './facade/assembly'
type WorkerRequest = { id: number; snapshot: AssemblySnapshot }
type WorkerResponse = { id: number; snapshot?: AssemblySnapshot; error?: string }
self.addEventListener('message', (event: MessageEvent<WorkerRequest>) => {
const response: WorkerResponse = { id: event.data.id }
try {
const source = event.data.snapshot
const assembly = createAssembly(source.id, source.label)
for (const component of source.components) assembly.addComponent(component)
for (const connector of source.connectors) assembly.addConnector(connector)
for (const joint of source.joints) assembly.addJoint(joint)
response.snapshot = assembly.solve()
} catch (error) {
response.error = error instanceof Error ? error.message : String(error)
}
self.postMessage(response)
})
export {}

View File

@@ -0,0 +1,7 @@
import { createMockFacade } from './facade/mockFacade'
import { createAddonManager, createSignedAddonPackage, type AddonManifest } from './facade/addonGovernance'
type AddonReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; catalog?: { installed: number; revision: number; rollbackVersion: string }; security?: { forgedRejected: boolean; permissionRejected: boolean; removed: boolean }; persistence?: { mode: string; byteLength: number; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; markerInstalled: 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 run = async (): Promise<AddonReport> => { const facade = createMockFacade(); const report: AddonReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }; try { if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome addon evidence requires isolated OPFS.'); await facade.project.list(); const manager = createAddonManager({ trustedKeys: { release: 'chrome-secret' } }); const base: AddonManifest = { id: 'measure-addon', version: '1.0.0', name: 'Measure', entrypoint: 'facade.measure', permissions: ['geometry.read'], dependencies: [] }; const first = createSignedAddonPackage(base, 'commands:v1', 'release', 'chrome-secret'); manager.install(first); const second = createSignedAddonPackage({ ...base, version: '1.1.0' }, 'commands:v2', 'release', 'chrome-secret'); manager.update(second); const rolled = manager.rollback('measure-addon'); let forgedRejected = false; try { manager.install({ ...first, payload: 'tampered' }) } catch { forgedRejected = true }; let permissionRejected = false; try { manager.install(createSignedAddonPackage({ ...base, id: 'network-addon', permissions: ['network'] }, 'network', 'release', 'chrome-secret')) } catch { permissionRejected = true }; const snapshot = manager.snapshot(); const payload = new TextEncoder().encode(manager.exportManifest()); const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.addon+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 = 'addon-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'ADDON-ALL', installed: snapshot.catalog.length })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; installed: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }; manager.remove('measure-addon'); report.catalog = { installed: snapshot.catalog.length, revision: rolled.revision, rollbackVersion: rolled.manifest.version }; report.security = { forgedRejected, permissionRejected, removed: manager.snapshot().catalog.length === 0 }; report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, roundTrip, released }; report.opfs = { markerSuite: markerPayload.suite, markerInstalled: markerPayload.installed, markerRemoved }; report.status = report.catalog.installed === 1 && report.catalog.revision === 1 && report.catalog.rollbackVersion === '1.0.0' && report.security.forgedRejected && report.security.permissionRejected && report.security.removed && report.persistence.mode === 'sqlite-opfs' && report.persistence.byteLength > 0 && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'ADDON-ALL' && report.opfs.markerInstalled === 1 && 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 & { __bitbybitAddonReport?: AddonReport }).__bitbybitAddonReport = 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 {}

View File

@@ -0,0 +1,143 @@
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 {}

66
src/chromeBimHarness.ts Normal file
View File

@@ -0,0 +1,66 @@
import { createMockFacade } from './facade/mockFacade'
import { createBimModel } from './facade/bim'
type BimReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
model?: { sites: number; buildings: number; levels: number; spaces: number; materials: number; elements: number; scheduleRows: number; ifc4Bytes: number; ifc2x3Bytes: number; ifc4Schema: string; ifc2x3Schema: string; ifc4Entities: number; ifc4Hierarchy: number; ifc4PropertySets: number; ifc4Classifications: number; ifc2x3RoundTrip: boolean }
persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; elements: 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 run = async (): Promise<BimReport> => {
const facade = createMockFacade()
const report: BimReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome BIM evidence requires isolated OPFS.')
await facade.project.list()
const shape = await facade.geometry.createBox({ documentId: 'bim', documentVersion: 1, width: 5, length: 2, height: 3 })
const bim = createBimModel('bim', 'Plant BIM')
bim.addSite({ id: 'site', label: 'Campus', latitude: 40, longitude: -74 })
bim.addBuilding({ id: 'building', label: 'Plant', siteId: 'site' })
bim.addLevel({ id: 'L1', label: 'Ground', elevation: 0, buildingId: 'building' })
bim.addSpace({ id: 'S1', label: 'Plant room', levelId: 'L1', area: 24 })
bim.addMaterial({ id: 'concrete', name: 'Concrete', category: 'structural' })
bim.addElement({ id: 'wall-1', label: 'Wall 1', type: 'wall', shapeId: shape.id, levelId: 'L1', spaceId: 'S1', properties: { FireRating: 'A1' }, quantity: { length: 5, area: 6, volume: 0.5 } })
bim.assignMaterial('wall-1', 'concrete')
bim.setClassification('wall-1', { system: 'OmniClass', code: '21-02' })
const schedule = bim.schedule()
const ifc4Text = bim.exportIfc('IFC4')
const ifc2x3Text = bim.exportIfc('IFC2X3')
const ifc4 = new TextEncoder().encode(ifc4Text)
const ifc2x3 = new TextEncoder().encode(ifc2x3Text)
const imported4 = bim.importIfc(ifc4Text)
const imported2x3 = bim.importIfc(ifc2x3Text)
const snapshot = bim.snapshot()
const payload = new TextEncoder().encode(JSON.stringify({ snapshot, ifc4: imported4, ifc2x3: imported2x3 }))
const stored = await facade.project.resource.put(payload, 'application/ifc')
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 = 'bim-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'BIM-IFC', elements: snapshot.elements.length })); await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; elements: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.model = { sites: snapshot.sites.length, buildings: snapshot.buildings.length, levels: snapshot.levels.length, spaces: snapshot.spaces.length, materials: snapshot.materials.length, elements: snapshot.elements.length, scheduleRows: schedule.length, ifc4Bytes: ifc4.byteLength, ifc2x3Bytes: ifc2x3.byteLength, ifc4Schema: imported4.schema, ifc2x3Schema: imported2x3.schema, ifc4Entities: imported4.entities, ifc4Hierarchy: imported4.hierarchy, ifc4PropertySets: imported4.propertySets, ifc4Classifications: imported4.classifications, ifc2x3RoundTrip: imported2x3.entities === imported4.entities }
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, elements: markerPayload.elements, markerRemoved }
report.status = report.model.sites === 1 && report.model.buildings === 1 && report.model.levels === 1 && report.model.spaces === 1 && report.model.materials === 1 && report.model.elements === 1 && report.model.scheduleRows === 1 && report.model.ifc4Bytes > 0 && report.model.ifc2x3Bytes > 0 && report.model.ifc4Schema === 'IFC4' && report.model.ifc2x3Schema === 'IFC2X3' && report.model.ifc4Entities >= 7 && report.model.ifc4Hierarchy === 4 && report.model.ifc4PropertySets >= 1 && report.model.ifc4Classifications === 1 && report.model.ifc2x3RoundTrip && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'BIM-IFC' && report.opfs.elements === 1 && 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 & { __bitbybitBimReport?: BimReport }).__bitbybitBimReport = 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 {}

203
src/chromeCamHarness.ts Normal file
View File

@@ -0,0 +1,203 @@
import { createMockFacade } from './facade/mockFacade'
import { createCamJob, type CamDressupKind, type CamOperationKind } from './facade/cam'
import { menuDefinitions, workbenchDefinitions } from './freecadManifest'
type CamReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
geometry?: { sourceShape: string; valid: boolean; volume: number }
job?: {
stockMode: string
tools: number
controllers: number
operations: number
operationKinds: string[]
operationStatuses: string[]
pathPoints: number
dressups: number
setup: { safeHeightOffset: number; clearanceHeightOffset: number; coolantMode: string }
sanity: string
sanityIssues: number
simulation: string
collisions: number
collisionFixture: number
simulationDiagnostics: number
postprocessors: string[]
post: string
postHashes: Record<string, string>
unsafePostRejected: boolean
gcodeBytes: number
gcodeHash: string
deterministic: boolean
}
extended?: { commandSurface: number; operationKinds: number; dressupKinds: number; toolAssetRoundTrip: boolean; jobAssetRoundTrip: boolean; metadataRoundTrip: boolean; metadataComments: number; metadataProperties: number; metadataCompounds: number; undoRedo: boolean; startPointChanged: boolean; materialRemoval: boolean; removalTimelinePoints: number; fixtureCollision: boolean; multiAxis: boolean }
persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; operations: 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 run = async (): Promise<CamReport> => {
const facade = createMockFacade()
const report: CamReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome CAM evidence requires isolated OPFS.')
await facade.project.list()
const shape = await facade.geometry.createBox({ documentId: 'cam', documentVersion: 1, width: 5, length: 2, height: 3 })
const quality = await facade.geometry.qualityReport(shape)
const mass = await facade.geometry.massProperties(shape)
const cam = createCamJob('job', 'Box machining job', { min: [0, 0, 0], max: [5, 2, 3], mode: 'from-base-bound-box' })
cam.updateSetupSheet({ safeHeightOffset: 1, clearanceHeightOffset: 2, coolantMode: 'flood' })
cam.addTool({ id: 'T1', name: '1mm end mill', shape: 'endmill', diameter: 1, length: 20, cuttingEdgeHeight: 8, shankDiameter: 1 })
cam.addToolController({ id: 'TC1', label: 'End Mill Controller', toolId: 'T1', toolNumber: 1, spindleSpeed: 16000, spindleDirection: 'forward', horizontalFeed: 240, verticalFeed: 80, horizontalRapid: 1800, verticalRapid: 900 })
cam.addTool({ id: 'T2', name: '0.5mm drill', shape: 'drill', diameter: 0.5, length: 15, cuttingEdgeHeight: 6, shankDiameter: 0.5 })
cam.addToolController({ id: 'TC2', label: 'Drill Controller', toolId: 'T2', toolNumber: 2, spindleSpeed: 12000, spindleDirection: 'forward', horizontalFeed: 120, verticalFeed: 60, horizontalRapid: 1800, verticalRapid: 900 })
cam.addOperation({ id: 'profile', label: 'Outside Profile', kind: 'profile', toolId: 'T1', controllerId: 'TC1', depth: 1, feed: 240, verticalFeed: 80, stepDown: 1, stepOver: 50, coolantMode: 'flood' })
cam.generatePath('profile')
cam.applyDressup('profile', { kind: 'lead-in-out', parameters: { length: 0.5 } })
cam.generatePath('profile')
cam.addOperation({ id: 'pocket', label: 'Top Pocket', kind: 'pocket', toolId: 'T1', controllerId: 'TC1', depth: 1, feed: 220, verticalFeed: 80, stepDown: 1, stepOver: 50, direction: 'climb', coolantMode: 'flood' })
cam.generatePath('pocket')
cam.addOperation({ id: 'drilling', label: 'Mounting Holes', kind: 'drilling', toolId: 'T2', controllerId: 'TC2', depth: 1.5, feed: 60, verticalFeed: 60, stepDown: 1.5, locations: [[1, 1, 3], [4, 1, 3]], coolantMode: 'mist' })
cam.generatePath('drilling')
cam.addComment('Chrome CAM metadata evidence')
cam.setPropertyBagValue('Material', 'Al6061')
cam.setPropertyBagValue('BatchSize', 1)
cam.setPropertyBagValue('DryRun', false)
cam.createCompound(['profile', 'pocket'], 'roughing', 'Roughing Operations')
const sanity = cam.sanityCheck()
const simulation = cam.simulate()
const postHashes: Record<string, string> = {}
for (const postprocessor of cam.postprocessors()) postHashes[postprocessor] = await sha256(new TextEncoder().encode(cam.exportGcode(postprocessor)))
const gcodeText = cam.exportGcode('linuxcnc')
const secondGcode = cam.exportGcode('linuxcnc')
const gcode = new TextEncoder().encode(gcodeText)
const collision = createCamJob('collision', 'Collision')
collision.addTool({ id: 'T1', name: 'Tool', diameter: 2, length: 10 })
collision.addOperation({ id: 'draft', kind: 'contour', toolId: 'T1', depth: 1, feed: 10 })
const collisionFixture = collision.simulate().collisions.length
let unsafePostRejected = false
try { cam.exportGcode('shell' as never) } catch { unsafePostRejected = true }
const assetText = cam.exportToolBit('T1')
const assetCam = createCamJob('asset')
const importedTool = assetCam.importToolBit(assetText, 'T99')
const toolAssetRoundTrip = importedTool.id === 'T99' && importedTool.name === '1mm end mill' && assetText === cam.exportToolBit('T1')
const extendedCam = createCamJob('extended', 'Extended command surface', { min: [0, 0, 0], max: [10, 10, 5] })
extendedCam.addTool({ id: 'T1', name: '2mm end mill', diameter: 2, length: 25 })
const operationKinds: CamOperationKind[] = ['profile', 'pocket', 'contour', 'mill-face', 'helix', 'adaptive', 'slot', 'drilling', 'tapping', 'engrave', 'deburr', 'v-carve', 'pocket-3d', 'surface', 'waterline', 'thread-milling', 'probe', 'area', 'area-workplane', 'custom', 'shape', 'path-shape-tool-controller']
for (const [index, kind] of operationKinds.entries()) {
extendedCam.addOperation({ id: `extended-${index}`, kind, toolId: 'T1', depth: 1, feed: 100 })
extendedCam.generatePath(`extended-${index}`)
}
const beforeStart = extendedCam.snapshot().operations[0].path[0]
const afterStart = extendedCam.setStartPoint('extended-0', 1).path[0]
const startPointChanged = beforeStart.some((value, index) => value !== afterStart[index])
const dressupKinds: CamDressupKind[] = ['array', 'axis-map', 'boundary', 'dogbone', 'drag-knife', 'lead-in-out', 'ramp-entry', 'holding-tags', 'z-correct']
for (const [index, kind] of dressupKinds.entries()) {
const operationId = `extended-${index + 1}`
extendedCam.applyDressup(operationId, { kind, parameters: kind === 'array' ? { count: 2, offsetX: 0, offsetY: 0 } : kind === 'dogbone' ? { radius: 0.5 } : kind === 'drag-knife' ? { offset: 0.5 } : {} })
extendedCam.generatePath(operationId)
}
const fixtureCam = createCamJob('fixture', 'Fixture collision', { min: [0, 0, 0], max: [10, 10, 5] })
fixtureCam.addTool({ id: 'T1', name: 'Fixture probe tool', diameter: 2, length: 20, cuttingEdgeHeight: 1, shankDiameter: 2, holderDiameter: 4, holderLength: 20 })
fixtureCam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
fixtureCam.generatePath('profile')
fixtureCam.updateCollisionFixtures([{ id: 'clamp', min: [-1, -1, 5], max: [1, 1, 7] }])
const fixtureSimulation = fixtureCam.simulate()
const jobAsset = cam.exportJob()
const reopenedCam = createCamJob('job', 'Box machining job')
reopenedCam.importJob(jobAsset)
const jobAssetRoundTrip = reopenedCam.exportJob() === jobAsset
const metadataRoundTrip = reopenedCam.snapshot().metadata.comments.length === 1 && reopenedCam.snapshot().metadata.propertyBag.Material === 'Al6061' && reopenedCam.snapshot().metadata.propertyBag.BatchSize === 1 && reopenedCam.snapshot().metadata.propertyBag.DryRun === false && reopenedCam.snapshot().metadata.compounds[0]?.id === 'roughing'
const metadataBeforeRemoval = reopenedCam.snapshot().metadata
reopenedCam.removeOperation('pocket')
const metadataDependencyCleanup = reopenedCam.snapshot().metadata.compounds.length === 0
reopenedCam.undo()
const metadataUndoRestored = reopenedCam.snapshot().metadata.compounds.length === metadataBeforeRemoval.compounds.length
reopenedCam.toggleOperation('profile', false)
const undoRedo = metadataDependencyCleanup && metadataUndoRestored && reopenedCam.canUndo() && reopenedCam.undo().operations.find((operation) => operation.id === 'profile')?.active === true && reopenedCam.canRedo() && reopenedCam.redo().operations.find((operation) => operation.id === 'profile')?.active === false
const multiAxisResult = cam.kinematics('profile', { configuration: '5-axis', rotary: { axis: 'C', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } })
const multiAxisGcode = cam.exportMultiAxisGcode('profile', 'linuxcnc', { configuration: '5-axis', rotary: { axis: 'C', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } })
const multiAxis = multiAxisResult.status === 'pass' && multiAxisResult.poses.length === cam.snapshot().operations.find((operation) => operation.id === 'profile')?.path.length && multiAxisResult.poses.at(-1)?.c === 30 && multiAxisResult.poses.at(-1)?.b === 45 && multiAxisGcode.includes('B45.000 C30.000') && multiAxisGcode.includes('M428') && multiAxisGcode.includes('G93') && multiAxisGcode.includes('M429')
report.extended = { commandSurface: workbenchDefinitions.CAM.groups.flatMap((group) => group.commands).length, operationKinds: new Set(extendedCam.snapshot().operations.map((operation) => operation.kind)).size, dressupKinds: new Set(extendedCam.snapshot().operations.flatMap((operation) => operation.dressups.map((dressup) => dressup.kind))).size, toolAssetRoundTrip, jobAssetRoundTrip, metadataRoundTrip: metadataRoundTrip && metadataDependencyCleanup && metadataUndoRestored, metadataComments: cam.snapshot().metadata.comments.length, metadataProperties: Object.keys(cam.snapshot().metadata.propertyBag).length, metadataCompounds: cam.snapshot().metadata.compounds.length, undoRedo, startPointChanged, materialRemoval: simulation.materialRemoval.enabled && simulation.materialRemoval.removedVolume > 0 && simulation.materialRemoval.remainingVolume < simulation.materialRemoval.stockVolume, removalTimelinePoints: simulation.materialRemoval.timeline.length, fixtureCollision: fixtureSimulation.status === 'collision' && fixtureSimulation.diagnostics.some((diagnostic) => diagnostic.type === 'fixture'), multiAxis }
if (menuDefinitions.CAM.length !== report.extended.commandSurface) throw new Error('CAM menu and toolbar command surfaces diverged.')
const snapshot = cam.snapshot()
const payload = new TextEncoder().encode(JSON.stringify(snapshot))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.cam-job+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 = 'cam-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'CAM-ALL', operations: snapshot.operations.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; operations: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.geometry = { sourceShape: shape.id, valid: quality.structuralValid, volume: mass.volume }
report.job = {
stockMode: snapshot.stock.mode || '',
tools: snapshot.tools.length,
controllers: snapshot.toolControllers.length,
operations: snapshot.operations.length,
operationKinds: snapshot.operations.map((operation) => operation.kind),
operationStatuses: snapshot.operations.map((operation) => operation.status),
pathPoints: snapshot.operations.reduce((total, operation) => total + operation.path.length, 0),
dressups: snapshot.operations.reduce((total, operation) => total + operation.dressups.length, 0),
setup: { safeHeightOffset: snapshot.setupSheet.safeHeightOffset, clearanceHeightOffset: snapshot.setupSheet.clearanceHeightOffset, coolantMode: snapshot.setupSheet.coolantMode },
sanity: sanity.status,
sanityIssues: sanity.issues.length,
simulation: simulation.status,
collisions: simulation.collisions.length,
collisionFixture,
simulationDiagnostics: simulation.diagnostics.length,
postprocessors: cam.postprocessors(),
post: 'linuxcnc',
postHashes,
unsafePostRejected,
gcodeBytes: gcode.byteLength,
gcodeHash: await sha256(gcode),
deterministic: gcodeText === secondGcode,
}
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, operations: markerPayload.operations, markerRemoved }
const expectedKinds = JSON.stringify(['profile', 'pocket', 'drilling'])
report.status = report.geometry.valid && report.job.stockMode === 'from-base-bound-box' && report.job.tools === 2 && report.job.controllers === 2 && report.job.operations === 3 && JSON.stringify(report.job.operationKinds) === expectedKinds && report.job.operationStatuses.every((status) => status === 'generated') && report.job.pathPoints >= 20 && report.job.dressups === 1 && report.job.setup.safeHeightOffset === 1 && report.job.setup.clearanceHeightOffset === 2 && report.job.setup.coolantMode === 'flood' && report.job.sanity === 'pass' && report.job.sanityIssues === 0 && report.job.simulation === 'pass' && report.job.collisions === 0 && report.job.collisionFixture === 1 && report.job.simulationDiagnostics === 0 && report.job.postprocessors.length === 7 && Object.keys(report.job.postHashes).length === 7 && report.job.post === 'linuxcnc' && report.job.unsafePostRejected && report.job.gcodeBytes > 0 && report.job.deterministic && report.extended.commandSurface === 60 && report.extended.operationKinds === 22 && report.extended.dressupKinds === 9 && report.extended.toolAssetRoundTrip && report.extended.jobAssetRoundTrip && report.extended.metadataRoundTrip && report.extended.metadataComments === 1 && report.extended.metadataProperties === 3 && report.extended.metadataCompounds === 1 && report.extended.undoRedo && report.extended.startPointChanged && report.extended.materialRemoval && report.extended.removalTimelinePoints === report.job.pathPoints && report.extended.fixtureCollision && report.extended.multiAxis && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'CAM-ALL' && report.opfs.operations === 3 && 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'
}
return report
}
run().then((report) => {
;(window as Window & { __bitbybitCamReport?: CamReport }).__bitbybitCamReport = 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 = String(error)
})
export {}

View File

@@ -0,0 +1,67 @@
import { createCamJob } from './facade/cam'
import { createLinuxcncIframeAdapter, prepareCamPipeline, submitCamPipelineToLinuxcnc } from './facade/camPipeline'
type MachineReport = {
schemaVersion: 1
status: 'pass' | 'failed'
stages: string[]
openCamLib?: { backend: string; sourceRevision: string; artifactSha256: string; triangleCount: number; inputPoints: number; outputPoints: number; sampling: number }
gcode?: { bytes: number; hasM428: boolean; hasM429: boolean; hasG93: boolean; hasG94: boolean; hasB: boolean; hasC359: boolean; hasC361: boolean }
machine?: { backend: string; status: string; dryRunStatus?: string; parserAuthority?: string; lines?: number; blocks?: number; trajectorySegments?: number; message?: string }
error?: string
}
declare global { interface Window { __bitbybitCamLinuxcncMachineReport?: MachineReport } }
const run = async (): Promise<MachineReport> => {
const report: MachineReport = { schemaVersion: 1, status: 'failed', stages: ['CAD', 'OCL', 'CAMotics', 'GCODE', 'LinuxCNC WASM'] }
try {
// Keep the test stock inside the selected LinuxCNC machine's Z workspace
// (AXIS_Z is [-475, 0]); positive-Z work coordinates require a G54 offset.
const cam = createCamJob('machine-cert', 'LinuxCNC WASM machine acceptance', { min: [0, 0, -10], max: [10, 10, -5] })
cam.addTool({ id: 'T1', name: '2 mm end mill', diameter: 2, length: 20 })
cam.addToolController({ id: 'TC1', label: 'T1 controller', toolId: 'T1', toolNumber: 1, spindleSpeed: 6000, spindleDirection: 'forward', horizontalFeed: 120, verticalFeed: 60, horizontalRapid: 600, verticalRapid: 300 })
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', controllerId: 'TC1', depth: 1, feed: 120 })
cam.generatePath('profile')
const prepared = await prepareCamPipeline(cam, 'profile', {
postprocessor: 'linuxcnc',
kinematics: { configuration: '5-axis', rotary: { axis: 'C', startDegrees: 359, endDegrees: 361 }, tilt: { axis: 'B', startDegrees: 5, endDegrees: 15 } },
})
report.openCamLib = prepared.openCamLib
report.gcode = {
bytes: new TextEncoder().encode(prepared.gcode).byteLength,
hasM428: /(^|\n)M428(\n|$)/.test(prepared.gcode),
hasM429: /(^|\n)M429(\n|$)/.test(prepared.gcode),
hasG93: /(^|\n)G93(\n|$)/.test(prepared.gcode),
hasG94: /(^|\n)G94(\n|$)/.test(prepared.gcode),
hasB: /\bB15\.000\b/.test(prepared.gcode),
hasC359: /\bC359\.000\b/.test(prepared.gcode),
hasC361: /\bC361\.000\b/.test(prepared.gcode),
}
const dryRun = await submitCamPipelineToLinuxcnc(prepared, createLinuxcncIframeAdapter({
url: '/linuxcnc-machine/?case=axis-vismach-5axis-table-rotary-tilting-xyzbc-trt',
run: false,
name: 'cad-ocl-camotics-xyzbc-dry-run.ngc',
timeoutMs: 180_000,
}))
const result = await submitCamPipelineToLinuxcnc(prepared, createLinuxcncIframeAdapter({
url: '/linuxcnc-machine/?case=axis-vismach-5axis-table-rotary-tilting-xyzbc-trt',
run: true,
name: 'cad-ocl-camotics-xyzbc.ngc',
timeoutMs: 180_000,
}))
const trace = result.linuxcnc?.trace as { lines?: number; blocks?: number; trajectorySegments?: number; parserAuthority?: string } | undefined
report.machine = { backend: result.linuxcnc?.backend || '', status: result.linuxcnc?.status || '', dryRunStatus: dryRun.linuxcnc?.status || '', parserAuthority: trace?.parserAuthority, lines: trace?.lines, blocks: trace?.blocks, trajectorySegments: trace?.trajectorySegments, message: result.linuxcnc?.message }
report.status = report.openCamLib.backend === 'upstream-opencamlib-wasm' && report.openCamLib.triangleCount >= 2 && report.openCamLib.outputPoints >= report.openCamLib.inputPoints && Object.values(report.gcode).every(Boolean) && report.machine.backend === 'linuxcnc-wasm' && report.machine.status === 'accepted' && report.machine.dryRunStatus === 'dry-run' && report.machine.parserAuthority === 'linuxcnc-wasm' && Number(report.machine.lines) >= 10 && Number(report.machine.lines) < 1000 && Number(report.machine.blocks) >= 2 && Number(report.machine.blocks) < 1000 && Number(report.machine.trajectorySegments) >= 2 && Number(report.machine.trajectorySegments) < 1000 ? 'pass' : 'failed'
} catch (error) {
report.error = error instanceof Error ? error.stack || error.message : String(error)
}
return report
}
void run().then((report) => {
window.__bitbybitCamLinuxcncMachineReport = report
document.documentElement.dataset.status = report.status
const output = document.querySelector('#result')
if (output) output.textContent = JSON.stringify(report, null, 2)
})

View File

@@ -0,0 +1,163 @@
import { BitbybitGeometryRuntime } from './facade/geometryRuntime'
import {
generateCamoticsSourceStageGcode,
OPEN_CAM_LIB_ARTIFACT_SHA256,
nativeCamCapabilities,
runOpenCamLibDropCutter,
simulateOcctSolidRemoval,
} from './facade/camNativeSimulation'
type NativeSimulationReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
capabilities?: ReturnType<typeof nativeCamCapabilities>
openCamLib?: { backend: string; sourceRevision: string; artifactSha256: string; triangles: number; points: number; minimumZ: number; maximumZ: number; deterministic: boolean }
camotics?: { backend: string; sourceRevision: string; nativeExecutable: false; lineCount: number; gcodeBytes: number; gcodeHash: string; parserAuthority: string; camoticsParsesCanonicalGcode: false }
pipeline?: { stages: string[]; canonicalGcodeAuthority: string; camoticsSemanticParser: false; linuxcncWasmExecution: 'handoff-required' }
solidRemoval?: { backend: string; model: string; sweepMode: string; nativeSolid: boolean; freeCadNativeEquivalent: boolean; sampleCount: number; stockVolume: number; removedVolume: number; remainingVolume: number; structuralValid: boolean; solids: number; meshVertices: number; meshTriangles: number; brepBytes: number; brepHash: string }
curvedSolidRemoval?: { backend: string; model: string; sweepMode: string; sampleCount: number; stockVolume: number; removedVolume: number; remainingVolume: number; structuralValid: boolean; solids: number; meshVertices: number; meshTriangles: number }
afterRelease?: { shapeCount: number; kernelReferenceCount: number }
error?: string
}
declare global { interface Window { __bitbybitCamNativeSimulationReport?: NativeSimulationReport } }
const sha256 = async (value: string) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))].map((byte) => byte.toString(16).padStart(2, '0')).join('')
const run = async (): Promise<NativeSimulationReport> => {
const runtime = new BitbybitGeometryRuntime()
const report: NativeSimulationReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
let remainingShape: Awaited<ReturnType<typeof simulateOcctSolidRemoval>>['remainingShape'] | null = null
let curvedRemainingShape: Awaited<ReturnType<typeof simulateOcctSolidRemoval>>['remainingShape'] | null = null
try {
if (!report.crossOriginIsolated) throw new Error('Native CAM evidence requires an isolated browser runtime.')
report.capabilities = nativeCamCapabilities()
const surface = [
[[0, 0, 2], [10, 0, 2], [10, 10, 2]],
[[0, 0, 2], [10, 10, 2], [0, 10, 2]],
] as const
const ocl = await runOpenCamLibDropCutter({
triangles: surface.map((triangle) => triangle.map((point) => [...point]) as [[number, number, number], [number, number, number], [number, number, number]]),
path: [[2, 5, 0], [8, 5, 0]],
cutter: { shape: 'endmill', diameter: 2, length: 20 },
sampling: 0.5,
minimumZ: 0,
})
const oclAgain = await runOpenCamLibDropCutter({
triangles: surface.map((triangle) => triangle.map((point) => [...point]) as [[number, number, number], [number, number, number], [number, number, number]]),
path: [[2, 5, 0], [8, 5, 0]],
cutter: { shape: 'endmill', diameter: 2, length: 20 },
sampling: 0.5,
minimumZ: 0,
})
const heights = ocl.points.map((point) => point[2])
report.openCamLib = {
backend: ocl.backend,
sourceRevision: ocl.sourceRevision,
artifactSha256: ocl.artifactSha256,
triangles: ocl.triangleCount,
points: ocl.points.length,
minimumZ: Math.min(...heights),
maximumZ: Math.max(...heights),
deterministic: JSON.stringify(ocl.points) === JSON.stringify(oclAgain.points),
}
const sourceStage = generateCamoticsSourceStageGcode([
{ type: 'rapid', point: ocl.points[0] },
...ocl.points.slice(1).map((point) => ({ type: 'cut' as const, point, feed: 120 })),
], { toolNumber: 1, feed: 120 })
report.camotics = {
backend: sourceStage.backend,
sourceRevision: sourceStage.sourceRevision,
nativeExecutable: sourceStage.nativeExecutable,
lineCount: sourceStage.lineCount,
gcodeBytes: new TextEncoder().encode(sourceStage.gcode).byteLength,
gcodeHash: await sha256(sourceStage.gcode),
parserAuthority: sourceStage.parserAuthority,
camoticsParsesCanonicalGcode: sourceStage.camoticsParsesCanonicalGcode,
}
report.pipeline = {
stages: ['cad', 'opencamlib-wasm', 'camotics-source-stage', 'gcode', 'linuxcnc-wasm-parse-execute'],
canonicalGcodeAuthority: 'linuxcnc-wasm',
camoticsSemanticParser: false,
linuxcncWasmExecution: 'handoff-required',
}
const removal = await simulateOcctSolidRemoval(runtime, {
id: 'browser-evidence',
stock: { min: [0, 0, 0], max: [10, 6, 5] },
path: [[2, 3, 4], [8, 3, 4]],
cutterDiameter: 2,
maxChord: 1,
meshPrecision: 0.1,
})
remainingShape = removal.remainingShape
const brep = await runtime.exportBrep(removal.remainingShape, 'remaining-stock.brep')
report.solidRemoval = {
backend: removal.backend,
model: removal.model,
sweepMode: removal.sweepMode,
nativeSolid: removal.nativeSolid,
freeCadNativeEquivalent: removal.freeCadNativeEquivalent,
sampleCount: removal.sampleCount,
stockVolume: removal.stockVolume,
removedVolume: removal.removedVolume,
remainingVolume: removal.remainingVolume,
structuralValid: removal.structuralValid,
solids: removal.solids,
meshVertices: removal.meshVertices,
meshTriangles: removal.meshTriangles,
brepBytes: new TextEncoder().encode(brep.text).byteLength,
brepHash: await sha256(brep.text),
}
const curvedRemoval = await simulateOcctSolidRemoval(runtime, {
id: 'browser-curved-sweep-evidence',
stock: { min: [0, 0, 0], max: [10, 6, 5] },
path: [[2, 2, 4], [4, 3, 3.5], [6, 2, 3], [8, 3, 2.5]],
cutterDiameter: 1.5,
maxChord: 0.75,
meshPrecision: 0.1,
sweepMode: 'continuous-segment',
})
curvedRemainingShape = curvedRemoval.remainingShape
report.curvedSolidRemoval = {
backend: curvedRemoval.backend,
model: curvedRemoval.model,
sweepMode: curvedRemoval.sweepMode,
sampleCount: curvedRemoval.sampleCount,
stockVolume: curvedRemoval.stockVolume,
removedVolume: curvedRemoval.removedVolume,
remainingVolume: curvedRemoval.remainingVolume,
structuralValid: curvedRemoval.structuralValid,
solids: curvedRemoval.solids,
meshVertices: curvedRemoval.meshVertices,
meshTriangles: curvedRemoval.meshTriangles,
}
const oclPass = report.openCamLib.artifactSha256 === OPEN_CAM_LIB_ARTIFACT_SHA256 && report.openCamLib.triangles === 2 && report.openCamLib.points >= 2 && report.openCamLib.minimumZ >= 2 - 1e-9 && report.openCamLib.maximumZ <= 2 + 1e-9 && report.openCamLib.deterministic
const removalPass = report.solidRemoval.nativeSolid && report.solidRemoval.sweepMode === 'sampled-flat-end' && !report.solidRemoval.freeCadNativeEquivalent && report.solidRemoval.structuralValid && report.solidRemoval.solids >= 1 && report.solidRemoval.stockVolume > report.solidRemoval.remainingVolume && report.solidRemoval.removedVolume > 0 && Math.abs(report.solidRemoval.stockVolume - report.solidRemoval.removedVolume - report.solidRemoval.remainingVolume) < 1e-7 && report.solidRemoval.meshVertices > 0 && report.solidRemoval.meshTriangles > 0 && report.solidRemoval.brepBytes > 0 && /^[0-9a-f]{64}$/.test(report.solidRemoval.brepHash)
const curvedRemovalPass = report.curvedSolidRemoval?.backend === 'bitbybit-occt-wasm' && report.curvedSolidRemoval.model === 'continuous-segment-sweep-brep' && report.curvedSolidRemoval.sweepMode === 'continuous-segment' && report.curvedSolidRemoval.sampleCount > 4 && report.curvedSolidRemoval.structuralValid && report.curvedSolidRemoval.solids >= 1 && report.curvedSolidRemoval.stockVolume > report.curvedSolidRemoval.remainingVolume && report.curvedSolidRemoval.removedVolume > 0 && report.curvedSolidRemoval.meshVertices > 0 && report.curvedSolidRemoval.meshTriangles > 0
const camoticsPass = report.capabilities.camotics.status === 'native-host-available' && report.capabilities.camotics.backend === 'camotics-native-host-qt5-tpl' && report.capabilities.camotics.workspaceNativeExecutable && report.capabilities.camotics.nativeGuiPath === 'CAMotics/camotics' && report.capabilities.camotics.nativeTplPath === 'CAMotics/tplang' && !report.capabilities.camotics.browserExecutable && report.capabilities.camotics.browserWasmKernelExecutable && report.capabilities.camotics.wasmKernel.backend === 'camotics-upstream-sweep-wasm' && !report.capabilities.camotics.wasmKernel.fullCamoticsProgram && !report.capabilities.camotics.wasmKernel.gcodeParserIncluded && report.camotics?.backend === 'camotics-source-stage-contract' && report.camotics.nativeExecutable === false && report.camotics.lineCount >= 6 && report.camotics.gcodeBytes > 0 && /^[0-9a-f]{64}$/.test(report.camotics.gcodeHash) && report.camotics.parserAuthority === 'linuxcnc-wasm' && report.camotics.camoticsParsesCanonicalGcode === false && report.pipeline?.canonicalGcodeAuthority === 'linuxcnc-wasm' && report.pipeline.camoticsSemanticParser === false && report.pipeline.linuxcncWasmExecution === 'handoff-required'
report.status = oclPass && removalPass && curvedRemovalPass && camoticsPass ? 'pass' : 'failed'
} catch (error) {
report.error = error instanceof Error ? error.stack || error.message : String(error)
} finally {
if (remainingShape) await runtime.release(remainingShape)
if (curvedRemainingShape) await runtime.release(curvedRemainingShape)
runtime.dispose()
await new Promise((resolve) => setTimeout(resolve, 0))
const capabilities = runtime.capabilities()
report.afterRelease = { shapeCount: capabilities.shapeCount, kernelReferenceCount: capabilities.kernelReferenceCount }
if (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) {
report.status = 'failed'
report.error = `${report.error ? `${report.error} ` : ''}OCCT ownership gate failed.`
}
}
return report
}
void run().then((report) => {
window.__bitbybitCamNativeSimulationReport = report
const output = document.querySelector('#result')
if (output) output.textContent = JSON.stringify(report, null, 2)
})

View File

@@ -0,0 +1,43 @@
import { CAMOTICS_WASM_ARTIFACT_SHA256, CAMOTICS_WASM_SOURCE_REVISION, loadCamoticsSweepKernel } from './facade/camoticsWasm'
type CamoticsWasmReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
kernel?: { backend: string; sourceRevision: string; artifactSha256: string; abiVersion: number; imports: number; fullCamoticsProgram: boolean; gcodeParserIncluded: boolean }
sweep?: { conicInsideDepth: number; conicOutsideDepth: number; spheroidInsideDepth: number; longMoveBoundingBoxes: number; deterministic: boolean }
parserAuthority?: 'linuxcnc-wasm'
error?: string
}
declare global { interface Window { __bitbybitCamoticsWasmReport?: CamoticsWasmReport } }
const run = async (): Promise<CamoticsWasmReport> => {
const report: CamoticsWasmReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated) throw new Error('CAMotics WASM evidence requires a cross-origin isolated browser runtime.')
const kernel = await loadCamoticsSweepKernel()
const evaluate = () => ({
conicInsideDepth: kernel.conicDepth({ length: 5, topRadius: 2, start: [0, 0, 0], end: [10, 0, 0], point: [5, 0, 0] }),
conicOutsideDepth: kernel.conicDepth({ length: 5, topRadius: 2, start: [0, 0, 0], end: [10, 0, 0], point: [5, 4, 0] }),
spheroidInsideDepth: kernel.spheroidDepth({ radius: 2, length: 4, start: [0, 0, 0], end: [10, 0, 0], point: [5, 0, 0] }),
longMoveBoundingBoxes: kernel.conicBoundingBoxCount({ length: 5, topRadius: 2, start: [0, 0, 0], end: [100, 0, 0], tolerance: 0.01 }),
})
const first = evaluate()
const second = evaluate()
report.kernel = { backend: kernel.backend, sourceRevision: kernel.sourceRevision, artifactSha256: kernel.artifactSha256, abiVersion: kernel.abiVersion, imports: kernel.imports.length, fullCamoticsProgram: kernel.fullCamoticsProgram, gcodeParserIncluded: kernel.gcodeParserIncluded }
report.sweep = { ...first, deterministic: JSON.stringify(first) === JSON.stringify(second) }
report.parserAuthority = 'linuxcnc-wasm'
report.status = report.kernel.backend === 'camotics-upstream-sweep-wasm' && report.kernel.sourceRevision === CAMOTICS_WASM_SOURCE_REVISION && report.kernel.artifactSha256 === CAMOTICS_WASM_ARTIFACT_SHA256 && report.kernel.abiVersion === 1 && report.kernel.imports === 0 && !report.kernel.fullCamoticsProgram && !report.kernel.gcodeParserIncluded && report.sweep.conicInsideDepth === 1 && report.sweep.conicOutsideDepth === -1 && report.sweep.spheroidInsideDepth === 1 && report.sweep.longMoveBoundingBoxes === 3 && report.sweep.deterministic ? 'pass' : 'failed'
} catch (error) {
report.error = error instanceof Error ? error.stack || error.message : String(error)
}
return report
}
void run().then((report) => {
window.__bitbybitCamoticsWasmReport = report
const output = document.querySelector('#result')
if (output) output.textContent = JSON.stringify(report, null, 2)
})

72
src/chromeDataHarness.ts Normal file
View File

@@ -0,0 +1,72 @@
import { createMockFacade } from './facade/mockFacade'
import { createDataModules } from './facade/dataModules'
type DataReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
adapters?: { total: number; supported: number; proxy: number }
records?: { total: number; proxy: number; manifestBytes: number }
points?: { sourceCount: number; parsedFormats: string[]; mergedCount: number; croppedCount: number; polygonCroppedCount: number; reducedCount: number; structured: { width: number; height: number; cells: number }; centered: { x: number; y: number; z: number }; exportBytes: Record<string, number> }
reverseEngineering?: { planeRms: number; sphereRadius: number; cylinderRadius: number; cylinderHeight: number; polynomialRms: number; segmentCount: number; segmentSizes: number[] }
persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; records: 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 encode = (value: string) => new TextEncoder().encode(value)
const close = (actual: number, expected: number, tolerance = 1e-7) => Math.abs(actual - expected) <= tolerance
const run = async (): Promise<DataReport> => {
const facade = createMockFacade(); const report: DataReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome data adapter evidence requires isolated OPFS.')
await facade.project.list()
const data = createDataModules()
const genericInputs = [['reverse', 'ReverseEngineering', 'point-cloud'], ['openscad', 'OpenSCAD', 'scad'], ['idf', 'Idf', 'idf'], ['jt', 'JtReader', 'jt'], ['material', 'Material', 'json'], ['import', 'Import', 'step']] as const
for (const [id, module, format] of genericInputs) data.importRecord({ id, module, format, bytes: encode(`${module}:${format}`), metadata: { source: 'chrome' } })
const planeRows: string[] = []; for (const x of [-2, 0, 2]) for (const y of [-1, 1]) planeRows.push(`${x},${y},${2 * x + 3 * y + 4}`)
const grid = data.importPointCloud({ id: 'points', format: 'csv', bytes: encode(`x,y,z\n${['0,0,0', '1,0,1', '2,0,2', '0,1,3', '1,1,4', '2,1,5'].join('\n')}\n`) })
const parsedFormats = ['pts', 'pcd', 'ply'].map((format, index) => {
const payload = format === 'pts' ? '2\n1 2 3\n4 5 6\n' : format === 'pcd' ? 'VERSION 0.7\nFIELDS x y z\nWIDTH 2\nHEIGHT 1\nPOINTS 2\nDATA ascii\n1 2 3\n4 5 6\n' : 'ply\nformat ascii 1.0\nelement vertex 2\nproperty float x\nproperty float y\nproperty float z\nend_header\n1 2 3\n4 5 6\n'
data.importPointCloud({ id: `parsed-${index}`, format: format as 'pts' | 'pcd' | 'ply', bytes: encode(payload), record: false }); return format
})
const centered = data.translatePointCloud({ id: 'centered', sourceId: 'points', offset: { x: -1, y: -0.5, z: -2.5 } })
const merged = data.mergePointClouds({ id: 'merged', sourceIds: ['points', 'centered'] })
const cropped = data.cropPointCloud({ id: 'cropped', sourceId: 'points', bounds: { min: { x: 1, y: 0, z: 0 }, max: { x: 2, y: 1, z: 5 } } })
const polygonCropped = data.polygonCropPointCloud({ id: 'polygon-cropped', sourceId: 'points', polygon: [{ x: 0, y: 0 }, { x: 1.1, y: 0 }, { x: 1.1, y: 1.1 }, { x: 0, y: 1.1 }] })
const reduced = data.voxelDownsample({ id: 'reduced', sourceId: 'points', size: 10 })
const structured = data.structurePointCloud({ id: 'structured', sourceId: 'points' })
const plane = data.importPointCloud({ id: 'plane', format: 'csv', bytes: encode(`x,y,z\n${planeRows.join('\n')}\n`), record: false })
const planeFit = data.fitPlane({ id: 'plane-fit', sourceId: plane.id, referenceNormal: { x: -2, y: -3, z: 1 } })
const sphere = data.importPointCloud({ id: 'sphere', format: 'xyz', bytes: encode('3 2 3\n-1 2 3\n1 4 3\n1 0 3\n1 2 5\n1 2 1\n2.414213562373095 3.414213562373095 3\n-0.414213562373095 0.585786437626905 3\n'), record: false })
const sphereFit = data.fitSphere({ id: 'sphere-fit', sourceId: sphere.id })
const cylinderRows: string[] = []; for (const z of [-4, 0, 4]) for (let index = 0; index < 8; index += 1) { const angle = index * Math.PI / 4; cylinderRows.push(`${2 + 3 * Math.cos(angle)} ${-1 + 3 * Math.sin(angle)} ${z}`) }
const cylinder = data.importPointCloud({ id: 'cylinder', format: 'xyz', bytes: encode(`${cylinderRows.join('\n')}\n`), record: false })
const cylinderFit = data.fitCylinder({ id: 'cylinder-fit', sourceId: cylinder.id })
const polynomialRows: string[] = []; for (const x of [-1, 0, 1]) for (const y of [-1, 0, 1]) polynomialRows.push(`${x} ${y} ${x * x + 2 * x * y + 3 * y * y + 4 * x + 5 * y + 6}`)
const polynomial = data.importPointCloud({ id: 'polynomial', format: 'xyz', bytes: encode(`${polynomialRows.join('\n')}\n`), record: false })
const polynomialFit = data.fitPolynomialSurface({ id: 'polynomial-fit', sourceId: polynomial.id })
const segmentation = data.importPointCloud({ id: 'segmentation', format: 'xyz', bytes: encode('0 0 0\n0 0.1 0\n10 0 0\n10 0.1 0\n'), record: false })
const segmented = data.segmentPointCloud({ id: 'segmented', sourceId: segmentation.id, radius: 0.3 })
const snapshot = data.snapshot(); const manifest = encode(data.exportManifest()); const payload = encode(JSON.stringify(snapshot)); const stored = await facade.project.resource.put(payload, 'application/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 = 'data-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'DATA-ALL', records: snapshot.records.length })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; records: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
const exportBytes = Object.fromEntries((['asc', 'pts', 'xyz', 'csv', 'pcd', 'ply'] as const).map((format) => [format, data.exportPointCloud('points', format).byteLength]))
report.adapters = { total: snapshot.adapters.length, supported: snapshot.adapters.filter((entry) => entry.status === 'supported').length, proxy: snapshot.adapters.filter((entry) => entry.status === 'proxy').length }
report.records = { total: snapshot.records.length, proxy: snapshot.records.filter((entry) => entry.proxy).length, manifestBytes: manifest.byteLength }
report.points = { sourceCount: grid.points.length, parsedFormats, mergedCount: merged.points.length, croppedCount: cropped.points.length, polygonCroppedCount: polygonCropped.points.length, reducedCount: reduced.points.length, structured: { width: structured.structured!.width, height: structured.structured!.height, cells: structured.structured!.grid.length }, centered: centered.centroid, exportBytes }
report.reverseEngineering = { planeRms: planeFit.rms, sphereRadius: sphereFit.radius, cylinderRadius: cylinderFit.radius, cylinderHeight: cylinderFit.height, polynomialRms: polynomialFit.rms, segmentCount: segmented.clusters.length, segmentSizes: segmented.clusters.map((cluster) => cluster.pointIndices.length) }
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, records: markerPayload.records, markerRemoved }
report.status = report.adapters.total === 7 && report.adapters.supported === 4 && report.adapters.proxy === 3 && report.records.total === 7 && report.records.proxy === 3 && report.points.sourceCount === 6 && report.points.parsedFormats.join(',') === 'pts,pcd,ply' && report.points.mergedCount === 12 && report.points.croppedCount === 4 && report.points.polygonCroppedCount === 4 && report.points.reducedCount === 1 && report.points.structured.width === 3 && report.points.structured.height === 2 && close(report.points.centered.x, 0) && close(report.points.centered.y, 0) && close(report.points.centered.z, 0) && Object.values(report.points.exportBytes).every((value) => value > 0) && close(report.reverseEngineering.planeRms, 0) && close(report.reverseEngineering.sphereRadius, 2) && close(report.reverseEngineering.cylinderRadius, 3) && close(report.reverseEngineering.cylinderHeight, 8) && close(report.reverseEngineering.polynomialRms, 0) && report.reverseEngineering.segmentCount === 2 && JSON.stringify(report.reverseEngineering.segmentSizes) === JSON.stringify([2, 2]) && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'DATA-ALL' && report.opfs.records === 7 && 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 & { __bitbybitDataReport?: DataReport }).__bitbybitDataReport = 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 {}

91
src/chromeDraftHarness.ts Normal file
View File

@@ -0,0 +1,91 @@
import { createMockFacade } from './facade/mockFacade'
import { createDraftDocument } from './facade/draft'
type DraftReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
workingPlane?: { worldPoint: { x: number; y: number; z: number }; snapped: { x: number; y: number } }
objects?: { total: number; lines: number; wires: number; circles: number; clones: number; arrays: number; layers: number; sourceDependencies: number }
operations?: { moved: boolean; rotated: boolean; scaled: boolean; offsetLength: number; trimmedLength: number; cloneTranslation: { x: number; y: number }; arrayCount: number }
persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; markerObjects: 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 run = async (): Promise<DraftReport> => {
const facade = createMockFacade()
const report: DraftReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome Draft evidence requires isolated OPFS.')
await facade.project.list()
const draft = createDraftDocument('draft', 'Draft layout')
draft.setWorkingPlane({ origin: { x: 10, y: 20, z: 30 }, xAxis: { x: 0, y: 1, z: 0 }, yAxis: { x: 0, y: 0, z: 1 } })
draft.setGrid(0.5)
const snapped = draft.snap({ x: 1.24, y: 2.26 })
const worldPoint = draft.mapToWorld({ x: 2, y: 3 })
draft.addLayer({ id: 'construction', label: 'Construction', visible: true, color: '#007f86' })
draft.createLine('line', { x: 0, y: 0 }, { x: 4, y: 0 })
draft.createWire('wire', [{ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: 2 }], false, 'construction')
draft.createCircle('circle', { x: 1, y: 1 }, 2)
const moved = draft.move('line', { x: 1, y: 2 })
const rotated = draft.rotate('line', 90, { x: 1, y: 2 })
const scaled = draft.scale('line', 2, { x: 1, y: 2 })
const offset = draft.offsetLine('line', 1, 'offset')
const trimmed = draft.trimLine('line', 0.25, 0.75)
const clone = draft.clone('line', 'clone', { x: 10, y: 0 })
const array = draft.array('line', { columns: 2, rows: 2, columnSpacing: 5, rowSpacing: 7, idPrefix: 'array' })
draft.assignLayer('clone', 'construction')
const snapshot = draft.snapshot()
const payload = new TextEncoder().encode(JSON.stringify(snapshot))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.draft+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 sourceDependencies = snapshot.objects.filter((object) => object.kind === 'clone' && object.sourceId === 'line').length
const counts = { total: snapshot.objects.length, lines: snapshot.objects.filter((object) => object.kind === 'line').length, wires: snapshot.objects.filter((object) => object.kind === 'wire').length, circles: snapshot.objects.filter((object) => object.kind === 'circle').length, clones: snapshot.objects.filter((object) => object.kind === 'clone').length, arrays: snapshot.objects.filter((object) => object.kind === 'clone' && object.id.startsWith('array-')).length, layers: snapshot.layers.length, sourceDependencies }
const directory = await navigator.storage.getDirectory()
const markerName = 'draft-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'DRAFT-OPS', objects: snapshot.objects.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; objects: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.workingPlane = { worldPoint, snapped }
report.objects = counts
report.operations = { moved: moved.kind === 'line' && moved.start.x === 1 && moved.start.y === 2, rotated: rotated.kind === 'line' && Math.round(rotated.end.y - rotated.start.y) === 4, scaled: scaled.kind === 'line' && Math.round(Math.hypot(scaled.end.x - scaled.start.x, scaled.end.y - scaled.start.y)) === 8, offsetLength: Math.hypot(offset.end.x - offset.start.x, offset.end.y - offset.start.y), trimmedLength: Math.hypot(trimmed.end.x - trimmed.start.x, trimmed.end.y - trimmed.start.y), cloneTranslation: clone.translation, arrayCount: array.length }
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, markerObjects: markerPayload.objects, markerRemoved }
report.status = report.workingPlane.worldPoint.x === 10 && report.workingPlane.worldPoint.y === 22 && report.workingPlane.worldPoint.z === 33 && JSON.stringify(report.workingPlane.snapped) === JSON.stringify({ x: 1, y: 2.5 })
&& counts.total === 9 && counts.lines === 2 && counts.wires === 1 && counts.circles === 1 && counts.clones === 5 && counts.arrays === 4 && counts.layers === 2 && counts.sourceDependencies === 5
&& report.operations.moved && report.operations.rotated && report.operations.scaled && report.operations.offsetLength === 8 && report.operations.trimmedLength === 4 && report.operations.cloneTranslation.x === 10 && report.operations.arrayCount === 4
&& report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'DRAFT-OPS' && report.opfs.markerObjects === 9 && 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))
report.afterRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }
if (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed.` }
}
return report
}
run().then((report) => {
;(window as Window & { __bitbybitDraftReport?: DraftReport }).__bitbybitDraftReport = 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 {}

View File

@@ -0,0 +1,75 @@
import { createMockFacade } from './facade/mockFacade'
import { createAssembly } from './facade/assembly'
import { createBimModel } from './facade/bim'
import { createEngineeringProject } from './facade/engineeringProject'
import { createMeshDocument } from './facade/mesh'
import { createSurfaceDocument } from './facade/surface'
type EngineeringReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; integrity?: { complete: boolean; artifacts: number; kinds: number; missingKinds: number; missingRefs: number; projectVersion: number; artifactVersions: number[]; reopenedComplete: boolean }; workflow?: { assemblyComponents: number; bimElements: number; meshTriangles: number; surfacePatches: number; exports: { assemblyBytes: number; ifcBytes: number; meshObjBytes: number; surfaceObjBytes: number }; edited: { assemblyX: number; fireRating: string; meshMinX: number; surfaceOffset: number } }; scale?: { components: number; bomRows: number; durationMs: number; budgetMs: number }; persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; artifacts: 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 run = async (): Promise<EngineeringReport> => {
const facade = createMockFacade()
const report: EngineeringReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome engineering closure evidence requires isolated OPFS.')
await facade.project.list()
const shape = await facade.geometry.createBox({ documentId: 'engineering', documentVersion: 1, width: 5, length: 2, height: 3 })
const assembly = createAssembly('assembly', 'Assembly')
assembly.addComponent({ id: 'base', sourceObjectId: shape.id, label: 'Base', grounded: true })
assembly.addComponent({ id: 'part', sourceObjectId: shape.id, label: 'Part', grounded: false, placement: { x: 4 } })
assembly.addConnector({ id: 'base-c', componentId: 'base', origin: { x: 0, y: 0, z: 0 }, axis: { x: 1, y: 0, z: 0 } })
assembly.addConnector({ id: 'part-c', componentId: 'part', origin: { x: 0, y: 0, z: 0 }, axis: { x: 1, y: 0, z: 0 } })
assembly.addJoint({ id: 'distance', kind: 'distance', first: 'base-c', second: 'part-c', value: 3 })
const assemblySolved = assembly.solve()
const bim = createBimModel('bim', 'BIM')
bim.addSite({ id: 'site', label: 'Site' }); bim.addBuilding({ id: 'building', label: 'Building', siteId: 'site' }); bim.addLevel({ id: 'level', label: 'Level 1', elevation: 0, buildingId: 'building' }); bim.addSpace({ id: 'space', label: 'Space', levelId: 'level', area: 10 }); bim.addMaterial({ id: 'material', name: 'Concrete' }); bim.addElement({ id: 'wall', label: 'Wall', type: 'wall', shapeId: shape.id, levelId: 'level', spaceId: 'space', properties: {}, quantity: { area: 10 } }); bim.assignMaterial('wall', 'material')
const mesh = createMeshDocument('mesh', 'Mesh', await facade.geometry.mesh(shape, 0.05)); mesh.weldVertices(); mesh.removeDegenerate()
const surface = createSurfaceDocument('surface', 'Surface'); surface.addBezier('panel', [[[0, 0, 0], [0, 1, 0]], [[1, 0, 0], [1, 1, 0]]]); surface.fill('cap', [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]]); surface.offset('cap', 'cap-offset', 0.25); surface.loft('loft', [[[0, 0, 0], [1, 0, 0]], [[0, 0, 1], [1, 0, 1]]])
const project = createEngineeringProject('engineering', 'Mixed engineering project')
project.add({ id: 'assembly', kind: 'Assembly', sourceRefs: [], payload: { snapshot: assemblySolved } })
project.add({ id: 'bim', kind: 'BIM', sourceRefs: ['assembly'], payload: { snapshot: bim.snapshot() } })
project.add({ id: 'mesh', kind: 'Mesh', sourceRefs: ['bim'], payload: { snapshot: mesh.snapshot() } })
project.add({ id: 'surface', kind: 'Surface', sourceRefs: ['mesh'], payload: { snapshot: surface.snapshot() } })
assembly.setPlacement('part', { x: 6 })
const editedAssembly = assembly.snapshot()
bim.setElementProperty('wall', 'FireRating', 'A1')
mesh.transform({ translation: [1, 0, 0], scale: 1.5 })
const editedSurface = surface.offset('panel', 'panel-offset', 0.5)
const assemblyExport = new TextEncoder().encode(JSON.stringify(editedAssembly))
const ifcExport = new TextEncoder().encode(bim.exportIfc('IFC4'))
const meshExport = new TextEncoder().encode(mesh.exportObj())
const surfaceExport = new TextEncoder().encode(surface.exportObj())
project.update('assembly', { payload: { snapshot: editedAssembly, exportBytes: assemblyExport.byteLength } })
project.update('bim', { payload: { snapshot: bim.snapshot(), exportBytes: ifcExport.byteLength } })
project.update('mesh', { payload: { snapshot: mesh.snapshot(), exportBytes: meshExport.byteLength } })
project.update('surface', { payload: { snapshot: surface.snapshot(), exportBytes: surfaceExport.byteLength } })
const scaleStarted = performance.now()
const large = createAssembly('large', 'Large mixed assembly')
for (let index = 0; index < 1000; index += 1) large.addComponent({ id: `part-${index}`, sourceObjectId: `shape-${index % 20}`, label: `Part ${index % 20}`, grounded: index === 0, placement: { x: index } })
const largeBom = large.bom()
const scaleDurationMs = performance.now() - scaleStarted
const integrity = project.integrity()
const serialized = project.save()
const reopened = createEngineeringProject('reopened', 'Reopened')
const reopenedSnapshot = reopened.load(serialized)
const reopenedIntegrity = reopened.integrity()
const payload = new TextEncoder().encode(serialized)
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.engineering+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 = 'engineering-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'ENG-CLOSURE', artifacts: integrity.artifacts })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; artifacts: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.integrity = { complete: integrity.complete, artifacts: integrity.artifacts, kinds: integrity.kinds.length, missingKinds: integrity.missingKinds.length, missingRefs: integrity.missingRefs.length, projectVersion: project.snapshot().version, artifactVersions: project.snapshot().artifacts.map((artifact) => artifact.version).sort(), reopenedComplete: reopenedIntegrity.complete && reopenedSnapshot.artifacts.length === 4 }
report.workflow = { assemblyComponents: editedAssembly.components.length, bimElements: bim.snapshot().elements.length, meshTriangles: mesh.snapshot().triangles.length, surfacePatches: surface.snapshot().patches.length, exports: { assemblyBytes: assemblyExport.byteLength, ifcBytes: ifcExport.byteLength, meshObjBytes: meshExport.byteLength, surfaceObjBytes: surfaceExport.byteLength }, edited: { assemblyX: editedAssembly.components.find((component) => component.id === 'part')?.placement.x ?? Number.NaN, fireRating: String(bim.snapshot().elements.find((element) => element.id === 'wall')?.properties.FireRating ?? ''), meshMinX: mesh.analyze().bounds.min[0], surfaceOffset: editedSurface.poles[0][0][2] - surface.snapshot().patches.find((patch) => patch.id === 'panel')!.poles[0][0][2] } }
report.scale = { components: large.snapshot().components.length, bomRows: largeBom.length, durationMs: scaleDurationMs, budgetMs: 1000 }
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, artifacts: markerPayload.artifacts, markerRemoved }
report.status = report.integrity.complete && report.integrity.artifacts === 4 && report.integrity.kinds === 4 && report.integrity.missingKinds === 0 && report.integrity.missingRefs === 0 && report.integrity.projectVersion === 8 && JSON.stringify(report.integrity.artifactVersions) === JSON.stringify([2, 2, 2, 2]) && report.integrity.reopenedComplete && report.workflow.assemblyComponents === 2 && report.workflow.bimElements === 1 && report.workflow.meshTriangles > 0 && report.workflow.surfacePatches === 5 && report.workflow.edited.assemblyX === 6 && report.workflow.edited.fireRating === 'A1' && report.workflow.edited.meshMinX === -2.75 && report.workflow.edited.surfaceOffset === 0.5 && Object.values(report.workflow.exports).every((value) => value > 0) && report.scale.components === 1000 && report.scale.bomRows === 20 && report.scale.durationMs <= report.scale.budgetMs && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'ENG-CLOSURE' && report.opfs.artifacts === 4 && 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 (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed.` } }
return report
}
run().then((report) => { ;(window as Window & { __bitbybitEngineeringReport?: EngineeringReport }).__bitbybitEngineeringReport = 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 {}

View File

@@ -0,0 +1,473 @@
import { BitbybitGeometryRuntime } from './facade/geometryRuntime'
import { inspectFcstdArchive, instantiateFcstdShapeResource, serializeFcstdMetadataArchive } from './facade/fcstd'
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator } from './facade/recomputeEngine'
import type { DocumentSnapshot, ShapeHandle, ShapeMassProperties, ShapeQualityReport } from './facade/types'
type Point3 = [number, number, number]
type GoldenPlacement = { translation?: Point3; rotation?: { axis: Point3; angle: number } }
type GoldenOperation = {
type: 'box' | 'cylinder' | 'sphere' | 'cone' | 'torus' | 'prism' | 'wedge' | 'ellipsoid' | 'cut' | 'fuse' | 'common'
length?: number
width?: number
height?: number
radius?: number
radius1?: number
radius2?: number
angle?: number
placement?: GoldenPlacement
base?: GoldenOperation
tool?: GoldenOperation
}
type GoldenExpected = {
shapeType: 'Solid' | 'Compound'
isNull: false
isValid: true
solids: number
faces?: number
edges?: number
vertices?: number
volume: number
area?: number
boundingBox: { min: Point3; max: Point3 }
}
type GoldenScenario = {
schemaVersion: 1
id: string
operation: GoldenOperation
tolerance: { linear: number; scalar: number }
expected: GoldenExpected
}
type Difference = { classification: 'unknown'; domain: 'identity' | 'shape' | 'topology' | 'resources'; path: string; expected: unknown; actual: unknown; tolerance?: number }
type KnownDifference = { classification: 'kernel-container-normalization'; domain: 'identity'; path: string; expected: unknown; actual: unknown }
type Comparison = { status: 'pass' | 'fail-unknown-difference'; differences: Difference[]; knownDifferences: KnownDifference[] }
type FormatRoundTrip = { sourceBytes: number; importedBytes: number; declaredUnit: 'millimeter' | 'unknown'; sourceVolume: number; importedVolume: number; sourceSolids: number; importedSolids: number; importedShapeType: string; importedStructuralValid: boolean; importedStructuralErrors: number; structuralNormalization: 'none' | 'format-topology-normalization'; status: 'pass' | 'fail' }
type GoldenExchangeFormat = 'step' | 'iges' | 'brep'
type FormatSummary = Record<GoldenExchangeFormat, { passed: number; totalBytes: number; maximumVolumeDelta: number; topologyNormalizations: number; importedShapeTypes: Record<string, number> }>
type ScenarioReport = {
id: string
operation: GoldenOperation['type']
status: 'pass' | 'fail-unknown-difference'
tolerance: GoldenScenario['tolerance']
source: { quality: ShapeQualityReport; massProperties: ShapeMassProperties }
fcstd: { archiveBytes: number; brepBytes: number; brepHash: string; shapeResources: number; references: number }
imported: { quality: ShapeQualityReport; massProperties: ShapeMassProperties }
formatRoundTrips: Record<GoldenExchangeFormat, FormatRoundTrip>
comparisons: { sourceVsFreecad: Comparison; importedVsFreecad: Comparison; importedVsSource: Comparison }
}
type ProductRecomputeProbe = {
status: 'pass'
placedBox: { quality: ShapeQualityReport; massProperties: ShapeMassProperties; comparison: Comparison }
cut: { quality: ShapeQualityReport; massProperties: ShapeMassProperties; comparison: Comparison }
sphereTrim: { quality: ShapeQualityReport; massProperties: ShapeMassProperties; comparison: Comparison }
torus: { quality: ShapeQualityReport; massProperties: ShapeMassProperties; comparison: Comparison }
prism: { quality: ShapeQualityReport; massProperties: ShapeMassProperties; comparison: Comparison }
wedge: { quality: ShapeQualityReport; massProperties: ShapeMassProperties; comparison: Comparison }
ellipsoid: { quality: ShapeQualityReport; massProperties: ShapeMassProperties; comparison: Comparison }
afterRelease: { shapeCount: number; kernelReferenceCount: number }
}
type HarnessReport = {
schemaVersion: 1
baselineId: 'freecad-1.1.1'
freecadCommit: string
bitbybitVersion: '1.1.1'
browserId: 'chrome'
geometryProvider: 'Bitbybit OCCT'
unknownDifferencesFail: true
scenarioCount: number
reports: ScenarioReport[]
productRecompute?: ProductRecomputeProbe
summary: { passed: number; failed: number; knownDifferences: number; unknownDifferences: number; operationCounts: Record<string, number>; totalArchiveBytes: number; totalBrepBytes: number; formatRoundTrips: FormatSummary }
beforeRelease: { shapeCount: number; kernelReferenceCount: number }
afterRelease: { shapeCount: number; kernelReferenceCount: number }
status: 'pass' | 'failed'
error?: string
}
declare global {
interface Window { __bitbybitFcstdGoldenReport?: HarnessReport }
}
const setStatus = (message: string) => { document.querySelector('#status')!.textContent = message }
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
const fnv1a = (bytes: Uint8Array) => {
let hash = 0x811c9dc5
for (const byte of bytes) { hash ^= byte; hash = Math.imul(hash, 0x01000193) >>> 0 }
return hash.toString(16).padStart(8, '0')
}
const fcstdShapeDocument = (scenarioId: string, resourcePath: string): DocumentSnapshot => ({
id: `fcstd-golden-${scenarioId}`,
label: `FCStd golden ${scenarioId}`,
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [{ id: 'Model001', label: scenarioId, type: 'feature', state: 'valid' }],
objects: [{ id: 'Model001', typeId: 'Part::Feature', properties: [{ name: 'Shape', label: 'Shape', group: 'Base', scope: 'data', type: 'Part::PropertyPartShape', value: { path: resourcePath, format: 'brep', elementMap: '1' } }] }],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { Model001: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const compareExpected = (scenario: GoldenScenario, quality: ShapeQualityReport, mass: ShapeMassProperties): Comparison => {
const differences: Difference[] = []
const knownDifferences: KnownDifference[] = []
const scalar = (domain: Difference['domain'], path: string, actual: number, expected: number, tolerance: number) => {
const roundingAllowance = Number.EPSILON * Math.max(1, Math.abs(expected), Math.abs(actual)) * 32
if (!finite(actual) || Math.abs(actual - expected) > tolerance + roundingAllowance) differences.push({ classification: 'unknown', domain, path, expected, actual, tolerance })
}
if (quality.isNull !== scenario.expected.isNull) differences.push({ classification: 'unknown', domain: 'shape', path: 'isNull', expected: scenario.expected.isNull, actual: quality.isNull })
if (quality.structuralValid !== scenario.expected.isValid || quality.structuralErrors !== 0) differences.push({ classification: 'unknown', domain: 'shape', path: 'structuralValid', expected: true, actual: { valid: quality.structuralValid, errors: quality.structuralErrors } })
if (quality.solids !== scenario.expected.solids) differences.push({ classification: 'unknown', domain: 'topology', path: 'solids', expected: scenario.expected.solids, actual: quality.solids })
for (const key of ['faces', 'edges', 'vertices'] as const) {
const expected = scenario.expected[key]
if (expected !== undefined && quality[key] !== expected) differences.push({ classification: 'unknown', domain: 'topology', path: key, expected, actual: quality[key] })
}
const expectedShapeType = scenario.expected.shapeType.toLowerCase()
if (quality.shapeType !== expectedShapeType) {
if (((expectedShapeType === 'compound' && quality.shapeType === 'solid') || (expectedShapeType === 'solid' && quality.shapeType === 'compound')) && quality.solids === 1) knownDifferences.push({ classification: 'kernel-container-normalization', domain: 'identity', path: 'shapeType', expected: expectedShapeType, actual: quality.shapeType })
else differences.push({ classification: 'unknown', domain: 'identity', path: 'shapeType', expected: expectedShapeType, actual: quality.shapeType })
}
scalar('shape', 'volume', mass.volume, scenario.expected.volume, scenario.tolerance.scalar)
if (scenario.expected.area !== undefined) scalar('shape', 'area', mass.surfaceArea, scenario.expected.area, scenario.tolerance.scalar)
for (const bound of ['min', 'max'] as const) for (let index = 0; index < 3; index += 1) scalar('shape', `boundingBox.${bound}[${index}]`, quality.boundingBox[bound][index], scenario.expected.boundingBox[bound][index], scenario.tolerance.linear)
return { status: differences.length === 0 ? 'pass' : 'fail-unknown-difference', differences, knownDifferences }
}
const compareRoundTrip = (scenario: GoldenScenario, sourceQuality: ShapeQualityReport, sourceMass: ShapeMassProperties, importedQuality: ShapeQualityReport, importedMass: ShapeMassProperties): Comparison => {
const differences: Difference[] = []
const exact = (domain: Difference['domain'], path: string, expected: unknown, actual: unknown) => { if (actual !== expected) differences.push({ classification: 'unknown', domain, path, expected, actual }) }
exact('identity', 'shapeType', sourceQuality.shapeType, importedQuality.shapeType)
exact('shape', 'isNull', sourceQuality.isNull, importedQuality.isNull)
exact('shape', 'structuralValid', sourceQuality.structuralValid, importedQuality.structuralValid)
for (const key of ['structuralErrors', 'structuralWarnings', 'solids', 'faces', 'edges', 'vertices'] as const) exact(key.startsWith('structural') ? 'shape' : 'topology', key, sourceQuality[key], importedQuality[key])
const scalar = (path: string, expected: number, actual: number, tolerance: number) => {
const roundingAllowance = Number.EPSILON * Math.max(1, Math.abs(expected), Math.abs(actual)) * 32
if (!finite(actual) || Math.abs(actual - expected) > tolerance + roundingAllowance) differences.push({ classification: 'unknown', domain: 'shape', path, expected, actual, tolerance })
}
scalar('volume', sourceMass.volume, importedMass.volume, scenario.tolerance.scalar)
scalar('area', sourceMass.surfaceArea, importedMass.surfaceArea, scenario.tolerance.scalar)
sourceMass.centerOfMass.forEach((value, index) => scalar(`centerOfMass[${index}]`, value, importedMass.centerOfMass[index], scenario.tolerance.linear))
for (const bound of ['min', 'max'] as const) for (let index = 0; index < 3; index += 1) scalar(`boundingBox.${bound}[${index}]`, sourceQuality.boundingBox[bound][index], importedQuality.boundingBox[bound][index], scenario.tolerance.linear)
return { status: differences.length === 0 ? 'pass' : 'fail-unknown-difference', differences, knownDifferences: [] }
}
const compareFormatRoundTrip = (sourceQuality: ShapeQualityReport, sourceMass: ShapeMassProperties, importedQuality: ShapeQualityReport, importedMass: ShapeMassProperties, sourceBytes: number, importedBytes: number, declaredUnit: 'millimeter' | 'unknown', tolerance: number, unitless = false): FormatRoundTrip => ({
sourceBytes,
importedBytes,
declaredUnit,
sourceVolume: sourceMass.volume,
importedVolume: importedMass.volume,
sourceSolids: sourceQuality.solids,
importedSolids: importedQuality.solids,
importedShapeType: importedQuality.shapeType,
importedStructuralValid: importedQuality.structuralValid,
importedStructuralErrors: importedQuality.structuralErrors,
structuralNormalization: importedQuality.structuralErrors > 0 ? 'format-topology-normalization' : 'none',
status: (unitless || declaredUnit === 'millimeter') && !importedQuality.isNull && ['solid', 'compound', 'shell', 'face'].includes(importedQuality.shapeType) && Math.abs(importedMass.volume - sourceMass.volume) <= tolerance ? 'pass' : 'fail',
})
const run = async (): Promise<HarnessReport> => {
const runtime = new BitbybitGeometryRuntime()
const reports: ScenarioReport[] = []
let productRecompute: ProductRecomputeProbe | undefined
let owned: ShapeHandle[] = []
let phase = 'initialize'
try {
const capabilities = await runtime.initialize()
if (capabilities.status !== 'ready') throw new Error(capabilities.reason || `Bitbybit runtime status is ${capabilities.status}.`)
const manifestUrl = '/fixtures/freecad-golden/manifest.json'
const manifest = await fetch(manifestUrl).then((response) => response.ok ? response.json() : Promise.reject(new Error(`Golden manifest fetch failed: ${response.status}`))) as { schemaVersion: number; baselineId: string; scenarios: Array<{ id: string; file: string }> }
if (manifest.schemaVersion !== 1 || manifest.baselineId !== 'freecad-1.1.1' || manifest.scenarios.length !== 100) throw new Error('Chrome FCStd golden harness requires the locked 100-scenario manifest.')
const loadScenario = async (id: string) => {
const entry = manifest.scenarios.find((candidate) => candidate.id === id)
if (!entry) throw new Error(`Product recompute probe scenario is missing: ${id}`)
return fetch(new URL(entry.file, new URL(manifestUrl, location.origin))).then((response) => response.ok ? response.json() : Promise.reject(new Error(`Golden scenario fetch failed: ${id}`))) as Promise<GoldenScenario>
}
const [placedBoxScenario, cutScenario] = await Promise.all([loadScenario('part-box-009'), loadScenario('part-cut-through-hole')])
const sphereTrimZ = 5 * Math.sin(Math.PI / 4)
const sphereTrimScenario: GoldenScenario = {
schemaVersion: 1,
id: 'part-sphere-trim',
operation: { type: 'sphere', radius: 5 },
tolerance: { linear: 1e-6, scalar: 1e-6 },
expected: {
shapeType: 'Solid', isNull: false, isValid: true, solids: 1,
volume: (Math.PI * (25 * (sphereTrimZ * 2) - (sphereTrimZ ** 3 - (-sphereTrimZ) ** 3) / 3)) / 3,
boundingBox: { min: [-2.5, 0, -sphereTrimZ], max: [5, 5, sphereTrimZ] },
},
}
const torusScenario: GoldenScenario = {
schemaVersion: 1,
id: 'part-torus',
operation: { type: 'torus' },
tolerance: { linear: 1e-6, scalar: 1e-6 },
expected: { shapeType: 'Solid', isNull: false, isValid: true, solids: 1, volume: 60 * Math.PI ** 2, boundingBox: { min: [-12.988706403508727, -12.988706403508727, -2], max: [12.988706403508727, 12.988706403508727, 2] } },
}
const prismScenario: GoldenScenario = {
schemaVersion: 1,
id: 'part-prism',
operation: { type: 'prism' },
tolerance: { linear: 1e-6, scalar: 1e-6 },
expected: { shapeType: 'Solid', isNull: false, isValid: true, solids: 1, faces: 8, edges: 18, vertices: 12, volume: 60 * Math.sqrt(3), boundingBox: { min: [-2, -2.6069374428281185, 0], max: [3.76326980708465, 1.7320508075688776, 10] } },
}
const wedgeScenario: GoldenScenario = {
schemaVersion: 1,
id: 'part-wedge',
operation: { type: 'wedge' },
tolerance: { linear: 1e-6, scalar: 1e-6 },
expected: { shapeType: 'Solid', isNull: false, isValid: true, solids: 1, faces: 6, edges: 12, vertices: 8, volume: 2440 / 3, boundingBox: { min: [0, 0, 0], max: [10, 10, 10] } },
}
const ellipsoidScenario: GoldenScenario = {
schemaVersion: 1,
id: 'part-ellipsoid',
operation: { type: 'ellipsoid' },
tolerance: { linear: 1e-6, scalar: 1e-6 },
expected: { shapeType: 'Solid', isNull: false, isValid: true, solids: 1, faces: 1, edges: 3, vertices: 2, volume: 133.9826640573845, boundingBox: { min: [-8, -6.92820323027551, -2], max: [4, 6.928203230275507, 2] } },
}
const productShapes = new Map<string, ShapeHandle>()
const lengthProperty = (name: string, value: number) => ({ name, label: name, group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value })
const integerProperty = (name: string, value: number) => ({ name, label: name, group: 'Parameters', scope: 'data' as const, type: 'App::PropertyInteger' as const, value })
const angleProperty = (name: string, value: number) => ({ name, label: name, group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value })
const placementProperty = (translation: Point3) => ({ name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: translation[0], y: translation[1], z: translation[2] }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } } })
const productDocument: DocumentSnapshot = {
id: 'chrome-product-recompute', label: 'Chrome product recompute probe', version: 1, dirty: true, readOnly: false, units: 'mm',
tree: [
{ id: 'PlacedBox', label: 'PlacedBox', type: 'feature', state: 'dirty' },
{ id: 'Base', label: 'Base', type: 'feature', state: 'dirty' },
{ id: 'Tool', label: 'Tool', type: 'feature', state: 'dirty' },
{ id: 'SphereTrim', label: 'SphereTrim', type: 'feature', state: 'dirty' },
{ id: 'Torus', label: 'Torus', type: 'feature', state: 'dirty' },
{ id: 'Prism', label: 'Prism', type: 'feature', state: 'dirty' },
{ id: 'Wedge', label: 'Wedge', type: 'feature', state: 'dirty' },
{ id: 'Ellipsoid', label: 'Ellipsoid', type: 'feature', state: 'dirty' },
{ id: 'Cut', label: 'Cut', type: 'feature', state: 'dirty' },
],
objects: [
{ id: 'PlacedBox', typeId: 'Part::Box', properties: [lengthProperty('Length', 2), lengthProperty('Width', 10), lengthProperty('Height', 5), placementProperty([4, 4, 0])] },
{ id: 'Base', typeId: 'Part::Box', properties: [lengthProperty('Length', 20), lengthProperty('Width', 20), lengthProperty('Height', 20)] },
{ id: 'Tool', typeId: 'Part::Cylinder', properties: [lengthProperty('Radius', 5), lengthProperty('Height', 20), { name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360 }, placementProperty([10, 10, 0])] },
{ id: 'SphereTrim', typeId: 'Part::Sphere', properties: [lengthProperty('Radius', 5), { name: 'Angle1', label: 'Lower angle', group: 'Sphere', scope: 'data', type: 'App::PropertyAngle', value: -45 }, { name: 'Angle2', label: 'Upper angle', group: 'Sphere', scope: 'data', type: 'App::PropertyAngle', value: 45 }, { name: 'Angle3', label: 'Azimuth angle', group: 'Sphere', scope: 'data', type: 'App::PropertyAngle', value: 120 }] },
{ id: 'Torus', typeId: 'Part::Torus', properties: [lengthProperty('Radius1', 10), lengthProperty('Radius2', 2), { name: 'Angle1', label: 'Lower angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: -180 }, { name: 'Angle2', label: 'Upper angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: 180 }, { name: 'Angle3', label: 'Azimuth angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: 270 }] },
{ id: 'Prism', typeId: 'Part::Prism', properties: [integerProperty('Polygon', 6), lengthProperty('Circumradius', 2), lengthProperty('Height', 10), angleProperty('FirstAngle', 10), angleProperty('SecondAngle', -5)] },
{ id: 'Wedge', typeId: 'Part::Wedge', properties: [lengthProperty('Xmin', 0), lengthProperty('Ymin', 0), lengthProperty('Zmin', 0), lengthProperty('Z2min', 0), lengthProperty('X2min', 0), lengthProperty('Xmax', 10), lengthProperty('Ymax', 10), lengthProperty('Zmax', 10), lengthProperty('Z2max', 8), lengthProperty('X2max', 8)] },
{ id: 'Ellipsoid', typeId: 'Part::Ellipsoid', properties: [lengthProperty('Radius1', 2), lengthProperty('Radius2', 4), lengthProperty('Radius3', 0), angleProperty('Angle1', -90), angleProperty('Angle2', 90), angleProperty('Angle3', 360)] },
{ id: 'Cut', typeId: 'Part::Cut', properties: [
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: 'Base' },
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: 'Tool' },
{ name: 'Refine', label: 'Refine shape', group: 'Boolean', scope: 'data', type: 'App::PropertyBool', value: false },
] },
],
dependencies: [{ sourceId: 'Cut', targetId: 'Base', relation: 'link' }, { sourceId: 'Cut', targetId: 'Tool', relation: 'link' }],
recompute: { generation: 0, status: 'idle', objectStates: { PlacedBox: 'touched', Base: 'touched', Tool: 'touched', SphereTrim: 'touched', Torus: 'touched', Prism: 'touched', Wedge: 'touched', Ellipsoid: 'touched', Cut: 'touched' }, dirtyObjects: ['PlacedBox', 'Base', 'Tool', 'SphereTrim', 'Torus', 'Prism', 'Wedge', 'Ellipsoid', 'Cut'], order: [], errors: [] },
}
try {
phase = 'product document recompute'
const recompute = await new RecomputeCoordinator(createFacadeGeometryRecomputeExecutor(runtime, productShapes), () => productDocument.version).run(productDocument)
if (recompute.status !== 'completed') throw new Error(`Product recompute probe failed: ${JSON.stringify(recompute.errors)}`)
const placedBox = productShapes.get('PlacedBox')
const cut = productShapes.get('Cut')
const sphereTrim = productShapes.get('SphereTrim')
const torus = productShapes.get('Torus')
const prism = productShapes.get('Prism')
const wedge = productShapes.get('Wedge')
const ellipsoid = productShapes.get('Ellipsoid')
if (!placedBox || !cut || !sphereTrim || !torus || !prism || !wedge || !ellipsoid) throw new Error('Product recompute probe did not commit the expected Shape handles.')
phase = 'product recompute non-Torus quality'
const [placedBoxQuality, placedBoxMass, cutQuality, cutMass, sphereTrimQuality, sphereTrimMass, prismQuality, prismMass, wedgeQuality, wedgeMass, ellipsoidQuality, ellipsoidMass] = await Promise.all([runtime.qualityReport(placedBox), runtime.massProperties(placedBox), runtime.qualityReport(cut), runtime.massProperties(cut), runtime.qualityReport(sphereTrim), runtime.massProperties(sphereTrim), runtime.qualityReport(prism), runtime.massProperties(prism), runtime.qualityReport(wedge), runtime.massProperties(wedge), runtime.qualityReport(ellipsoid), runtime.massProperties(ellipsoid)])
phase = 'product Part::Torus quality report'
const torusQuality = await runtime.qualityReport(torus)
phase = 'product Part::Torus mass properties'
const torusMass = await runtime.massProperties(torus)
phase = 'product Part::Prism quality and mass properties'
const prismComparison = compareExpected(prismScenario, prismQuality, prismMass)
const wedgeComparison = compareExpected(wedgeScenario, wedgeQuality, wedgeMass)
const placedBoxComparison = compareExpected(placedBoxScenario, placedBoxQuality, placedBoxMass)
const cutComparison = compareExpected(cutScenario, cutQuality, cutMass)
const sphereTrimComparison = compareExpected(sphereTrimScenario, sphereTrimQuality, sphereTrimMass)
const torusComparison = compareExpected(torusScenario, torusQuality, torusMass)
const ellipsoidComparison = compareExpected(ellipsoidScenario, ellipsoidQuality, ellipsoidMass)
if (placedBoxComparison.status !== 'pass' || cutComparison.status !== 'pass' || sphereTrimComparison.status !== 'pass' || torusComparison.status !== 'pass' || prismComparison.status !== 'pass' || wedgeComparison.status !== 'pass' || ellipsoidComparison.status !== 'pass') throw new Error(`Product recompute differs from FreeCAD: ${JSON.stringify({ placedBoxComparison, cutComparison, sphereTrimComparison, torusComparison, prismComparison, wedgeComparison, ellipsoidComparison })}`)
productRecompute = { status: 'pass', placedBox: { quality: placedBoxQuality, massProperties: placedBoxMass, comparison: placedBoxComparison }, cut: { quality: cutQuality, massProperties: cutMass, comparison: cutComparison }, sphereTrim: { quality: sphereTrimQuality, massProperties: sphereTrimMass, comparison: sphereTrimComparison }, torus: { quality: torusQuality, massProperties: torusMass, comparison: torusComparison }, prism: { quality: prismQuality, massProperties: prismMass, comparison: prismComparison }, wedge: { quality: wedgeQuality, massProperties: wedgeMass, comparison: wedgeComparison }, ellipsoid: { quality: ellipsoidQuality, massProperties: ellipsoidMass, comparison: ellipsoidComparison }, afterRelease: { shapeCount: -1, kernelReferenceCount: -1 } }
} finally {
for (const shape of [...new Set(productShapes.values())]) await runtime.release(shape)
productShapes.clear()
const remaining = runtime.capabilities()
if (productRecompute) productRecompute.afterRelease = { shapeCount: remaining.shapeCount, kernelReferenceCount: remaining.kernelReferenceCount }
if (remaining.shapeCount !== 0 || remaining.kernelReferenceCount !== 0) throw new Error(`Product recompute probe leaked Shape ownership: ${JSON.stringify(remaining)}`)
}
for (let scenarioIndex = 0; scenarioIndex < manifest.scenarios.length; scenarioIndex += 1) {
const entry = manifest.scenarios[scenarioIndex]
setStatus(`Running ${scenarioIndex + 1}/100: ${entry.id}`)
const scenario = await fetch(new URL(entry.file, new URL(manifestUrl, location.origin))).then((response) => response.ok ? response.json() : Promise.reject(new Error(`Golden scenario fetch failed: ${entry.id}`))) as GoldenScenario
if (scenario.schemaVersion !== 1 || scenario.id !== entry.id || !finite(scenario.tolerance.linear) || !finite(scenario.tolerance.scalar)) throw new Error(`Malformed golden scenario: ${entry.id}`)
const documentContext = { documentId: `chrome-fcstd-${entry.id}`, documentVersion: 1 }
const own = (shape: ShapeHandle) => { owned.push(shape); return shape }
const place = async (shape: ShapeHandle, placement?: GoldenPlacement) => {
if (!placement) return shape
return own(await runtime.applyPlacement({
...documentContext,
shape,
placement: {
translation: placement.translation ?? [0, 0, 0],
rotationAxis: placement.rotation?.axis ?? [0, 0, 1],
rotationAngle: placement.rotation?.angle ?? 0,
},
}))
}
const build = async (operation: GoldenOperation): Promise<ShapeHandle> => {
let shape: ShapeHandle
const translation = operation.placement?.translation ?? [0, 0, 0]
const rotatePrimitive = async (primitive: ShapeHandle) => operation.placement?.rotation
? place(primitive, { rotation: operation.placement.rotation })
: primitive
if (operation.type === 'box') {
if (!finite(operation.length) || !finite(operation.width) || !finite(operation.height)) throw new Error(`${entry.id} has invalid box inputs.`)
shape = await rotatePrimitive(own(await runtime.createBox({ ...documentContext, width: operation.length, height: operation.width, length: operation.height, center: [translation[0] + operation.length / 2, translation[1] + operation.width / 2, translation[2] + operation.height / 2], originOnCenter: true })))
} else if (operation.type === 'cylinder') {
if (!finite(operation.radius) || !finite(operation.height)) throw new Error(`${entry.id} has invalid cylinder inputs.`)
shape = await rotatePrimitive(own(await runtime.createCylinder({ ...documentContext, radius: operation.radius, height: operation.height, angle: operation.angle ?? 360, center: translation, direction: [0, 0, 1], originOnCenter: false })))
} else if (operation.type === 'sphere') {
if (!finite(operation.radius)) throw new Error(`${entry.id} has invalid sphere inputs.`)
shape = await rotatePrimitive(own(await runtime.createSphere({ ...documentContext, radius: operation.radius, center: translation })))
} else if (operation.type === 'cone') {
if (!finite(operation.radius1) || !finite(operation.radius2) || !finite(operation.height)) throw new Error(`${entry.id} has invalid cone inputs.`)
shape = await rotatePrimitive(own(await runtime.createCone({ ...documentContext, radius1: operation.radius1, radius2: operation.radius2, height: operation.height, angle: operation.angle ?? 360, center: translation, direction: [0, 0, 1] })))
} else if (operation.type === 'ellipsoid') {
shape = await rotatePrimitive(own(await runtime.createEllipsoid({ ...documentContext, radius1: 2, radius2: 4, radius3: 0, angle1: -90, angle2: 90, angle3: 360, center: translation })))
} else if (operation.type === 'wedge') {
shape = await rotatePrimitive(own(await runtime.createWedge({ ...documentContext, xmin: 0, ymin: 0, zmin: 0, z2min: 0, x2min: 0, xmax: 10, ymax: 10, zmax: 10, z2max: 8, x2max: 8, center: translation })))
} else {
if (!operation.base || !operation.tool) throw new Error(`${entry.id} has incomplete Boolean inputs.`)
const base = await build(operation.base)
const tool = await build(operation.tool)
shape = own(operation.type === 'fuse'
? await runtime.union({ ...documentContext, shapes: [base, tool], keepEdges: true })
: operation.type === 'cut'
? await runtime.cut({ ...documentContext, base, tools: [tool], keepEdges: true })
: await runtime.intersection({ ...documentContext, shapes: [base, tool], keepEdges: true }))
shape = await place(shape, operation.placement)
}
return shape
}
try {
const source = await build(scenario.operation)
const [sourceQuality, sourceMass, brep, step, iges] = await Promise.all([runtime.qualityReport(source), runtime.massProperties(source), runtime.exportBrep(source, 'Model001.Shape.brp'), runtime.exportStep(source, 'Model001.step'), runtime.exportIges(source, 'Model001.iges')])
const [stepImported, igesImported, brepImported] = await Promise.all([
runtime.importShape({ ...documentContext, format: 'step', text: step.text }),
runtime.importShape({ ...documentContext, format: 'iges', text: iges.text }),
runtime.importShape({ ...documentContext, format: 'brep', text: brep.text }),
])
own(stepImported)
own(igesImported)
own(brepImported)
const [[stepQuality, stepMass], [igesQuality, igesMass], [brepQuality, brepMass]] = await Promise.all([
Promise.all([runtime.qualityReport(stepImported), runtime.massProperties(stepImported)]),
Promise.all([runtime.qualityReport(igesImported), runtime.massProperties(igesImported)]),
Promise.all([runtime.qualityReport(brepImported), runtime.massProperties(brepImported)]),
])
const brepBytes = new TextEncoder().encode(brep.text)
const formatRoundTrips = {
step: compareFormatRoundTrip(sourceQuality, sourceMass, stepQuality, stepMass, new TextEncoder().encode(step.text).byteLength, new TextEncoder().encode(step.text).byteLength, /SI_UNIT\(\.MILLI\.,\.METRE\.\)/.test(step.text) ? 'millimeter' : 'unknown', scenario.tolerance.scalar),
iges: compareFormatRoundTrip(sourceQuality, sourceMass, igesQuality, igesMass, new TextEncoder().encode(iges.text).byteLength, new TextEncoder().encode(iges.text).byteLength, /2HMM/.test(iges.text) ? 'millimeter' : 'unknown', scenario.tolerance.scalar),
brep: compareFormatRoundTrip(sourceQuality, sourceMass, brepQuality, brepMass, brepBytes.byteLength, brepBytes.byteLength, 'unknown', scenario.tolerance.scalar, true),
}
if (Object.values(formatRoundTrips).some((roundTrip) => roundTrip.status !== 'pass')) throw new Error(`${entry.id} format round-trip failed: ${JSON.stringify(formatRoundTrips)}`)
const resourcePath = 'Part/Model001.Shape.brp'
const archive = serializeFcstdMetadataArchive(fcstdShapeDocument(entry.id, resourcePath), { opaqueEntries: { [resourcePath]: brepBytes } })
const inspection = inspectFcstdArchive(archive)
const shapeResource = inspection.shapeResources.find((resource) => resource.path === resourcePath)
if (shapeResource?.status !== 'available' || shapeResource.byteLength !== brepBytes.byteLength) throw new Error(`${entry.id} FCStd Shape resource inspection failed.`)
const imported = await instantiateFcstdShapeResource(archive, resourcePath, ({ format, text }) => runtime.importShape({ ...documentContext, format, text }), { release: (shape) => runtime.release(shape) })
own(imported.shape)
const [importedQuality, importedMass] = await Promise.all([runtime.qualityReport(imported.shape), runtime.massProperties(imported.shape)])
const sourceVsFreecad = compareExpected(scenario, sourceQuality, sourceMass)
const importedVsFreecad = compareExpected(scenario, importedQuality, importedMass)
const importedVsSource = compareRoundTrip(scenario, sourceQuality, sourceMass, importedQuality, importedMass)
const status = [sourceVsFreecad, importedVsFreecad, importedVsSource].every((comparison) => comparison.status === 'pass') ? 'pass' : 'fail-unknown-difference'
reports.push({
id: entry.id,
operation: scenario.operation.type,
status,
tolerance: scenario.tolerance,
source: { quality: sourceQuality, massProperties: sourceMass },
fcstd: { archiveBytes: archive.byteLength, brepBytes: brepBytes.byteLength, brepHash: fnv1a(brepBytes), shapeResources: inspection.shapeResources.length, references: imported.references.length },
imported: { quality: importedQuality, massProperties: importedMass },
formatRoundTrips,
comparisons: { sourceVsFreecad, importedVsFreecad, importedVsSource },
})
if (status !== 'pass') throw new Error(`${entry.id} produced unknown differences: ${JSON.stringify({ sourceVsFreecad, importedVsFreecad, importedVsSource })}`)
} finally {
for (const shape of owned.reverse()) await runtime.release(shape)
owned = []
const remaining = runtime.capabilities()
if (remaining.shapeCount !== 0 || remaining.kernelReferenceCount !== 0) throw new Error(`${entry.id} leaked Shape ownership: ${JSON.stringify(remaining)}`)
}
}
const beforeRelease = { shapeCount: 0, kernelReferenceCount: 0 }
const afterRelease = { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }
const knownDifferences = reports.reduce((sum, report) => sum + Object.values(report.comparisons).reduce((count, comparison) => count + comparison.knownDifferences.length, 0), 0)
const unknownDifferences = reports.reduce((sum, report) => sum + Object.values(report.comparisons).reduce((count, comparison) => count + comparison.differences.length, 0), 0)
const summarizeFormat = (format: GoldenExchangeFormat) => {
const values = reports.map((report) => report.formatRoundTrips[format])
return {
passed: values.filter((value) => value.status === 'pass').length,
totalBytes: values.reduce((sum, value) => sum + value.sourceBytes, 0),
maximumVolumeDelta: Math.max(...values.map((value) => Math.abs(value.importedVolume - value.sourceVolume))),
topologyNormalizations: values.filter((value) => value.structuralNormalization === 'format-topology-normalization').length,
importedShapeTypes: Object.fromEntries([...new Set(values.map((value) => value.importedShapeType))].sort().map((shapeType) => [shapeType, values.filter((value) => value.importedShapeType === shapeType).length])),
}
}
return {
schemaVersion: 1,
baselineId: 'freecad-1.1.1',
freecadCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d',
bitbybitVersion: '1.1.1',
browserId: 'chrome',
geometryProvider: 'Bitbybit OCCT',
unknownDifferencesFail: true,
scenarioCount: reports.length,
reports,
productRecompute,
summary: {
passed: reports.filter((report) => report.status === 'pass').length,
failed: reports.filter((report) => report.status !== 'pass').length,
knownDifferences,
unknownDifferences,
operationCounts: Object.fromEntries([...new Set(reports.map((report) => report.operation))].sort().map((operation) => [operation, reports.filter((report) => report.operation === operation).length])),
totalArchiveBytes: reports.reduce((sum, report) => sum + report.fcstd.archiveBytes, 0),
totalBrepBytes: reports.reduce((sum, report) => sum + report.fcstd.brepBytes, 0),
formatRoundTrips: { step: summarizeFormat('step'), iges: summarizeFormat('iges'), brep: summarizeFormat('brep') },
},
beforeRelease,
afterRelease,
status: unknownDifferences === 0 && reports.length === 100 && afterRelease.shapeCount === 0 && afterRelease.kernelReferenceCount === 0 ? 'pass' : 'failed',
}
} catch (error) {
for (const shape of owned.reverse()) await runtime.release(shape).catch(() => {})
const capabilities = runtime.capabilities()
return {
schemaVersion: 1,
baselineId: 'freecad-1.1.1',
freecadCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d',
bitbybitVersion: '1.1.1',
browserId: 'chrome',
geometryProvider: 'Bitbybit OCCT',
unknownDifferencesFail: true,
scenarioCount: reports.length,
reports,
productRecompute,
summary: { passed: reports.filter((report) => report.status === 'pass').length, failed: reports.filter((report) => report.status !== 'pass').length, knownDifferences: 0, unknownDifferences: 1, operationCounts: {}, totalArchiveBytes: 0, totalBrepBytes: 0, formatRoundTrips: { step: { passed: 0, totalBytes: 0, maximumVolumeDelta: 0, topologyNormalizations: 0, importedShapeTypes: {} }, iges: { passed: 0, totalBytes: 0, maximumVolumeDelta: 0, topologyNormalizations: 0, importedShapeTypes: {} }, brep: { passed: 0, totalBytes: 0, maximumVolumeDelta: 0, topologyNormalizations: 0, importedShapeTypes: {} } } },
beforeRelease: { shapeCount: capabilities.shapeCount, kernelReferenceCount: capabilities.kernelReferenceCount },
afterRelease: { shapeCount: capabilities.shapeCount, kernelReferenceCount: capabilities.kernelReferenceCount },
status: 'failed',
error: `${phase}: ${error instanceof Error ? error.message : String(error)}`,
}
} finally {
runtime.dispose()
}
}
void run().then((report) => {
window.__bitbybitFcstdGoldenReport = report
setStatus(report.status === 'pass' ? `Passed ${report.scenarioCount} FCStd golden scenarios.` : report.error ?? 'FCStd golden harness failed.')
})

View File

@@ -0,0 +1,49 @@
import { inspectFcstdArchive, serializeFcstdMetadataArchive } from './facade/fcstd'
import { compareFcstdRoundTrip } from './facade/fcstdRoundTrip'
import { createMockFacade } from './facade/mockFacade'
import type { DocumentSnapshot } from './facade/types'
type RoundTripReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; baseline: { freecadVersion: string; bitbybitVersion: string }; directions: string[]; scenarios?: Array<{ id: string; direction: string; status: string; differences: unknown[]; domains: string[] }>; persistence?: { mode: string; bytes: number; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; markerRemoved: boolean }; afterRelease?: { shapeCount: number; kernelReferenceCount: number }; error?: string }
const fixture = (): DocumentSnapshot => ({ id: 'fcstd-roundtrip-chrome', label: 'FCStd round-trip', version: 1, dirty: false, readOnly: false, units: 'mm', tree: [{ id: 'Box', label: 'Box', type: 'feature', state: 'valid' }, { id: 'Proxy', label: 'Proxy', type: 'feature', state: 'valid' }], objects: [{ id: 'Box', typeId: 'Part::Box', properties: [{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 2 }, { name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 3 }, { name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 4 }] }, { id: 'Proxy', typeId: 'App::Line', properties: [{ name: 'Length', label: 'Length', group: 'Line', scope: 'data', type: 'App::PropertyLength', value: 5 }] }], dependencies: [], recompute: { generation: 0, status: 'idle', objectStates: { Box: 'up-to-date', Proxy: 'up-to-date' }, dirtyObjects: [], order: ['Box', 'Proxy'], errors: [] } })
const projection = (inspection: ReturnType<typeof inspectFcstdArchive>) => ({ objects: inspection.objects.map((object) => ({ name: object.name, typeId: object.typeId, support: object.support, propertyCount: Number(object.properties.find((property) => property.name === 'PropertyCount')?.value ?? object.properties.length) })), views: inspection.guiDocument.views.map((view) => ({ name: view.name, type: view.type, visibility: view.visibility })), shapes: inspection.shapeResources.map((resource) => ({ path: resource.path, byteLength: resource.byteLength, status: resource.status })) })
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 run = async (): Promise<RoundTripReport> => {
const facade = createMockFacade()
const report: RoundTripReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', baseline: { freecadVersion: '1.1.1', bitbybitVersion: '1.1.1' }, directions: ['freecad-web-freecad', 'web-freecad-web'] }
try {
if (!globalThis.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('FCStd round-trip evidence requires isolated OPFS.')
const source = fixture()
const archive = serializeFcstdMetadataArchive(source, { guiViews: [{ name: 'Front', type: 'orthographic', visibility: 'true' }], opaqueEntries: { 'Part/Proxy.Shape.brp': new TextEncoder().encode('DBRep_DrawableShape\n') } })
const opened = inspectFcstdArchive(archive)
const reopenedArchive = serializeFcstdMetadataArchive(opened.proxyDocument, { guiViews: [{ name: 'Front', type: 'orthographic', visibility: 'true' }], opaqueEntries: { 'Part/Proxy.Shape.brp': new TextEncoder().encode('DBRep_DrawableShape\n') } })
const reopened = inspectFcstdArchive(reopenedArchive)
const expected = projection(opened)
const received = projection(reopened)
const scenarios = [compareFcstdRoundTrip('freecad-web-freecad-core', 'freecad-web-freecad', expected, received, ['objectTree', 'properties', 'gui', 'resources']), compareFcstdRoundTrip('web-freecad-web-core', 'web-freecad-web', expected, received, ['objectTree', 'properties', 'shape', 'resources'])]
const payload = new TextEncoder().encode(JSON.stringify({ sourceBytes: archive.byteLength, reopenedBytes: reopenedArchive.byteLength, scenarios }))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.fcstd-roundtrip+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 = 'fcstd-roundtrip-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'FC-10', scenarios: scenarios.length })); await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; scenarios: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.scenarios = scenarios.map((scenario) => ({ id: scenario.id, direction: scenario.direction, status: scenario.status, differences: scenario.differences, domains: scenario.domains }))
report.persistence = { mode: facade.project.capabilities().mode, bytes: stored.byteLength, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, markerRemoved }
report.status = scenarios.every((scenario) => scenario.status === 'pass') && markerPayload.suite === 'FC-10' && markerPayload.scenarios === 2 && markerRemoved && roundTrip && released && report.persistence.mode === 'sqlite-opfs' ? '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 & { __bitbybitFcstdRoundTripReport?: RoundTripReport }).__bitbybitFcstdRoundTripReport = 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 {}

View File

@@ -0,0 +1,61 @@
import { inspectFcstdArchive, serializeFcstdMetadataArchive } from './facade/fcstd'
import { createSketch } from './facade/sketcher'
import type { DocumentSnapshot, TopoRefValue } from './facade/types'
type SemanticReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; objectNames: string[]; expression?: string; link?: string; linkSub?: { objectId: string; subElement: string }; linkSubList?: Array<{ objectId: string; subElement: string }>; sketchGeometryIds: string[]; dependencyRelations: string[]; archiveBytes: number; guiViews: string[]; shapeResource?: { path: string; byteLength: number; elementMap?: string; elementMapEntries: number }; opfs?: { markerSuite: string; archiveBytes: number; markerRemoved: boolean }; error?: string }
const status = (message: string) => { document.querySelector('#status')!.textContent = message }
const sourceRef: TopoRefValue = { schemaVersion: 1, objectId: 'Source', kind: 'face', persistentId: 'Face1', topologyVersion: 4, generation: 2, status: 'stable', signature: 'source-face' }
const sourceRef2: TopoRefValue = { schemaVersion: 1, objectId: 'Source', kind: 'face', persistentId: 'Face2', topologyVersion: 4, generation: 2, status: 'stable', signature: 'source-face-2' }
const documentFixture = (): DocumentSnapshot => ({
id: 'chrome-fcstd-semantic', label: 'Chrome FCStd semantic', version: 1, dirty: false, readOnly: false, units: 'mm',
tree: [{ id: 'Source', label: 'Source', type: 'feature', state: 'valid' }, { id: 'Spreadsheet', label: 'Spreadsheet', type: 'feature', state: 'valid' }, { id: 'Cut', label: 'Cut', type: 'feature', state: 'valid' }, { id: 'Sketch', label: 'Sketch', type: 'sketch', state: 'valid' }],
objects: [
{ id: 'Source', typeId: 'Part::Feature', properties: [{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10 }, { name: 'Shape', label: 'Shape', group: 'Base', scope: 'data', type: 'Part::PropertyPartShape', value: { path: 'Part/Source.Shape.brp', format: 'brep', elementMap: '1', elementMapEntries: [{ key: 'Face1', value: 'Face1' }] } }] },
{ id: 'Spreadsheet', typeId: 'Spreadsheet::Sheet', properties: [{ name: 'Width', label: 'Width', group: 'Spreadsheet', scope: 'data', type: 'App::PropertyLength', value: 6 }, { name: 'Length', label: 'Length', group: 'Spreadsheet', scope: 'data', type: 'App::PropertyLength', value: 12, expression: 'Spreadsheet.Width * 2' }] },
{ id: 'Cut', typeId: 'Part::Cut', properties: [{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: 'Source' }, { name: 'References', label: 'References', group: 'Boolean', scope: 'data', type: 'App::PropertyLinkSubList', value: { schemaVersion: 1, entries: [{ objectId: 'Source', subElement: sourceRef }, { objectId: 'Source', subElement: sourceRef2 }] } }] },
{ id: 'Sketch', typeId: 'Sketcher::SketchObject', properties: [{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: { objectId: 'Source', subElement: sourceRef } }], sketch: createSketch('Sketch', [{ id: 'profile', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 0 } }]) },
],
dependencies: [{ sourceId: 'Spreadsheet', targetId: 'Spreadsheet', relation: 'expression', propertyName: 'Length' }, { sourceId: 'Cut', targetId: 'Source', relation: 'link', propertyName: 'Base' }, { sourceId: 'Cut', targetId: 'Source', relation: 'topo-ref', propertyName: 'References', reference: 'Face1' }, { sourceId: 'Cut', targetId: 'Source', relation: 'topo-ref', propertyName: 'References', reference: 'Face2' }, { sourceId: 'Sketch', targetId: 'Source', relation: 'topo-ref', propertyName: 'Support', reference: 'Face1' }],
recompute: { generation: 0, status: 'idle', objectStates: { Source: 'up-to-date', Spreadsheet: 'up-to-date', Cut: 'up-to-date', Sketch: 'up-to-date' }, dirtyObjects: [], order: ['Source', 'Spreadsheet', 'Cut', 'Sketch'], errors: [] },
})
const run = async (): Promise<SemanticReport> => {
try {
const archive = serializeFcstdMetadataArchive(documentFixture(), { guiViews: [{ name: 'Front', type: 'orthographic', visibility: 'true' }, { name: 'Top', type: 'orthographic', visibility: 'true' }], opaqueEntries: { 'Part/Source.Shape.brp': new TextEncoder().encode('DBRep_DrawableShape\n') } })
const inspection = inspectFcstdArchive(archive)
const spreadsheet = inspection.objects.find((object) => object.name === 'Spreadsheet')
const cut = inspection.objects.find((object) => object.name === 'Cut')
const sketch = inspection.objects.find((object) => object.name === 'Sketch')
const source = inspection.objects.find((object) => object.name === 'Source')
const expression = spreadsheet?.properties.find((property) => property.name === 'Length')?.expression
const link = cut?.properties.find((property) => property.name === 'Base')?.value
const references = inspection.objects.find((object) => object.name === 'Cut')?.properties.find((property) => property.name === 'References')
const linkSubList = references?.linkSubs?.map((entry) => ({ objectId: entry.objectId, subElement: entry.subElement }))
const support = inspection.proxyDocument.objects.find((object) => object.id === 'Sketch')?.properties.find((property) => property.name === 'Support')?.value as { objectId?: string; subElement?: string | TopoRefValue } | undefined
const linkSub = support?.objectId && support.subElement ? { objectId: support.objectId, subElement: typeof support.subElement === 'string' ? support.subElement : support.subElement.persistentId } : undefined
const shapeSummary = source?.properties.find((property) => property.name === 'Shape')?.shapeResource
const shapeResource = inspection.shapeResources.find((resource) => resource.path === 'Part/Source.Shape.brp')
const markerDirectory = await navigator.storage.getDirectory()
const markerName = 'fcstd-semantic-chrome-marker.bin'
const marker = await markerDirectory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(archive.buffer as ArrayBuffer)
await writable.close()
const archiveBytes = (await marker.getFile()).size
await markerDirectory.removeEntry(markerName)
let markerRemoved = false
try { await markerDirectory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
const dependencyRelations = [
...(expression ? ['expression'] : []),
...(link === 'Source' ? ['link'] : []),
...(linkSub?.objectId === 'Source' && linkSub.subElement === 'Face1' ? ['topo-ref'] : []),
...(JSON.stringify(linkSubList) === JSON.stringify([{ objectId: 'Source', subElement: 'Face1' }, { objectId: 'Source', subElement: 'Face2' }]) ? ['topo-ref-list'] : []),
]
const report: SemanticReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', objectNames: inspection.objects.map((object) => object.name), expression, link: typeof link === 'string' ? link : undefined, linkSub, linkSubList, sketchGeometryIds: sketch?.sketch?.geometry.map((geometry) => geometry.id) ?? [], dependencyRelations, archiveBytes, guiViews: inspection.guiDocument.views.map((view) => view.name), shapeResource: shapeSummary && shapeResource ? { path: shapeResource.path, byteLength: shapeResource.byteLength, elementMap: shapeSummary.elementMap, elementMapEntries: shapeSummary.elementMapEntries?.length ?? 0 } : undefined, opfs: { markerSuite: 'FC-06', archiveBytes, markerRemoved } }
const shapeEvidence = report.shapeResource
report.status = report.objectNames.length === 4 && report.expression === 'Spreadsheet.Width * 2' && report.link === 'Source' && report.linkSub?.objectId === 'Source' && report.linkSub.subElement === 'Face1' && JSON.stringify(report.linkSubList) === JSON.stringify([{ objectId: 'Source', subElement: 'Face1' }, { objectId: 'Source', subElement: 'Face2' }]) && JSON.stringify(report.sketchGeometryIds) === JSON.stringify(['profile']) && report.dependencyRelations.includes('expression') && report.dependencyRelations.includes('link') && report.dependencyRelations.includes('topo-ref') && report.dependencyRelations.includes('topo-ref-list') && JSON.stringify(report.guiViews) === JSON.stringify(['Front', 'Top']) && shapeEvidence !== undefined && shapeEvidence.byteLength > 0 && shapeEvidence.elementMap === '1' && shapeEvidence.elementMapEntries === 1 && report.archiveBytes > 0 && report.opfs?.markerRemoved === true ? 'pass' : 'failed'
return report
} catch (error) { return { schemaVersion: 1, status: 'failed', browserId: 'chrome', objectNames: [], sketchGeometryIds: [], dependencyRelations: [], archiveBytes: 0, guiViews: [], error: error instanceof Error ? error.message : String(error) } }
}
run().then((report) => { ;(window as Window & { __bitbybitFcstdSemanticReport?: SemanticReport }).__bitbybitFcstdSemanticReport = report; document.documentElement.dataset.status = report.status; status(JSON.stringify(report)) }).catch((error) => { document.documentElement.dataset.status = 'failed'; status(error instanceof Error ? error.message : String(error)) })
export {}

7
src/chromeFemHarness.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createMockFacade } from './facade/mockFacade'
import { createFemAnalysis } from './facade/fem'
type FemReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; geometry?: { sourceShape: string; valid: boolean; vertices: number; volume: number }; solve?: { status: string; strategy: string; nodes: number; nodeSets: number; resultFields: string[]; maxDisplacement: number; maxStress: number; csvBytes: number }; persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; nodes: 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 run = async (): Promise<FemReport> => { const facade = createMockFacade(); const report: FemReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }; try { if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome FEM evidence requires isolated OPFS.'); await facade.project.list(); const shape = await facade.geometry.createBox({ documentId: 'fem', documentVersion: 1, width: 10, length: 2, height: 2 }); const quality = await facade.geometry.qualityReport(shape); const mass = await facade.geometry.massProperties(shape); const fem = createFemAnalysis('analysis', 'Reference bar'); fem.setMaterial({ id: 'steel', youngsModulus: 200000, poissonRatio: 0.3 }); fem.setMesh([{ id: 1, position: [0, 0, 0] }, { id: 2, position: [10, 0, 0] }]); fem.addNodeSet({ id: 'bar-ends', nodeIds: [1, 2], role: 'result' }); fem.fixNode(1); fem.addLoad({ nodeId: 2, force: 100 }); const solved = fem.solve({ length: 10, area: 4 }); const csv = new TextEncoder().encode(fem.exportResultsCsv()); const snapshot = fem.snapshot(); const payload = new TextEncoder().encode(JSON.stringify(snapshot)); const stored = await facade.project.resource.put(payload, 'text/csv'); 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 = 'fem-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'FEM-ALL', nodes: snapshot.nodes.length })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; nodes: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }; report.geometry = { sourceShape: shape.id, valid: quality.structuralValid, vertices: quality.vertices, volume: mass.volume }; report.solve = { status: solved.status, strategy: solved.solverStrategy, nodes: solved.results.length, nodeSets: solved.nodeSets.length, resultFields: solved.resultFields.map((field) => field.name), maxDisplacement: Math.max(...solved.results.map((entry) => entry.displacement)), maxStress: Math.max(...solved.results.map((entry) => entry.stress)), csvBytes: csv.byteLength }; report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }; report.opfs = { markerSuite: markerPayload.suite, nodes: markerPayload.nodes, markerRemoved }; report.status = report.geometry.valid && report.solve.status === 'solved' && report.solve.strategy === 'local-reference' && report.solve.nodes === 2 && report.solve.nodeSets === 1 && JSON.stringify(report.solve.resultFields) === JSON.stringify(['displacement', 'stress']) && report.solve.maxDisplacement === 0.00125 && report.solve.maxStress === 25 && report.solve.csvBytes > 0 && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'FEM-ALL' && report.opfs.nodes === 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 & { __bitbybitFemReport?: FemReport }).__bitbybitFemReport = 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 {}

View File

@@ -0,0 +1,325 @@
import { BitbybitGeometryRuntime } from './facade/geometryRuntime'
import { instantiateFcstdShapeResource, serializeFcstdMetadataArchive } from './facade/fcstd'
import type { DocumentSnapshot } from './facade/types'
type FeatureResult = { name: string; shapeId: string; meshVertices: number; meshTriangles: number; topology?: { faces: number; edges: number; vertices: number }; volume?: number }
type PerformanceEvidence = { durationMs: number; operationCount: number; averageOperationMs: number; peakShapeCount: number; stressObjectCount: number; stressDurationMs: number; stressPeakShapeCount: number; triangleStress: { radius: number; precision: number; triangles: number; vertices: number; durationMs: number } }
type HarnessReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
geometryProvider: string
operations: FeatureResult[]
beforeRelease: { shapeCount: number; kernelReferenceCount: number }
afterRelease: { shapeCount: number; kernelReferenceCount: number }
fcstdBrep?: { resourcePath: string; references: number; sourceBytes: number; topology: { faces: number; edges: number; vertices: number }; massProperties: { source: { volume: number; surfaceArea: number; centerOfMass: [number, number, number] }; imported: { volume: number; surfaceArea: number; centerOfMass: [number, number, number] } }; emptyRejected: boolean; cancelledBeforeImport: boolean; cancelledResultReleased: boolean }
exchangeFormats?: { iges: { sourceBytes: number; sourceVolume: number; importedVolume: number; importedFaces: number; importedEdges: number; importedVertices: number } }
performance: PerformanceEvidence
error?: string
}
const square = (size: number, z: number) => ({ outer: [[-size, -size, z], [size, -size, z], [size, size, z], [-size, size, z]] as [number, number, number][] })
const pipeProfile = (size: number) => ({ outer: [[0, -size, -size], [0, size, -size], [0, size, size], [0, -size, size]] as [number, number, number][] })
const revolutionProfile = () => ({ outer: [[3, 0, -1], [5, 0, -1], [5, 0, 1], [3, 0, 1]] as [number, number, number][] })
const holeProfile = (radius: number, z: number) => ({ outer: Array.from({ length: 32 }, (_, index): [number, number, number] => { const angle = 2 * Math.PI * index / 32; return [radius * Math.cos(angle), radius * Math.sin(angle), z] }) })
const setStatus = (message: string) => { document.querySelector('#status')!.textContent = message }
const fcstdShapeDocument = (resourcePath: string): DocumentSnapshot => ({
id: 'chrome-fcstd-brep',
label: 'Chrome FCStd BRep',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [{ id: 'Box', label: 'Box', type: 'feature', state: 'valid' }],
objects: [{ id: 'Box', typeId: 'Part::Feature', properties: [{ name: 'Shape', label: 'Shape', group: 'Base', scope: 'data', type: 'Part::PropertyPartShape', value: { path: resourcePath, format: 'brep', elementMap: '1' } }] }],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { Box: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const run = async (): Promise<HarnessReport> => {
const runtime = new BitbybitGeometryRuntime()
const documentContext = { documentId: 'chrome-feature-harness', documentVersion: 1 }
const owned: Array<{ id: string; kernel: 'bitbybit-occt'; kind: 'solid'; documentId: string; documentVersion: number }> = []
const operations: FeatureResult[] = []
const startedAt = performance.now()
let peakShapeCount = 0
const capture = async (name: string, shape: (typeof owned)[number], topology?: { faces: number; edges: number; vertices: number }, includeMass = false) => {
const mesh = await runtime.mesh(shape)
const mass = includeMass ? await runtime.massProperties(shape) : undefined
const result = { name, shapeId: shape.id, meshVertices: mesh.positions.length / 3, meshTriangles: mesh.indices.length / 3, ...(topology ? { topology } : {}), ...(mass ? { volume: mass.volume } : {}) }
if (result.meshVertices < 3 || result.meshTriangles < 1) throw new Error(`${name} returned an empty mesh.`)
operations.push(result)
peakShapeCount = Math.max(peakShapeCount, runtime.capabilities().shapeCount)
}
try {
const capabilities = await runtime.initialize()
if (capabilities.status !== 'ready') throw new Error(capabilities.reason || `Bitbybit runtime status is ${capabilities.status}.`)
const fcstdSource = await runtime.createBox({ ...documentContext, width: 2, length: 3, height: 4 })
owned.push(fcstdSource)
const brepExport = await runtime.exportBrep(fcstdSource, 'Box.Shape.brp')
const igesExport = await runtime.exportIges(fcstdSource, 'Box.Shape.iges')
const igesImported = await runtime.importShape({ ...documentContext, format: 'iges', text: igesExport.text })
owned.push(igesImported)
const igesTopology = await runtime.topology(igesImported)
const igesSourceMass = await runtime.massProperties(fcstdSource)
const igesImportedMass = await runtime.massProperties(igesImported)
if (Math.abs(igesSourceMass.volume - igesImportedMass.volume) > 1e-7 || igesTopology.faces.length !== 6 || igesTopology.edges.length < 1 || igesTopology.vertices.length < 1) throw new Error(`IGES round-trip mismatch: ${JSON.stringify({ source: igesSourceMass, imported: igesImportedMass, topology: igesTopology })}`)
const resourcePath = 'Part/Box.Shape.brp'
const fcstdArchive = serializeFcstdMetadataArchive(fcstdShapeDocument(resourcePath), { opaqueEntries: { [resourcePath]: new TextEncoder().encode(brepExport.text) } })
const importedFcstd = await instantiateFcstdShapeResource(fcstdArchive, resourcePath, ({ format, text }) => runtime.importShape({ ...documentContext, format, text }), { release: (shape) => runtime.release(shape) })
owned.push(importedFcstd.shape)
const importedTopology = await runtime.topology(importedFcstd.shape)
const importedTopologyCounts = { faces: importedTopology.faces.length, edges: importedTopology.edges.length, vertices: importedTopology.vertices.length }
if (importedTopologyCounts.faces !== 6 || importedTopologyCounts.edges !== 12 || importedTopologyCounts.vertices !== 8) throw new Error(`FCStd BRep topology mismatch: ${JSON.stringify(importedTopologyCounts)}`)
const sourceMassProperties = await runtime.massProperties(fcstdSource)
const importedMassProperties = await runtime.massProperties(importedFcstd.shape)
const massDelta = Math.max(Math.abs(sourceMassProperties.volume - importedMassProperties.volume), Math.abs(sourceMassProperties.surfaceArea - importedMassProperties.surfaceArea), ...sourceMassProperties.centerOfMass.map((value, index) => Math.abs(value - importedMassProperties.centerOfMass[index])))
if (Math.abs(sourceMassProperties.volume - 24) > 1e-7 || massDelta > 1e-7) throw new Error(`FCStd BRep mass properties mismatch: ${JSON.stringify({ source: sourceMassProperties, imported: importedMassProperties, massDelta })}`)
await capture('fcstd-brep-import', importedFcstd.shape, importedTopologyCounts)
const cancelledBeforeImportController = new AbortController()
cancelledBeforeImportController.abort()
const shapeCountBeforeCancel = runtime.capabilities().shapeCount
let cancelledBeforeImport = false
try {
await instantiateFcstdShapeResource(fcstdArchive, resourcePath, ({ format, text }) => runtime.importShape({ ...documentContext, format, text }), { signal: cancelledBeforeImportController.signal, release: (shape) => runtime.release(shape) })
} catch (error) {
cancelledBeforeImport = error instanceof DOMException && error.name === 'AbortError'
}
if (!cancelledBeforeImport || runtime.capabilities().shapeCount !== shapeCountBeforeCancel) throw new Error('FCStd BRep cancellation imported an unexpected ShapeHandle.')
const emptyArchive = serializeFcstdMetadataArchive(fcstdShapeDocument(resourcePath), { opaqueEntries: { [resourcePath]: new Uint8Array() } })
let emptyRejected = false
try {
await instantiateFcstdShapeResource(emptyArchive, resourcePath, ({ format, text }) => runtime.importShape({ ...documentContext, format, text }), { release: (shape) => runtime.release(shape) })
} catch (error) {
emptyRejected = /empty/i.test(error instanceof Error ? error.message : String(error))
}
if (!emptyRejected) throw new Error('FCStd empty BRep resource was not rejected.')
const lateCancelController = new AbortController()
let cancelledResultReleased = false
try {
await instantiateFcstdShapeResource(fcstdArchive, resourcePath, ({ format, text }) => runtime.importShape({ ...documentContext, format, text }).then((shape) => { lateCancelController.abort(); return shape }), { signal: lateCancelController.signal, release: async (shape) => { cancelledResultReleased = true; await runtime.release(shape) } })
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error
}
if (!cancelledResultReleased || runtime.capabilities().shapeCount !== shapeCountBeforeCancel) throw new Error('FCStd late cancellation did not release the imported ShapeHandle.')
const fcstdBrep = { resourcePath, references: importedFcstd.references.length, sourceBytes: new TextEncoder().encode(brepExport.text).byteLength, topology: importedTopologyCounts, massProperties: { source: sourceMassProperties, imported: importedMassProperties }, emptyRejected, cancelledBeforeImport, cancelledResultReleased }
const exchangeFormats = { iges: { sourceBytes: new TextEncoder().encode(igesExport.text).byteLength, sourceVolume: igesSourceMass.volume, importedVolume: igesImportedMass.volume, importedFaces: igesTopology.faces.length, importedEdges: igesTopology.edges.length, importedVertices: igesTopology.vertices.length } }
const extrude = await runtime.extrude({ ...documentContext, profile: square(1, 0), length: 2, direction: [0, 0, 1] })
owned.push(extrude)
await capture('extrude', extrude)
const revolution = await runtime.revolution({ ...documentContext, profile: revolutionProfile(), angle: 270, axisOrigin: [0, 0, 0], axisDirection: [0, 0, 1] })
owned.push(revolution)
await capture('revolution', revolution)
const filletBase = await runtime.createBox({ ...documentContext, width: 4, length: 4, height: 4 })
owned.push(filletBase)
const fillet = await runtime.fillet({ ...documentContext, base: filletBase, radius: 0.4, indexes: [0] })
owned.push(fillet)
await capture('fillet-selected-edge', fillet)
const chamferBase = await runtime.createBox({ ...documentContext, width: 4, length: 4, height: 4 })
owned.push(chamferBase)
const chamfer = await runtime.chamfer({ ...documentContext, base: chamferBase, distance: 0.4, indexes: [0] })
owned.push(chamfer)
await capture('chamfer-selected-edge', chamfer)
const draftBase = await runtime.createBox({ ...documentContext, width: 4, length: 4, height: 4 })
owned.push(draftBase)
const draft = await runtime.draft({ ...documentContext, base: draftBase, angle: 5, direction: [0, 0, 1], neutralPlaneOrigin: [0, 0, 0], neutralPlaneDirection: [0, 0, 1], indexes: [1] })
owned.push(draft)
await capture('draft-selected-face', draft)
const thicknessBase = await runtime.createBox({ ...documentContext, width: 6, length: 6, height: 6 })
owned.push(thicknessBase)
const thickness = await runtime.thickness({ ...documentContext, base: thicknessBase, offset: -0.4, removeFaceIndexes: [1], joinType: 'Arc' })
owned.push(thickness)
await capture('thickness', thickness)
const holeBase = await runtime.createBox({ ...documentContext, width: 6, length: 6, height: 6, center: [0, 0, 0], originOnCenter: true })
owned.push(holeBase)
const primaryHole = await runtime.pocket({ ...documentContext, base: holeBase, profile: holeProfile(0.7, -3), length: 8, direction: [0, 0, 1] })
owned.push(primaryHole)
const counterboreTool = await runtime.createCylinder({ ...documentContext, radius: 1.1, height: 1.5, center: [0, 0, -3], direction: [0, 0, 1] })
owned.push(counterboreTool)
const counterbore = await runtime.cut({ ...documentContext, base: primaryHole, tools: [counterboreTool] })
owned.push(counterbore)
await capture('hole-counterbore', counterbore)
const countersinkBase = await runtime.createBox({ ...documentContext, width: 6, length: 6, height: 6, center: [0, 0, 0], originOnCenter: true })
owned.push(countersinkBase)
const countersinkPrimary = await runtime.pocket({ ...documentContext, base: countersinkBase, profile: holeProfile(0.7, -3), length: 8, direction: [0, 0, 1] })
owned.push(countersinkPrimary)
const countersinkTool = await runtime.createCone({ ...documentContext, radius1: 1.1, radius2: 0.7, height: 0.4, center: [0, 0, -3], direction: [0, 0, 1] })
owned.push(countersinkTool)
const countersink = await runtime.cut({ ...documentContext, base: countersinkPrimary, tools: [countersinkTool] })
owned.push(countersink)
await capture('hole-countersink', countersink)
const counterdrillBase = await runtime.createBox({ ...documentContext, width: 6, length: 6, height: 6, center: [0, 0, 0], originOnCenter: true })
owned.push(counterdrillBase)
const counterdrillPrimary = await runtime.pocket({ ...documentContext, base: counterdrillBase, profile: holeProfile(0.7, -3), length: 8, direction: [0, 0, 1] })
owned.push(counterdrillPrimary)
const counterdrillCylinder = await runtime.createCylinder({ ...documentContext, radius: 1.1, height: 1.5, center: [0, 0, -3], direction: [0, 0, 1] })
owned.push(counterdrillCylinder)
const counterdrillCone = await runtime.createCone({ ...documentContext, radius1: 1.1, radius2: 0.7, height: 0.4, center: [0, 0, -1.5], direction: [0, 0, 1] })
owned.push(counterdrillCone)
const counterdrill = await runtime.cut({ ...documentContext, base: counterdrillPrimary, tools: [counterdrillCylinder, counterdrillCone] })
owned.push(counterdrill)
await capture('hole-counterdrill', counterdrill)
const angledBase = await runtime.createBox({ ...documentContext, width: 6, length: 6, height: 6, center: [0, 0, 0], originOnCenter: true })
owned.push(angledBase)
const angledPrimary = await runtime.pocket({ ...documentContext, base: angledBase, profile: holeProfile(0.7, -3), length: 3, direction: [0, 0, 1] })
owned.push(angledPrimary)
const angledHeight = 0.7 / Math.tan((180 - 118) * Math.PI / 360)
const angledTool = await runtime.createCone({ ...documentContext, radius1: 0.7, radius2: 0, height: angledHeight, center: [0, 0, 0], direction: [0, 0, 1] })
owned.push(angledTool)
const angled = await runtime.cut({ ...documentContext, base: angledPrimary, tools: [angledTool] })
owned.push(angled)
await capture('hole-angled-drill-point', angled)
const includedDepthBase = await runtime.createBox({ ...documentContext, width: 6, length: 6, height: 6, center: [0, 0, 0], originOnCenter: true })
owned.push(includedDepthBase)
const includedDepth = 3
const includedTipHeight = 0.7 / Math.tan((180 - 118) * Math.PI / 360)
const includedCylinderDepth = includedDepth - includedTipHeight
const includedPrimary = await runtime.pocket({ ...documentContext, base: includedDepthBase, profile: holeProfile(0.7, -3), length: includedCylinderDepth, direction: [0, 0, 1] })
owned.push(includedPrimary)
const includedTip = await runtime.createCone({ ...documentContext, radius1: 0.7, radius2: 0, height: includedTipHeight, center: [0, 0, -3 + includedCylinderDepth], direction: [0, 0, 1] })
owned.push(includedTip)
const includedDepthHole = await runtime.cut({ ...documentContext, base: includedPrimary, tools: [includedTip] })
owned.push(includedDepthHole)
await capture('hole-angled-included-depth', includedDepthHole)
const taperedBase = await runtime.createBox({ ...documentContext, width: 6, length: 6, height: 6, center: [0, 0, 0], originOnCenter: true })
owned.push(taperedBase)
const taperedRadius = 0.7 + Math.tan((95 - 90) * Math.PI / 180) * 4
const taperedTool = await runtime.createCone({ ...documentContext, radius1: 0.7, radius2: taperedRadius, height: 4, center: [0, 0, -3], direction: [0, 0, 1] })
owned.push(taperedTool)
const tapered = await runtime.cut({ ...documentContext, base: taperedBase, tools: [taperedTool] })
owned.push(tapered)
await capture('hole-tapered', tapered)
const sections = [square(1, 0), square(0.7, 3)]
const loft = await runtime.loft({ ...documentContext, sections })
owned.push(loft)
await capture('loft', loft)
const ruled = await runtime.loft({ ...documentContext, sections, ruled: true })
owned.push(ruled)
await capture('loft-ruled', ruled)
const additiveBase = await runtime.createBox({ ...documentContext, width: 5, length: 5, height: 5, center: [0, 0, 0], originOnCenter: true })
owned.push(additiveBase)
const additive = await runtime.loft({ ...documentContext, sections, mode: 'additive', base: additiveBase })
owned.push(additive)
await capture('loft-additive', additive)
const subtractiveBase = await runtime.createBox({ ...documentContext, width: 5, length: 5, height: 6, center: [0, 0, 1.5], originOnCenter: true })
owned.push(subtractiveBase)
const subtractive = await runtime.loft({ ...documentContext, sections: [square(1, 0.5), square(0.7, 2.5)], mode: 'subtractive', base: subtractiveBase })
owned.push(subtractive)
await capture('loft-subtractive', subtractive)
const pipe = await runtime.pipe({ ...documentContext, profile: pipeProfile(0.35), path: [[0, 0, 0], [3, 0, 0]] })
owned.push(pipe)
await capture('pipe', pipe)
const twoSidedExtrudeForward = await runtime.extrude({ ...documentContext, profile: square(0.8, 0), length: 2, direction: [0, 0, 1] })
owned.push(twoSidedExtrudeForward)
const twoSidedExtrudeReverse = await runtime.extrude({ ...documentContext, profile: square(0.8, 0), length: 1, direction: [0, 0, 1], reversed: true })
owned.push(twoSidedExtrudeReverse)
const twoSidedExtrude = await runtime.union({ ...documentContext, shapes: [twoSidedExtrudeForward, twoSidedExtrudeReverse] })
owned.push(twoSidedExtrude)
await capture('extrude-two-sided', twoSidedExtrude)
const symmetricRevolutionPositive = await runtime.revolution({ ...documentContext, profile: revolutionProfile(), angle: 90, axisOrigin: [0, 0, 0], axisDirection: [0, 0, 1] })
owned.push(symmetricRevolutionPositive)
const symmetricRevolutionNegative = await runtime.revolution({ ...documentContext, profile: revolutionProfile(), angle: 90, axisOrigin: [0, 0, 0], axisDirection: [0, 0, -1] })
owned.push(symmetricRevolutionNegative)
const symmetricRevolution = await runtime.union({ ...documentContext, shapes: [symmetricRevolutionPositive, symmetricRevolutionNegative] })
owned.push(symmetricRevolution)
await capture('revolution-symmetric', symmetricRevolution)
const twoAngleRevolutionPositive = await runtime.revolution({ ...documentContext, profile: revolutionProfile(), angle: 120, axisOrigin: [0, 0, 0], axisDirection: [0, 1, 0] })
owned.push(twoAngleRevolutionPositive)
const twoAngleRevolutionNegative = await runtime.revolution({ ...documentContext, profile: revolutionProfile(), angle: 60, axisOrigin: [0, 0, 0], axisDirection: [0, -1, 0] })
owned.push(twoAngleRevolutionNegative)
const twoAngleRevolution = await runtime.union({ ...documentContext, shapes: [twoAngleRevolutionPositive, twoAngleRevolutionNegative] })
owned.push(twoAngleRevolution)
await capture('revolution-two-angles', twoAngleRevolution)
const twoSidedPadForward = await runtime.pad({ ...documentContext, profile: square(0.7, 0), length: 2, direction: [0, 0, 1] })
owned.push(twoSidedPadForward)
const twoSidedPadReverse = await runtime.pad({ ...documentContext, profile: square(0.7, 0), length: 1, direction: [0, 0, 1], reversed: true })
owned.push(twoSidedPadReverse)
const twoSidedPad = await runtime.union({ ...documentContext, shapes: [twoSidedPadForward, twoSidedPadReverse] })
owned.push(twoSidedPad)
await capture('pad-two-sided', twoSidedPad)
const twoSidedPocketBase = await runtime.createBox({ ...documentContext, width: 5, length: 5, height: 5, center: [0, 0, 0], originOnCenter: true })
owned.push(twoSidedPocketBase)
const twoSidedPocketForward = await runtime.pocket({ ...documentContext, base: twoSidedPocketBase, profile: holeProfile(0.8, 0), length: 4, direction: [0, 0, 1] })
owned.push(twoSidedPocketForward)
const twoSidedPocket = await runtime.pocket({ ...documentContext, base: twoSidedPocketForward, profile: holeProfile(0.8, 0), length: 4, direction: [0, 0, 1], reversed: true })
owned.push(twoSidedPocket)
await capture('pocket-two-sided', twoSidedPocket)
const taperedPad = await runtime.pad({ ...documentContext, profile: square(2, 0), length: 5, direction: [0, 0, 1], taperAngle: 5 })
owned.push(taperedPad)
await capture('pad-tapered', taperedPad, undefined, true)
const taperedPocketBase = await runtime.createBox({ ...documentContext, width: 10, length: 10, height: 10, center: [0, 0, 0], originOnCenter: true })
owned.push(taperedPocketBase)
const taperedPocket = await runtime.pocket({ ...documentContext, base: taperedPocketBase, profile: square(2, 5), length: 10, direction: [0, 0, 1], reversed: true, taperAngle: 2 })
owned.push(taperedPocket)
await capture('pocket-tapered', taperedPocket, undefined, true)
const throughAllPocketBase = await runtime.createBox({ ...documentContext, width: 10, length: 10, height: 10, center: [0, 0, 0], originOnCenter: true })
owned.push(throughAllPocketBase)
const throughAllPocket = await runtime.pocket({ ...documentContext, base: throughAllPocketBase, profile: square(1, 5), length: 1, direction: [0, 0, 1], throughAll: true })
owned.push(throughAllPocket)
await capture('pocket-through-all', throughAllPocket, undefined, true)
const upToFacePocketBase = await runtime.createBox({ ...documentContext, width: 10, length: 10, height: 10, center: [0, 0, 0], originOnCenter: true })
owned.push(upToFacePocketBase)
const upToFacePocket = await runtime.pocket({ ...documentContext, base: upToFacePocketBase, profile: square(1, 5), length: 10, direction: [0, 0, 1], reversed: true })
owned.push(upToFacePocket)
await capture('pocket-up-to-face', upToFacePocket, undefined, true)
const midplanePad = await runtime.pad({ ...documentContext, profile: square(2, 0), length: 6, direction: [0, 0, 1], symmetricToPlane: true })
owned.push(midplanePad)
await capture('pad-midplane', midplanePad, undefined, true)
const linearPatternBase = await runtime.createBox({ ...documentContext, width: 0.8, length: 0.8, height: 0.8, center: [0, 0, 0], originOnCenter: true })
owned.push(linearPatternBase)
const linearPatternCopy1 = await runtime.applyPlacement({ ...documentContext, shape: linearPatternBase, placement: { translation: [0, 2, 0], rotationAxis: [0, 0, 1], rotationAngle: 0 } })
owned.push(linearPatternCopy1)
const linearPatternCopy2 = await runtime.applyPlacement({ ...documentContext, shape: linearPatternBase, placement: { translation: [0, 7, 0], rotationAxis: [0, 0, 1], rotationAngle: 0 } })
owned.push(linearPatternCopy2)
const linearPattern = await runtime.union({ ...documentContext, shapes: [linearPatternBase, linearPatternCopy1, linearPatternCopy2] })
owned.push(linearPattern)
await capture('linear-pattern-spacing', linearPattern)
const polarPatternBase = await runtime.createBox({ ...documentContext, width: 0.8, length: 0.8, height: 0.8, center: [2, 0, 0], originOnCenter: true })
owned.push(polarPatternBase)
const polarPatternCopy1 = await runtime.applyPlacement({ ...documentContext, shape: polarPatternBase, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 1], rotationAngle: 30 } })
owned.push(polarPatternCopy1)
const polarPatternCopy2 = await runtime.applyPlacement({ ...documentContext, shape: polarPatternBase, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 1], rotationAngle: 75 } })
owned.push(polarPatternCopy2)
const polarPattern = await runtime.union({ ...documentContext, shapes: [polarPatternBase, polarPatternCopy1, polarPatternCopy2] })
owned.push(polarPattern)
await capture('polar-pattern-spacing', polarPattern)
const stressStart = performance.now()
let stressPeakShapeCount = runtime.capabilities().shapeCount
for (let index = 0; index < 1000; index += 1) {
const stressShape = await runtime.createBox({ ...documentContext, width: 0.1, length: 0.1, height: 0.1, center: [index * 0.2, 0, 0] })
owned.push(stressShape)
stressPeakShapeCount = Math.max(stressPeakShapeCount, runtime.capabilities().shapeCount)
}
const stressDurationMs = performance.now() - stressStart
const triangleStressShape = await runtime.createSphere({ ...documentContext, radius: 100 })
owned.push(triangleStressShape)
const triangleStressPrecision = 0.01
const triangleStart = performance.now()
const triangleStressMesh = await runtime.mesh(triangleStressShape, triangleStressPrecision)
const triangleStress = { radius: 100, precision: triangleStressPrecision, triangles: triangleStressMesh.indices.length / 3, vertices: triangleStressMesh.positions.length / 3, durationMs: performance.now() - triangleStart }
if (triangleStress.triangles < 100_000 || triangleStress.vertices < 3 || !Number.isFinite(triangleStress.durationMs) || triangleStress.durationMs <= 0) throw new Error(`High-tessellation mesh budget was not reached: ${JSON.stringify(triangleStress)}`)
const beforeRelease = { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }
const durationMs = performance.now() - startedAt
const performanceEvidence = { durationMs, operationCount: operations.length, averageOperationMs: durationMs / operations.length, peakShapeCount, stressObjectCount: 1000, stressDurationMs, stressPeakShapeCount, triangleStress }
await Promise.all(owned.map((shape) => runtime.release(shape)))
const afterRelease = { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }
if (afterRelease.shapeCount !== 0 || afterRelease.kernelReferenceCount !== 0) throw new Error(`Shape ownership gate failed after release: ${JSON.stringify(afterRelease)}`)
runtime.dispose()
return { schemaVersion: 1, status: 'pass', browserId: 'chrome', geometryProvider: capabilities.provider, operations, beforeRelease, afterRelease, fcstdBrep, exchangeFormats, performance: performanceEvidence }
} catch (error) {
runtime.dispose()
const durationMs = performance.now() - startedAt
return { schemaVersion: 1, status: 'failed', browserId: 'chrome', geometryProvider: 'Bitbybit OCCT', operations, beforeRelease: { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }, afterRelease: { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }, performance: { durationMs, operationCount: operations.length, averageOperationMs: operations.length ? durationMs / operations.length : 0, peakShapeCount, stressObjectCount: 0, stressDurationMs: 0, stressPeakShapeCount: runtime.capabilities().shapeCount, triangleStress: { radius: 0, precision: 0, triangles: 0, vertices: 0, durationMs: 0 } }, error: error instanceof Error ? error.message : String(error) }
}
}
run().then((report) => {
;(window as Window & { __bitbybitGeometryFeatureReport?: HarnessReport }).__bitbybitGeometryFeatureReport = report
document.documentElement.dataset.status = report.status
setStatus(JSON.stringify(report))
}).catch((error) => {
document.documentElement.dataset.status = 'failed'
setStatus(error instanceof Error ? error.message : String(error))
})
export {}

View File

@@ -0,0 +1,18 @@
import { createMockFacade } from './facade/mockFacade'
import { createInspection } from './facade/inspection'
type InspectionReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; shape?: { volume: number; area: number; valid: boolean; solids: number; faces: number; vertices: number }; measurements?: { distance: number; angle: number; volume: number; area: number; deviation: number; deviationStatus: string; sectionArea: number; topoRefs: number; stableRefs: number; unresolvedRefs: string[]; csvRows: number }; persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; measurements: 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 run = async (): Promise<InspectionReport> => {
const facade = createMockFacade(); const report: InspectionReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome Inspection evidence requires isolated OPFS.')
await facade.project.list(); const shape = await facade.geometry.createBox({ documentId: 'inspection', documentVersion: 1, width: 4, length: 5, height: 6 }); const mass = await facade.geometry.massProperties(shape); const quality = await facade.geometry.qualityReport(shape)
const inspection = createInspection('inspection', 'Box inspection'); inspection.registerShape({ id: shape.id, label: 'Bitbybit Box', volume: mass.volume, area: mass.surfaceArea, structuralValid: quality.structuralValid, bounds: { min: quality.boundingBox.min, max: quality.boundingBox.max }, topoRefs: [{ persistentId: 'Face1', kind: 'face', status: 'stable' }, { persistentId: 'Face2', kind: 'face', status: 'ambiguous' }] }); const distance = inspection.measureDistance('distance', [0, 0, 0], [3, 4, 0]); const angle = inspection.measureAngle('angle', [1, 0, 0], [0, 1, 0]); const volume = inspection.measureShape('volume', shape.id, 'volume'); const area = inspection.measureShape('area', shape.id, 'area'); const deviation = inspection.measureDeviation('deviation', 10, 10.01, 0.02); const section = inspection.section(shape.id, (quality.boundingBox.min[2] + quality.boundingBox.max[2]) / 2); const topoRefs = inspection.topoRefReport(shape.id); const csv = inspection.exportCsv(); const snapshot = inspection.snapshot();
const payload = new TextEncoder().encode(JSON.stringify(snapshot)); const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.inspection+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 = 'inspection-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'INSP-ALL', measurements: snapshot.measurements.length })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; measurements: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.shape = { volume: mass.volume, area: mass.surfaceArea, valid: quality.structuralValid, solids: quality.solids, faces: quality.faces, vertices: quality.vertices }; report.measurements = { distance: distance.value, angle: angle.value, volume: volume.value, area: area.value, deviation: deviation.value, deviationStatus: deviation.status, sectionArea: section.area, topoRefs: topoRefs.total, stableRefs: topoRefs.stable, unresolvedRefs: topoRefs.unresolved, csvRows: csv.trim().split('\n').length }; report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }; report.opfs = { markerSuite: markerPayload.suite, measurements: markerPayload.measurements, markerRemoved }; report.status = report.shape.valid && report.shape.solids === 1 && report.measurements.distance === 5 && report.measurements.angle === 90 && report.measurements.volume === report.shape.volume && report.measurements.area === report.shape.area && Math.abs(report.measurements.deviation - 0.01) < 1e-12 && report.measurements.deviationStatus === 'resolved' && Math.abs(report.measurements.sectionArea - 24) < 1e-5 && report.measurements.topoRefs === 2 && report.measurements.stableRefs === 1 && JSON.stringify(report.measurements.unresolvedRefs) === JSON.stringify(['Face2']) && report.measurements.csvRows === 6 && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'INSP-ALL' && report.opfs.measurements === 5 && 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 & { __bitbybitInspectionReport?: InspectionReport }).__bitbybitInspectionReport = 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 {}

67
src/chromeMeshHarness.ts Normal file
View File

@@ -0,0 +1,67 @@
import { createMockFacade } from './facade/mockFacade'
import { createMeshDocument } from './facade/mesh'
type MeshReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; geometry?: { sourceShape: string; vertices: number; triangles: number; boundaryEdges: number; nonManifoldEdges: number; degenerateTriangles: number; selfIntersections: number; surfaceArea: number }; operations?: { welded: number; transformedMin: [number, number, number]; objBytes: number; objHash: string; plyBytes: number; stlBytes: number; lodTriangles: number; workerLodTriangles: number }; repairs?: { selfIntersectionFixture: number; nonManifoldFixture: number; holeFilledTriangles: number }; persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; triangles: 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 lodInWorker = async (positions: number[], indices: number[], targetTriangles: number) => {
const worker = new Worker(new URL('./meshLodWorker.ts', import.meta.url), { type: 'module', name: 'mesh-lod' })
try { return await new Promise<{ level: number; vertices: number; triangles: number; indices: number[] }>((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Mesh LOD Worker timed out.')), 10_000); worker.addEventListener('message', (event: MessageEvent<{ lod?: { level: number; vertices: number; triangles: number; indices: number[] }; error?: string }>) => { clearTimeout(timer); event.data.lod ? resolve(event.data.lod) : reject(new Error(event.data.error ?? 'Mesh LOD Worker failed.')) }, { once: true }); worker.addEventListener('error', (event) => { clearTimeout(timer); reject(new Error(event.message)) }, { once: true }); worker.postMessage({ positions, indices, targetTriangles }) }) } finally { worker.terminate() }
}
const run = async (): Promise<MeshReport> => {
const facade = createMockFacade()
const report: MeshReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome Mesh evidence requires isolated OPFS.')
await facade.project.list()
const shape = await facade.geometry.createBox({ documentId: 'mesh', documentVersion: 1, width: 10, length: 8, height: 4 })
const source = await facade.geometry.mesh(shape, 0.05)
const mesh = createMeshDocument('mesh', 'Box mesh', source)
const before = mesh.analyze()
mesh.weldVertices(1e-5)
mesh.removeDegenerate()
const quality = mesh.analyze()
mesh.transform({ translation: [1, 2, 3], scale: 1.5 })
const obj = new TextEncoder().encode(mesh.exportObj())
const ply = new TextEncoder().encode(mesh.exportPly())
const stl = new TextEncoder().encode(mesh.exportStl())
const lod = mesh.lod(Math.max(1, Math.floor(quality.triangles / 2)))
const workerLod = await lodInWorker(Array.from(source.positions), Array.from(source.indices), Math.max(1, Math.floor(quality.triangles / 2)))
const cross = createMeshDocument('cross', 'Cross', { positions: [-1, -1, 0, 1, 1, 0, -1, 1, 0, -1, 1, 0, 1, -1, 0, 1, 1, 0], indices: [0, 1, 2, 3, 4, 5] })
const nonManifold = createMeshDocument('non-manifold', 'Non manifold', { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 1], indices: [0, 1, 2, 0, 1, 3, 0, 1, 4] })
const hole = createMeshDocument('hole', 'Hole', { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [] }); hole.fillBoundaryTriangle([0, 1], 2)
const objHash = await sha256(obj)
const snapshot = mesh.snapshot()
const payload = new TextEncoder().encode(JSON.stringify({ ...snapshot, vertices: snapshot.vertices, triangles: snapshot.triangles }))
const stored = await facade.project.resource.put(payload, 'text/plain')
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 = 'mesh-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'MESH-CORE', triangles: quality.triangles })); await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; triangles: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.geometry = { sourceShape: shape.id, vertices: quality.vertices, triangles: quality.triangles, boundaryEdges: quality.boundaryEdges, nonManifoldEdges: quality.nonManifoldEdges, degenerateTriangles: quality.degenerateTriangles, selfIntersections: quality.selfIntersections, surfaceArea: quality.surfaceArea }
report.operations = { welded: before.vertices - quality.vertices, transformedMin: mesh.analyze().bounds.min, objBytes: obj.byteLength, objHash, plyBytes: ply.byteLength, stlBytes: stl.byteLength, lodTriangles: lod.triangles, workerLodTriangles: workerLod.triangles }
report.repairs = { selfIntersectionFixture: cross.detectSelfIntersections(), nonManifoldFixture: nonManifold.analyze().nonManifoldEdges, holeFilledTriangles: hole.analyze().triangles }
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, triangles: markerPayload.triangles, markerRemoved }
report.status = report.geometry.vertices > 0 && report.geometry.triangles > 0 && report.geometry.degenerateTriangles === 0 && report.geometry.boundaryEdges === 0 && report.geometry.nonManifoldEdges === 0 && report.geometry.selfIntersections === 0 && report.operations.welded > 0 && JSON.stringify(report.operations.transformedMin) === JSON.stringify([-6.5, -1, -3]) && report.operations.objBytes > 0 && report.operations.plyBytes > 0 && report.operations.stlBytes > 0 && report.operations.lodTriangles === report.operations.workerLodTriangles && report.repairs.selfIntersectionFixture === 1 && report.repairs.nonManifoldFixture === 1 && report.repairs.holeFilledTriangles === 1 && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'MESH-CORE' && report.opfs.triangles === report.geometry.triangles && 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 & { __bitbybitMeshReport?: MeshReport }).__bitbybitMeshReport = 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 {}

View File

@@ -0,0 +1,74 @@
import { createMockFacade } from './facade/mockFacade'
type OfflineReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
serviceWorker?: { registered: boolean; controlled: boolean; shellCached: boolean; staleCacheRemoved: boolean; offlineFallback: boolean; cacheNames: string[] }
persistence?: { mode: string; bytes: number; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; 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 reloadKey = 'bitbybit-rel01-offline-reload'
const run = async (): Promise<OfflineReport> => {
const report: OfflineReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
const facade = createMockFacade()
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory || !('serviceWorker' in navigator)) throw new Error('REL-01 requires isolated OPFS and Service Worker support.')
const staleCache = await caches.open('bitbybit-cad-shell-stale-test')
await staleCache.put('/stale-rel01.txt', new Response('stale'))
const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
await registration.update()
await navigator.serviceWorker.ready
if (!navigator.serviceWorker.controller) {
sessionStorage.setItem(reloadKey, '1')
location.reload()
return report
}
const currentCache = await caches.open('bitbybit-cad-shell-v1')
const shell = await currentCache.match('/index.html')
await currentCache.put('/offline-probe.txt', new Response('cached-offline-probe', { headers: { 'content-type': 'text/plain' } }))
await fetch('/__offline-toggle', { method: 'POST', cache: 'no-store' })
const offlineResponse = await fetch('/offline-probe.txt', { cache: 'no-store' })
const offlineFallback = (await offlineResponse.text()) === 'cached-offline-probe'
const cacheNames = await caches.keys()
const staleCacheRemoved = !cacheNames.includes('bitbybit-cad-shell-stale-test')
const payload = new TextEncoder().encode(JSON.stringify({ suite: 'REL-01', offlineFallback, cacheNames }))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.rel01+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 = 'rel01-offline-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'REL-01' }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.serviceWorker = { registered: Boolean(registration.active), controlled: Boolean(navigator.serviceWorker.controller), shellCached: Boolean(shell), staleCacheRemoved, offlineFallback, cacheNames }
report.persistence = { mode: facade.project.capabilities().mode, bytes: stored.byteLength, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, markerRemoved }
report.status = report.serviceWorker.registered && report.serviceWorker.controlled && report.serviceWorker.shellCached && staleCacheRemoved && offlineFallback && report.persistence.mode === 'sqlite-opfs' && roundTrip && released && markerPayload.suite === 'REL-01' && markerRemoved ? 'pass' : 'failed'
sessionStorage.removeItem(reloadKey)
} 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 & { __bitbybitOfflineReport?: OfflineReport }).__bitbybitOfflineReport = 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 {}

View File

@@ -0,0 +1,71 @@
import { BitbybitGeometryRuntime } from './facade/geometryRuntime'
import type { ShapeHandle } from './facade/types'
type PrimitiveEvidence = { name: string; meshVertices: number; meshTriangles: number; volume?: number; length?: number }
type PrimitiveReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; operations: PrimitiveEvidence[]; beforeRelease: { shapeCount: number; kernelReferenceCount: number }; afterRelease: { shapeCount: number; kernelReferenceCount: number }; opfs?: { markerSuite: string; markerOperations: number; markerRemoved: boolean }; error?: string }
const status = (message: string) => { document.querySelector('#status')!.textContent = message }
const run = async (): Promise<PrimitiveReport> => {
const runtime = new BitbybitGeometryRuntime()
const context = { documentId: 'chrome-part-primitives', documentVersion: 1 }
const owned: ShapeHandle[] = []
const operations: PrimitiveEvidence[] = []
try {
const capabilities = await runtime.initialize()
if (capabilities.status !== 'ready') throw new Error(capabilities.reason || `Bitbybit runtime status is ${capabilities.status}.`)
const capture = async (name: string, shape: ShapeHandle, includeMass = true, requireMesh = true) => {
if (!requireMesh) {
const length = await runtime.linearLength(shape)
if (!(length > 0)) throw new Error(`${name} returned a zero-length wire.`)
operations.push({ name, meshVertices: 0, meshTriangles: 0, length })
return
}
let mesh
try { mesh = await runtime.mesh(shape) } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`) }
if (mesh.positions.length < 9 || mesh.indices.length < 3) throw new Error(`${name} returned an empty mesh.`)
const mass = includeMass ? await runtime.massProperties(shape) : undefined
operations.push({ name, meshVertices: mesh.positions.length / 3, meshTriangles: mesh.indices.length / 3, ...(mass ? { volume: mass.volume } : {}) })
}
const ellipsoid = await runtime.createEllipsoid({ ...context, radius1: 2, radius2: 3, radius3: 4 })
owned.push(ellipsoid); await capture('ellipsoid', ellipsoid)
const torus = await runtime.createTorus({ ...context, majorRadius: 5, minorRadius: 1, angle: 360 })
owned.push(torus); await capture('torus', torus)
const prism = await runtime.createPrism({ ...context, polygon: 6, circumradius: 2, height: 4 })
owned.push(prism); await capture('prism', prism)
const wedge = await runtime.createWedge({ ...context, xmin: 0, ymin: 0, zmin: 0, z2min: 0, x2min: 0, xmax: 4, ymax: 5, zmax: 6, z2max: 6, x2max: 4 })
owned.push(wedge); await capture('wedge', wedge)
const helix = await runtime.createHelix({ ...context, radius: 2, pitch: 3, height: 6 })
owned.push(helix); await capture('helix', helix, false, false)
const markerDirectory = await navigator.storage.getDirectory()
const markerName = 'part-primitives-chrome-marker.json'
const marker = await markerDirectory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'PART-ALL', operations: operations.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text())
await markerDirectory.removeEntry(markerName)
let markerRemoved = false
try { await markerDirectory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
const beforeRelease = { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }
await Promise.all(owned.map((shape) => runtime.release(shape)))
const afterRelease = { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }
runtime.dispose()
if (afterRelease.shapeCount !== 0 || afterRelease.kernelReferenceCount !== 0) throw new Error(`Part primitive ownership gate failed: ${JSON.stringify(afterRelease)}`)
return { schemaVersion: 1, status: capabilities.status === 'ready' && operations.length === 5 && markerPayload.suite === 'PART-ALL' && markerPayload.operations === 5 && markerRemoved ? 'pass' : 'failed', browserId: 'chrome', operations, beforeRelease, afterRelease, opfs: { markerSuite: markerPayload.suite, markerOperations: markerPayload.operations, markerRemoved } }
} catch (error) {
const beforeRelease = { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }
await Promise.all(owned.map((shape) => runtime.release(shape).catch(() => {})))
const afterRelease = { shapeCount: runtime.capabilities().shapeCount, kernelReferenceCount: runtime.capabilities().kernelReferenceCount }
runtime.dispose()
return { schemaVersion: 1, status: 'failed', browserId: 'chrome', operations, beforeRelease, afterRelease, error: error instanceof Error ? error.message : String(error) }
}
}
run().then((report) => {
;(window as Window & { __bitbybitPartPrimitiveReport?: PrimitiveReport }).__bitbybitPartPrimitiveReport = report
document.documentElement.dataset.status = report.status
status(JSON.stringify(report))
}).catch((error) => { document.documentElement.dataset.status = 'failed'; status(error instanceof Error ? error.message : String(error)) })
export {}

View File

@@ -0,0 +1,200 @@
import { createMockFacade } from './facade/mockFacade'
import type { BitBybitWebCadFacade, DocumentObjectSnapshot, PlacementValue } from './facade/types'
type FeatureEvidence = {
command: string
objectId: string
typeId: string
recompute: string
base: unknown
profile: unknown
sections: unknown
spine: unknown
transition: unknown
tip: unknown
shapeId: string | null
volume: number
}
type LoftReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
operations: FeatureEvidence[]
failureRecovery?: {
loft: { status: string; code: string; retainedShapeId: string | null; previousShapeId: string | null; restoredStatus: string; restoredShapeId: string | null }
pipe: { status: string; code: string; retainedShapeId: string | null; previousShapeId: string | null; restoredStatus: string; restoredShapeId: string | null }
}
persistence?: { mode: string; savedVersion: number; reopenedVersion: number; objectCount: number; reopenedTip: unknown }
opfs?: { markerSuite: string; markerOperations: number; markerRemoved: boolean }
beforeRelease?: { shapeCount: number; kernelReferenceCount: number }
afterRelease?: { shapeCount: number; kernelReferenceCount: number }
error?: string
}
const rectangleAt = (prefix: string, x0: number, y0: number, x1: number, y1: number) => [
{ id: `${prefix}-1`, type: 'line' as const, start: { x: x0, y: y0 }, end: { x: x1, y: y0 } },
{ id: `${prefix}-2`, type: 'line' as const, start: { x: x1, y: y0 }, end: { x: x1, y: y1 } },
{ id: `${prefix}-3`, type: 'line' as const, start: { x: x1, y: y1 }, end: { x: x0, y: y1 } },
{ id: `${prefix}-4`, type: 'line' as const, start: { x: x0, y: y1 }, end: { x: x0, y: y0 } },
]
const rectangle = (prefix: string) => rectangleAt(prefix, 0, 0, 4, 3)
const property = (facade: BitBybitWebCadFacade, objectId: string, name: string): unknown => facade.app.document.getObject(objectId)?.properties.find((entry) => entry.name === name)?.value ?? null
const ids = (facade: BitBybitWebCadFacade) => new Set(facade.app.document.getActive().objects.map((object) => object.id))
const newest = (facade: BitBybitWebCadFacade, previous: Set<string>): DocumentObjectSnapshot => {
const object = facade.app.document.getActive().objects.find((candidate) => !previous.has(candidate.id))
if (!object) throw new Error('PartDesign loft task did not append an object.')
return object
}
const createSketch = (facade: BitBybitWebCadFacade, geometry: ReturnType<typeof rectangle>, offsetZ = 0): string => {
const previous = ids(facade)
facade.task.begin('create-sketch')
facade.task.apply()
const sketch = newest(facade, previous)
for (const item of geometry) facade.app.sketcher.addGeometry(sketch.id, item)
if (offsetZ !== 0) {
const offset: PlacementValue = { position: { x: 0, y: 0, z: offsetZ }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } }
facade.app.document.setProperty({ objectId: sketch.id, propertyName: 'AttachmentOffset', value: offset })
}
return sketch.id
}
const applyFeature = async (facade: BitBybitWebCadFacade, command: string, draft: Record<string, unknown>): Promise<FeatureEvidence> => {
const previous = ids(facade)
if (facade.gui.command.getState(command).status !== 'enabled') throw new Error(`${command} is not enabled for ${facade.selection.getObjectId()}.`)
facade.gui.command.execute({ commandId: command })
const task = facade.task.getActive()
if (!task || task.commandId !== command || task.status !== 'preview') throw new Error(`${command} did not open a preview task.`)
facade.task.update(draft)
facade.task.apply()
const object = newest(facade, previous)
const recompute = await facade.app.document.recomputeAsync({ dirtyObjectIds: [object.id] })
if (recompute.status !== 'completed') throw new Error(`${command} recompute failed: ${JSON.stringify(recompute.errors)}`)
const shape = facade.geometry.getObjectShape(object.id)
const tip = property(facade, 'body', 'Tip')
if (!shape || tip !== object.id) throw new Error(`${command} did not retain a ShapeHandle or redirect Body.Tip.`)
const mass = await facade.geometry.massProperties(shape)
if (!(mass.volume > 0)) throw new Error(`${command} produced a non-solid or empty result.`)
return { command, objectId: object.id, typeId: object.typeId, recompute: recompute.status, base: property(facade, object.id, 'Base'), profile: property(facade, object.id, 'Profile'), sections: property(facade, object.id, 'Sections'), spine: property(facade, object.id, 'Spine'), transition: property(facade, object.id, 'Transition'), tip, shapeId: shape.id, volume: mass.volume }
}
const run = async (): Promise<LoftReport> => {
const facade = createMockFacade()
const report: LoftReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true, operations: [] }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome PartDesign loft evidence requires isolated OPFS.')
const initialized = await facade.geometry.initialize()
if (initialized.status !== 'ready') throw new Error(initialized.reason || 'Bitbybit geometry runtime is unavailable.')
for (const item of rectangle('base')) facade.app.sketcher.addGeometry('sketch', item)
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: true })
facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Suppressed', value: true })
const initial = await facade.app.document.recomputeAsync()
if (initial.status !== 'completed') throw new Error(`Initial Pad recompute failed: ${JSON.stringify(initial.errors)}`)
const section = createSketch(facade, rectangle('section'), 6)
const spine = createSketch(facade, [{ id: 'spine-1', type: 'line' as const, start: { x: 0, y: 0 }, end: { x: 6, y: 0 } }])
const pipeProfile = createSketch(facade, [
{ id: 'pipe-profile-1', type: 'line' as const, start: { x: -1, y: -1 }, end: { x: 1, y: -1 } },
{ id: 'pipe-profile-2', type: 'line' as const, start: { x: 1, y: -1 }, end: { x: 1, y: 1 } },
{ id: 'pipe-profile-3', type: 'line' as const, start: { x: 1, y: 1 }, end: { x: -1, y: 1 } },
{ id: 'pipe-profile-4', type: 'line' as const, start: { x: -1, y: 1 }, end: { x: -1, y: -1 } },
])
facade.app.document.setProperty({ objectId: pipeProfile, propertyName: 'AttachmentOffset', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 1, y: 1, z: 1 }, angle: 120 } } })
facade.selection.select('sketch')
const loft = await applyFeature(facade, 'additive-loft', { base: 'pad', sections: ['sketch', section], ruled: false, closed: false })
report.operations.push(loft)
const previousShapeId = loft.shapeId
facade.app.document.setProperty({ objectId: loft.objectId, propertyName: 'Sections', value: [section, section] })
const invalid = await facade.app.document.recomputeAsync({ dirtyObjectIds: [loft.objectId] })
const diagnostic = facade.diagnostics.list().find((entry) => entry.objectId === loft.objectId && entry.code === 'LOFT_SECTIONS_MISSING') ?? facade.diagnostics.list().find((entry) => entry.objectId === loft.objectId)
const retainedShape = facade.geometry.getObjectShape(loft.objectId)
if (invalid.status !== 'failed' || !diagnostic || !retainedShape || retainedShape.id !== previousShapeId) throw new Error(`Loft failure did not retain the last valid Shape: ${JSON.stringify({ invalid, diagnostic, previousShapeId, retainedShapeId: retainedShape?.id })}`)
facade.app.document.setProperty({ objectId: loft.objectId, propertyName: 'Sections', value: [section] })
facade.app.document.setProperty({ objectId: loft.objectId, propertyName: 'Profile', value: 'sketch' })
const restored = await facade.app.document.recomputeAsync({ dirtyObjectIds: [loft.objectId] })
const restoredShape = facade.geometry.getObjectShape(loft.objectId)
if (restored.status !== 'completed' || !restoredShape) throw new Error(`Loft did not recover after restoring sections: ${JSON.stringify(restored.errors)}`)
const loftRecovery = { status: invalid.status, code: diagnostic.code, retainedShapeId: retainedShape.id, previousShapeId, restoredStatus: restored.status, restoredShapeId: restoredShape.id }
facade.gui.workbench.setActive('Part Design')
facade.selection.select(pipeProfile)
const additivePipe = await applyFeature(facade, 'additive-pipe', { base: loft.objectId, profile: pipeProfile, spine, transition: 'Transformed' })
report.operations.push(additivePipe)
const cutSection0 = createSketch(facade, rectangleAt('cut-section-0', 1, 1, 3, 2))
const cutSection1 = createSketch(facade, rectangleAt('cut-section-1', 1, 1, 3, 2), 6)
facade.selection.select(cutSection0)
const subtractiveLoft = await applyFeature(facade, 'subtractive-loft', { base: additivePipe.objectId, sections: [cutSection0, cutSection1], ruled: false, closed: false })
report.operations.push(subtractiveLoft)
const subtractivePipeProfile = createSketch(facade, rectangleAt('cut-pipe-profile', -0.25, -0.25, 0.25, 0.25))
facade.app.document.setProperty({ objectId: subtractivePipeProfile, propertyName: 'AttachmentOffset', value: { position: { x: 0, y: 0.5, z: 3 }, rotation: { axis: { x: 1, y: 1, z: 1 }, angle: 120 } } })
const subtractivePipeSpine = createSketch(facade, [{ id: 'cut-pipe-spine-1', type: 'line' as const, start: { x: 0, y: 0 }, end: { x: 4, y: 0 } }])
facade.app.document.setProperty({ objectId: subtractivePipeSpine, propertyName: 'AttachmentOffset', value: { position: { x: 0, y: 0.5, z: 3 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } } })
facade.selection.select(subtractivePipeProfile)
const subtractivePipe = await applyFeature(facade, 'subtractive-pipe', { base: subtractiveLoft.objectId, profile: subtractivePipeProfile, spine: subtractivePipeSpine, transition: 'Transformed' })
report.operations.push(subtractivePipe)
const pipeShapeBeforeFailure = facade.geometry.getObjectShape(subtractivePipe.objectId)
const branchSpine = createSketch(facade, [
{ id: 'branch-a', type: 'line' as const, start: { x: 0, y: 0 }, end: { x: 1, y: 0 } },
{ id: 'branch-b', type: 'line' as const, start: { x: 1, y: 0 }, end: { x: 2, y: 0 } },
{ id: 'branch-c', type: 'line' as const, start: { x: 1, y: 0 }, end: { x: 1, y: 1 } },
])
facade.app.document.setProperty({ objectId: subtractivePipe.objectId, propertyName: 'Spine', value: { schemaVersion: 1, objectId: branchSpine, subElements: [] } })
const invalidPipe = await facade.app.document.recomputeAsync({ dirtyObjectIds: [subtractivePipe.objectId] })
const pipeDiagnostic = facade.diagnostics.list().find((entry) => entry.objectId === subtractivePipe.objectId && entry.code === 'PIPE_PATH_BRANCH')
const retainedPipeShape = facade.geometry.getObjectShape(subtractivePipe.objectId)
if (invalidPipe.status !== 'failed' || !pipeDiagnostic || !pipeShapeBeforeFailure || retainedPipeShape?.id !== pipeShapeBeforeFailure.id) throw new Error('Pipe failure did not retain the last valid Shape.')
facade.app.document.setProperty({ objectId: subtractivePipe.objectId, propertyName: 'Spine', value: { schemaVersion: 1, objectId: subtractivePipeSpine, subElements: [] } })
const restoredPipe = await facade.app.document.recomputeAsync({ dirtyObjectIds: [subtractivePipe.objectId] })
const restoredPipeShape = facade.geometry.getObjectShape(subtractivePipe.objectId)
if (restoredPipe.status !== 'completed' || !restoredPipeShape) throw new Error('Pipe did not recover after restoring the Spine.')
report.failureRecovery = {
loft: loftRecovery,
pipe: { status: invalidPipe.status, code: pipeDiagnostic.code, retainedShapeId: retainedPipeShape.id, previousShapeId: pipeShapeBeforeFailure.id, restoredStatus: restoredPipe.status, restoredShapeId: restoredPipeShape.id },
}
const saved = await facade.project.save()
const loaded = await facade.project.load(facade.app.document.getActive().id)
if (!loaded) throw new Error('PartDesign loft document did not load from OPFS.')
await facade.app.document.load(facade.app.document.getActive().id)
const reopened = facade.app.document.getActive()
if (reopened.objects.length !== loaded.objects.length || property(facade, 'body', 'Tip') !== subtractivePipe.objectId) throw new Error('PartDesign loft OPFS round-trip changed object count or Body.Tip.')
const directory = await navigator.storage.getDirectory()
const markerName = 'partdesign-loft-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'PD-LOFT', operations: report.operations.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; operations: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.persistence = { mode: facade.project.capabilities().mode, savedVersion: saved.documentVersion, reopenedVersion: reopened.version, objectCount: reopened.objects.length, reopenedTip: property(facade, 'body', 'Tip') }
report.opfs = { markerSuite: markerPayload.suite, markerOperations: markerPayload.operations, markerRemoved }
report.beforeRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }
report.status = 'pass'
} catch (error) {
report.error = error instanceof Error ? error.stack || error.message : String(error)
} finally {
facade.geometry.dispose()
await new Promise((resolve) => setTimeout(resolve, 0))
report.afterRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }
if (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed: ${JSON.stringify(report.afterRelease)}` }
}
return report
}
run().then((report) => {
;(window as Window & { __bitbybitPartdesignLoftReport?: LoftReport }).__bitbybitPartdesignLoftReport = 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 {}

View File

@@ -0,0 +1,277 @@
import { createMockFacade } from './facade/mockFacade'
import type { BitBybitWebCadFacade, DocumentObjectSnapshot, MultiTransformValue, RecomputeResult } from './facade/types'
type TransformEvidence = {
command: string
objectId: string
typeId: string
base: unknown
tip: unknown
recompute: RecomputeResult['status']
shapeCount: number
kernelReferenceCount: number
shapeId: string
volume: number
solids: number
structuralValid: boolean
transformMode?: unknown
originals?: unknown
threaded?: unknown
modelThread?: unknown
threadType?: unknown
threadSize?: unknown
threadDirection?: unknown
}
type TransformReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
operations: TransformEvidence[]
topologyMigration?: { editedPadLength: number; migrated: Array<{ command: string; objectId: string; kind: string; status: string; persistentId: string }>; stable: number; ambiguous: number; ambiguityDiagnostics: number; wrongBindings: number }
ambiguityRecovery?: { command: string; status: string; code: string; retainedShapeId: string; previousShapeId: string; restoredStatus: string; restoredShapeId: string }
persistence?: { mode: string; savedVersion: number; reopenedVersion: number; reopenedTip: unknown; objectCount: number }
opfs?: { markerSuite: string; markerOperations: number; markerRemoved: boolean }
beforeRelease?: { shapeCount: number; kernelReferenceCount: number }
afterRelease?: { shapeCount: number; kernelReferenceCount: number }
error?: string
}
const rectangle = [
{ id: 'transform-edge-1', type: 'line' as const, start: { x: 0, y: 0 }, end: { x: 4, y: 0 } },
{ id: 'transform-edge-2', type: 'line' as const, start: { x: 4, y: 0 }, end: { x: 4, y: 3 } },
{ id: 'transform-edge-3', type: 'line' as const, start: { x: 4, y: 3 }, end: { x: 0, y: 3 } },
{ id: 'transform-edge-4', type: 'line' as const, start: { x: 0, y: 3 }, end: { x: 0, y: 0 } },
]
const property = (facade: BitBybitWebCadFacade, objectId: string, name: string): unknown => facade.app.document.getObject(objectId)?.properties.find((entry) => entry.name === name)?.value ?? null
const objectIds = (facade: BitBybitWebCadFacade) => new Set(facade.app.document.getActive().objects.map((object) => object.id))
const latestObject = (facade: BitBybitWebCadFacade, previous: Set<string>): DocumentObjectSnapshot => {
const object = facade.app.document.getActive().objects.find((candidate) => !previous.has(candidate.id))
if (!object) throw new Error('Transform task did not append a document object.')
return object
}
const selectSubshape = (facade: BitBybitWebCadFacade, objectId: string, kind: 'edge' | 'face', index = 0) => {
const object = facade.app.document.getObject(objectId)
const available = object?.topology?.entries.filter((entry) => entry.ref.kind === kind && entry.ref.status !== 'deleted') ?? []
const stable = available.filter((entry) => entry.ref.status === 'stable')
const fresh = available.filter((entry) => entry.ref.status === 'new')
const candidates = stable.length > 0 ? stable : fresh.length > 0 ? fresh : available
const selected = candidates[index]
if (!selected || selected.ref.status === 'ambiguous' || selected.ref.status === 'deleted') throw new Error(`${objectId} does not expose an unambiguous selectable ${kind} ${index}: ${JSON.stringify(available.map((entry) => ({ persistentId: entry.ref.persistentId, status: entry.ref.status, candidates: entry.ref.candidates })))}`)
facade.selection.selectSubshape({ objectId, kind, persistentId: selected.ref.persistentId })
return selected.ref
}
const selectOperableFace = async (facade: BitBybitWebCadFacade, objectId: string, operation: 'draft' | 'thickness') => {
const object = facade.app.document.getObject(objectId)
const shape = facade.geometry.getObjectShape(objectId)
const faces = object?.topology?.entries.filter((entry) => entry.ref.kind === 'face') ?? []
if (!shape || faces.length === 0) throw new Error(`${objectId} has no Shape or face topology for ${operation}.`)
const failures: string[] = []
for (const [index, entry] of faces.entries()) {
if (entry.ref.status === 'ambiguous' || entry.ref.status === 'deleted') continue
try {
const candidate = operation === 'draft'
? await facade.geometry.draft({ documentId: shape.documentId, documentVersion: shape.documentVersion, base: shape, angle: 5, direction: [0, 0, 1], neutralPlaneOrigin: [0, 0, 0], neutralPlaneDirection: [0, 0, 1], indexes: [index] })
: await facade.geometry.thickness({ documentId: shape.documentId, documentVersion: shape.documentVersion, base: shape, offset: -0.4, removeFaceIndexes: [index], joinType: 'Arc' })
try {
const mass = await facade.geometry.massProperties(candidate)
if (!(mass.volume > 0)) throw new Error('candidate volume is not positive')
} finally {
await facade.geometry.release(candidate)
}
facade.selection.selectSubshape({ objectId, kind: 'face', persistentId: entry.ref.persistentId })
return entry.ref
} catch (error) {
failures.push(`${index}:${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`${objectId} has no operable ${operation} face: ${failures.join(' | ')}`)
}
const applyFeature = async (facade: BitBybitWebCadFacade, command: string, draft: Record<string, unknown>): Promise<TransformEvidence> => {
const previous = objectIds(facade)
const selected = facade.selection.getObjectId()
if (!selected) throw new Error(`${command} requires a selected source object.`)
if (facade.gui.command.getState(command).status !== 'enabled') throw new Error(`${command} is not enabled for ${selected}.`)
facade.gui.command.execute({ commandId: command })
const task = facade.task.getActive()
if (!task || task.commandId !== command || task.status !== 'preview') throw new Error(`${command} did not open a preview task.`)
facade.task.update(draft)
facade.task.apply()
const object = latestObject(facade, previous)
const recompute = await facade.app.document.recomputeAsync({ dirtyObjectIds: [object.id] })
if (recompute.status !== 'completed') throw new Error(`${command} recompute failed: ${JSON.stringify(recompute.errors)}`)
const tip = property(facade, 'body', 'Tip')
const shapeCount = facade.geometry.capabilities().shapeCount
const kernelReferenceCount = facade.geometry.capabilities().kernelReferenceCount
const shape = facade.geometry.getObjectShape(object.id)
if (tip !== object.id) throw new Error(`${command} did not redirect Body.Tip to ${object.id}; got ${String(tip)}.`)
if (!shape || shapeCount < 1 || kernelReferenceCount < 1) throw new Error(`${command} did not retain a recomputed ShapeHandle.`)
const [mass, quality] = await Promise.all([facade.geometry.massProperties(shape), facade.geometry.qualityReport(shape)])
if (!(mass.volume > 0)) throw new Error(`${command} did not produce a positive-volume Shape.`)
if (quality.isNull || quality.solids !== 1) throw new Error(`${command} violated the PartDesign single-solid rule: ${JSON.stringify(quality)}`)
return {
command,
objectId: object.id,
typeId: object.typeId,
base: property(facade, object.id, 'Base'),
tip,
recompute: recompute.status,
shapeCount,
kernelReferenceCount,
shapeId: shape.id,
volume: mass.volume,
solids: quality.solids,
structuralValid: quality.structuralValid,
transformMode: property(facade, object.id, 'TransformMode') ?? undefined,
originals: property(facade, object.id, 'Originals') ?? undefined,
threaded: property(facade, object.id, 'Threaded') ?? undefined,
modelThread: property(facade, object.id, 'ModelThread') ?? undefined,
threadType: property(facade, object.id, 'ThreadType') ?? undefined,
threadSize: property(facade, object.id, 'ThreadSize') ?? undefined,
threadDirection: property(facade, object.id, 'ThreadDirection') ?? undefined,
}
}
const run = async (): Promise<TransformReport> => {
const facade = createMockFacade()
const report: TransformReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true, operations: [] }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome PartDesign transform evidence requires isolated OPFS.')
const initialized = await facade.geometry.initialize()
if (initialized.status !== 'ready') throw new Error(initialized.reason || 'Bitbybit geometry runtime is unavailable.')
for (const edge of rectangle) facade.app.sketcher.addGeometry('sketch', edge)
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: true })
facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Suppressed', value: true })
const initialRecompute = await facade.app.document.recomputeAsync()
if (initialRecompute.status !== 'completed') throw new Error(`Initial Pad recompute failed: ${JSON.stringify(initialRecompute.errors)}`)
facade.selection.select('pad')
const asymmetricBase = await applyFeature(facade, 'hole', { base: 'pad', diameter: 1, depth: 8, type: 'Dimension', position: { x: 1.3, y: 0.8, z: 0 }, direction: { x: 0, y: 0, z: 1 }, holeCutType: 'None' })
report.operations.push(asymmetricBase)
selectSubshape(facade, asymmetricBase.objectId, 'edge', 0)
report.operations.push(await applyFeature(facade, 'fillet', { radius: 0.4 }))
selectSubshape(facade, asymmetricBase.objectId, 'edge', 1)
report.operations.push(await applyFeature(facade, 'chamfer', { size: 0.3, chamferType: 'Equal distance' }))
await selectOperableFace(facade, asymmetricBase.objectId, 'draft')
report.operations.push(await applyFeature(facade, 'draft', { angle: 5, direction: { x: 0, y: 0, z: 1 }, neutralPlaneOrigin: { x: 0, y: 0, z: 0 }, neutralPlaneDirection: { x: 0, y: 0, z: 1 } }))
await selectOperableFace(facade, asymmetricBase.objectId, 'thickness')
report.operations.push(await applyFeature(facade, 'thickness', { value: 0.4, reversed: true, join: 'Arc' }))
const fillet = report.operations[1]
const originalFilletBase = property(facade, fillet.objectId, 'Base') as { persistentId: string; objectId: string; kind: string; status: string }
const retainedFilletShape = facade.geometry.getObjectShape(fillet.objectId)
facade.app.document.setProperty({ objectId: fillet.objectId, propertyName: 'Base', value: { ...originalFilletBase, status: 'ambiguous', candidates: [originalFilletBase.persistentId, 'ambiguous-edge-candidate'] } })
const ambiguous = await facade.app.document.recomputeAsync({ dirtyObjectIds: [fillet.objectId] })
const ambiguityDiagnostic = facade.diagnostics.list().find((entry) => entry.objectId === fillet.objectId && entry.code === 'DRESSUP_EDGE_REFERENCE_UNRESOLVED')
const shapeDuringAmbiguity = facade.geometry.getObjectShape(fillet.objectId)
if (ambiguous.status !== 'failed' || !ambiguityDiagnostic || !retainedFilletShape || shapeDuringAmbiguity?.id !== retainedFilletShape.id) throw new Error('Fillet ambiguity did not retain the last valid Shape and report a stable diagnostic.')
facade.app.document.setProperty({ objectId: fillet.objectId, propertyName: 'Base', value: originalFilletBase })
const restoredFillet = await facade.app.document.recomputeAsync({ dirtyObjectIds: [fillet.objectId] })
const restoredFilletShape = facade.geometry.getObjectShape(fillet.objectId)
if (restoredFillet.status !== 'completed' || !restoredFilletShape) throw new Error('Fillet did not recover after resolving the ambiguous edge.')
report.ambiguityRecovery = { command: 'fillet', status: ambiguous.status, code: ambiguityDiagnostic.code, retainedShapeId: shapeDuringAmbiguity.id, previousShapeId: retainedFilletShape.id, restoredStatus: restoredFillet.status, restoredShapeId: restoredFilletShape.id }
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 31 })
const migratedRecompute = await facade.app.document.recomputeAsync()
if (migratedRecompute.status !== 'completed') throw new Error(`Dress-up topology migration recompute failed: ${JSON.stringify(migratedRecompute.errors)}`)
const selectionProperties = [
{ operation: report.operations[1], propertyName: 'Base' },
{ operation: report.operations[2], propertyName: 'Base' },
{ operation: report.operations[3], propertyName: 'Base' },
{ operation: report.operations[4], propertyName: 'RemoveFaces' },
]
const migrated = selectionProperties.map(({ operation, propertyName }) => {
const ref = property(facade, operation.objectId, propertyName) as { objectId?: string; kind?: string; status?: string; persistentId?: string }
return { command: operation.command, objectId: String(ref.objectId), kind: String(ref.kind), status: String(ref.status), persistentId: String(ref.persistentId) }
})
const stable = migrated.filter((entry) => entry.status === 'stable').length
const ambiguousCount = migrated.filter((entry) => entry.status === 'ambiguous').length
const topologyDiagnostics = facade.diagnostics.list().filter((entry) => entry.code === 'TOPOLOGY_REFERENCE_AMBIGUOUS' && migrated.some((candidate) => candidate.objectId === asymmetricBase.objectId && candidate.status === 'ambiguous' && candidate.command && entry.objectId === report.operations.find((operation) => operation.command === candidate.command)?.objectId))
const wrongBindings = migrated.filter((entry) => entry.objectId !== asymmetricBase.objectId || !['stable', 'ambiguous'].includes(entry.status) || !entry.persistentId).length
if (wrongBindings !== 0 || stable + ambiguousCount !== migrated.length || topologyDiagnostics.length !== ambiguousCount) throw new Error(`Dress-up topology migration produced unsafe bindings or incomplete diagnostics: ${JSON.stringify({ migrated, topologyDiagnostics })}`)
report.topologyMigration = { editedPadLength: Number(property(facade, 'pad', 'Length')), migrated, stable, ambiguous: ambiguousCount, ambiguityDiagnostics: topologyDiagnostics.length, wrongBindings }
facade.selection.select(asymmetricBase.objectId)
const cosmeticThread = await applyFeature(facade, 'hole', { base: asymmetricBase.objectId, diameter: 0.75, depth: 8, type: 'Dimension', position: { x: 0.8, y: 0.8, z: 0 }, direction: { x: 0, y: 0, z: 1 }, threaded: true, modelThread: false, threadType: 'ISOMetricProfile', threadSize: 'M1x0.25', threadDiameter: 1, threadPitch: 0.25, threadDirection: 'Right' })
report.operations.push(cosmeticThread)
facade.selection.select(cosmeticThread.objectId)
report.operations.push(await applyFeature(facade, 'hole', { base: cosmeticThread.objectId, diameter: 1.6, depth: 8, type: 'Dimension', position: { x: 3.1, y: 2.2, z: 0 }, direction: { x: 0, y: 0, z: 1 }, threaded: true, modelThread: true, threadType: 'ISOMetricProfile', threadSize: 'M2x0.4', threadDiameter: 2, threadPitch: 0.4, threadDirection: 'Left', threadDepthType: 'Dimension', threadDepth: 1.2 }))
facade.selection.select(cosmeticThread.objectId)
report.operations.push(await applyFeature(facade, 'linear-pattern', { base: cosmeticThread.objectId, transformMode: 'Features', originals: [asymmetricBase.objectId], occurrences: 2, length: 1, offset: 1, direction: 'Horizontal', mode: 'Extent' }))
facade.selection.select(report.operations.at(-1)!.objectId)
report.operations.push(await applyFeature(facade, 'mirrored', { base: report.operations.at(-1)!.objectId, transformMode: 'Features', originals: [asymmetricBase.objectId], plane: 'YZ plane', planeOrigin: { x: 2, y: 0, z: 0 }, planeNormal: { x: 1, y: 0, z: 0 }, fuse: true }))
facade.selection.select(report.operations.at(-1)!.objectId)
report.operations.push(await applyFeature(facade, 'mirrored', { base: report.operations.at(-1)!.objectId, plane: 'YZ plane', fuse: false }))
facade.selection.select(report.operations.at(-1)!.objectId)
report.operations.push(await applyFeature(facade, 'linear-pattern', { base: report.operations.at(-1)!.objectId, occurrences: 3, length: 2, offset: 2, direction: 'Horizontal', mode: 'Extent', reversed: false }))
facade.selection.select(report.operations.at(-1)!.objectId)
report.operations.push(await applyFeature(facade, 'polar-pattern', { base: report.operations.at(-1)!.objectId, occurrences: 3, angle: 270, axis: 'Normal', mode: 'Extent', reversed: false }))
facade.selection.select(report.operations.at(-1)!.objectId)
const transformations: MultiTransformValue = { steps: [{ id: 'linear-1', type: 'linear', occurrences: 2, length: 1, direction: 'Horizontal' }, { id: 'polar-1', type: 'polar', occurrences: 2, angle: 180, axis: 'Normal' }] }
const multi = await applyFeature(facade, 'multi-transform', { base: report.operations.at(-1)!.objectId, transformations })
report.operations.push({ ...multi, base: property(facade, multi.objectId, 'Base') })
facade.selection.select(report.operations.at(-1)!.objectId)
report.operations.push(await applyFeature(facade, 'hole', { base: report.operations.at(-1)!.objectId, diameter: 2, depth: 8, type: 'Dimension', position: { x: 2, y: 1.5, z: 0 }, direction: { x: 0, y: 0, z: 1 }, holeCutType: 'Counterbore', holeCutDiameter: 3.5, holeCutDepth: 1.5 }))
const transformed = facade.app.document.getActive()
const linear = report.operations.find((entry) => entry.command === 'linear-pattern' && entry.transformMode === 'Whole shape')
const polar = report.operations.find((entry) => entry.command === 'polar-pattern')
const multiObject = report.operations.find((entry) => entry.command === 'multi-transform')
const hole = report.operations.find((entry) => entry.command === 'hole' && entry.modelThread === true)
if (!linear || property(facade, linear.objectId, 'Occurrences') !== 3 || property(facade, linear.objectId, 'Direction') !== 'Horizontal') throw new Error('Linear Pattern parameter contract was not persisted.')
if (!polar || property(facade, polar.objectId, 'Occurrences') !== 3 || property(facade, polar.objectId, 'Angle') !== 270) throw new Error('Polar Pattern parameter contract was not persisted.')
if (!multiObject || !property(facade, multiObject.objectId, 'Transformations') || JSON.stringify(property(facade, multiObject.objectId, 'Transformations')) !== JSON.stringify(transformations)) throw new Error('MultiTransform ordered step contract was not persisted.')
const featureTransforms = report.operations.filter((entry) => entry.transformMode === 'Features')
const threadedHoles = report.operations.filter((entry) => entry.command === 'hole' && entry.threaded === true)
if (featureTransforms.length !== 2 || featureTransforms.some((entry) => JSON.stringify(entry.originals) !== JSON.stringify([asymmetricBase.objectId]))) throw new Error('Feature-list transform contracts were not persisted.')
if (threadedHoles.length !== 2 || threadedHoles[0].modelThread !== false || threadedHoles[1].modelThread !== true || threadedHoles[1].threadDirection !== 'Left') throw new Error('Hole thread standard/model contracts were not persisted.')
if (!hole || property(facade, hole.objectId, 'ThreadType') !== 'ISOMetricProfile' || property(facade, hole.objectId, 'ThreadSize') !== 'M2x0.4') throw new Error('Hole thread parameter contract was not persisted.')
const saved = await facade.project.save(transformed)
const loaded = await facade.project.load(transformed.id)
if (!loaded) throw new Error('PartDesign transform document did not load from OPFS.')
await facade.app.document.load(transformed.id)
const reopened = facade.app.document.getActive()
const reopenedTip = property(facade, 'body', 'Tip')
if (reopened.objects.length !== transformed.objects.length || reopenedTip !== report.operations.at(-1)?.objectId) throw new Error('PartDesign transform OPFS round-trip changed object count or Body.Tip.')
const directory = await navigator.storage.getDirectory()
const markerName = 'partdesign-transform-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'PD-TRANSFORM', operations: report.operations.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; operations: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.persistence = { mode: facade.project.capabilities().mode, savedVersion: saved.documentVersion, reopenedVersion: reopened.version, reopenedTip, objectCount: reopened.objects.length }
report.opfs = { markerSuite: markerPayload.suite, markerOperations: markerPayload.operations, markerRemoved }
report.beforeRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }
report.status = 'pass'
} catch (error) {
report.error = error instanceof Error ? error.stack || error.message : String(error)
} finally {
facade.geometry.dispose()
await new Promise((resolve) => setTimeout(resolve, 0))
report.afterRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }
if (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed: ${JSON.stringify(report.afterRelease)}` }
}
return report
}
run().then((report) => {
;(window as Window & { __bitbybitPartdesignTransformReport?: TransformReport }).__bitbybitPartdesignTransformReport = 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 {}

View File

@@ -0,0 +1,7 @@
import { createMockFacade } from './facade/mockFacade'
import { runPerformanceBudget } from './facade/performanceBudget'
type PerformanceReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; benchmark?: { objects: number; triangles: number; tableCells: number; objectMs: number; triangleMs: number; tableMs: number; heapBytes: number | null; pass: boolean }; persistence?: { mode: string; byteLength: number; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; triangles: 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 run = async (): Promise<PerformanceReport> => { const facade = createMockFacade(); const report: PerformanceReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }; try { if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome performance evidence requires isolated OPFS.'); await facade.project.list(); const benchmark = runPerformanceBudget(); const payload = new TextEncoder().encode(JSON.stringify(benchmark)); const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.performance+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 = 'performance-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'QA-05', triangles: benchmark.triangles.count })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; triangles: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }; report.benchmark = { objects: benchmark.objects.count, triangles: benchmark.triangles.count, tableCells: benchmark.table.cells, objectMs: benchmark.objects.durationMs, triangleMs: benchmark.triangles.durationMs, tableMs: benchmark.table.durationMs, heapBytes: benchmark.heapBytes, pass: benchmark.pass }; report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, roundTrip, released }; report.opfs = { markerSuite: markerPayload.suite, triangles: markerPayload.triangles, markerRemoved }; report.status = report.benchmark.objects === 1000 && report.benchmark.triangles === 1_000_000 && report.benchmark.tableCells === 100_000 && report.benchmark.pass && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'QA-05' && report.opfs.triangles === 1_000_000 && 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 & { __bitbybitPerformanceReport?: PerformanceReport }).__bitbybitPerformanceReport = 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 {}

137
src/chromePlotHarness.ts Normal file
View File

@@ -0,0 +1,137 @@
import { createMockFacade } from './facade/mockFacade'
import { createPlot } from './facade/plot'
import { createSpreadsheet } from './facade/spreadsheet'
type PlotReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
series?: { count: number; boundSource: string; points: number; updatedValue: number; legend: boolean }
axes?: { x: string; y: string; xRange: number[]; yRange: number[] }
logAxis?: { xScale: string; yScale: string; svgHasLogMapping: boolean; rejectedNonPositive: boolean }
svg?: { bytes: number; sha256: string; deterministic: boolean; paths: number; accessible: boolean }
png?: { bytes: number; sha256: string; deterministic: boolean; width: number; height: number; validSignature: boolean }
csv?: { bytes: number; rows: number; sha256: string }
resources?: Array<{ mediaType: string; hash: string; byteLength: number; roundTrip: boolean; released: boolean }>
opfs?: { markerSuite: string; markerSeries: 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 rasterizeSvg = async (svg: string, width: number, height: number) => {
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }))
try {
const source = new Image()
await new Promise<void>((resolve, reject) => {
source.addEventListener('load', () => resolve(), { once: true })
source.addEventListener('error', () => reject(new Error('SVG image decoding failed.')), { once: true })
source.src = url
})
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas 2D context is unavailable.')
context.fillStyle = '#ffffff'
context.fillRect(0, 0, width, height)
context.drawImage(source, 0, 0, width, height)
const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error('PNG encoding failed.')), 'image/png'))
return new Uint8Array(await blob.arrayBuffer())
} finally {
URL.revokeObjectURL(url)
}
}
const run = async (): Promise<PlotReport> => {
const facade = createMockFacade()
const report: PlotReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome Plot evidence requires isolated OPFS.')
await facade.project.list()
const sheet = createSpreadsheet('Spreadsheet', 'Plot data')
sheet.setCell('A1', 0); sheet.setCell('A2', 1); sheet.setCell('A3', 2)
sheet.setCell('B1', '6 mm'); sheet.setCell('B2', '12 mm'); sheet.setCell('B3', '16 mm')
const cells = sheet.evaluate().cells
const boundPoints = ['1', '2', '3'].map((row) => ({ x: cells[`A${row}`].value!.value, y: cells[`B${row}`].value!.value }))
const plot = createPlot('plot', 'Length history')
plot.setAxes({ x: { label: 'Revision', scale: 'linear', minimum: 0, maximum: 2 }, y: { label: 'Length (mm)', scale: 'linear', minimum: 0, maximum: 20 } })
plot.setSeries({ id: 'length', label: 'Measured length', points: boundPoints.slice(0, 2), style: { color: '#007f86', lineWidth: 2 } })
plot.bindSeries('length', { sourceId: 'Spreadsheet', xRange: 'A1:A3', yRange: 'B1:B3' })
plot.setSeries({ id: 'target', label: 'Target', points: [{ x: 0, y: 10 }, { x: 1, y: 10 }, { x: 2, y: 10 }], style: { color: '#c24135', lineWidth: 1.5, dash: [6, 3] } })
plot.updateBoundSeries('length', boundPoints)
const snapshot = plot.snapshot()
const svg = plot.exportSvg(640, 360)
const secondSvg = plot.exportSvg(640, 360)
const csv = plot.exportCsv()
const svgBytes = new TextEncoder().encode(svg)
const csvBytes = new TextEncoder().encode(csv)
const pngBytes = await rasterizeSvg(svg, 640, 360)
const secondPngBytes = await rasterizeSvg(svg, 640, 360)
const svgHash = await sha256(svgBytes)
const csvHash = await sha256(csvBytes)
const pngHash = await sha256(pngBytes)
const logPlot = createPlot('log-plot', 'Log history')
logPlot.setAxes({ x: { label: 'Log X', scale: 'log', minimum: 1, maximum: 100 }, y: { label: 'Log Y', scale: 'log', minimum: 1, maximum: 1000 } })
logPlot.setSeries({ id: 'log', label: 'Log series', points: [{ x: 1, y: 1 }, { x: 10, y: 10 }, { x: 100, y: 1000 }], style: { color: '#007f86', lineWidth: 1 } })
const logSnapshot = logPlot.snapshot()
const logSvg = logPlot.exportSvg()
let rejectedNonPositive = false
try { logPlot.setSeries({ id: 'invalid-log', label: 'Invalid', points: [{ x: 0, y: 1 }, { x: 1, y: 2 }], style: { color: '#c24135', lineWidth: 1 } }) } catch { rejectedNonPositive = true }
const resources: PlotReport['resources'] = []
for (const [bytes, mediaType] of [[svgBytes, 'image/svg+xml'], [csvBytes, 'text/csv'], [pngBytes, 'image/png']] as const) {
const stored = await facade.project.resource.put(bytes, mediaType)
const loaded = await facade.project.resource.get(stored.hash)
const roundTrip = loaded !== null && await sha256(loaded) === await sha256(bytes)
await facade.project.resource.release(stored.hash)
const released = await facade.project.resource.get(stored.hash) === null
resources.push({ mediaType, hash: stored.hash, byteLength: stored.byteLength, roundTrip, released })
}
const directory = await navigator.storage.getDirectory()
const markerName = 'plot-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'PLOT-ALL', series: snapshot.series.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; series: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.series = { count: snapshot.series.length, boundSource: snapshot.series.find((series) => series.id === 'length')?.binding?.sourceId ?? '', points: snapshot.series.find((series) => series.id === 'length')?.points.length ?? 0, updatedValue: snapshot.series.find((series) => series.id === 'length')?.points.at(-1)?.y ?? Number.NaN, legend: snapshot.legend }
report.axes = { x: snapshot.xAxis.label, y: snapshot.yAxis.label, xRange: [snapshot.xAxis.minimum!, snapshot.xAxis.maximum!], yRange: [snapshot.yAxis.minimum!, snapshot.yAxis.maximum!] }
report.logAxis = { xScale: logSnapshot.xAxis.scale, yScale: logSnapshot.yAxis.scale, svgHasLogMapping: logSvg.includes('data-x-scale="log" data-y-scale="log"') && /M56\.000 312\.000/.test(logSvg), rejectedNonPositive }
report.svg = { bytes: svgBytes.byteLength, sha256: svgHash, deterministic: svg === secondSvg, paths: (svg.match(/<path /g) ?? []).length, accessible: svg.includes('role="img"') && svg.includes('aria-label="Length history"') }
report.png = { bytes: pngBytes.byteLength, sha256: pngHash, deterministic: pngHash === await sha256(secondPngBytes), width: 640, height: 360, validSignature: [137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => pngBytes[index] === value) }
report.csv = { bytes: csvBytes.byteLength, rows: csv.split('\n').length, sha256: csvHash }
report.resources = resources
report.opfs = { markerSuite: markerPayload.suite, markerSeries: markerPayload.series, markerRemoved }
report.status = report.series.count === 2 && report.series.boundSource === 'Spreadsheet' && report.series.points === 3 && report.series.updatedValue === 16 && report.series.legend
&& JSON.stringify(report.axes.xRange) === JSON.stringify([0, 2]) && JSON.stringify(report.axes.yRange) === JSON.stringify([0, 20])
&& report.logAxis.xScale === 'log' && report.logAxis.yScale === 'log' && report.logAxis.svgHasLogMapping && report.logAxis.rejectedNonPositive
&& report.svg.bytes > 0 && report.svg.deterministic && report.svg.paths === 2 && report.svg.accessible && report.png.bytes > 0 && report.png.deterministic && report.png.validSignature && report.png.width === 640 && report.png.height === 360 && report.csv.rows === 7
&& resources.every((resource) => resource.roundTrip && resource.released) && report.opfs.markerSuite === 'PLOT-ALL' && report.opfs.markerSeries === 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))
report.afterRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }
if (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed.` }
}
return report
}
run().then((report) => {
;(window as Window & { __bitbybitPlotReport?: PlotReport }).__bitbybitPlotReport = 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 {}

View File

@@ -0,0 +1,140 @@
import { createMockFacade } from './facade/mockFacade'
import { createDraftDocument } from './facade/draft'
import { createPlot } from './facade/plot'
import { createProductionDocument } from './facade/productionDocument'
import { createSpreadsheet } from './facade/spreadsheet'
import { createTechDrawPage } from './facade/techDraw'
import type { TopoRefValue } from './facade/types'
type ProductionReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
closure?: { complete: boolean; artifacts: number; missingKinds: number; missingDependencies: number; cycles: number; documentVersion: number; reopenedComplete: boolean; artifactVersions: number[] }
workflow?: {
created: { spreadsheetCells: number; draftObjects: number; techdrawViews: number; plotSeries: number }
edited: { spreadsheetValue: number; draftStartX: number; techdrawDimension: number; plotValue: number }
exports: { spreadsheetCsvBytes: number; draftJsonBytes: number; techdrawSvgBytes: number; techdrawPdfBytes: number; techdrawPdfValid: boolean; plotSvgBytes: number; plotCsvBytes: number }
}
persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; artifacts: 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 ref = (id: string, version: number): TopoRefValue => ({ schemaVersion: 1, objectId: 'draft-line', kind: 'vertex', persistentId: id, topologyVersion: version, generation: version, status: 'stable', signature: id })
const run = async (): Promise<ProductionReport> => {
const facade = createMockFacade()
const report: ProductionReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome production document evidence requires isolated OPFS.')
await facade.project.list()
const document = createProductionDocument('doc', 'Production workflow')
const sheet = createSpreadsheet('sheet', 'Parameters')
sheet.setCell('A1', 2, { alias: 'Input' })
sheet.setCell('A2', '=Input * 2', { alias: 'Output' })
const initialSheet = sheet.evaluate()
document.add({ id: 'sheet', kind: 'Spreadsheet', dependencies: [], payload: { snapshot: sheet.snapshot(), evaluation: initialSheet } })
const draft = createDraftDocument('draft', 'Profile')
draft.createLine('profile', { x: 0, y: 0 }, { x: 4, y: 0 })
document.add({ id: 'draft', kind: 'Draft', dependencies: [], payload: { snapshot: draft.snapshot() } })
const page = createTechDrawPage('page', 'Profile drawing')
page.addSource({ id: 'draft-line', revision: 1, points: [{ id: 'start', ref: ref('Vertex1', 1), point: { x: 0, y: 0, z: 0 } }, { id: 'end', ref: ref('Vertex2', 1), point: { x: 4, y: 0, z: 0 } }], edges: [{ id: 'edge', start: 'start', end: 'end' }] })
page.addView({ id: 'front', sourceId: 'draft-line', direction: { x: 0, y: 0, z: 1 }, position: { x: 80, y: 80 }, scale: 8 })
page.addDimension({ id: 'length', sourceId: 'draft-line', first: ref('Vertex1', 1), second: ref('Vertex2', 1), label: 'Profile length' })
page.addAnnotation({ id: 'note', text: 'Production profile', position: { x: 20, y: 20 } })
page.addGeometricTolerance({ id: 'straightness', sourceId: 'draft-line', reference: ref('Vertex1', 1), characteristic: 'flatness', value: 0.05 })
document.add({ id: 'page', kind: 'TechDraw', dependencies: ['draft'], payload: { snapshot: page.snapshot() } })
const plot = createPlot('plot', 'Parameter history')
plot.setAxes({ x: { label: 'Revision', scale: 'linear', minimum: 2, maximum: 3 }, y: { label: 'Output', scale: 'linear', minimum: 0, maximum: 8 } })
plot.setSeries({ id: 'output', label: 'Output', points: [{ x: 2, y: 4 }, { x: 3, y: 4 }], style: { color: '#007f86', lineWidth: 2 } })
plot.bindSeries('output', { sourceId: 'sheet', xRange: 'A1:A2', yRange: 'A1:A2' })
document.add({ id: 'plot', kind: 'Plot', dependencies: ['sheet'], payload: { snapshot: plot.snapshot() } })
sheet.setCell('A1', 3, { alias: 'Input' })
const editedSheet = sheet.evaluate()
const spreadsheetValue = editedSheet.cells.A2.value?.value ?? Number.NaN
const movedLine = draft.move('profile', { x: 1, y: 2 })
page.updateSource({ id: 'draft-line', revision: 2, points: [{ id: 'start', ref: ref('Vertex1', 2), point: { x: 1, y: 2, z: 0 } }, { id: 'end', ref: ref('Vertex2', 2), point: { x: spreadsheetValue, y: 2, z: 0 } }], edges: [{ id: 'edge', start: 'start', end: 'end' }] })
plot.updateBoundSeries('output', [{ x: 2, y: 4 }, { x: 3, y: spreadsheetValue }])
const spreadsheetCsv = sheet.exportCsv()
const draftJson = JSON.stringify(draft.snapshot())
const techdrawSvg = page.exportSvg()
const techdrawPdf = page.exportPdf()
const plotSvg = plot.exportSvg()
const plotCsv = plot.exportCsv()
const techdrawPdfText = new TextDecoder().decode(techdrawPdf)
document.update('sheet', { payload: { snapshot: sheet.snapshot(), evaluation: editedSheet, exportBytes: new TextEncoder().encode(spreadsheetCsv).byteLength } })
document.update('draft', { payload: { snapshot: draft.snapshot(), exportBytes: new TextEncoder().encode(draftJson).byteLength } })
document.update('page', { payload: { snapshot: page.snapshot(), svgBytes: new TextEncoder().encode(techdrawSvg).byteLength, pdfBytes: techdrawPdf.byteLength } })
document.update('plot', { payload: { snapshot: plot.snapshot(), svgBytes: new TextEncoder().encode(plotSvg).byteLength, csvBytes: new TextEncoder().encode(plotCsv).byteLength } })
const closure = document.closure()
const serialized = document.save()
const reopened = createProductionDocument('reopened', 'Reopened production workflow')
const reopenedSnapshot = reopened.load(serialized)
const reopenedClosure = reopened.closure()
const payload = new TextEncoder().encode(serialized)
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.production-document+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 = 'production-document-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'DOC-CLOSURE', artifacts: closure.artifacts }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; artifacts: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
const pageSnapshot = page.snapshot()
const plotSnapshot = plot.snapshot()
report.closure = { complete: closure.complete, artifacts: closure.artifacts, missingKinds: closure.missingKinds.length, missingDependencies: closure.missingDependencies.length, cycles: closure.cycles.length, documentVersion: document.snapshot().version, reopenedComplete: reopenedClosure.complete && reopenedSnapshot.artifacts.length === 4, artifactVersions: document.snapshot().artifacts.map((artifact) => artifact.version).sort() }
report.workflow = {
created: { spreadsheetCells: 2, draftObjects: 1, techdrawViews: 1, plotSeries: 1 },
edited: { spreadsheetValue, draftStartX: movedLine.kind === 'line' ? movedLine.start.x : Number.NaN, techdrawDimension: pageSnapshot.dimensions[0]?.value ?? Number.NaN, plotValue: plotSnapshot.series[0]?.points.at(-1)?.y ?? Number.NaN },
exports: { spreadsheetCsvBytes: new TextEncoder().encode(spreadsheetCsv).byteLength, draftJsonBytes: new TextEncoder().encode(draftJson).byteLength, techdrawSvgBytes: new TextEncoder().encode(techdrawSvg).byteLength, techdrawPdfBytes: techdrawPdf.byteLength, techdrawPdfValid: techdrawPdfText.startsWith('%PDF-1.4') && techdrawPdfText.endsWith('%%EOF\n'), plotSvgBytes: new TextEncoder().encode(plotSvg).byteLength, plotCsvBytes: new TextEncoder().encode(plotCsv).byteLength },
}
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, artifacts: markerPayload.artifacts, markerRemoved }
report.status = report.closure.complete && report.closure.artifacts === 4 && report.closure.missingKinds === 0 && report.closure.missingDependencies === 0 && report.closure.cycles === 0 && report.closure.documentVersion === 8 && report.closure.reopenedComplete && report.closure.artifactVersions.every((version) => version === 2)
&& report.workflow.created.spreadsheetCells === 2 && report.workflow.created.draftObjects === 1 && report.workflow.created.techdrawViews === 1 && report.workflow.created.plotSeries === 1
&& report.workflow.edited.spreadsheetValue === 6 && report.workflow.edited.draftStartX === 1 && report.workflow.edited.techdrawDimension === 5 && report.workflow.edited.plotValue === 6
&& Object.entries(report.workflow.exports).every(([name, value]) => name === 'techdrawPdfValid' ? value === true : typeof value === 'number' && value > 0)
&& report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'DOC-CLOSURE' && report.opfs.artifacts === 4 && 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 & { __bitbybitProductionReport?: ProductionReport }).__bitbybitProductionReport = 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 {}

7
src/chromeQa08Harness.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createMockFacade } from './facade/mockFacade'
import { checkAccessibilitySemantics, formatLength, localeSnapshot, supportedLocales } from './facade/locale'
type Qa08Report = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; locales?: { supported: number; snapshots: number; longLabel: boolean; decimalSamples: string[] }; accessibility?: { checked: number; missingNames: number; duplicates: number; unnamedFocusable: number; pass: boolean }; persistence?: { mode: string; byteLength: number; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; localeCount: 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 run = async (): Promise<Qa08Report> => { const facade = createMockFacade(); const report: Qa08Report = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }; try { if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome QA-08 evidence requires isolated OPFS.'); await facade.project.list(); const locales = supportedLocales(); const snapshots = locales.map(localeSnapshot); const semantics = checkAccessibilitySemantics([{ id: 'workbench', role: 'combobox', accessibleName: 'Workbench', focusable: true }, { id: 'save', role: 'button', accessibleName: 'Save project', focusable: true }, { id: 'viewport', role: 'img', accessibleName: '3D viewport' }, { id: 'status', role: 'status', accessibleName: 'Ready' }]); const payload = new TextEncoder().encode(JSON.stringify(snapshots)); const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.qa08+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 = 'qa08-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'QA-08', localeCount: locales.length })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; localeCount: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }; report.locales = { supported: locales.length, snapshots: snapshots.length, longLabel: snapshots.every((entry) => entry.messages.longLabel.length > 20), decimalSamples: snapshots.map((entry) => entry.samples.decimal) }; report.accessibility = { checked: semantics.checked, missingNames: semantics.missingNames.length, duplicates: semantics.duplicateIds.length, unnamedFocusable: semantics.unnamedFocusable.length, pass: semantics.pass }; report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, roundTrip, released }; report.opfs = { markerSuite: markerPayload.suite, localeCount: markerPayload.localeCount, markerRemoved }; report.status = report.locales.supported === 3 && report.locales.snapshots === 3 && report.locales.longLabel && report.locales.decimalSamples.every((sample) => sample.includes('mm')) && report.accessibility.checked === 4 && report.accessibility.pass && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'QA-08' && report.opfs.localeCount === 3 && 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 & { __bitbybitQa08Report?: Qa08Report }).__bitbybitQa08Report = 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 {}

View File

@@ -0,0 +1,7 @@
import { createMockFacade } from './facade/mockFacade'
import { createRobot } from './facade/robot'
type RobotReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; geometry?: { sourceShape: string; valid: boolean; volume: number }; trajectory?: { joints: number; waypoints: number; poses: number; status: string; violations: number; maxReach: number; homeX: number; collisions: number; controllerBytes: number; csvBytes: number }; persistence?: { mode: string; byteLength: number; hash: string; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; poses: 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 run = async (): Promise<RobotReport> => { const facade = createMockFacade(); const report: RobotReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }; try { if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome Robot evidence requires isolated OPFS.'); await facade.project.list(); const shape = await facade.geometry.createBox({ documentId: 'robot', documentVersion: 1, width: 2, length: 2, height: 2 }); const quality = await facade.geometry.qualityReport(shape); const mass = await facade.geometry.massProperties(shape); const robot = createRobot('arm', 'Robot arm'); robot.addJoint({ id: 'J1', label: 'Base', minimum: -180, maximum: 180, linkLength: 3 }); robot.addJoint({ id: 'J2', label: 'Arm', minimum: -90, maximum: 90, linkLength: 2 }); robot.addJoint({ id: 'J3', label: 'Wrist', minimum: -90, maximum: 90, linkLength: 1 }); robot.addWaypoint({ id: 'home', values: [0, 0, 0] }); robot.addWaypoint({ id: 'pick', values: [90, 45, -30] }); const solved = robot.generateLinearTrajectory(4); const validation = robot.validateTrajectory(); const workspace = robot.workspace(); const home = robot.forwardKinematics([0, 0, 0]); const collisions = robot.collisions([{ id: 'home-obstacle', min: [5.9, -0.1, -0.1], max: [6.1, 0.1, 0.1] }]); const controller = new TextEncoder().encode(robot.exportController()); const csv = new TextEncoder().encode(robot.exportCsv()); const snapshot = robot.snapshot(); const payload = new TextEncoder().encode(JSON.stringify(snapshot)); const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.robot+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 = 'robot-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'ROBOT-ALL', poses: snapshot.trajectory.length })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; poses: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }; report.geometry = { sourceShape: shape.id, valid: quality.structuralValid, volume: mass.volume }; report.trajectory = { joints: snapshot.joints.length, waypoints: snapshot.waypoints.length, poses: solved.trajectory.length, status: solved.status, violations: validation.violations.length, maxReach: workspace.maxReach, homeX: home.position[0], collisions: collisions.length, controllerBytes: controller.byteLength, csvBytes: csv.byteLength }; report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, hash: stored.hash, roundTrip, released }; report.opfs = { markerSuite: markerPayload.suite, poses: markerPayload.poses, markerRemoved }; report.status = report.geometry.valid && report.trajectory.joints === 3 && report.trajectory.waypoints === 2 && report.trajectory.poses === 5 && report.trajectory.status === 'generated' && report.trajectory.violations === 0 && report.trajectory.maxReach === 6 && report.trajectory.homeX === 6 && report.trajectory.collisions >= 1 && report.trajectory.controllerBytes > 0 && report.trajectory.csvBytes > 0 && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'ROBOT-ALL' && report.opfs.poses === 5 && 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' } return report }
run().then((report) => { ;(window as Window & { __bitbybitRobotReport?: RobotReport }).__bitbybitRobotReport = 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 = String(error) })
export {}

View File

@@ -0,0 +1,7 @@
import { createMockFacade } from './facade/mockFacade'
import { createScriptSandbox } from './facade/scriptSandbox'
type ScriptReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; macro?: { commands: number; replay: string; executed: number; bytes: number }; security?: { disallowedRejected: boolean; quotaRejected: boolean; noDynamicExecution: boolean; deniedCapabilities: string[] }; persistence?: { mode: string; byteLength: number; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; commands: 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 run = async (): Promise<ScriptReport> => { const facade = createMockFacade(); const report: ScriptReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }; try { if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome script evidence requires isolated OPFS.'); await facade.project.list(); const sandbox = createScriptSandbox({ allowedCommands: ['project.list', 'geometry.createBox'], allowedCapabilities: [], maxCommands: 4, maxArgumentBytes: 512 }); sandbox.record('project.list'); sandbox.record('geometry.createBox', { width: 2, height: 3 }); const macro = sandbox.exportMacro(); const replay = sandbox.replay(sandbox.importMacro(macro)); let disallowedRejected = false; try { sandbox.record('geometry.boolean') } catch { disallowedRejected = true }; const quotaSandbox = createScriptSandbox({ maxArgumentBytes: 8 }); let quotaRejected = false; try { quotaSandbox.record('project.list', 'too-long') } catch { quotaRejected = true }; const deniedCapabilities = (['file', 'network', 'time', 'resource'] as const).filter((capability) => !sandbox.requestCapability(capability).granted); const payload = new TextEncoder().encode(macro); const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.macro+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 = 'script-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'SCRIPT-ALL', commands: sandbox.snapshot().commands.length })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; commands: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }; report.macro = { commands: sandbox.snapshot().commands.length, replay: replay.status, executed: replay.executed, bytes: payload.byteLength }; report.security = { disallowedRejected, quotaRejected, noDynamicExecution: sandbox.snapshot().policy.allowedCapabilities.length === 0, deniedCapabilities }; report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, roundTrip, released }; report.opfs = { markerSuite: markerPayload.suite, commands: markerPayload.commands, markerRemoved }; report.status = report.macro.commands === 2 && report.macro.replay === 'replayed' && report.macro.executed === 2 && report.macro.bytes > 0 && report.security.disallowedRejected && report.security.quotaRejected && report.security.noDynamicExecution && JSON.stringify(report.security.deniedCapabilities) === JSON.stringify(['file', 'network', 'time', 'resource']) && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'SCRIPT-ALL' && report.opfs.commands === 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 & { __bitbybitScriptReport?: ScriptReport }).__bitbybitScriptReport = 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 {}

View File

@@ -0,0 +1,79 @@
import { createMockFacade } from './facade/mockFacade'
import { createSecondaryFormats, type SecondaryFormat } from './facade/secondaryFormats'
type FormatEvidence = { format: SecondaryFormat; category: string; byteLength: number; imported: boolean; rejectedInvalid: boolean; byteExactRoundTrip: boolean; deterministic: boolean; proxy: boolean }
type SecondaryReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; formats?: { descriptors: number; records: number; nativeExports: number; proxyImports: number; bytes: number; categories: string[]; matrix: FormatEvidence[] }; persistence?: { mode: string; byteLength: number; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; records: 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 formats: SecondaryFormat[] = ['DXF', 'SVG', 'OBJ', 'PLY', 'STL', 'PDF', 'IFC', 'CSV']
const run = async (): Promise<SecondaryReport> => {
const facade = createMockFacade()
const report: SecondaryReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome secondary format evidence requires isolated OPFS.')
await facade.project.list()
const api = createSecondaryFormats()
const descriptorByFormat = new Map(api.descriptors().map((entry) => [entry.format, entry]))
const invalid = new TextEncoder().encode('invalid secondary format payload')
const matrix: FormatEvidence[] = []
let bytesTotal = 0
for (const format of formats) {
const id = format.toLowerCase()
const bytes = api.exportBytes(format, { label: 'Chrome format fixture' })
const second = api.exportBytes(format, { label: 'Chrome format fixture' })
bytesTotal += bytes.byteLength
const imported = api.importBytes({ id, format, bytes })
const exported = api.exportRecord(id)
let rejectedInvalid = false
try { api.importBytes({ id: `invalid-${id}`, format, bytes: invalid }) } catch (error) { rejectedInvalid = error instanceof RangeError && /signature/.test(error.message) }
const descriptor = descriptorByFormat.get(format)
if (!descriptor) throw new Error(`${format} descriptor is missing.`)
matrix.push({ format, category: descriptor.category, byteLength: bytes.byteLength, imported: imported.detected, rejectedInvalid, byteExactRoundTrip: await sha256(exported) === await sha256(bytes), deterministic: await sha256(second) === await sha256(bytes), proxy: imported.proxy })
}
const snapshot = api.snapshot()
const payload = new TextEncoder().encode(JSON.stringify(snapshot))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.secondary-formats+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 = 'secondary-formats-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'FC-09', records: snapshot.records.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; records: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.formats = { descriptors: snapshot.descriptors.length, records: snapshot.records.length, nativeExports: snapshot.descriptors.filter((entry) => entry.exportMode === 'native').length, proxyImports: snapshot.records.filter((entry) => entry.proxy).length, bytes: bytesTotal, categories: [...new Set(snapshot.descriptors.map((entry) => entry.category))].sort(), matrix }
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, records: markerPayload.records, markerRemoved }
report.status = report.formats.descriptors === formats.length && report.formats.records === formats.length && report.formats.nativeExports === 3 && report.formats.proxyImports === formats.length && JSON.stringify(report.formats.categories) === JSON.stringify(['2d', 'bim', 'data', 'mesh']) && report.formats.matrix.every((entry) => entry.imported && entry.rejectedInvalid && entry.byteExactRoundTrip && entry.deterministic && entry.proxy) && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'FC-09' && report.opfs.records === formats.length && 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 & { __bitbybitSecondaryReport?: SecondaryReport }).__bitbybitSecondaryReport = 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 {}

View File

@@ -0,0 +1,7 @@
import { createMockFacade } from './facade/mockFacade'
import { createSecurityPreflight } from './facade/securityPreflight'
type SecurityReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; checks?: { path: number; archiveEntries: number; xmlBytes: number; permissionChecks: number; rejectedCases: number; pass: boolean }; persistence?: { mode: string; byteLength: number; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; checks: 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 run = async (): Promise<SecurityReport> => { const facade = createMockFacade(); const report: SecurityReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }; try { if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome security evidence requires isolated OPFS.'); await facade.project.list(); const security = createSecurityPreflight(); security.path('projects/Document.xml'); security.archive([{ path: 'Document.xml', compressedBytes: 100, uncompressedBytes: 500 }, { path: 'GuiDocument.xml', compressedBytes: 100, uncompressedBytes: 700 }]); security.xml('<Document><Object id="box"/></Document>'); security.permissions(['geometry.read', 'project.read']); let rejectedCases = 0; for (const action of [() => security.path('../outside'), () => security.archive([{ path: 'bomb', compressedBytes: 1, uncompressedBytes: 101 }]), () => security.xml('<!DOCTYPE x SYSTEM "file:///etc/passwd">'), () => security.permissions(['network'])]) { try { action() } catch { rejectedCases += 1 } }; const snapshot = security.report(); const payload = new TextEncoder().encode(JSON.stringify(snapshot)); const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.security+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 = 'security-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'QA-07', checks: rejectedCases })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; checks: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }; report.checks = { path: snapshot.pathChecks, archiveEntries: snapshot.archiveEntries, xmlBytes: snapshot.xmlBytes, permissionChecks: snapshot.permissionChecks, rejectedCases, pass: snapshot.errors.length === 4 && rejectedCases === 4 }; report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, roundTrip, released }; report.opfs = { markerSuite: markerPayload.suite, checks: markerPayload.checks, markerRemoved }; report.status = report.checks.path === 5 && report.checks.archiveEntries === 3 && report.checks.xmlBytes > 0 && report.checks.permissionChecks === 3 && report.checks.rejectedCases === 4 && report.checks.pass && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'QA-07' && report.opfs.checks === 4 && 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 & { __bitbybitSecurityReport?: SecurityReport }).__bitbybitSecurityReport = 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 {}

View File

@@ -0,0 +1,145 @@
import { createMockFacade } from './facade/mockFacade'
import { createSpreadsheet } from './facade/spreadsheet'
type SpreadsheetReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
cells?: { width: number; length: number; updatedLength: number; dimension: string }
aliases?: string[]
dependencies?: string[]
cycle?: { detected: boolean; cells: string[] }
binding?: { alias: string; objectId: string; propertyName: string; appliedValue: number; undoValue: number; redoValue: number }
expression?: { value: number; dimension: string; references: string[] }
layout?: { styledCells: number; namedRange: string; mergedRange: string; shiftedFormulaAddress: string }
csv?: { rows: number; columns: number; bytes: number }
largeSheet?: { cells: number; rows: number; columns: number; evaluationErrors: number; csvBytes: number; durationMs: number; budgetMs: number }
persistence?: { mode: string; savedVersion: number; reopenedVersion: number; reopenedValue: number }
resource?: { hash: string; byteLength: number; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; markerCells: number; markerRemoved: boolean }
afterRelease?: { shapeCount: number; kernelReferenceCount: number }
error?: string
}
const propertyValue = (facade: ReturnType<typeof createMockFacade>, objectId: string, name: string) => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === name)?.value
const run = async (): Promise<SpreadsheetReport> => {
const facade = createMockFacade()
const report: SpreadsheetReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome Spreadsheet evidence requires isolated OPFS.')
await facade.project.list()
const sheet = createSpreadsheet('spreadsheet', 'Parameters')
sheet.setCell('A1', '6 mm', { alias: 'Width' })
sheet.setCell('A2', '=Width * 2', { alias: 'Length' })
sheet.setCell('B1', '=max(A1, 4 mm)')
const binding = sheet.bindProperty('Length', 'pad', 'Length')
const first = sheet.evaluate()
const width = first.cells.A1.value
const length = first.cells.A2.value
if (!width || !length || first.errors.length > 0) throw new Error(`Spreadsheet formula evaluation failed: ${JSON.stringify(first.errors)}`)
const expression = facade.app.expression.evaluate('Spreadsheet.Length + 1 mm', { 'Spreadsheet.Length': length })
facade.app.document.setProperty({ objectId: binding.objectId, propertyName: binding.propertyName, value: length.value })
sheet.setCell('A1', '8 mm', { alias: 'Width' })
const updated = sheet.evaluate()
const updatedLength = updated.cells.A2.value
if (!updatedLength) throw new Error('Spreadsheet alias update did not recompute Length.')
facade.app.document.setProperty({ objectId: binding.objectId, propertyName: binding.propertyName, value: updatedLength.value })
const appliedValue = Number(propertyValue(facade, 'pad', 'Length'))
facade.history.undo()
const undoValue = Number(propertyValue(facade, 'pad', 'Length'))
facade.history.redo()
const redoValue = Number(propertyValue(facade, 'pad', 'Length'))
sheet.setCell('C1', '=D1 + 1')
sheet.setCell('D1', '=C1 + 1')
const diagnosed = sheet.evaluate()
const cycleCells = [...new Set(diagnosed.cycles.flat())].sort()
const csv = sheet.exportCsv()
const csvRows = csv.split('\n')
const layout = createSpreadsheet('layout', 'Layout')
layout.setCell('A1', 1)
layout.setCell('A2', '=A1 + 1')
const styledCells = layout.setStyle('A1:B2', { background: '#e6f4f1', bold: true, numberFormat: '0.00' })
layout.mergeCells('B1:A2')
layout.setNamedRange('Inputs', 'A1:A2')
layout.insertRows(2)
layout.insertColumns(2)
const layoutSnapshot = layout.snapshot()
const largeStarted = performance.now()
const large = createSpreadsheet('large', 'Large sheet')
const columnName = (column: number) => { let value = ''; for (let current = column; current > 0; current = Math.floor((current - 1) / 26)) value = String.fromCharCode(65 + ((current - 1) % 26)) + value; return value }
for (let row = 1; row <= 100; row += 1) for (let column = 1; column <= 100; column += 1) large.setCell(`${columnName(column)}${row}`, row + column)
const largeEvaluation = large.evaluate()
const largeCsv = large.exportCsv()
const largeDurationMs = performance.now() - largeStarted
const document = facade.app.document.getActive()
const saved = await facade.project.save(document)
const reopened = await facade.project.load(document.id)
const reopenedValue = Number(reopened?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value)
if (!reopened || reopenedValue !== updatedLength.value) throw new Error('Spreadsheet-bound property did not survive the OPFS project round-trip.')
const payload = new TextEncoder().encode(JSON.stringify({ sheet: sheet.snapshot(), layout: layoutSnapshot }))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.spreadsheet+json')
const resourceRoundTrip = await facade.project.resource.get(stored.hash)
const roundTrip = resourceRoundTrip !== null && new TextDecoder().decode(resourceRoundTrip) === new TextDecoder().decode(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 = 'spreadsheet-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'SS-ALL', cells: sheet.snapshot().cells.length + layoutSnapshot.cells.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; cells: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.cells = { width: width.value, length: length.value, updatedLength: updatedLength.value, dimension: updatedLength.dimension }
report.aliases = sheet.snapshot().cells.flatMap((cell) => cell.alias ? [cell.alias] : []).sort()
report.dependencies = first.dependencies.map((dependency) => `${dependency.source}->${dependency.target}:${dependency.reference}`).sort()
report.cycle = { detected: diagnosed.cycles.length > 0, cells: cycleCells }
report.binding = { ...binding, appliedValue, undoValue, redoValue }
report.expression = { value: expression.value.value, dimension: expression.value.dimension, references: expression.references }
report.layout = { styledCells: styledCells.length, namedRange: layoutSnapshot.namedRanges[0]?.range ?? '', mergedRange: layoutSnapshot.mergedRanges[0] ?? '', shiftedFormulaAddress: layoutSnapshot.cells.find((cell) => cell.input === '=A1 + 1')?.address ?? '' }
report.csv = { rows: csvRows.length, columns: Math.max(...csvRows.map((row) => row.split(',').length)), bytes: new TextEncoder().encode(csv).byteLength }
report.largeSheet = { cells: large.snapshot().cells.length, rows: 100, columns: 100, evaluationErrors: largeEvaluation.errors.length, csvBytes: new TextEncoder().encode(largeCsv).byteLength, durationMs: largeDurationMs, budgetMs: 5000 }
report.persistence = { mode: facade.project.capabilities().mode, savedVersion: saved.documentVersion, reopenedVersion: reopened.version, reopenedValue }
report.resource = { hash: stored.hash, byteLength: stored.byteLength, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, markerCells: markerPayload.cells, markerRemoved }
report.status = report.cells.width === 6 && report.cells.length === 12 && report.cells.updatedLength === 16 && report.cells.dimension === 'length'
&& JSON.stringify(report.aliases) === JSON.stringify(['Length', 'Width'])
&& report.dependencies.length === 2 && report.cycle.detected && JSON.stringify(report.cycle.cells) === JSON.stringify(['C1', 'D1'])
&& report.binding.appliedValue === 16 && report.binding.undoValue === 12 && report.binding.redoValue === 16
&& report.expression.value === 13 && report.expression.dimension === 'length' && report.expression.references.includes('Spreadsheet.Length')
&& report.layout.styledCells === 4 && report.layout.namedRange === 'A1:A3' && report.layout.mergedRange === 'A1:C3' && report.layout.shiftedFormulaAddress === 'A3'
&& report.largeSheet.cells === 10000 && report.largeSheet.evaluationErrors === 0 && report.largeSheet.csvBytes > 0 && report.largeSheet.durationMs <= report.largeSheet.budgetMs
&& report.persistence.mode === 'sqlite-opfs' && report.resource.roundTrip && report.resource.released
&& report.opfs.markerSuite === 'SS-ALL' && report.opfs.markerCells === 9 && 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))
report.afterRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }
if (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed.` }
}
return report
}
run().then((report) => {
;(window as Window & { __bitbybitSpreadsheetReport?: SpreadsheetReport }).__bitbybitSpreadsheetReport = 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 {}

View File

@@ -0,0 +1,49 @@
import { createMockFacade } from './facade/mockFacade'
import { createSurfaceDocument } from './facade/surface'
type SurfaceReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; model?: { patches: number; poles: number; trimmed: number; sewn: number; continuity: string; fillKind: string; offsetDistance: number; topoRefs: number; objBytes: number }; persistence?: { mode: string; byteLength: number; roundTrip: boolean; released: boolean }; opfs?: { markerSuite: string; patches: 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 run = async (): Promise<SurfaceReport> => {
const facade = createMockFacade()
const report: SurfaceReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome surface evidence requires isolated OPFS.')
await facade.project.list()
const shape = await facade.geometry.createBox({ documentId: 'surface', documentVersion: 1, width: 2, length: 2, height: 1 })
const surface = createSurfaceDocument('surfaces', 'Surface model')
surface.addBezier('A', [[[0, 0, 0], [0, 1, 0]], [[1, 0, 0], [1, 1, 0]]])
surface.addBspline('B', [[[1, 0, 0], [1, 1, 0]], [[2, 0, 0], [2, 1, 0]]], { degreeU: 1, degreeV: 1 })
surface.trim('B', [0, 0.8], [0, 1])
surface.sew(['A', 'B'])
surface.loft('L', [[[0, 0, 0], [1, 0, 0]], [[0, 0, 1], [1, 0, 1]]], { ruled: true })
const fill = surface.fill('F', [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]])
const offset = surface.offset('F', 'O', 1.5)
const quality = surface.analyze()
const topoRefs = surface.topologyRefs('F')
const obj = new TextEncoder().encode(surface.exportObj())
const payload = new TextEncoder().encode(JSON.stringify(surface.snapshot()))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.surface+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 = 'surface-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'SURF-CORE', patches: quality.patches })); await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; patches: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
const continuity = surface.continuity('A', 'B')
report.model = { patches: quality.patches, poles: quality.poles, trimmed: quality.trimmed, sewn: surface.snapshot().sewn.length, continuity: continuity.relation, fillKind: fill.kind, offsetDistance: offset.poles[0][0][2] - fill.poles[0][0][2], topoRefs: topoRefs.filter((ref) => ref.status === 'stable' && ref.persistentId.startsWith('F:')).length, objBytes: obj.byteLength }
report.persistence = { mode: facade.project.capabilities().mode, byteLength: stored.byteLength, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, patches: markerPayload.patches, markerRemoved }
report.status = shape.id.length > 0 && report.model.patches === 5 && report.model.poles === 20 && report.model.trimmed === 1 && report.model.sewn === 1 && report.model.continuity === 'C0' && report.model.fillKind === 'bezier' && report.model.offsetDistance === 1.5 && report.model.topoRefs === 4 && report.model.objBytes > 0 && report.persistence.mode === 'sqlite-opfs' && report.persistence.roundTrip && report.persistence.released && report.opfs.markerSuite === 'SURF-CORE' && report.opfs.patches === 5 && 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 (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed.` } }
return report
}
run().then((report) => { ;(window as Window & { __bitbybitSurfaceReport?: SurfaceReport }).__bitbybitSurfaceReport = 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 {}

View File

@@ -0,0 +1,57 @@
import { createMockFacade } from './facade/mockFacade'
import { createTechDrawPage } from './facade/techDraw'
import type { TopoRefValue } from './facade/types'
type TechDrawReport = { schemaVersion: 1; status: 'pass' | 'failed'; browserId: 'chrome'; crossOriginIsolated: boolean; page?: { views: number; sections: number; dimensions: number; annotations: number; tolerances: number; updatedDimension: number; unresolvedRefs: string[]; unresolvedTolerances: string[] }; svg?: { bytes: number; sha256: string; deterministic: boolean; lines: number; accessible: boolean }; pdf?: { bytes: number; sha256: string; deterministic: boolean; validHeader: boolean; validXref: boolean }; resources?: Array<{ mediaType: string; hash: string; byteLength: number; roundTrip: boolean; released: boolean }>; opfs?: { markerSuite: string; markerViews: number; markerRemoved: boolean }; afterRelease?: { shapeCount: number; kernelReferenceCount: number }; error?: string }
const ref = (id: string, version: number): TopoRefValue => ({ schemaVersion: 1, objectId: 'box', kind: 'vertex', persistentId: id, topologyVersion: version, generation: version, status: 'stable', signature: id })
const fixture = (revision: number, length = 4, includeThird = true) => ({ id: 'box', revision, points: [{ id: 'a', ref: ref('Vertex1', revision), point: { x: 0, y: 0, z: 0 } }, { id: 'b', ref: ref('Vertex2', revision), point: { x: length, y: 0, z: 0 } }, ...(includeThird ? [{ id: 'c', ref: ref('Vertex3', revision), point: { x: length, y: 3, z: 0 } }] : [])], edges: includeThird ? [{ id: 'edge', start: 'a', end: 'b' }, { id: 'edge2', start: 'b', end: 'c' }] : [{ id: 'edge', start: 'a', end: 'b' }] })
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 run = async (): Promise<TechDrawReport> => {
const facade = createMockFacade()
const report: TechDrawReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome TechDraw evidence requires isolated OPFS.')
await facade.project.list()
const page = createTechDrawPage('page', 'Box drawing')
page.addSource(fixture(1))
page.addView({ id: 'front', sourceId: 'box', direction: { x: 0, y: 0, z: 1 }, position: { x: 80, y: 80 }, scale: 4 })
page.addView({ id: 'section', sourceId: 'box', kind: 'section', direction: { x: 0, y: 0, z: 1 }, position: { x: 180, y: 80 }, scale: 4, section: { normal: { x: 1, y: 0, z: 0 }, offset: 2 } })
page.addDimension({ id: 'length', sourceId: 'box', first: ref('Vertex1', 1), second: ref('Vertex2', 1), label: 'Length' })
page.addAnnotation({ id: 'note', text: 'Box / Front', position: { x: 20, y: 20 }, style: 'callout' })
page.addGeometricTolerance({ id: 'flatness', sourceId: 'box', reference: ref('Vertex3', 1), characteristic: 'flatness', value: 0.05, datum: 'A' })
page.updateSource(fixture(2, 6))
const afterUpdate = page.snapshot()
page.updateSource(fixture(3, 6, false))
const afterDelete = page.snapshot()
const svg = page.exportSvg()
const secondSvg = page.exportSvg()
const svgBytes = new TextEncoder().encode(svg)
const pdfBytes = page.exportPdf()
const secondPdf = page.exportPdf()
const pdfText = new TextDecoder().decode(pdfBytes)
const resources: TechDrawReport['resources'] = []
for (const [bytes, mediaType] of [[svgBytes, 'image/svg+xml'], [pdfBytes, 'application/pdf'], [new TextEncoder().encode(JSON.stringify(afterDelete)), 'application/vnd.bitbybit.techdraw+json']] as const) {
const stored = await facade.project.resource.put(bytes, mediaType)
const loaded = await facade.project.resource.get(stored.hash)
const roundTrip = loaded !== null && await sha256(loaded) === await sha256(bytes)
await facade.project.resource.release(stored.hash)
const released = await facade.project.resource.get(stored.hash) === null
resources.push({ mediaType, hash: stored.hash, byteLength: stored.byteLength, roundTrip, released })
}
const directory = await navigator.storage.getDirectory(); const markerName = 'techdraw-chrome-marker.json'; const marker = await directory.getFileHandle(markerName, { create: true }); const writable = await marker.createWritable(); await writable.write(JSON.stringify({ suite: 'TD-ALL', views: afterDelete.views.length })); await writable.close(); const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; views: number }; await directory.removeEntry(markerName); let markerRemoved = false; try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
const unresolvedRefs = [...new Set(afterDelete.views.flatMap((view) => view.unresolved))].sort()
const unresolvedTolerances = afterDelete.tolerances.filter((tolerance) => tolerance.status === 'unresolved').map((tolerance) => tolerance.id).sort()
report.page = { views: afterDelete.views.length, sections: afterDelete.views.filter((view) => view.kind === 'section').length, dimensions: afterDelete.dimensions.length, annotations: afterDelete.annotations.length, tolerances: afterDelete.tolerances.length, updatedDimension: afterUpdate.dimensions[0].value ?? Number.NaN, unresolvedRefs, unresolvedTolerances }
report.svg = { bytes: svgBytes.byteLength, sha256: await sha256(svgBytes), deterministic: svg === secondSvg, lines: (svg.match(/<line /g) ?? []).length, accessible: svg.includes('role="img"') && svg.includes('aria-label="Box drawing"') }
report.pdf = { bytes: pdfBytes.byteLength, sha256: await sha256(pdfBytes), deterministic: await sha256(pdfBytes) === await sha256(secondPdf), validHeader: pdfText.startsWith('%PDF-1.4'), validXref: /xref[\s\S]+startxref[\s\S]+%%EOF\n$/.test(pdfText) }
report.resources = resources
report.opfs = { markerSuite: markerPayload.suite, markerViews: markerPayload.views, markerRemoved }
report.status = report.page.views === 2 && report.page.sections === 1 && report.page.dimensions === 1 && report.page.annotations === 1 && report.page.tolerances === 1 && report.page.updatedDimension === 6 && JSON.stringify(report.page.unresolvedRefs) === JSON.stringify(['Vertex3']) && JSON.stringify(report.page.unresolvedTolerances) === JSON.stringify(['flatness']) && report.svg.bytes > 0 && report.svg.deterministic && report.svg.lines === 2 && report.svg.accessible && report.pdf.bytes > 0 && report.pdf.deterministic && report.pdf.validHeader && report.pdf.validXref && resources.every((resource) => resource.roundTrip && resource.released) && report.opfs.markerSuite === 'TD-ALL' && report.opfs.markerViews === 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)); report.afterRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }; if (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed.` } }
return report
}
run().then((report) => { ;(window as Window & { __bitbybitTechDrawReport?: TechDrawReport }).__bitbybitTechDrawReport = 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 {}

View File

@@ -0,0 +1,39 @@
export type AddonPermission = 'geometry.read' | 'geometry.write' | 'project.read' | 'project.write' | 'network' | 'filesystem' | 'script.execute'
export type AddonManifest = { id: string; version: string; name: string; entrypoint: string; permissions: AddonPermission[]; dependencies: string[] }
export type AddonPackage = { manifest: AddonManifest; payload: string; digest: string; signer: string; signature: string }
export type AddonVerification = { valid: boolean; digest: string; signatureValid: boolean; permissionsValid: boolean; dependenciesValid: boolean; reason?: string }
export type InstalledAddon = { manifest: AddonManifest; digest: string; signer: string; state: 'installed' | 'disabled'; revision: number }
export type AddonSnapshot = { catalog: InstalledAddon[]; history: Record<string, InstalledAddon[]>; version: number }
export type AddonManagerApi = { verify(pkg: AddonPackage): AddonVerification; install(pkg: AddonPackage): InstalledAddon; update(pkg: AddonPackage): InstalledAddon; remove(id: string): void; rollback(id: string): InstalledAddon; snapshot(): AddonSnapshot; exportManifest(): string }
export type AddonManagerOptions = { trustedKeys: Record<string, string>; allowedPermissions?: AddonPermission[] }
const allowedByDefault: AddonPermission[] = ['geometry.read', 'project.read']
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const digestText = (value: string) => { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) hash = Math.imul(hash ^ value.charCodeAt(index), 16777619); return (hash >>> 0).toString(16).padStart(8, '0') }
const canonicalManifest = (manifest: AddonManifest) => JSON.stringify({ ...manifest, permissions: [...manifest.permissions].sort(), dependencies: [...manifest.dependencies].sort() })
const canonicalContent = (manifest: AddonManifest, payload: string) => `${canonicalManifest(manifest)}\n${payload}`
export const createSignedAddonPackage = (manifest: AddonManifest, payload: string, signer: string, secret: string): AddonPackage => {
const digest = digestText(canonicalContent(manifest, payload))
return { manifest: clone(manifest), payload, digest, signer, signature: digestText(`${signer}:${secret}:${digest}`) }
}
export const createAddonManager = (options: AddonManagerOptions): AddonManagerApi => {
const allowed = new Set(options.allowedPermissions ?? allowedByDefault)
const installed = new Map<string, InstalledAddon>(); const history = new Map<string, InstalledAddon[]>(); let version = 0
const verify = (pkg: AddonPackage): AddonVerification => {
const digest = digestText(canonicalContent(pkg.manifest, pkg.payload)); const digestValid = digest === pkg.digest
const secret = options.trustedKeys[pkg.signer]; const signatureValid = Boolean(secret && digestText(`${pkg.signer}:${secret}:${pkg.digest}`) === pkg.signature)
const permissionsValid = pkg.manifest.permissions.every((permission) => allowed.has(permission))
const dependenciesValid = pkg.manifest.dependencies.every((dependency) => installed.has(dependency))
const reason = !digestValid ? 'package digest mismatch' : !signatureValid ? 'untrusted or invalid signature' : !permissionsValid ? 'permission exceeds policy' : !dependenciesValid ? 'dependency is not installed' : undefined
return { valid: !reason, digest, signatureValid, permissionsValid, dependenciesValid, ...(reason ? { reason } : {}) }
}
const requireValid = (pkg: AddonPackage) => { const result = verify(pkg); if (!result.valid) throw new Error(`Addon ${pkg.manifest.id} rejected: ${result.reason}`); if (!pkg.manifest.id.trim() || !pkg.manifest.version.trim() || !pkg.manifest.entrypoint.trim()) throw new RangeError('Addon manifest identity and entrypoint are required.'); return result }
const install = (pkg: AddonPackage) => { requireValid(pkg); if (installed.has(pkg.manifest.id)) throw new Error(`Addon ${pkg.manifest.id} is already installed.`); const record: InstalledAddon = { manifest: clone(pkg.manifest), digest: pkg.digest, signer: pkg.signer, state: 'installed', revision: 1 }; installed.set(record.manifest.id, record); history.set(record.manifest.id, [clone(record)]); version += 1; return clone(record) }
const update = (pkg: AddonPackage) => { requireValid(pkg); const previous = installed.get(pkg.manifest.id); if (!previous) throw new Error(`Addon ${pkg.manifest.id} is not installed.`); if (previous.digest === pkg.digest) throw new Error(`Addon ${pkg.manifest.id} has no content change.`); const record: InstalledAddon = { manifest: clone(pkg.manifest), digest: pkg.digest, signer: pkg.signer, state: 'installed', revision: previous.revision + 1 }; installed.set(record.manifest.id, record); history.get(record.manifest.id)!.push(clone(record)); version += 1; return clone(record) }
const remove = (id: string) => { if (!installed.delete(id)) throw new Error(`Addon ${id} is not installed.`); version += 1 }
const rollback = (id: string) => { const entries = history.get(id) ?? []; if (entries.length < 2) throw new Error(`Addon ${id} has no previous revision.`); entries.pop(); const record = clone(entries[entries.length - 1]); installed.set(id, record); version += 1; return record }
const snapshot = (): AddonSnapshot => ({ catalog: [...installed.values()].map(clone), history: Object.fromEntries([...history.entries()].map(([id, entries]) => [id, entries.map(clone)])), version })
return { verify, install, update, remove, rollback, snapshot, exportManifest: () => `${JSON.stringify(snapshot(), null, 2)}\n` }
}

113
src/facade/assembly.ts Normal file
View File

@@ -0,0 +1,113 @@
export type AssemblyPoint = { x: number; y: number; z: number }
export type AssemblyAxis = AssemblyPoint
export type AssemblyPlacement = AssemblyPoint & { yaw: number; pitch: number; roll: number }
export type AssemblyBounds = { min: AssemblyPoint; max: AssemblyPoint }
export type AssemblyComponent = { id: string; sourceObjectId: string; label: string; placement: AssemblyPlacement; grounded: boolean; bounds?: AssemblyBounds }
export type AssemblyConnector = { id: string; componentId: string; origin: AssemblyPoint; axis: AssemblyAxis }
export type AssemblyJointKind = 'fixed' | 'coincident' | 'distance' | 'angle' | 'concentric'
export type AssemblyJoint = { id: string; kind: AssemblyJointKind; first: string; second: string; value?: number; status: 'pending' | 'solved' | 'conflicting' }
export type AssemblyDiagnostic = { code: 'MISSING_CONNECTOR' | 'GROUND_CONFLICT' | 'UNSUPPORTED_CHAIN' | 'DISTANCE_CONFLICT' | 'ANGLE_CONFLICT'; jointId: string; message: string }
export type AssemblySnapshot = { id: string; label: string; components: AssemblyComponent[]; connectors: AssemblyConnector[]; joints: AssemblyJoint[]; diagnostics: AssemblyDiagnostic[]; version: number; solver: { status: 'unsolved' | 'solved' | 'conflicting'; iterations: number } }
export type AssemblyBomRow = { sourceObjectId: string; label: string; quantity: number; componentIds: string[] }
export type AssemblyCollision = { first: string; second: string; overlap: AssemblyBounds }
export type AssemblyVariant = { name: string; placements: AssemblyComponent[]; version: number }
export type AssemblyMotionFrame = { step: number; parameter: number; placement: AssemblyPlacement }
export type AssemblyApi = {
snapshot(): AssemblySnapshot
addComponent(input: Omit<AssemblyComponent, 'placement'> & { placement?: Partial<AssemblyPlacement> }): AssemblyComponent
setPlacement(componentId: string, placement: Partial<AssemblyPlacement>): AssemblyComponent
setGrounded(componentId: string, grounded: boolean): AssemblyComponent
addConnector(input: { id: string; componentId: string; origin: AssemblyPoint; axis: AssemblyAxis }): AssemblyConnector
addJoint(input: { id: string; kind: AssemblyJointKind; first: string; second: string; value?: number }): AssemblyJoint
solve(): AssemblySnapshot
bom(): AssemblyBomRow[]
collisions(): AssemblyCollision[]
exploded(distance?: number): AssemblyComponent[]
variant(name: string, placements?: Record<string, Partial<AssemblyPlacement>>): AssemblyVariant
motion(componentId: string, target: Partial<AssemblyPlacement>, steps: number): AssemblyMotionFrame[]
}
const finite = (value: number, name: string) => { if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite.`) }
const point = (value: AssemblyPoint, name: string) => { for (const axis of ['x', 'y', 'z'] as const) finite(value[axis], `${name}.${axis}`); return { x: value.x, y: value.y, z: value.z } }
const axis = (value: AssemblyAxis, name: string) => { const result = point(value, name); const length = Math.hypot(result.x, result.y, result.z); if (length <= 1e-12) throw new RangeError(`${name} must be non-zero.`); return { x: result.x / length, y: result.y / length, z: result.z / length } }
const placement = (value?: Partial<AssemblyPlacement>): AssemblyPlacement => ({ x: value?.x ?? 0, y: value?.y ?? 0, z: value?.z ?? 0, yaw: value?.yaw ?? 0, pitch: value?.pitch ?? 0, roll: value?.roll ?? 0 })
const clonePlacement = (value: AssemblyPlacement): AssemblyPlacement => ({ ...value })
const cloneComponent = (value: AssemblyComponent): AssemblyComponent => ({ ...value, placement: clonePlacement(value.placement), bounds: value.bounds ? { min: { ...value.bounds.min }, max: { ...value.bounds.max } } : undefined })
const cloneConnector = (value: AssemblyConnector): AssemblyConnector => ({ ...value, origin: { ...value.origin }, axis: { ...value.axis } })
const cloneJoint = (value: AssemblyJoint): AssemblyJoint => ({ ...value })
const rotate = (p: AssemblyPoint, transform: AssemblyPlacement): AssemblyPoint => {
const cy = Math.cos(transform.yaw), sy = Math.sin(transform.yaw), cp = Math.cos(transform.pitch), sp = Math.sin(transform.pitch), cr = Math.cos(transform.roll), sr = Math.sin(transform.roll)
const x1 = cy * p.x - sy * p.y; const y1 = sy * p.x + cy * p.y; const z1 = p.z
const x2 = cp * x1 + sp * z1; const y2 = y1; const z2 = -sp * x1 + cp * z1
return { x: cr * x2 - sr * y2, y: sr * x2 + cr * y2, z: z2 }
}
const worldPoint = (connector: AssemblyConnector, component: AssemblyComponent) => { const local = rotate(connector.origin, component.placement); return { x: local.x + component.placement.x, y: local.y + component.placement.y, z: local.z + component.placement.z } }
const worldAxis = (connector: AssemblyConnector, component: AssemblyComponent) => rotate(connector.axis, component.placement)
const distance = (a: AssemblyPoint, b: AssemblyPoint) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z)
const dot = (a: AssemblyPoint, b: AssemblyPoint) => a.x * b.x + a.y * b.y + a.z * b.z
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value))
const bounds = (value: AssemblyBounds | undefined): AssemblyBounds => { const result = value ?? { min: { x: -0.5, y: -0.5, z: -0.5 }, max: { x: 0.5, y: 0.5, z: 0.5 } }; const min = point(result.min, 'Assembly bounds min'); const max = point(result.max, 'Assembly bounds max'); if (min.x > max.x || min.y > max.y || min.z > max.z) throw new RangeError('Assembly bounds min must not exceed max.'); return { min, max } }
const worldBounds = (component: AssemblyComponent): AssemblyBounds => { const local = component.bounds ?? bounds(undefined); return { min: { x: local.min.x + component.placement.x, y: local.min.y + component.placement.y, z: local.min.z + component.placement.z }, max: { x: local.max.x + component.placement.x, y: local.max.y + component.placement.y, z: local.max.z + component.placement.z } } }
export const createAssembly = (id = 'assembly', label = 'Assembly'): AssemblyApi => {
const components = new Map<string, AssemblyComponent>()
const connectors = new Map<string, AssemblyConnector>()
const joints = new Map<string, AssemblyJoint>()
let diagnostics: AssemblyDiagnostic[] = []
let version = 0
let solver: AssemblySnapshot['solver'] = { status: 'unsolved', iterations: 0 }
const snapshot = (): AssemblySnapshot => ({ id, label, components: [...components.values()].map(cloneComponent), connectors: [...connectors.values()].map(cloneConnector), joints: [...joints.values()].map(cloneJoint), diagnostics: diagnostics.map((entry) => ({ ...entry })), version, solver: { ...solver } })
const componentById = (componentId: string) => { const component = components.get(componentId); if (!component) throw new RangeError(`Assembly component does not exist: ${componentId}`); return component }
const connectorById = (connectorId: string) => { const connector = connectors.get(connectorId); if (!connector) throw new RangeError(`Assembly connector does not exist: ${connectorId}`); return connector }
const addComponent = (input: Omit<AssemblyComponent, 'placement'> & { placement?: Partial<AssemblyPlacement> }) => { if (!input.id.trim() || components.has(input.id)) throw new RangeError(`Assembly component already exists: ${input.id}`); if (!input.sourceObjectId.trim()) throw new RangeError('Assembly component sourceObjectId is required.'); const value: AssemblyComponent = { id: input.id, sourceObjectId: input.sourceObjectId, label: input.label, placement: placement(input.placement), grounded: Boolean(input.grounded), bounds: input.bounds ? bounds(input.bounds) : undefined }; components.set(value.id, value); version += 1; return cloneComponent(value) }
const setPlacement = (componentId: string, update: Partial<AssemblyPlacement>) => { const component = componentById(componentId); const next = { ...component.placement, ...update }; component.placement = placement(next); version += 1; solver = { status: 'unsolved', iterations: 0 }; return cloneComponent(component) }
const setGrounded = (componentId: string, grounded: boolean) => { const component = componentById(componentId); component.grounded = grounded; version += 1; solver = { status: 'unsolved', iterations: 0 }; return cloneComponent(component) }
const addConnector = (input: { id: string; componentId: string; origin: AssemblyPoint; axis: AssemblyAxis }) => { componentById(input.componentId); if (!input.id.trim() || connectors.has(input.id)) throw new RangeError(`Assembly connector already exists: ${input.id}`); const value: AssemblyConnector = { id: input.id, componentId: input.componentId, origin: point(input.origin, 'Assembly connector origin'), axis: axis(input.axis, 'Assembly connector axis') }; connectors.set(value.id, value); version += 1; return cloneConnector(value) }
const addJoint = (input: { id: string; kind: AssemblyJointKind; first: string; second: string; value?: number }) => { if (!input.id.trim() || joints.has(input.id)) throw new RangeError(`Assembly joint already exists: ${input.id}`); connectorById(input.first); connectorById(input.second); if (input.first === input.second) throw new RangeError('Assembly joint requires two distinct connectors.'); if (input.value !== undefined) finite(input.value, 'Assembly joint value'); if ((input.kind === 'distance' && (input.value === undefined || input.value < 0)) || (input.kind === 'angle' && (input.value === undefined || input.value < -Math.PI || input.value > Math.PI))) throw new RangeError(`Assembly ${input.kind} joint value is outside the supported range.`); const value: AssemblyJoint = { id: input.id, kind: input.kind, first: input.first, second: input.second, value: input.value, status: 'pending' }; joints.set(value.id, value); version += 1; solver = { status: 'unsolved', iterations: 0 }; return cloneJoint(value) }
const solve = () => {
diagnostics = []
let iterations = 0
let conflicting = false
for (const joint of joints.values()) {
const firstConnector = connectors.get(joint.first); const secondConnector = connectors.get(joint.second)
if (!firstConnector || !secondConnector) { joint.status = 'conflicting'; diagnostics.push({ code: 'MISSING_CONNECTOR', jointId: joint.id, message: 'Joint references a missing connector.' }); conflicting = true; continue }
const firstComponent = componentById(firstConnector.componentId); const secondComponent = componentById(secondConnector.componentId)
if (firstComponent.id === secondComponent.id) { joint.status = 'conflicting'; diagnostics.push({ code: 'UNSUPPORTED_CHAIN', jointId: joint.id, message: 'A joint cannot connect two connectors on the same component.' }); conflicting = true; continue }
const firstPoint = worldPoint(firstConnector, firstComponent); const secondPoint = worldPoint(secondConnector, secondComponent)
const movable = firstComponent.grounded ? (secondComponent.grounded ? null : secondComponent) : firstComponent
if (joint.kind === 'fixed' || joint.kind === 'coincident' || joint.kind === 'concentric') {
if (!movable) { if (distance(firstPoint, secondPoint) > 1e-7) { joint.status = 'conflicting'; diagnostics.push({ code: 'GROUND_CONFLICT', jointId: joint.id, message: 'Grounded components cannot satisfy the joint.' }); conflicting = true; continue } }
else { const target = movable.id === secondComponent.id ? firstPoint : secondPoint; const current = movable.id === secondComponent.id ? secondPoint : firstPoint; movable.placement.x += target.x - current.x; movable.placement.y += target.y - current.y; movable.placement.z += target.z - current.z }
}
const afterFirst = worldPoint(firstConnector, firstComponent); const afterSecond = worldPoint(secondConnector, secondComponent)
const actualDistance = distance(afterFirst, afterSecond)
if (joint.kind === 'distance') {
const desired = joint.value ?? 0; if (firstComponent.grounded && secondComponent.grounded) { if (Math.abs(actualDistance - desired) > 1e-7) { joint.status = 'conflicting'; diagnostics.push({ code: 'DISTANCE_CONFLICT', jointId: joint.id, message: `Distance ${actualDistance} does not match ${desired}.` }); conflicting = true; continue } }
else { const target = movable?.id === secondComponent.id ? afterFirst : afterSecond; const current = movable?.id === secondComponent.id ? afterSecond : afterFirst; const raw = { x: current.x - target.x, y: current.y - target.y, z: current.z - target.z }; const fallback = movable?.id === secondComponent.id ? worldAxis(secondConnector, secondComponent) : worldAxis(firstConnector, firstComponent); const direction = Math.hypot(raw.x, raw.y, raw.z) > 1e-12 ? raw : fallback; const length = Math.hypot(direction.x, direction.y, direction.z) || 1; const scale = (desired - actualDistance) / length; if (movable) { movable.placement.x += direction.x * scale; movable.placement.y += direction.y * scale; movable.placement.z += direction.z * scale } }
}
if (joint.kind === 'angle') {
const actual = Math.acos(clamp(dot(worldAxis(firstConnector, firstComponent), worldAxis(secondConnector, secondComponent)), -1, 1))
const desired = joint.value ?? 0
if (firstComponent.grounded && secondComponent.grounded && Math.abs(actual - desired) > 1e-7) { joint.status = 'conflicting'; diagnostics.push({ code: 'ANGLE_CONFLICT', jointId: joint.id, message: `Angle ${actual} does not match ${desired}.` }); conflicting = true; continue }
if (movable && Math.abs(actual - desired) > 1e-7) movable.placement.yaw += (movable.id === secondComponent.id ? 1 : -1) * (desired - actual)
}
joint.status = 'solved'; iterations += 1
}
solver = { status: conflicting ? 'conflicting' : 'solved', iterations }
version += 1
return snapshot()
}
const bom = (): AssemblyBomRow[] => { const rows = new Map<string, AssemblyBomRow>(); for (const component of components.values()) { const row = rows.get(component.sourceObjectId) ?? { sourceObjectId: component.sourceObjectId, label: component.label, quantity: 0, componentIds: [] }; row.quantity += 1; row.componentIds.push(component.id); rows.set(component.sourceObjectId, row) } return [...rows.values()].map((row) => ({ ...row, componentIds: [...row.componentIds].sort() })).sort((first, second) => first.sourceObjectId.localeCompare(second.sourceObjectId)) }
const collisions = (): AssemblyCollision[] => { const output: AssemblyCollision[] = []; const list = [...components.values()]; for (let first = 0; first < list.length; first += 1) for (let second = first + 1; second < list.length; second += 1) { const a = worldBounds(list[first]); const b = worldBounds(list[second]); const min = { x: Math.max(a.min.x, b.min.x), y: Math.max(a.min.y, b.min.y), z: Math.max(a.min.z, b.min.z) }; const max = { x: Math.min(a.max.x, b.max.x), y: Math.min(a.max.y, b.max.y), z: Math.min(a.max.z, b.max.z) }; if (min.x < max.x && min.y < max.y && min.z < max.z) output.push({ first: list[first].id, second: list[second].id, overlap: { min, max } }) } return output }
const exploded = (offset = 1): AssemblyComponent[] => { finite(offset, 'Assembly explode distance'); if (offset < 0) throw new RangeError('Assembly explode distance must be non-negative.'); const center = [...components.values()].reduce((sum, component) => ({ x: sum.x + component.placement.x, y: sum.y + component.placement.y, z: sum.z + component.placement.z }), { x: 0, y: 0, z: 0 }); const count = components.size || 1; return [...components.values()].map((component, index) => { const dx = component.placement.x - center.x / count; const dy = component.placement.y - center.y / count; const dz = component.placement.z - center.z / count; const length = Math.hypot(dx, dy, dz) || 1; return cloneComponent({ ...component, placement: { ...component.placement, x: component.placement.x + offset * dx / length, y: component.placement.y + offset * dy / length, z: component.placement.z + offset * dz / length } }) }) }
const variant = (name: string, updates: Record<string, Partial<AssemblyPlacement>> = {}): AssemblyVariant => { if (!name.trim()) throw new RangeError('Assembly variant name is required.'); const placements = [...components.values()].map((component) => cloneComponent({ ...component, placement: placement({ ...component.placement, ...(updates[component.id] ?? {}) }) })); return { name, placements, version }
}
const motion = (componentId: string, target: Partial<AssemblyPlacement>, steps: number): AssemblyMotionFrame[] => {
const component = componentById(componentId)
if (!Number.isSafeInteger(steps) || steps < 1 || steps > 10_000) throw new RangeError('Assembly motion steps must be between 1 and 10000.')
const end = placement({ ...component.placement, ...target })
return Array.from({ length: steps + 1 }, (_, step) => { const parameter = step / steps; return { step, parameter, placement: { x: component.placement.x + (end.x - component.placement.x) * parameter, y: component.placement.y + (end.y - component.placement.y) * parameter, z: component.placement.z + (end.z - component.placement.z) * parameter, yaw: component.placement.yaw + (end.yaw - component.placement.yaw) * parameter, pitch: component.placement.pitch + (end.pitch - component.placement.pitch) * parameter, roll: component.placement.roll + (end.roll - component.placement.roll) * parameter } } })
}
return { snapshot, addComponent, setPlacement, setGrounded, addConnector, addJoint, solve, bom, collisions, exploded, variant, motion }
}

92
src/facade/attachment.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { AttachmentSupportValue, PlacementValue, TopoRefValue, VectorValue } from './types'
export const ATTACHMENT_MAP_MODES = ['Deactivated', 'ObjectXY', 'ObjectXZ', 'ObjectYZ', 'FlatFace', 'NormalToEdge'] as const
export type AttachmentMapMode = typeof ATTACHMENT_MAP_MODES[number]
export type AttachmentSupport = AttachmentSupportValue
export type AttachmentFrame = {
support: AttachmentSupport | null
mapMode: AttachmentMapMode
offset: PlacementValue
}
const finite = (value: number, name: string) => {
if (!Number.isFinite(value)) throw new TypeError(`${name} must be finite.`)
return value
}
const vector = (value: VectorValue, name: string) => ({ x: finite(value.x, `${name}.x`), y: finite(value.y, `${name}.y`), z: finite(value.z, `${name}.z`) })
export function validateAttachmentMapMode(mode: unknown): asserts mode is AttachmentMapMode {
if (typeof mode !== 'string' || !(ATTACHMENT_MAP_MODES as readonly string[]).includes(mode)) throw new RangeError(`MapMode '${String(mode)}' is not supported.`)
}
export function validateAttachmentSupport(support: unknown): asserts support is AttachmentSupport | null {
if (support === null) return
if (!support || typeof support !== 'object' || Array.isArray(support)) throw new TypeError('Support requires an object link.')
const candidate = support as Record<string, unknown>
if (typeof candidate.objectId !== 'string' || !candidate.objectId.trim()) throw new TypeError('Support objectId is required.')
if (candidate.subElement !== undefined && candidate.subElement !== null) {
if (typeof candidate.subElement === 'string') {
if (!candidate.subElement.trim()) throw new TypeError('Support subElement must be a non-empty string.')
} else if (typeof candidate.subElement === 'object' && !Array.isArray(candidate.subElement)) {
const ref = candidate.subElement as Partial<TopoRefValue>
if (ref.schemaVersion !== 1 || typeof ref.objectId !== 'string' || typeof ref.persistentId !== 'string' || !['face', 'edge', 'vertex'].includes(ref.kind as string)) throw new TypeError('Support subElement TopoRef is invalid.')
} else throw new TypeError('Support subElement must be a non-empty string or TopoRef.')
}
}
export function validateAttachmentOffset(offset: unknown): asserts offset is PlacementValue {
if (!offset || typeof offset !== 'object' || Array.isArray(offset)) throw new TypeError('AttachmentOffset requires a Placement value.')
const value = offset as PlacementValue
if (!value.position || typeof value.position !== 'object' || !value.rotation || typeof value.rotation !== 'object' || !value.rotation.axis || typeof value.rotation.axis !== 'object') throw new TypeError('AttachmentOffset requires position and rotation.')
const position = vector(value.position, 'AttachmentOffset position')
const axis = vector(value.rotation?.axis, 'AttachmentOffset rotation axis')
const angle = finite(value.rotation?.angle, 'AttachmentOffset rotation angle')
if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError('AttachmentOffset rotation axis cannot be zero.')
if (angle < 0 || angle > 360) throw new RangeError('AttachmentOffset rotation angle must be between 0 and 360 degrees.')
void position
}
const quaternion = (placement: PlacementValue): [number, number, number, number] => {
const axis = vector(placement.rotation.axis, 'rotation axis')
const length = Math.hypot(axis.x, axis.y, axis.z)
if (length === 0) return [1, 0, 0, 0]
const half = placement.rotation.angle * Math.PI / 360
const scale = Math.sin(half) / length
return [Math.cos(half), axis.x * scale, axis.y * scale, axis.z * scale]
}
const multiply = (left: [number, number, number, number], right: [number, number, number, number]): [number, number, number, number] => [
left[0] * right[0] - left[1] * right[1] - left[2] * right[2] - left[3] * right[3],
left[0] * right[1] + left[1] * right[0] + left[2] * right[3] - left[3] * right[2],
left[0] * right[2] - left[1] * right[3] + left[2] * right[0] + left[3] * right[1],
left[0] * right[3] + left[1] * right[2] - left[2] * right[1] + left[3] * right[0],
]
const rotate = (value: VectorValue, q: [number, number, number, number]): VectorValue => {
const point: [number, number, number, number] = [0, value.x, value.y, value.z]
const inverse: [number, number, number, number] = [q[0], -q[1], -q[2], -q[3]]
const result = multiply(multiply(q, point), inverse)
return { x: result[1], y: result[2], z: result[3] }
}
export const composeAttachmentPlacement = (supportPlacement: PlacementValue, offset: PlacementValue): PlacementValue => {
validateAttachmentOffset(supportPlacement)
validateAttachmentOffset(offset)
const rotation = multiply(quaternion(supportPlacement), quaternion(offset))
const translatedOffset = rotate(offset.position, quaternion(supportPlacement))
const position = {
x: supportPlacement.position.x + translatedOffset.x,
y: supportPlacement.position.y + translatedOffset.y,
z: supportPlacement.position.z + translatedOffset.z,
}
const vectorLength = Math.hypot(rotation[1], rotation[2], rotation[3])
const rawAngle = Math.min(360, Math.max(0, 2 * Math.atan2(vectorLength, rotation[0]) * 180 / Math.PI))
const angle = Math.abs(rawAngle - Math.round(rawAngle)) < 1e-12 ? Math.round(rawAngle) : Number(rawAngle.toFixed(12))
const axis = vectorLength < 1e-12 ? { x: 0, y: 0, z: 1 } : { x: rotation[1] / vectorLength, y: rotation[2] / vectorLength, z: rotation[3] / vectorLength }
return { position, rotation: { axis, angle } }
}
export const identityAttachmentOffset = (): PlacementValue => ({ position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } })

37
src/facade/bim.ts Normal file
View File

@@ -0,0 +1,37 @@
export type BimLevel = { id: string; label: string; elevation: number }
export type BimSite = { id: string; label: string; latitude?: number; longitude?: number }
export type BimBuilding = { id: string; label: string; siteId: string }
export type BimSpace = { id: string; label: string; levelId: string; area?: number }
export type BimMaterial = { id: string; name: string; category?: string }
export type BimElement = { id: string; label: string; type: 'wall' | 'structure' | 'window' | 'door' | 'space' | 'generic'; shapeId: string; levelId: string; spaceId?: string; materialId?: string; classification?: { system: string; code: string }; properties: Record<string, string | number | boolean>; quantity: { length?: number; area?: number; volume?: number } }
export type BimSnapshot = { id: string; label: string; sites: BimSite[]; buildings: BimBuilding[]; levels: BimLevel[]; spaces: BimSpace[]; materials: BimMaterial[]; elements: BimElement[]; version: number }
export type BimIfcSchema = 'IFC2X3' | 'IFC4'
export type BimIfcImport = { schema: BimIfcSchema; entities: number; hierarchy: number; propertySets: number; classifications: number }
export type BimApi = { snapshot(): BimSnapshot; addSite(input: BimSite): BimSite; addBuilding(input: BimBuilding): BimBuilding; addLevel(input: BimLevel & { buildingId?: string }): BimLevel; addSpace(input: BimSpace): BimSpace; addMaterial(input: BimMaterial): BimMaterial; addElement(input: BimElement): BimElement; setElementProperty(elementId: string, name: string, value: string | number | boolean): BimElement; assignMaterial(elementId: string, materialId: string): BimElement; setClassification(elementId: string, classification: { system: string; code: string }): BimElement; schedule(): Array<{ elementId: string; type: string; quantity: BimElement['quantity'] }>; exportIfc(schema?: BimIfcSchema): string; importIfc(serialized: string): BimIfcImport }
const finite = (value: number, label: string) => { if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`) }
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const ifcEscape = (value: string) => value.replace(/'/g, "''")
export const createBimModel = (id = 'bim', label = 'BIM model'): BimApi => {
const sites = new Map<string, BimSite>(); const buildings = new Map<string, BimBuilding>(); const levels = new Map<string, BimLevel>(); const spaces = new Map<string, BimSpace>(); const materials = new Map<string, BimMaterial>(); const elements = new Map<string, BimElement>(); const levelBuildings = new Map<string, string>(); let version = 0
const snapshot = (): BimSnapshot => ({ id, label, sites: [...sites.values()].map(clone), buildings: [...buildings.values()].map(clone), levels: [...levels.values()].map(clone), spaces: [...spaces.values()].map(clone), materials: [...materials.values()].map(clone), elements: [...elements.values()].map(clone), version })
const site = (siteId: string) => { const value = sites.get(siteId); if (!value) throw new RangeError(`BIM site does not exist: ${siteId}`); return value }
const building = (buildingId: string) => { const value = buildings.get(buildingId); if (!value) throw new RangeError(`BIM building does not exist: ${buildingId}`); return value }
const level = (id: string) => { const value = levels.get(id); if (!value) throw new RangeError(`BIM level does not exist: ${id}`); return value }
const space = (id: string) => { const value = spaces.get(id); if (!value) throw new RangeError(`BIM space does not exist: ${id}`); return value }
const material = (id: string) => { const value = materials.get(id); if (!value) throw new RangeError(`BIM material does not exist: ${id}`); return value }
const element = (id: string) => { const value = elements.get(id); if (!value) throw new RangeError(`BIM element does not exist: ${id}`); return value }
const addSite = (input: BimSite) => { if (!input.id.trim() || sites.has(input.id)) throw new RangeError(`BIM site already exists: ${input.id}`); for (const coordinate of [input.latitude, input.longitude]) if (coordinate !== undefined) finite(coordinate, 'BIM site coordinate'); sites.set(input.id, clone(input)); version += 1; return clone(input) }
const addBuilding = (input: BimBuilding) => { if (!input.id.trim() || buildings.has(input.id)) throw new RangeError(`BIM building already exists: ${input.id}`); site(input.siteId); buildings.set(input.id, clone(input)); version += 1; return clone(input) }
const addLevel = (input: BimLevel & { buildingId?: string }) => { if (!input.id.trim() || levels.has(input.id)) throw new RangeError(`BIM level already exists: ${input.id}`); finite(input.elevation, 'BIM level elevation'); if (input.buildingId) { building(input.buildingId); levelBuildings.set(input.id, input.buildingId) } levels.set(input.id, { id: input.id, label: input.label, elevation: input.elevation }); version += 1; return clone(levels.get(input.id)!) }
const addSpace = (input: BimSpace) => { if (!input.id.trim() || spaces.has(input.id)) throw new RangeError(`BIM space already exists: ${input.id}`); level(input.levelId); if (input.area !== undefined) { finite(input.area, 'BIM space area'); if (input.area < 0) throw new RangeError('BIM space area cannot be negative.') } spaces.set(input.id, clone(input)); version += 1; return clone(input) }
const addMaterial = (input: BimMaterial) => { if (!input.id.trim() || materials.has(input.id)) throw new RangeError(`BIM material already exists: ${input.id}`); if (!input.name.trim()) throw new RangeError('BIM material name is required.'); materials.set(input.id, clone(input)); version += 1; return clone(input) }
const addElement = (input: BimElement) => { if (!input.id.trim() || elements.has(input.id)) throw new RangeError(`BIM element already exists: ${input.id}`); if (!input.shapeId.trim()) throw new RangeError('BIM element shapeId is required.'); level(input.levelId); if (input.spaceId) space(input.spaceId); if (input.materialId) material(input.materialId); for (const value of Object.values(input.quantity)) if (value !== undefined) { finite(value, 'BIM element quantity'); if (value < 0) throw new RangeError('BIM element quantities cannot be negative.') } elements.set(input.id, clone(input)); version += 1; return clone(input) }
const setElementProperty = (elementId: string, name: string, value: string | number | boolean) => { if (!name.trim()) throw new RangeError('BIM property name is required.'); if (typeof value === 'number') finite(value, 'BIM property value'); const target = element(elementId); target.properties[name] = value; version += 1; return clone(target) }
const assignMaterial = (elementId: string, materialId: string) => { material(materialId); const target = element(elementId); target.materialId = materialId; version += 1; return clone(target) }
const setClassification = (elementId: string, classification: { system: string; code: string }) => { if (!classification.system.trim() || !classification.code.trim()) throw new RangeError('BIM classification system and code are required.'); const target = element(elementId); target.classification = { ...classification }; version += 1; return clone(target) }
const schedule = () => [...elements.values()].map((entry) => ({ elementId: entry.id, type: entry.type, quantity: clone(entry.quantity) }))
const exportIfc = (schema: BimIfcSchema = 'IFC4') => { if (schema !== 'IFC2X3' && schema !== 'IFC4') throw new RangeError(`Unsupported IFC schema: ${schema}`); const lines = ['ISO-10303-21;', 'HEADER;', `FILE_DESCRIPTION(('Bitbybit BIM'),'2;1');`, "FILE_NAME('model.ifc','2026-08-05',('Bitbybit'),('Bitbybit'),'','','');", `FILE_SCHEMA(('${schema}'));`, 'ENDSEC;', 'DATA;']; let entity = 1; for (const entry of sites.values()) lines.push(`#${entity++}=IFCSITE('${ifcEscape(entry.id)}','${ifcEscape(entry.label)}',$);`); for (const entry of buildings.values()) lines.push(`#${entity++}=IFCBUILDING('${ifcEscape(entry.id)}','${ifcEscape(entry.label)}',$);`); for (const entry of levels.values()) lines.push(`#${entity++}=IFCBUILDINGSTOREY('${ifcEscape(entry.id)}','${ifcEscape(entry.label)}',${entry.elevation});`); for (const entry of spaces.values()) lines.push(`#${entity++}=IFCSPACE('${ifcEscape(entry.id)}','${ifcEscape(entry.label)}',$);`); for (const entry of elements.values()) { lines.push(`#${entity++}=IFCBUILDINGELEMENTPROXY('${ifcEscape(entry.id)}','${ifcEscape(entry.label)}','${ifcEscape(entry.type)}');`); for (const [name, value] of Object.entries(entry.properties)) lines.push(`#${entity++}=IFCPROPERTYSET('${ifcEscape(entry.id)}.${ifcEscape(name)}','${ifcEscape(String(value))}');`); if (entry.classification) lines.push(`#${entity++}=IFCCLASSIFICATIONREFERENCE('${ifcEscape(entry.classification.system)}','${ifcEscape(entry.classification.code)}');`) } lines.push('ENDSEC;', 'END-ISO-10303-21;'); return `${lines.join('\n')}\n` }
const importIfc = (serialized: string): BimIfcImport => { if (!serialized.startsWith('ISO-10303-21;') || !serialized.includes('END-ISO-10303-21;')) throw new RangeError('IFC archive boundaries are invalid.'); const schemaMatch = serialized.match(/FILE_SCHEMA\(\('\s*(IFC2X3|IFC4)\s*'\)\)/); if (!schemaMatch) throw new RangeError('IFC schema is missing or unsupported.'); return { schema: schemaMatch[1] as BimIfcSchema, entities: (serialized.match(/^#\d+=/gm) ?? []).length, hierarchy: (serialized.match(/=IFC(?:SITE|BUILDING|BUILDINGSTOREY|SPACE)\(/g) ?? []).length, propertySets: (serialized.match(/=IFCPROPERTYSET\(/g) ?? []).length, classifications: (serialized.match(/=IFCCLASSIFICATIONREFERENCE\(/g) ?? []).length } }
return { snapshot, addSite, addBuilding, addLevel, addSpace, addMaterial, addElement, setElementProperty, assignMaterial, setClassification, schedule, exportIfc, importIfc }
}

42
src/facade/bodyRules.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { DocumentSnapshot } from './types'
export type BodyFeatureState = {
id: string
typeId: string
suppressed: boolean
upstreamSuppressed: boolean
solid: boolean
}
export const resolveBodyTip = (features: readonly BodyFeatureState[]): string | null => {
for (let index = features.length - 1; index >= 0; index -= 1) {
const feature = features[index]
if (feature.solid && !feature.suppressed && !feature.upstreamSuppressed) return feature.id
}
return null
}
export const redirectBodyTips = (document: DocumentSnapshot, objectStates: Record<string, string>): string[] => {
const changed: string[] = []
for (const bodyItem of document.tree.filter((item) => item.type === 'body')) {
const body = document.objects.find((object) => object.id === bodyItem.id)
const tip = body?.properties.find((property) => property.name === 'Tip')
if (!body || !tip) continue
const features = (bodyItem.children ?? []).map((id) => {
const object = document.objects.find((candidate) => candidate.id === id)
return {
id,
typeId: object?.typeId ?? '',
suppressed: objectStates[id] === 'suppressed',
upstreamSuppressed: objectStates[id] === 'upstream-suppressed',
solid: Boolean(object && object.typeId !== 'Sketcher::SketchObject' && object.typeId !== 'PartDesign::Body'),
}
})
const nextTip = resolveBodyTip(features)
if (tip.value !== nextTip) {
tip.value = nextTip
changed.push(body.id)
}
}
return changed
}

1189
src/facade/cam.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,482 @@
import type { ShapeHandle } from './types'
import type { CamPoint } from './cam'
import { BitbybitGeometryRuntime } from './geometryRuntime'
import { CAMOTICS_WASM_ARTIFACT_SHA256 } from './camoticsWasm'
export const OPEN_CAM_LIB_SOURCE_REVISION = '95b036fe28ce6d77c97b98e5fbc337904ae49560'
export const OPEN_CAM_LIB_ARTIFACT_URL = '/vendor/opencamlib/ocl.js'
export const OPEN_CAM_LIB_ARTIFACT_SHA256 = '0379040910f7af277c6e1f6411efb549292db0b071422a83954d93631253bf89'
export const OPEN_CAM_LIB_ARTIFACT_SRI = 'sha256-A3kECRD3ryd8bh9kEe+1SSktsLBxQiqDlU2TYxJTv4k='
export const CAMOTICS_SOURCE_REVISION = 'e84665f2fa9d1151f03282ac7e01320bc65e015b'
export const CAMOTICS_CBANG_REVISION = '9b6672a0e2b800a909d799ffa3ab46774cf171b8'
export const CAMOTICS_NATIVE_ARTIFACT_EVIDENCE = 'config/camotics-native-artifact.json'
export type NativeCamCapabilities = {
openCamLib: {
status: 'available'
backend: 'upstream-opencamlib-wasm'
sourceRevision: string
artifactSha256: string
algorithms: ['path-drop-cutter', 'adaptive-path-drop-cutter', 'waterline', 'adaptive-waterline']
}
solidRemoval: {
status: 'available'
backend: 'bitbybit-occt-wasm'
model: 'sampled-flat-end-brep'
nativeSolid: true
freeCadNativeEquivalent: false
sweepModes: readonly ['sampled-flat-end', 'continuous-segment']
}
camotics: {
status: 'native-host-available'
backend: 'camotics-native-host-qt5-tpl'
sourceRevision: string
cbangRevision: string
sourcePaths: string[]
workspaceNativeExecutable: true
nativeGuiPath: 'CAMotics/camotics'
nativeTplPath: 'CAMotics/tplang'
nativeArtifactEvidence: string
browserExecutable: false
browserWasmKernelExecutable: true
nativeCliPath: 'CAMotics/camsim'
wasmKernel: {
backend: 'camotics-upstream-sweep-wasm'
artifactSha256: string
scope: readonly ['conic-sweep', 'spheroid-sweep', 'sweep-bounding-boxes']
fullCamoticsProgram: false
gcodeParserIncluded: false
}
generationAuthority: 'camotics-source-stage'
parserAuthority: 'linuxcnc-wasm'
camoticsParsesCanonicalGcode: false
reason: string
}
}
export const nativeCamCapabilities = (): NativeCamCapabilities => ({
openCamLib: {
status: 'available',
backend: 'upstream-opencamlib-wasm',
sourceRevision: OPEN_CAM_LIB_SOURCE_REVISION,
artifactSha256: OPEN_CAM_LIB_ARTIFACT_SHA256,
algorithms: ['path-drop-cutter', 'adaptive-path-drop-cutter', 'waterline', 'adaptive-waterline'],
},
solidRemoval: {
status: 'available',
backend: 'bitbybit-occt-wasm',
model: 'sampled-flat-end-brep',
nativeSolid: true,
freeCadNativeEquivalent: false,
sweepModes: ['sampled-flat-end', 'continuous-segment'],
},
camotics: {
status: 'native-host-available',
backend: 'camotics-native-host-qt5-tpl',
sourceRevision: CAMOTICS_SOURCE_REVISION,
cbangRevision: CAMOTICS_CBANG_REVISION,
sourcePaths: ['CAMotics/src/gcode/machine/GCodeMachine.cpp', 'CAMotics/src/tplang/GCodeModule.cpp', 'CAMotics/src/camotics/sim/ToolSweep.cpp'],
workspaceNativeExecutable: true,
nativeGuiPath: 'CAMotics/camotics',
nativeTplPath: 'CAMotics/tplang',
nativeArtifactEvidence: CAMOTICS_NATIVE_ARTIFACT_EVIDENCE,
browserExecutable: false,
browserWasmKernelExecutable: true,
nativeCliPath: 'CAMotics/camsim',
wasmKernel: {
backend: 'camotics-upstream-sweep-wasm',
artifactSha256: CAMOTICS_WASM_ARTIFACT_SHA256,
scope: ['conic-sweep', 'spheroid-sweep', 'sweep-bounding-boxes'],
fullCamoticsProgram: false,
gcodeParserIncluded: false,
},
generationAuthority: 'camotics-source-stage',
parserAuthority: 'linuxcnc-wasm',
camoticsParsesCanonicalGcode: false,
reason: 'CAMotics 1.3 native Qt5 GUI and TPL compiler are workspace-verified host sidecars with pinned C! 1.7.2 and V8 ABI settings. The browser executes a standalone upstream Conic/Spheroid sweep WASM subset, not the native ELF, Qt GUI, TPL compiler, or full program; browser G-code generation preserves source-stage motion semantics and sends canonical G-code to LinuxCNC WASM.',
},
})
export type CamoticsSourceMove = {
type: 'rapid' | 'cut'
point: CamPoint
feed?: number
}
export type CamoticsSourceStageResult = {
backend: 'camotics-source-stage-contract'
sourceRevision: string
sourcePaths: string[]
nativeExecutable: false
gcode: string
lineCount: number
parserAuthority: 'linuxcnc-wasm'
camoticsParsesCanonicalGcode: false
}
const gcodeNumber = (value: number) => {
if (!Number.isFinite(value)) throw new RangeError('CAMotics source-stage coordinates must be finite.')
const formatted = value.toFixed(3).replace(/0+$/, '')
return formatted === '-0.' ? '0.' : formatted
}
/**
* Source-stage motion contract derived from CAMotics GCodeMachine start,
* changeTool, setFeed, move and end. It deliberately does not parse its
* output; LinuxCNC WASM is the only downstream parser/controller authority.
*/
export const generateCamoticsSourceStageGcode = (moves: CamoticsSourceMove[], options: { toolNumber?: number; feed?: number } = {}): CamoticsSourceStageResult => {
if (moves.length === 0) throw new RangeError('CAMotics source-stage motion list must not be empty.')
const toolNumber = options.toolNumber ?? 1
if (!Number.isSafeInteger(toolNumber) || toolNumber <= 0) throw new RangeError('CAMotics source-stage toolNumber must be a positive safe integer.')
if (options.feed !== undefined) positive(options.feed, 'CAMotics source-stage feed')
let previousFeed: number | undefined
let previousPoint: CamPoint | undefined
const lines = ['G21G90', `M6 T${toolNumber}`]
for (const [index, move] of moves.entries()) {
finitePoint(move.point, `moves[${index}].point`)
if (move.feed !== undefined) positive(move.feed, `moves[${index}].feed`)
const feed = move.feed ?? (move.type === 'cut' ? options.feed : undefined)
if (feed !== undefined && feed !== previousFeed) lines.push(`F${gcodeNumber(feed)}`)
const axes = (['X', 'Y', 'Z'] as const).flatMap((axis, coordinate) => previousPoint === undefined || gcodeNumber(move.point[coordinate]) !== gcodeNumber(previousPoint[coordinate]) ? [`${axis}${gcodeNumber(move.point[coordinate])}`] : [])
if (axes.length > 0) lines.push(`${move.type === 'rapid' ? 'G0' : 'G1'} ${axes.join(' ')}`)
previousFeed = feed ?? previousFeed
previousPoint = [...move.point]
}
lines.push('M2')
return {
backend: 'camotics-source-stage-contract',
sourceRevision: CAMOTICS_SOURCE_REVISION,
sourcePaths: ['CAMotics/src/gcode/machine/GCodeMachine.cpp'],
nativeExecutable: false,
gcode: `${lines.join('\n')}\n`,
lineCount: lines.length,
parserAuthority: 'linuxcnc-wasm',
camoticsParsesCanonicalGcode: false,
}
}
type OclOwned = { delete(): void }
type OclPoint = OclOwned & { x: number; y: number; z: number }
type OclVector<T> = OclOwned & { size(): number; get(index: number): T }
type OclModule = {
Point: new (x: number, y: number, z: number) => OclPoint
Triangle: new (a: OclPoint, b: OclPoint, c: OclPoint) => OclOwned
STLSurf: new () => OclOwned & { addTriangle(triangle: OclOwned): void; size(): number }
Line: new (a: OclPoint, b: OclPoint) => OclOwned
Path: new () => OclOwned & { appendLine(line: OclOwned): void }
CylCutter: new (diameter: number, length: number) => OclOwned
BallCutter: new (diameter: number, length: number) => OclOwned
PathDropCutter: new () => OclOwned & {
setSTL(surface: OclOwned): void
setCutter(cutter: OclOwned): void
setPath(path: OclOwned): void
setZ(z: number): void
setSampling(sampling: number): void
run(): void
getPoints(): OclVector<OclPoint>
}
}
declare global {
interface Window {
ocl?: () => Promise<OclModule>
}
}
let oclModulePromise: Promise<OclModule> | null = null
const loadOpenCamLibScript = () => new Promise<void>((resolve, reject) => {
if (typeof window.ocl === 'function') { resolve(); return }
const existing = document.querySelector<HTMLScriptElement>('script[data-opencamlib-wasm]')
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => reject(new Error('OpenCAMLib WASM script failed to load.')), { once: true })
return
}
const script = document.createElement('script')
script.src = OPEN_CAM_LIB_ARTIFACT_URL
script.integrity = OPEN_CAM_LIB_ARTIFACT_SRI
script.crossOrigin = 'anonymous'
script.dataset.opencamlibWasm = 'true'
script.addEventListener('load', () => resolve(), { once: true })
script.addEventListener('error', () => reject(new Error('OpenCAMLib WASM script failed integrity or network validation.')), { once: true })
document.head.append(script)
})
export const loadOpenCamLib = async (): Promise<OclModule> => {
if (typeof window === 'undefined' || typeof document === 'undefined' || typeof WebAssembly === 'undefined') throw new Error('OpenCAMLib WASM requires a browser WebAssembly runtime.')
if (!oclModulePromise) {
oclModulePromise = (async () => {
await loadOpenCamLibScript()
if (typeof window.ocl !== 'function') throw new Error('OpenCAMLib WASM factory was not registered by the verified artifact.')
return window.ocl()
})().catch((error) => {
oclModulePromise = null
throw error
})
}
return oclModulePromise
}
const finitePoint = (value: CamPoint, label: string) => {
if (value.length !== 3 || value.some((coordinate) => !Number.isFinite(coordinate))) throw new RangeError(`${label} must contain three finite coordinates.`)
}
const positive = (value: number, label: string) => {
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${label} must be a finite number greater than zero.`)
}
export type OpenCamLibTriangle = [CamPoint, CamPoint, CamPoint]
export type OpenCamLibDropCutterInput = {
triangles: OpenCamLibTriangle[]
path: CamPoint[]
cutter: { shape: 'endmill' | 'ballend'; diameter: number; length: number }
sampling: number
minimumZ?: number
}
export type OpenCamLibDropCutterResult = {
backend: 'upstream-opencamlib-wasm'
sourceRevision: string
artifactSha256: string
algorithm: 'path-drop-cutter'
triangleCount: number
points: CamPoint[]
}
export const runOpenCamLibDropCutter = async (input: OpenCamLibDropCutterInput): Promise<OpenCamLibDropCutterResult> => {
if (input.triangles.length === 0) throw new RangeError('OpenCAMLib requires at least one surface triangle.')
if (input.path.length < 2) throw new RangeError('OpenCAMLib path requires at least two points.')
input.triangles.forEach((triangle, triangleIndex) => triangle.forEach((candidate, pointIndex) => finitePoint(candidate, `triangles[${triangleIndex}][${pointIndex}]`)))
input.path.forEach((candidate, index) => finitePoint(candidate, `path[${index}]`))
positive(input.cutter.diameter, 'cutter.diameter')
positive(input.cutter.length, 'cutter.length')
positive(input.sampling, 'sampling')
if (input.minimumZ !== undefined && !Number.isFinite(input.minimumZ)) throw new RangeError('minimumZ must be finite.')
const module = await loadOpenCamLib()
const surface = new module.STLSurf()
const path = new module.Path()
const cutter = input.cutter.shape === 'ballend'
? new module.BallCutter(input.cutter.diameter, input.cutter.length)
: new module.CylCutter(input.cutter.diameter, input.cutter.length)
const operation = new module.PathDropCutter()
const transient: OclOwned[] = []
let clPoints: OclVector<OclPoint> | null = null
try {
for (const triangle of input.triangles) {
const points = triangle.map((candidate) => new module.Point(...candidate)) as [OclPoint, OclPoint, OclPoint]
const nativeTriangle = new module.Triangle(...points)
surface.addTriangle(nativeTriangle)
nativeTriangle.delete()
points.forEach((candidate) => candidate.delete())
}
for (let index = 1; index < input.path.length; index += 1) {
const from = new module.Point(...input.path[index - 1])
const to = new module.Point(...input.path[index])
const line = new module.Line(from, to)
path.appendLine(line)
transient.push(line, from, to)
}
operation.setSTL(surface)
operation.setCutter(cutter)
operation.setPath(path)
operation.setZ(input.minimumZ ?? Math.min(...input.path.map((candidate) => candidate[2])))
operation.setSampling(input.sampling)
operation.run()
clPoints = operation.getPoints()
const points: CamPoint[] = []
for (let index = 0; index < clPoints.size(); index += 1) {
const nativePoint = clPoints.get(index)
points.push([nativePoint.x, nativePoint.y, nativePoint.z])
nativePoint.delete()
}
if (points.length === 0 || points.some((candidate) => candidate.some((coordinate) => !Number.isFinite(coordinate)))) throw new Error('OpenCAMLib returned an invalid or empty cutter-location path.')
return {
backend: 'upstream-opencamlib-wasm',
sourceRevision: OPEN_CAM_LIB_SOURCE_REVISION,
artifactSha256: OPEN_CAM_LIB_ARTIFACT_SHA256,
algorithm: 'path-drop-cutter',
triangleCount: surface.size(),
points,
}
} finally {
clPoints?.delete()
operation.delete()
cutter.delete()
transient.reverse().forEach((candidate) => candidate.delete())
path.delete()
surface.delete()
}
}
export type OcctSolidRemovalInput = {
id: string
stock: { min: CamPoint; max: CamPoint }
path: CamPoint[]
cutterDiameter: number
maxChord: number
meshPrecision?: number
sweepMode?: 'sampled-flat-end' | 'continuous-segment'
}
export type OcctSolidRemovalResult = {
backend: 'bitbybit-occt-wasm'
model: 'sampled-flat-end-brep' | 'continuous-segment-sweep-brep'
sweepMode: 'sampled-flat-end' | 'continuous-segment'
nativeSolid: true
freeCadNativeEquivalent: false
sampleCount: number
stockVolume: number
removedVolume: number
remainingVolume: number
structuralValid: boolean
solids: number
meshVertices: number
meshTriangles: number
remainingShape: ShapeHandle
}
export const sampleFlatEndPath = (path: CamPoint[], maxChord: number): CamPoint[] => {
if (path.length === 0) throw new RangeError('Material-removal path must not be empty.')
path.forEach((candidate, index) => finitePoint(candidate, `path[${index}]`))
positive(maxChord, 'maxChord')
const samples: CamPoint[] = [[...path[0]]]
for (let index = 1; index < path.length; index += 1) {
const from = path[index - 1]
const to = path[index]
const segmentLength = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
const steps = Math.max(1, Math.ceil(segmentLength / maxChord))
for (let step = 1; step <= steps; step += 1) {
const amount = step / steps
samples.push([
from[0] + (to[0] - from[0]) * amount,
from[1] + (to[1] - from[1]) * amount,
from[2] + (to[2] - from[2]) * amount,
])
}
}
return samples
}
export const simulateOcctSolidRemoval = async (runtime: BitbybitGeometryRuntime, input: OcctSolidRemovalInput): Promise<OcctSolidRemovalResult> => {
if (!input.id.trim()) throw new RangeError('Material-removal id must not be empty.')
finitePoint(input.stock.min, 'stock.min')
finitePoint(input.stock.max, 'stock.max')
input.stock.max.forEach((coordinate, axis) => {
if (coordinate <= input.stock.min[axis]) throw new RangeError(`stock.max[${axis}] must be greater than stock.min[${axis}].`)
})
positive(input.cutterDiameter, 'cutterDiameter')
positive(input.maxChord, 'maxChord')
positive(input.meshPrecision ?? 0.1, 'meshPrecision')
const sweepMode = input.sweepMode ?? 'sampled-flat-end'
const samples = sampleFlatEndPath(input.path, input.maxChord).filter((candidate) => candidate[2] < input.stock.max[2])
if (samples.length === 0) throw new RangeError('Material-removal path does not intersect the stock height.')
await runtime.initialize()
if (runtime.capabilities().status !== 'ready') throw new Error(`Bitbybit OCCT is unavailable: ${runtime.capabilities().reason ?? 'unknown reason'}`)
const documentId = `cam-solid-removal:${input.id}`
const width = input.stock.max[0] - input.stock.min[0]
const length = input.stock.max[1] - input.stock.min[1]
const height = input.stock.max[2] - input.stock.min[2]
const center: CamPoint = [input.stock.min[0] + width / 2, input.stock.min[1] + length / 2, input.stock.min[2] + height / 2]
// Bitbybit's BoxDto names the Y span `height` and Z span `length`; map the
// CAM stock's XYZ bounds explicitly so the native BRep has the requested box.
const stock = await runtime.createBox({ documentId, documentVersion: 1, width, length: height, height: length, center, originOnCenter: true })
const tools: ShapeHandle[] = []
let mergedTool: ShapeHandle | null = null
let remaining: ShapeHandle | null = null
try {
const stockMass = await runtime.massProperties(stock)
const radius = input.cutterDiameter / 2
const first = samples[0]
const last = samples[samples.length - 1]
const straightX = Math.abs(last[1] - first[1]) <= 1e-9 && Math.abs(last[2] - first[2]) <= 1e-9
const straightY = Math.abs(last[0] - first[0]) <= 1e-9 && Math.abs(last[2] - first[2]) <= 1e-9
const bottom = Math.max(input.stock.min[2], Math.min(first[2], last[2]))
const cutterHeight = input.stock.max[2] - bottom
// Use one connected segment envelope for a horizontal axis-aligned pass.
// The sampled-cylinder fallback remains for arbitrary paths; the envelope
// avoids invalid seams from independently fusing every chord sample.
if (sweepMode === 'continuous-segment') {
const endpoints = samples.length === 1 ? samples : samples.slice(0, -1)
for (const [index, from] of endpoints.entries()) {
const to = samples.length === 1 ? from : samples[index + 1]
const dx = to[0] - from[0]
const dy = to[1] - from[1]
const dz = to[2] - from[2]
const length = Math.hypot(dx, dy, dz)
if (length <= 1e-12) continue
tools.push(await runtime.createCylinder({
documentId,
documentVersion: 1,
radius,
height: length,
center: from,
direction: [dx / length, dy / length, dz / length],
originOnCenter: false,
}))
// Endpoint spheres keep adjacent swept chords connected at sharp
// corners while retaining a native OCCT BRep for arbitrary curves.
tools.push(await runtime.createSphere({ documentId, documentVersion: 1, radius, center: from }))
}
if (samples.length > 0) tools.push(await runtime.createSphere({ documentId, documentVersion: 1, radius, center: samples.at(-1)! }))
} else if (cutterHeight > 0 && (straightX || straightY) && samples.length > 1) {
const spanX = Math.max(0, Math.abs(last[0] - first[0]))
const spanY = Math.max(0, Math.abs(last[1] - first[1]))
if (straightX && spanX > 0) {
tools.push(await runtime.createBox({ documentId, documentVersion: 1, width: spanX, length: cutterHeight, height: input.cutterDiameter, center: [(first[0] + last[0]) / 2, first[1], bottom + cutterHeight / 2], originOnCenter: true }))
} else if (straightY && spanY > 0) {
tools.push(await runtime.createBox({ documentId, documentVersion: 1, width: input.cutterDiameter, length: cutterHeight, height: spanY, center: [first[0], (first[1] + last[1]) / 2, bottom + cutterHeight / 2], originOnCenter: true }))
}
} else {
for (const sample of samples) {
const sampleBottom = Math.max(input.stock.min[2], sample[2])
const sampleHeight = input.stock.max[2] - sampleBottom
if (sampleHeight <= 0) continue
tools.push(await runtime.createCylinder({
documentId,
documentVersion: 1,
radius,
height: sampleHeight,
center: [sample[0], sample[1], sampleBottom],
direction: [0, 0, 1],
originOnCenter: false,
}))
}
}
if (tools.length === 0) throw new RangeError('Material-removal sampling produced no cutter solids.')
mergedTool = tools.length === 1
? tools[0]
: await runtime.union({ documentId, documentVersion: 2, shapes: tools, keepEdges: false })
remaining = await runtime.cut({ documentId, documentVersion: 3, base: stock, tools: [mergedTool], keepEdges: false })
const [remainingMass, quality, mesh] = await Promise.all([
runtime.massProperties(remaining),
runtime.qualityReport(remaining),
runtime.mesh(remaining, input.meshPrecision ?? 0.1),
])
const removedVolume = stockMass.volume - remainingMass.volume
if (!quality.structuralValid || quality.solids < 1 || removedVolume <= 0 || removedVolume > stockMass.volume) throw new Error(`OCCT solid material removal returned an invalid BRep or volume delta: ${JSON.stringify({ stockVolume: stockMass.volume, remainingVolume: remainingMass.volume, removedVolume, quality })}`)
return {
backend: 'bitbybit-occt-wasm',
model: sweepMode === 'continuous-segment' ? 'continuous-segment-sweep-brep' : 'sampled-flat-end-brep',
sweepMode,
nativeSolid: true,
freeCadNativeEquivalent: false,
sampleCount: samples.length,
stockVolume: stockMass.volume,
removedVolume,
remainingVolume: remainingMass.volume,
structuralValid: quality.structuralValid,
solids: quality.solids,
meshVertices: mesh.positions.length / 3,
meshTriangles: mesh.indices.length / 3,
remainingShape: remaining,
}
} catch (error) {
if (remaining) await runtime.release(remaining)
throw error
} finally {
if (mergedTool && !tools.some((tool) => tool.id === mergedTool?.id)) await runtime.release(mergedTool)
await Promise.all(tools.map((tool) => runtime.release(tool)))
await runtime.release(stock)
}
}

238
src/facade/camPipeline.ts Normal file
View File

@@ -0,0 +1,238 @@
import type { CamApi, CamKinematicOptions, CamPoint, CamPostprocessor } from './cam'
import {
OPEN_CAM_LIB_ARTIFACT_SHA256,
OPEN_CAM_LIB_SOURCE_REVISION,
generateCamoticsSourceStageGcode,
runOpenCamLibDropCutter,
type OpenCamLibDropCutterInput,
type OpenCamLibDropCutterResult,
} from './camNativeSimulation'
/**
* CAM stage order is deliberately explicit. LinuxCNC WASM is the only stage
* allowed to parse, plan, or execute the resulting G-code.
*/
export const CAM_PIPELINE_ORDER = ['CAD', 'OCL', 'CAMotics', 'GCODE', 'LinuxCNC WASM'] as const
export type CamPipelineStage = typeof CAM_PIPELINE_ORDER[number]
export const CAMOTICS_SOURCE_REVISION = 'e84665f2fa9d1151f03282ac7e01320bc65e015b'
export const CAMOTICS_SOURCE_PATH = 'CAMotics'
export const CAMOTICS_SOURCE_URL = 'https://github.com/CauldronDevelopmentLLC/CAMotics'
export type CamoticsStageEvidence = {
backend: 'camotics-source'
sourcePath: string
sourceRevision: string
sourceUrl: string
generator: 'camotics-source-stage'
simulator: 'camotics-camsim'
axisScope: '3-axis-source-capability'
workspaceNativeCli: 'verified-sidecar'
nativeCliPath: 'CAMotics/camsim'
browserNativeExecutable: false
parserAuthority: 'linuxcnc-wasm'
camoticsParsesCanonicalGcode: false
}
export type LinuxcncWasmSubmission = {
backend: 'linuxcnc-wasm'
status: 'accepted' | 'rejected' | 'dry-run' | 'unavailable'
programSha256?: string
message?: string
trace?: unknown
}
export type LinuxcncWasmAdapter = {
submit(program: string): Promise<LinuxcncWasmSubmission>
}
export type LinuxcncIframeAdapterOptions = {
url: string
run?: boolean
timeoutMs?: number
name?: string
}
export type OpenCamLibPipelineRunner = (input: OpenCamLibDropCutterInput) => Promise<OpenCamLibDropCutterResult>
export type CamPipelineOptions = {
postprocessor?: CamPostprocessor
kinematics?: CamKinematicOptions
openCamLib?: {
triangles?: OpenCamLibDropCutterInput['triangles']
sampling?: number
runner?: OpenCamLibPipelineRunner
}
}
export type OpenCamLibStageEvidence = {
backend: 'upstream-opencamlib-wasm'
sourceRevision: string
artifactSha256: string
algorithm: 'path-drop-cutter'
surfaceModel: 'cad-operation-plane' | 'cad-triangle-mesh'
triangleCount: number
inputPoints: number
outputPoints: number
sampling: number
}
export type CamPipelineResult = {
stage: CamPipelineStage
order: readonly CamPipelineStage[]
operationId: string
postprocessor: CamPostprocessor
gcode: string
camoticsMotionGcode: string
openCamLib: OpenCamLibStageEvidence
camotics: CamoticsStageEvidence
linuxcnc: LinuxcncWasmSubmission | null
}
export const camoticsSourceEvidence = (): CamoticsStageEvidence => ({
backend: 'camotics-source',
sourcePath: CAMOTICS_SOURCE_PATH,
sourceRevision: CAMOTICS_SOURCE_REVISION,
sourceUrl: CAMOTICS_SOURCE_URL,
generator: 'camotics-source-stage',
simulator: 'camotics-camsim',
axisScope: '3-axis-source-capability',
workspaceNativeCli: 'verified-sidecar',
nativeCliPath: 'CAMotics/camsim',
browserNativeExecutable: false,
parserAuthority: 'linuxcnc-wasm',
camoticsParsesCanonicalGcode: false,
})
/**
* Builds the machine program but does not submit it. This function is the
* only public route from CAD/OCL/CAMotics evidence to the LinuxCNC adapter.
*/
const operationPlane = (min: CamPoint, max: CamPoint, z: number): OpenCamLibDropCutterInput['triangles'] => [
[[min[0], min[1], z], [max[0], min[1], z], [max[0], max[1], z]],
[[min[0], min[1], z], [max[0], max[1], z], [min[0], max[1], z]],
]
export const prepareCamPipeline = async (
cam: CamApi,
operationId: string,
options: CamPipelineOptions = {},
): Promise<CamPipelineResult> => {
const postprocessor = options.postprocessor ?? 'linuxcnc'
if (postprocessor !== 'linuxcnc') throw new RangeError('The LinuxCNC WASM CAM pipeline requires the linuxcnc postprocessor.')
const operation = cam.snapshot().operations.find((candidate) => candidate.id === operationId)
if (!operation) throw new RangeError(`CAM pipeline operation does not exist: ${operationId}`)
if (operation.path.length < 2) throw new RangeError(`CAM pipeline operation has no generated path: ${operationId}`)
const snapshot = cam.snapshot()
const cutter = snapshot.tools.find((candidate) => candidate.id === operation.toolId)
if (!cutter) throw new RangeError(`CAM pipeline tool does not exist: ${operation.toolId}`)
const minimumZ = Math.min(...operation.path.map((point) => point[2]))
const triangles = options.openCamLib?.triangles ?? operationPlane(snapshot.stock.min, snapshot.stock.max, minimumZ)
const sampling = options.openCamLib?.sampling ?? Math.max(0.01, cutter.diameter / 2)
const runOpenCamLib = options.openCamLib?.runner ?? runOpenCamLibDropCutter
const ocl = await runOpenCamLib({
triangles,
path: operation.path,
cutter: { shape: cutter.shape === 'ballend' ? 'ballend' : 'endmill', diameter: cutter.diameter, length: cutter.length },
sampling,
minimumZ,
})
if (ocl.backend !== 'upstream-opencamlib-wasm' || ocl.sourceRevision !== OPEN_CAM_LIB_SOURCE_REVISION || ocl.artifactSha256 !== OPEN_CAM_LIB_ARTIFACT_SHA256) {
throw new Error('CAM pipeline rejected OpenCAMLib output without the pinned upstream WASM identity.')
}
if (ocl.points.length < 2) throw new Error('CAM pipeline rejected an empty OpenCAMLib cutter-location path.')
const resolvedOperation = cam.replaceGeneratedPath(operationId, ocl.points)
// CAMotics source stage emits canonical motion words. The full post output
// retains tool/controller/modal blocks, while LinuxCNC remains the parser.
const camoticsMotion = generateCamoticsSourceStageGcode(resolvedOperation.path.map((point, index) => ({ type: index === 0 ? 'rapid' as const : 'cut' as const, point, feed: resolvedOperation.feed })), { feed: resolvedOperation.feed })
const gcode = options.kinematics
? cam.exportMultiAxisGcode(operationId, postprocessor, options.kinematics)
: cam.exportGcode(postprocessor)
if (!gcode.trim()) throw new Error('CAM pipeline generated an empty G-code program.')
return {
stage: 'GCODE',
order: CAM_PIPELINE_ORDER,
operationId,
postprocessor,
gcode,
camoticsMotionGcode: camoticsMotion.gcode,
openCamLib: {
backend: ocl.backend,
sourceRevision: ocl.sourceRevision,
artifactSha256: ocl.artifactSha256,
algorithm: ocl.algorithm,
surfaceModel: options.openCamLib?.triangles ? 'cad-triangle-mesh' : 'cad-operation-plane',
triangleCount: ocl.triangleCount,
inputPoints: operation.path.length,
outputPoints: ocl.points.length,
sampling,
},
camotics: camoticsSourceEvidence(),
linuxcnc: null,
}
}
export const submitCamPipelineToLinuxcnc = async (
prepared: CamPipelineResult,
adapter: LinuxcncWasmAdapter,
): Promise<CamPipelineResult> => {
if (prepared.stage !== 'GCODE') throw new Error(`LinuxCNC WASM submission requires G-code stage, got ${prepared.stage}.`)
if (!prepared.gcode.trim()) throw new Error('LinuxCNC WASM submission requires a non-empty G-code program.')
const linuxcnc = await adapter.submit(prepared.gcode)
if (linuxcnc.backend !== 'linuxcnc-wasm') throw new Error('CAM pipeline rejected a non-LinuxCNC execution backend.')
return { ...prepared, stage: 'LinuxCNC WASM', linuxcnc }
}
/**
* Browser bridge to the real LinuxCNC WASM page. The iframe owns the worker,
* RS274 parser, task planner, kinematics and execution; this adapter only
* transports the already generated program and observes the result.
*/
export const createLinuxcncIframeAdapter = (options: LinuxcncIframeAdapterOptions): LinuxcncWasmAdapter => {
if (typeof window === 'undefined' || typeof document === 'undefined') throw new Error('LinuxCNC iframe adapter requires a browser document.')
const timeoutMs = options.timeoutMs ?? 120_000
const frame = document.createElement('iframe')
frame.src = options.url
frame.title = 'LinuxCNC WASM machine bridge'
frame.hidden = true
const ready = new Promise<void>((resolve) => frame.addEventListener('load', () => resolve(), { once: true }))
document.body.append(frame)
return {
async submit(program) {
await ready
if (!frame.contentWindow) throw new Error('LinuxCNC WASM iframe did not create a content window.')
const requestId = `bitbybit-${Date.now()}-${Math.random().toString(36).slice(2)}`
return new Promise<LinuxcncWasmSubmission>((resolve, reject) => {
const timer = window.setTimeout(() => {
window.removeEventListener('message', onMessage)
reject(new Error('LinuxCNC WASM bridge timed out waiting for parser/controller acceptance.'))
}, timeoutMs)
const onMessage = (event: MessageEvent) => {
const data = event.data
if (event.source !== frame.contentWindow || data?.type !== 'bitbybit-linuxcnc-result' || data.requestId !== requestId) return
window.clearTimeout(timer)
window.removeEventListener('message', onMessage)
resolve({
backend: 'linuxcnc-wasm',
status: data.status,
programSha256: data.programSha256,
message: data.message,
trace: { lines: data.lines, blocks: data.blocks, trajectorySegments: data.trajectorySegments, parserAuthority: data.parserAuthority },
})
}
window.addEventListener('message', onMessage)
const targetWindow = frame.contentWindow
if (!targetWindow) {
window.clearTimeout(timer)
window.removeEventListener('message', onMessage)
reject(new Error('LinuxCNC WASM iframe lost its content window before submission.'))
return
}
targetWindow.postMessage({ type: 'bitbybit-linuxcnc-submit', requestId, name: options.name ?? 'bitbybit-cam.ngc', program, run: options.run !== false }, new URL(options.url, window.location.href).origin)
})
},
}
}

106
src/facade/camoticsWasm.ts Normal file
View File

@@ -0,0 +1,106 @@
export const CAMOTICS_WASM_SOURCE_REVISION = 'e84665f2fa9d1151f03282ac7e01320bc65e015b'
export const CAMOTICS_WASM_ARTIFACT_URL = '/vendor/camotics/camotics-sweep.wasm'
export const CAMOTICS_WASM_ARTIFACT_SHA256 = '889588d2dc9755f84f56b8ebe1acc9bc1f0ded7e676e162a5a001011f4ebec5b'
export type CamoticsWasmPoint = [number, number, number]
type CamoticsSweepExports = WebAssembly.Exports & {
memory: WebAssembly.Memory
camotics_sweep_abi_version(): number
camotics_conic_depth(length: number, topRadius: number, bottomRadius: number, ax: number, ay: number, az: number, bx: number, by: number, bz: number, px: number, py: number, pz: number): number
camotics_spheroid_depth(radius: number, length: number, ax: number, ay: number, az: number, bx: number, by: number, bz: number, px: number, py: number, pz: number): number
camotics_conic_bbox_count(length: number, topRadius: number, bottomRadius: number, ax: number, ay: number, az: number, bx: number, by: number, bz: number, tolerance: number): number
}
export type CamoticsSweepKernel = {
backend: 'camotics-upstream-sweep-wasm'
sourceRevision: string
artifactSha256: string
abiVersion: 1
imports: readonly []
fullCamoticsProgram: false
gcodeParserIncluded: false
conicDepth(input: { length: number; topRadius: number; bottomRadius?: number; start: CamoticsWasmPoint; end: CamoticsWasmPoint; point: CamoticsWasmPoint }): number
spheroidDepth(input: { radius: number; length?: number; start: CamoticsWasmPoint; end: CamoticsWasmPoint; point: CamoticsWasmPoint }): number
conicBoundingBoxCount(input: { length: number; topRadius: number; bottomRadius?: number; start: CamoticsWasmPoint; end: CamoticsWasmPoint; tolerance?: number }): number
}
const finite = (value: number, label: string) => {
if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`)
return value
}
const positive = (value: number, label: string) => {
finite(value, label)
if (value <= 0) throw new RangeError(`${label} must be greater than zero.`)
return value
}
const point = (value: CamoticsWasmPoint, label: string) => {
if (value.length !== 3) throw new RangeError(`${label} must contain three coordinates.`)
return value.map((coordinate, index) => finite(coordinate, `${label}[${index}]`)) as CamoticsWasmPoint
}
const digest = async (bytes: ArrayBuffer) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map((value) => value.toString(16).padStart(2, '0')).join('')
export const createCamoticsSweepKernel = (exports: CamoticsSweepExports): CamoticsSweepKernel => {
const abiVersion = exports.camotics_sweep_abi_version()
if (abiVersion !== 1) throw new Error(`Unsupported CAMotics sweep WASM ABI: ${abiVersion}`)
return {
backend: 'camotics-upstream-sweep-wasm',
sourceRevision: CAMOTICS_WASM_SOURCE_REVISION,
artifactSha256: CAMOTICS_WASM_ARTIFACT_SHA256,
abiVersion,
imports: [],
fullCamoticsProgram: false,
gcodeParserIncluded: false,
conicDepth(input) {
const length = positive(input.length, 'Conic length')
const topRadius = positive(input.topRadius, 'Conic top radius')
const bottomRadius = input.bottomRadius === undefined ? topRadius : positive(input.bottomRadius, 'Conic bottom radius')
const start = point(input.start, 'Conic start')
const end = point(input.end, 'Conic end')
const sample = point(input.point, 'Conic sample point')
return exports.camotics_conic_depth(length, topRadius, bottomRadius, ...start, ...end, ...sample)
},
spheroidDepth(input) {
const radius = positive(input.radius, 'Spheroid radius')
const length = input.length === undefined ? radius * 2 : positive(input.length, 'Spheroid length')
const start = point(input.start, 'Spheroid start')
const end = point(input.end, 'Spheroid end')
const sample = point(input.point, 'Spheroid sample point')
return exports.camotics_spheroid_depth(radius, length, ...start, ...end, ...sample)
},
conicBoundingBoxCount(input) {
const length = positive(input.length, 'Conic length')
const topRadius = positive(input.topRadius, 'Conic top radius')
const bottomRadius = input.bottomRadius === undefined ? topRadius : positive(input.bottomRadius, 'Conic bottom radius')
const start = point(input.start, 'Conic start')
const end = point(input.end, 'Conic end')
const tolerance = input.tolerance === undefined ? 0.01 : finite(input.tolerance, 'Conic tolerance')
if (tolerance < 0) throw new RangeError('Conic tolerance must not be negative.')
return exports.camotics_conic_bbox_count(length, topRadius, bottomRadius, ...start, ...end, tolerance)
},
}
}
let kernelPromise: Promise<CamoticsSweepKernel> | null = null
export const loadCamoticsSweepKernel = async (): Promise<CamoticsSweepKernel> => {
if (typeof WebAssembly === 'undefined' || typeof crypto?.subtle === 'undefined') throw new Error('CAMotics sweep kernel requires WebAssembly and Web Crypto.')
if (!kernelPromise) kernelPromise = (async () => {
const response = await fetch(CAMOTICS_WASM_ARTIFACT_URL, { cache: 'no-store', credentials: 'same-origin' })
if (!response.ok) throw new Error(`CAMotics sweep WASM fetch failed: HTTP ${response.status}`)
const bytes = await response.arrayBuffer()
const actualDigest = await digest(bytes)
if (actualDigest !== CAMOTICS_WASM_ARTIFACT_SHA256) throw new Error(`CAMotics sweep WASM digest mismatch: ${actualDigest}`)
const module = await WebAssembly.compile(bytes)
if (WebAssembly.Module.imports(module).length !== 0) throw new Error('CAMotics sweep WASM must remain standalone with zero imports.')
const instance = await WebAssembly.instantiate(module, {})
return createCamoticsSweepKernel(instance.exports as CamoticsSweepExports)
})().catch((error) => {
kernelPromise = null
throw error
})
return kernelPromise
}

355
src/facade/dataModules.ts Normal file
View File

@@ -0,0 +1,355 @@
export type SpecialistModule = 'Points' | 'ReverseEngineering' | 'OpenSCAD' | 'Idf' | 'JtReader' | 'Material' | 'Import'
export type AdapterStatus = 'supported' | 'proxy' | 'unsupported'
export type DataAdapterDescriptor = { module: SpecialistModule; status: AdapterStatus; formats: string[]; operations: string[] }
export type DataRecord = { id: string; module: SpecialistModule; format: string; byteLength: number; proxy: boolean; metadata: Record<string, string> }
export type Point3 = { x: number; y: number; z: number }
export type PointCloudBounds = { min: Point3; max: Point3 }
export type StructuredPointCloud = { width: number; height: number; grid: (number | null)[] }
export type PointCloud = {
id: string
sourceFormat: PointCloudFormat | 'derived'
units: 'mm' | 'm' | 'in'
points: Point3[]
bounds: PointCloudBounds
centroid: Point3
structured?: StructuredPointCloud
}
export type PointCloudFormat = 'asc' | 'pts' | 'xyz' | 'csv' | 'pcd' | 'ply'
export type Polygon2 = { x: number; y: number }
export type PlaneFitResult = { id: string; sourceId: string; kind: 'plane'; origin: Point3; normal: Point3; u: Point3; v: Point3; length: number; width: number; rms: number; pointCount: number }
export type SphereFitResult = { id: string; sourceId: string; kind: 'sphere'; center: Point3; radius: number; rms: number; pointCount: number }
export type CylinderFitResult = { id: string; sourceId: string; kind: 'cylinder'; base: Point3; axis: Point3; radius: number; height: number; rms: number; pointCount: number }
export type PolynomialFitResult = { id: string; sourceId: string; kind: 'polynomial-surface'; coefficients: [number, number, number, number, number, number]; rms: number; pointCount: number }
export type PointCloudSegment = { id: string; sourceId: string; radius: number; labels: number[]; clusters: { label: number; pointIndices: number[] }[] }
export type ReverseEngineeringResult = PlaneFitResult | SphereFitResult | CylinderFitResult | PolynomialFitResult
export type DataModulesSnapshot = { adapters: DataAdapterDescriptor[]; records: DataRecord[]; pointClouds: PointCloud[]; reverseEngineering: ReverseEngineeringResult[]; segments: PointCloudSegment[]; version: number }
export type DataModulesApi = {
snapshot(): DataModulesSnapshot
descriptors(): DataAdapterDescriptor[]
importRecord(input: { id: string; module: SpecialistModule; format: string; bytes: ArrayLike<number>; metadata?: Record<string, string> }): DataRecord
importPointCloud(input: { id: string; format: PointCloudFormat; bytes: ArrayLike<number>; units?: PointCloud['units']; translateToOrigin?: boolean; record?: boolean }): PointCloud
exportPointCloud(id: string, format: PointCloudFormat): Uint8Array
translatePointCloud(input: { id: string; sourceId: string; offset: Point3 }): PointCloud
mergePointClouds(input: { id: string; sourceIds: string[] }): PointCloud
cropPointCloud(input: { id: string; sourceId: string; bounds: PointCloudBounds }): PointCloud
polygonCropPointCloud(input: { id: string; sourceId: string; polygon: Polygon2[]; invert?: boolean }): PointCloud
voxelDownsample(input: { id: string; sourceId: string; size: number }): PointCloud
structurePointCloud(input: { id: string; sourceId: string; tolerance?: number }): PointCloud
fitPlane(input: { id: string; sourceId: string; referenceNormal?: Point3 }): PlaneFitResult
fitSphere(input: { id: string; sourceId: string }): SphereFitResult
fitCylinder(input: { id: string; sourceId: string }): CylinderFitResult
fitPolynomialSurface(input: { id: string; sourceId: string }): PolynomialFitResult
segmentPointCloud(input: { id: string; sourceId: string; radius: number }): PointCloudSegment
removeRecord(id: string): void
exportManifest(): string
}
const MAX_DATA_BYTES = 8 * 1024 * 1024
const MAX_POINT_COUNT = 1_000_000
const adapters: DataAdapterDescriptor[] = [
{ module: 'Points', status: 'supported', formats: ['asc', 'pts', 'xyz', 'csv', 'pcd', 'ply'], operations: ['import', 'export', 'translate', 'merge', 'crop', 'polygon-crop', 'structure', 'voxel-downsample'] },
{ module: 'ReverseEngineering', status: 'supported', formats: ['point-cloud'], operations: ['fit-plane', 'fit-sphere', 'fit-cylinder', 'fit-polynomial-surface', 'segment', 'report'] },
{ module: 'OpenSCAD', status: 'proxy', formats: ['scad'], operations: ['import', 'preview', 'export'] },
{ module: 'Idf', status: 'proxy', formats: ['idf'], operations: ['import', 'export'] },
{ module: 'JtReader', status: 'proxy', formats: ['jt'], operations: ['import', 'inspect'] },
{ module: 'Material', status: 'supported', formats: ['json', 'yaml'], operations: ['card', 'assign', 'export'] },
{ module: 'Import', status: 'supported', formats: ['step', 'iges', 'brep', 'stl', 'obj', 'dxf'], operations: ['import', 'batch', 'options'] },
]
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const descriptor = (module: SpecialistModule) => adapters.find((entry) => entry.module === module)!
const finite = (value: number, label: string) => {
if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`)
return value
}
const point = (x: number, y: number, z: number): Point3 => ({ x: finite(x, 'Point x'), y: finite(y, 'Point y'), z: finite(z, 'Point z') })
const add = (a: Point3, b: Point3): Point3 => point(a.x + b.x, a.y + b.y, a.z + b.z)
const subtract = (a: Point3, b: Point3): Point3 => point(a.x - b.x, a.y - b.y, a.z - b.z)
const scale = (a: Point3, value: number): Point3 => point(a.x * value, a.y * value, a.z * value)
const dot = (a: Point3, b: Point3) => a.x * b.x + a.y * b.y + a.z * b.z
const cross = (a: Point3, b: Point3): Point3 => point(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x)
const length = (value: Point3) => Math.hypot(value.x, value.y, value.z)
const normalize = (value: Point3, label: string): Point3 => {
const magnitude = length(value)
if (!Number.isFinite(magnitude) || magnitude <= 1e-12) throw new RangeError(`${label} must have non-zero length.`)
return scale(value, 1 / magnitude)
}
const stableDirection = (value: Point3): Point3 => {
const direction = normalize(value, 'Direction')
const components = [direction.x, direction.y, direction.z]
let dominant = 0
for (let index = 1; index < components.length; index += 1) if (Math.abs(components[index]) > Math.abs(components[dominant])) dominant = index
return components[dominant] < 0 ? scale(direction, -1) : direction
}
const validateBounds = (bounds: PointCloudBounds) => {
const min = point(bounds.min.x, bounds.min.y, bounds.min.z)
const max = point(bounds.max.x, bounds.max.y, bounds.max.z)
if (min.x > max.x || min.y > max.y || min.z > max.z) throw new RangeError('Point-cloud bounds must be ordered on every axis.')
return { min, max }
}
const summarizePoints = (points: Point3[]): Pick<PointCloud, 'bounds' | 'centroid'> => {
if (points.length === 0) throw new RangeError('Point cloud must contain at least one point.')
if (points.length > MAX_POINT_COUNT) throw new RangeError(`Point cloud exceeds ${MAX_POINT_COUNT} points.`)
const min = { ...points[0] }; const max = { ...points[0] }; const sum = { x: 0, y: 0, z: 0 }
for (const entry of points) {
point(entry.x, entry.y, entry.z)
min.x = Math.min(min.x, entry.x); min.y = Math.min(min.y, entry.y); min.z = Math.min(min.z, entry.z)
max.x = Math.max(max.x, entry.x); max.y = Math.max(max.y, entry.y); max.z = Math.max(max.z, entry.z)
sum.x += entry.x; sum.y += entry.y; sum.z += entry.z
}
return { bounds: { min, max }, centroid: scale(sum, 1 / points.length) }
}
const decode = (bytes: ArrayLike<number>) => {
if (!Number.isSafeInteger(bytes.length) || bytes.length <= 0 || bytes.length > MAX_DATA_BYTES) throw new RangeError('Data record byte length is outside the supported limit.')
try { return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes)) } catch { throw new RangeError('Point cloud must be valid UTF-8 text.') }
}
const numericColumns = (line: string, comma: boolean) => (comma ? line.split(',') : line.trim().split(/[\s,;]+/)).map((value) => value.trim())
const parseTriples = (lines: string[], comma = false) => {
const points: Point3[] = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue
const values = numericColumns(trimmed, comma)
if (values.length < 3) throw new RangeError(`Point row must contain x, y and z: ${trimmed}`)
const xyz = values.slice(0, 3).map(Number)
if (xyz.some((value) => !Number.isFinite(value))) {
if (points.length === 0 && values.slice(0, 3).map((value) => value.toLowerCase()).join(',') === 'x,y,z') continue
throw new RangeError(`Point row contains a non-numeric coordinate: ${trimmed}`)
}
points.push(point(xyz[0], xyz[1], xyz[2]))
if (points.length > MAX_POINT_COUNT) throw new RangeError(`Point cloud exceeds ${MAX_POINT_COUNT} points.`)
}
return points
}
const parsePcd = (text: string) => {
const lines = text.split(/\r?\n/); const dataIndex = lines.findIndex((line) => /^DATA\s+/i.test(line.trim()))
if (dataIndex < 0 || !/^DATA\s+ascii$/i.test(lines[dataIndex].trim())) throw new RangeError('Only ASCII PCD point clouds are supported.')
const fieldsLine = lines.slice(0, dataIndex).find((line) => /^FIELDS\s+/i.test(line.trim()))
if (!fieldsLine) throw new RangeError('PCD FIELDS header is required.')
const fields = fieldsLine.trim().split(/\s+/).slice(1).map((value) => value.toLowerCase())
const indexes = ['x', 'y', 'z'].map((field) => fields.indexOf(field))
if (indexes.some((index) => index < 0)) throw new RangeError('PCD must declare x, y and z fields.')
return lines.slice(dataIndex + 1).filter((line) => line.trim()).map((line) => {
const values = line.trim().split(/\s+/).map(Number)
return point(values[indexes[0]], values[indexes[1]], values[indexes[2]])
})
}
const parsePly = (text: string) => {
const lines = text.split(/\r?\n/)
if (lines[0]?.trim() !== 'ply' || !lines.some((line) => line.trim() === 'format ascii 1.0')) throw new RangeError('Only ASCII PLY point clouds are supported.')
const end = lines.findIndex((line) => line.trim() === 'end_header')
if (end < 0) throw new RangeError('PLY end_header is required.')
const vertexLine = lines.slice(0, end).find((line) => /^element\s+vertex\s+/i.test(line.trim()))
const count = Number(vertexLine?.trim().split(/\s+/)[2])
if (!Number.isSafeInteger(count) || count <= 0 || count > MAX_POINT_COUNT) throw new RangeError('PLY vertex count is outside the supported limit.')
const properties: string[] = []
let inVertex = false
for (const line of lines.slice(0, end)) {
const trimmed = line.trim()
if (/^element\s+vertex\s+/i.test(trimmed)) { inVertex = true; continue }
if (/^element\s+/i.test(trimmed)) inVertex = false
if (inVertex && /^property\s+/i.test(trimmed)) properties.push(trimmed.split(/\s+/).at(-1)!.toLowerCase())
}
const indexes = ['x', 'y', 'z'].map((field) => properties.indexOf(field))
if (indexes.some((index) => index < 0)) throw new RangeError('PLY vertices must declare x, y and z properties.')
const rows = lines.slice(end + 1, end + 1 + count)
if (rows.length !== count) throw new RangeError('PLY contains fewer vertices than declared.')
return rows.map((line) => { const values = line.trim().split(/\s+/).map(Number); return point(values[indexes[0]], values[indexes[1]], values[indexes[2]]) })
}
const parsePointCloud = (format: PointCloudFormat, bytes: ArrayLike<number>) => {
const text = decode(bytes); const lines = text.split(/\r?\n/)
if (format === 'pcd') return parsePcd(text)
if (format === 'ply') return parsePly(text)
if (format === 'pts') {
const first = lines.findIndex((line) => line.trim() && !line.trim().startsWith('#'))
const declared = first >= 0 && /^\d+$/.test(lines[first].trim()) ? Number(lines[first].trim()) : null
const points = parseTriples(declared === null ? lines : lines.filter((_, index) => index !== first))
if (declared !== null && points.length !== declared) throw new RangeError(`PTS declared ${declared} points but contains ${points.length}.`)
return points
}
return parseTriples(lines, format === 'csv')
}
const formatNumber = (value: number) => Object.is(value, -0) ? '0' : Number(value.toPrecision(15)).toString()
const exportPointText = (cloud: PointCloud, format: PointCloudFormat) => {
const rows = cloud.points.map(({ x, y, z }) => `${formatNumber(x)}${format === 'csv' ? ',' : ' '}${formatNumber(y)}${format === 'csv' ? ',' : ' '}${formatNumber(z)}`)
if (format === 'pts') return `${cloud.points.length}\n${rows.join('\n')}\n`
if (format === 'csv') return `x,y,z\n${rows.join('\n')}\n`
if (format === 'pcd') return `# .PCD v0.7\nVERSION 0.7\nFIELDS x y z\nSIZE 4 4 4\nTYPE F F F\nCOUNT 1 1 1\nWIDTH ${cloud.points.length}\nHEIGHT 1\nVIEWPOINT 0 0 0 1 0 0 0\nPOINTS ${cloud.points.length}\nDATA ascii\n${rows.join('\n')}\n`
if (format === 'ply') return `ply\nformat ascii 1.0\nelement vertex ${cloud.points.length}\nproperty double x\nproperty double y\nproperty double z\nend_header\n${rows.join('\n')}\n`
return `${rows.join('\n')}\n`
}
const covariance = (points: Point3[], center: Point3) => {
const matrix = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
for (const entry of points) {
const values = [entry.x - center.x, entry.y - center.y, entry.z - center.z]
for (let row = 0; row < 3; row += 1) for (let column = row; column < 3; column += 1) matrix[row][column] += values[row] * values[column]
}
for (let row = 0; row < 3; row += 1) for (let column = row; column < 3; column += 1) matrix[row][column] = matrix[column][row] = matrix[row][column] / points.length
return matrix
}
const symmetricEigenvectors = (input: number[][]) => {
const matrix = input.map((row) => [...row]); const vectors = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
for (let iteration = 0; iteration < 32; iteration += 1) {
let p = 0; let q = 1
for (const [row, column] of [[0, 1], [0, 2], [1, 2]]) if (Math.abs(matrix[row][column]) > Math.abs(matrix[p][q])) { p = row; q = column }
if (Math.abs(matrix[p][q]) < 1e-14) break
const angle = 0.5 * Math.atan2(2 * matrix[p][q], matrix[q][q] - matrix[p][p]); const cosine = Math.cos(angle); const sine = Math.sin(angle)
for (let index = 0; index < 3; index += 1) {
const mip = matrix[index][p]; const miq = matrix[index][q]
matrix[index][p] = cosine * mip - sine * miq; matrix[index][q] = sine * mip + cosine * miq
}
for (let index = 0; index < 3; index += 1) {
const mpi = matrix[p][index]; const mqi = matrix[q][index]
matrix[p][index] = cosine * mpi - sine * mqi; matrix[q][index] = sine * mpi + cosine * mqi
const vip = vectors[index][p]; const viq = vectors[index][q]
vectors[index][p] = cosine * vip - sine * viq; vectors[index][q] = sine * vip + cosine * viq
}
}
return [0, 1, 2].map((index) => ({ value: matrix[index][index], vector: stableDirection(point(vectors[0][index], vectors[1][index], vectors[2][index])) })).sort((a, b) => a.value - b.value)
}
const solveLinear = (matrix: number[][], values: number[]) => {
const size = values.length; const augmented = matrix.map((row, index) => [...row, values[index]])
for (let column = 0; column < size; column += 1) {
let pivot = column
for (let row = column + 1; row < size; row += 1) if (Math.abs(augmented[row][column]) > Math.abs(augmented[pivot][column])) pivot = row
if (Math.abs(augmented[pivot][column]) < 1e-12) throw new RangeError('Point set is degenerate for the requested fit.')
;[augmented[column], augmented[pivot]] = [augmented[pivot], augmented[column]]
const divisor = augmented[column][column]
for (let index = column; index <= size; index += 1) augmented[column][index] /= divisor
for (let row = 0; row < size; row += 1) if (row !== column) {
const factor = augmented[row][column]
for (let index = column; index <= size; index += 1) augmented[row][index] -= factor * augmented[column][index]
}
}
return augmented.map((row) => row[size])
}
const leastSquares = (rows: number[][], values: number[]) => {
const size = rows[0].length; const normal = Array.from({ length: size }, () => Array(size).fill(0)); const rhs = Array(size).fill(0)
for (let index = 0; index < rows.length; index += 1) for (let row = 0; row < size; row += 1) {
rhs[row] += rows[index][row] * values[index]
for (let column = 0; column < size; column += 1) normal[row][column] += rows[index][row] * rows[index][column]
}
return solveLinear(normal, rhs)
}
const fitCircle2d = (points: { x: number; y: number }[]) => {
if (points.length < 3) throw new RangeError('Cylinder fit requires at least three points.')
const solution = leastSquares(points.map(({ x, y }) => [2 * x, 2 * y, 1]), points.map(({ x, y }) => x * x + y * y))
const center = { x: solution[0], y: solution[1] }; const distances = points.map(({ x, y }) => Math.hypot(x - center.x, y - center.y)); const radius = distances.reduce((sum, value) => sum + value, 0) / distances.length
return { center, radius, rms: Math.sqrt(distances.reduce((sum, value) => sum + (value - radius) ** 2, 0) / distances.length) }
}
const pointInPolygon = (entry: Point3, polygon: Polygon2[]) => {
let inside = false
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
const current = polygon[index]; const prior = polygon[previous]
const crosses = (current.y > entry.y) !== (prior.y > entry.y)
if (crosses && entry.x < ((prior.x - current.x) * (entry.y - current.y)) / (prior.y - current.y) + current.x) inside = !inside
}
return inside
}
const polygonArea = (polygon: Polygon2[]) => polygon.reduce((sum, current, index) => { const previous = polygon[(index + polygon.length - 1) % polygon.length]; return sum + previous.x * current.y - current.x * previous.y }, 0) / 2
export const createDataModules = (): DataModulesApi => {
const records = new Map<string, DataRecord>(); const pointClouds = new Map<string, PointCloud>(); const results = new Map<string, ReverseEngineeringResult>(); const segments = new Map<string, PointCloudSegment>(); let version = 0
const snapshot = (): DataModulesSnapshot => ({ adapters: clone(adapters), records: [...records.values()].map(clone), pointClouds: [...pointClouds.values()].map(clone), reverseEngineering: [...results.values()].map(clone), segments: [...segments.values()].map(clone), version })
const ensureNewCloudId = (id: string) => { if (!id.trim() || pointClouds.has(id)) throw new RangeError(`Point cloud already exists: ${id}`) }
const sourceCloud = (id: string) => { const cloud = pointClouds.get(id); if (!cloud) throw new RangeError(`Point cloud does not exist: ${id}`); return cloud }
const storeCloud = (id: string, points: Point3[], units: PointCloud['units'], sourceFormat: PointCloud['sourceFormat'], structured?: StructuredPointCloud) => {
ensureNewCloudId(id); const summary = summarizePoints(points); const cloud: PointCloud = { id, sourceFormat, units, points: points.map((entry) => ({ ...entry })), ...summary, ...(structured ? { structured } : {}) }
pointClouds.set(id, cloud); version += 1; return clone(cloud)
}
const storeResult = <T extends ReverseEngineeringResult>(result: T) => { if (!result.id.trim() || results.has(result.id)) throw new RangeError(`Reverse-engineering result already exists: ${result.id}`); results.set(result.id, result); version += 1; return clone(result) }
const importRecord = (input: { id: string; module: SpecialistModule; format: string; bytes: ArrayLike<number>; metadata?: Record<string, string> }) => {
const selected = descriptor(input.module)
if (!input.id.trim() || records.has(input.id)) throw new RangeError(`Data record already exists: ${input.id}`)
if (!selected.formats.includes(input.format.toLowerCase())) throw new RangeError(`${input.module} does not accept format ${input.format}.`)
if (!Number.isSafeInteger(input.bytes.length) || input.bytes.length <= 0 || input.bytes.length > MAX_DATA_BYTES) throw new RangeError('Data record byte length is outside the supported limit.')
const record: DataRecord = { id: input.id, module: input.module, format: input.format.toLowerCase(), byteLength: input.bytes.length, proxy: selected.status !== 'supported', metadata: { ...(input.metadata || {}) } }
records.set(record.id, record); version += 1; return clone(record)
}
const importPointCloud: DataModulesApi['importPointCloud'] = (input) => {
if (records.has(input.id)) throw new RangeError(`Data record already exists: ${input.id}`)
const parsed = parsePointCloud(input.format, input.bytes); const summary = summarizePoints(parsed)
const translated = input.translateToOrigin ? parsed.map((entry) => subtract(entry, summary.centroid)) : parsed
const cloud = storeCloud(input.id, translated, input.units || 'mm', input.format)
if (input.record !== false) records.set(input.id, { id: input.id, module: 'Points', format: input.format, byteLength: input.bytes.length, proxy: false, metadata: { units: cloud.units, points: String(cloud.points.length) } })
return cloud
}
const translatePointCloud: DataModulesApi['translatePointCloud'] = ({ id, sourceId, offset }) => { const source = sourceCloud(sourceId); const validOffset = point(offset.x, offset.y, offset.z); return storeCloud(id, source.points.map((entry) => add(entry, validOffset)), source.units, 'derived', source.structured ? clone(source.structured) : undefined) }
const mergePointClouds: DataModulesApi['mergePointClouds'] = ({ id, sourceIds }) => {
if (sourceIds.length < 2 || new Set(sourceIds).size !== sourceIds.length) throw new RangeError('Point-cloud merge requires at least two unique sources.')
const sources = sourceIds.map(sourceCloud); if (sources.some((source) => source.units !== sources[0].units)) throw new RangeError('Point-cloud merge requires matching units.')
return storeCloud(id, sources.flatMap((source) => source.points), sources[0].units, 'derived')
}
const cropPointCloud: DataModulesApi['cropPointCloud'] = ({ id, sourceId, bounds }) => {
const source = sourceCloud(sourceId); const valid = validateBounds(bounds); const filtered = source.points.filter(({ x, y, z }) => x >= valid.min.x && x <= valid.max.x && y >= valid.min.y && y <= valid.max.y && z >= valid.min.z && z <= valid.max.z)
if (filtered.length === 0) throw new RangeError('Point-cloud crop removed every point.')
return storeCloud(id, filtered, source.units, 'derived')
}
const polygonCropPointCloud: DataModulesApi['polygonCropPointCloud'] = ({ id, sourceId, polygon, invert = false }) => {
const source = sourceCloud(sourceId); const vertices = polygon.map((entry) => ({ x: finite(entry.x, 'Polygon x'), y: finite(entry.y, 'Polygon y') })); if (vertices.length < 3 || Math.abs(polygonArea(vertices)) <= 1e-12) throw new RangeError('Polygon crop requires a non-degenerate polygon.')
const filtered = source.points.filter((entry) => pointInPolygon(entry, vertices) !== invert); if (filtered.length === 0) throw new RangeError('Point-cloud polygon crop removed every point.')
return storeCloud(id, filtered, source.units, 'derived')
}
const voxelDownsample: DataModulesApi['voxelDownsample'] = ({ id, sourceId, size }) => {
const source = sourceCloud(sourceId); finite(size, 'Voxel size'); if (size <= 0) throw new RangeError('Voxel size must be positive.')
const cells = new Map<string, { sum: Point3; count: number }>()
for (const entry of source.points) { const key = `${Math.floor(entry.x / size)}:${Math.floor(entry.y / size)}:${Math.floor(entry.z / size)}`; const cell = cells.get(key) || { sum: { x: 0, y: 0, z: 0 }, count: 0 }; cell.sum = add(cell.sum, entry); cell.count += 1; cells.set(key, cell) }
const reduced = [...cells.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, cell]) => scale(cell.sum, 1 / cell.count))
return storeCloud(id, reduced, source.units, 'derived')
}
const structurePointCloud: DataModulesApi['structurePointCloud'] = ({ id, sourceId, tolerance = 1e-6 }) => {
const source = sourceCloud(sourceId); finite(tolerance, 'Structure tolerance'); if (tolerance <= 0) throw new RangeError('Structure tolerance must be positive.')
const unique = (values: number[]) => [...values].sort((a, b) => a - b).filter((value, index, sorted) => index === 0 || Math.abs(value - sorted[index - 1]) > tolerance)
const xs = unique(source.points.map((entry) => entry.x)); const ys = unique(source.points.map((entry) => entry.y))
if (xs.length < 2 || ys.length < 2) throw new RangeError('Structured point cloud requires at least two rows and columns.')
const sorted = [...source.points].sort((a, b) => a.y - b.y || a.x - b.x || a.z - b.z); const grid: (number | null)[] = Array(xs.length * ys.length).fill(null)
sorted.forEach((entry, index) => { const x = xs.findIndex((value) => Math.abs(value - entry.x) <= tolerance); const y = ys.findIndex((value) => Math.abs(value - entry.y) <= tolerance); const slot = y * xs.length + x; if (x < 0 || y < 0 || grid[slot] !== null) throw new RangeError('Point cloud cannot be represented as a unique XY grid.'); grid[slot] = index })
return storeCloud(id, sorted, source.units, 'derived', { width: xs.length, height: ys.length, grid })
}
const fitPlane: DataModulesApi['fitPlane'] = ({ id, sourceId, referenceNormal }) => {
const source = sourceCloud(sourceId); if (source.points.length < 3) throw new RangeError('Plane fit requires at least three points.')
const eigen = symmetricEigenvectors(covariance(source.points, source.centroid)); if (eigen[1].value <= 1e-14) throw new RangeError('Point set is degenerate for the requested plane fit.'); let normal = eigen[0].vector
if (referenceNormal && dot(normal, normalize(referenceNormal, 'Reference normal')) < 0) normal = scale(normal, -1)
let u = eigen[2].vector; if (Math.abs(dot(u, normal)) > 1e-8) u = normalize(cross(Math.abs(normal.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 }, normal), 'Plane u axis')
const v = normalize(cross(normal, u), 'Plane v axis'); const coordinates = source.points.map((entry) => { const relative = subtract(entry, source.centroid); return { u: dot(relative, u), v: dot(relative, v), n: dot(relative, normal) } })
const minU = Math.min(...coordinates.map((entry) => entry.u)); const maxU = Math.max(...coordinates.map((entry) => entry.u)); const minV = Math.min(...coordinates.map((entry) => entry.v)); const maxV = Math.max(...coordinates.map((entry) => entry.v))
return storeResult({ id, sourceId, kind: 'plane', origin: add(add(source.centroid, scale(u, minU)), scale(v, minV)), normal, u, v, length: maxU - minU, width: maxV - minV, rms: Math.sqrt(coordinates.reduce((sum, entry) => sum + entry.n ** 2, 0) / coordinates.length), pointCount: source.points.length })
}
const fitSphere: DataModulesApi['fitSphere'] = ({ id, sourceId }) => {
const source = sourceCloud(sourceId); if (source.points.length < 4) throw new RangeError('Sphere fit requires at least four points.')
const solution = leastSquares(source.points.map(({ x, y, z }) => [2 * x, 2 * y, 2 * z, 1]), source.points.map(({ x, y, z }) => x * x + y * y + z * z)); const center = point(solution[0], solution[1], solution[2]); const distances = source.points.map((entry) => length(subtract(entry, center))); const radius = distances.reduce((sum, value) => sum + value, 0) / distances.length
return storeResult({ id, sourceId, kind: 'sphere', center, radius, rms: Math.sqrt(distances.reduce((sum, value) => sum + (value - radius) ** 2, 0) / distances.length), pointCount: source.points.length })
}
const fitCylinder: DataModulesApi['fitCylinder'] = ({ id, sourceId }) => {
const source = sourceCloud(sourceId); if (source.points.length < 6) throw new RangeError('Cylinder fit requires at least six points.')
const axis = symmetricEigenvectors(covariance(source.points, source.centroid))[2].vector; const seed = Math.abs(axis.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 }; const u = normalize(cross(seed, axis), 'Cylinder u axis'); const v = normalize(cross(axis, u), 'Cylinder v axis')
const projected = source.points.map((entry) => { const relative = subtract(entry, source.centroid); return { x: dot(relative, u), y: dot(relative, v), axial: dot(relative, axis) } }); const circle = fitCircle2d(projected); const min = Math.min(...projected.map((entry) => entry.axial)); const max = Math.max(...projected.map((entry) => entry.axial)); const center = add(add(source.centroid, scale(u, circle.center.x)), scale(v, circle.center.y)); const base = add(center, scale(axis, min))
return storeResult({ id, sourceId, kind: 'cylinder', base, axis, radius: circle.radius, height: max - min, rms: circle.rms, pointCount: source.points.length })
}
const fitPolynomialSurface: DataModulesApi['fitPolynomialSurface'] = ({ id, sourceId }) => {
const source = sourceCloud(sourceId); if (source.points.length < 6) throw new RangeError('Polynomial surface fit requires at least six points.')
const solution = leastSquares(source.points.map(({ x, y }) => [x * x, x * y, y * y, x, y, 1]), source.points.map(({ z }) => z)) as [number, number, number, number, number, number]
const residuals = source.points.map(({ x, y, z }) => z - (solution[0] * x * x + solution[1] * x * y + solution[2] * y * y + solution[3] * x + solution[4] * y + solution[5]))
return storeResult({ id, sourceId, kind: 'polynomial-surface', coefficients: solution, rms: Math.sqrt(residuals.reduce((sum, value) => sum + value * value, 0) / residuals.length), pointCount: source.points.length })
}
const segmentPointCloud: DataModulesApi['segmentPointCloud'] = ({ id, sourceId, radius }) => {
const source = sourceCloud(sourceId); finite(radius, 'Segmentation radius'); if (radius <= 0) throw new RangeError('Segmentation radius must be positive.'); if (source.points.length > 50_000) throw new RangeError('Segmentation is limited to 50000 points in the browser subset.')
const cellSize = radius; const cells = new Map<string, number[]>(); const keyFor = (entry: Point3) => `${Math.floor(entry.x / cellSize)}:${Math.floor(entry.y / cellSize)}:${Math.floor(entry.z / cellSize)}`
source.points.forEach((entry, index) => { const key = keyFor(entry); const values = cells.get(key) || []; values.push(index); cells.set(key, values) })
const labels = Array(source.points.length).fill(-1); const clusters: { label: number; pointIndices: number[] }[] = []; const radiusSquared = radius * radius
for (let start = 0; start < source.points.length; start += 1) if (labels[start] < 0) {
const label = clusters.length; const queue = [start]; const pointIndices: number[] = []; labels[start] = label
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const currentIndex = queue[cursor]; const current = source.points[currentIndex]; pointIndices.push(currentIndex); const baseX = Math.floor(current.x / cellSize); const baseY = Math.floor(current.y / cellSize); const baseZ = Math.floor(current.z / cellSize)
for (let dx = -1; dx <= 1; dx += 1) for (let dy = -1; dy <= 1; dy += 1) for (let dz = -1; dz <= 1; dz += 1) for (const candidate of cells.get(`${baseX + dx}:${baseY + dy}:${baseZ + dz}`) || []) if (labels[candidate] < 0) {
const other = source.points[candidate]; const delta = subtract(other, current); if (dot(delta, delta) <= radiusSquared) { labels[candidate] = label; queue.push(candidate) }
}
}
clusters.push({ label, pointIndices })
}
const result: PointCloudSegment = { id, sourceId, radius, labels, clusters }; if (segments.has(id)) throw new RangeError(`Segmentation result already exists: ${id}`); segments.set(id, result); version += 1; return clone(result)
}
const removeRecord = (id: string) => { if (!records.delete(id)) throw new RangeError(`Data record does not exist: ${id}`); pointClouds.delete(id); for (const [resultId, result] of results) if (result.sourceId === id) results.delete(resultId); for (const [segmentId, segment] of segments) if (segment.sourceId === id) segments.delete(segmentId); version += 1 }
const exportManifest = () => `${JSON.stringify(snapshot(), null, 2)}\n`
return { snapshot, descriptors: () => clone(adapters), importRecord, importPointCloud, exportPointCloud: (id, format) => new TextEncoder().encode(exportPointText(sourceCloud(id), format)), translatePointCloud, mergePointClouds, cropPointCloud, polygonCropPointCloud, voxelDownsample, structurePointCloud, fitPlane, fitSphere, fitCylinder, fitPolynomialSurface, segmentPointCloud, removeRecord, exportManifest }
}

90
src/facade/draft.ts Normal file
View File

@@ -0,0 +1,90 @@
export type DraftPoint = { x: number; y: number }
export type DraftVector3 = { x: number; y: number; z: number }
export type DraftWorkingPlane = { origin: DraftVector3; xAxis: DraftVector3; yAxis: DraftVector3 }
export type DraftLayer = { id: string; label: string; visible: boolean; color: string }
type DraftObjectBase = { id: string; label: string; layerId: string }
export type DraftLine = DraftObjectBase & { kind: 'line'; start: DraftPoint; end: DraftPoint }
export type DraftWire = DraftObjectBase & { kind: 'wire'; points: DraftPoint[]; closed: boolean }
export type DraftCircle = DraftObjectBase & { kind: 'circle'; center: DraftPoint; radius: number }
export type DraftClone = DraftObjectBase & { kind: 'clone'; sourceId: string; translation: DraftPoint; rotation: number; scale: number }
export type DraftObject = DraftLine | DraftWire | DraftCircle | DraftClone
export type DraftSnapshot = { id: string; label: string; workingPlane: DraftWorkingPlane; grid: { spacing: number; enabled: boolean }; layers: DraftLayer[]; objects: DraftObject[]; version: number }
export type DraftApi = {
snapshot(): DraftSnapshot
setWorkingPlane(plane: DraftWorkingPlane): DraftSnapshot
setGrid(spacing: number, enabled?: boolean): DraftSnapshot
snap(point: DraftPoint): DraftPoint
mapToWorld(point: DraftPoint): DraftVector3
addLayer(layer: DraftLayer): DraftLayer
createLine(id: string, start: DraftPoint, end: DraftPoint, layerId?: string): DraftLine
createWire(id: string, points: DraftPoint[], closed?: boolean, layerId?: string): DraftWire
createCircle(id: string, center: DraftPoint, radius: number, layerId?: string): DraftCircle
move(id: string, delta: DraftPoint): DraftObject
rotate(id: string, angle: number, center?: DraftPoint): DraftObject
scale(id: string, factor: number, center?: DraftPoint): DraftObject
offsetLine(id: string, distance: number, outputId: string): DraftLine
trimLine(id: string, startParameter: number, endParameter: number): DraftLine
clone(sourceId: string, outputId: string, translation?: DraftPoint): DraftClone
array(sourceId: string, options: { columns: number; rows: number; columnSpacing: number; rowSpacing: number; idPrefix: string }): DraftClone[]
assignLayer(id: string, layerId: string): DraftObject
}
const finitePoint = (point: DraftPoint, label: string) => { if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) throw new RangeError(`${label} must be finite.`) }
const finiteVector = (vector: DraftVector3, label: string) => { if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) throw new RangeError(`${label} must be finite.`) }
const length3 = (vector: DraftVector3) => Math.hypot(vector.x, vector.y, vector.z)
const dot3 = (left: DraftVector3, right: DraftVector3) => left.x * right.x + left.y * right.y + left.z * right.z
const clonePoint = (point: DraftPoint): DraftPoint => ({ ...point })
const cloneVector = (vector: DraftVector3): DraftVector3 => ({ ...vector })
const clonePlane = (plane: DraftWorkingPlane): DraftWorkingPlane => ({ origin: cloneVector(plane.origin), xAxis: cloneVector(plane.xAxis), yAxis: cloneVector(plane.yAxis) })
const cloneObject = (object: DraftObject): DraftObject => object.kind === 'line' ? { ...object, start: clonePoint(object.start), end: clonePoint(object.end) } : object.kind === 'wire' ? { ...object, points: object.points.map(clonePoint) } : object.kind === 'circle' ? { ...object, center: clonePoint(object.center) } : { ...object, translation: clonePoint(object.translation) }
const validatePlane = (plane: DraftWorkingPlane) => {
finiteVector(plane.origin, 'Working plane origin'); finiteVector(plane.xAxis, 'Working plane X axis'); finiteVector(plane.yAxis, 'Working plane Y axis')
const xLength = length3(plane.xAxis); const yLength = length3(plane.yAxis)
if (Math.abs(xLength - 1) > 1e-9 || Math.abs(yLength - 1) > 1e-9) throw new RangeError('Working plane axes must be normalized.')
if (Math.abs(dot3(plane.xAxis, plane.yAxis)) > 1e-9) throw new RangeError('Working plane axes must be orthogonal.')
}
const transformPoint = (point: DraftPoint, center: DraftPoint, angle: number, factor = 1): DraftPoint => {
const radians = angle * Math.PI / 180
const x = (point.x - center.x) * factor
const y = (point.y - center.y) * factor
return { x: center.x + x * Math.cos(radians) - y * Math.sin(radians), y: center.y + x * Math.sin(radians) + y * Math.cos(radians) }
}
export const createDraftDocument = (id = 'draft', label = 'Draft'): DraftApi => {
let workingPlane: DraftWorkingPlane = { origin: { x: 0, y: 0, z: 0 }, xAxis: { x: 1, y: 0, z: 0 }, yAxis: { x: 0, y: 1, z: 0 } }
let grid = { spacing: 1, enabled: true }
let version = 0
const layers = new Map<string, DraftLayer>([['default', { id: 'default', label: 'Default', visible: true, color: '#202428' }]])
const objects = new Map<string, DraftObject>()
const requireLayer = (layerId: string) => { if (!layers.has(layerId)) throw new RangeError(`Draft layer does not exist: ${layerId}`) }
const requireObject = (objectId: string) => { const object = objects.get(objectId); if (!object) throw new RangeError(`Draft object does not exist: ${objectId}`); return object }
const putObject = <T extends DraftObject>(object: T): T => { if (objects.has(object.id)) throw new RangeError(`Draft object already exists: ${object.id}`); objects.set(object.id, cloneObject(object)); version += 1; return cloneObject(object) as T }
const replaceObject = <T extends DraftObject>(object: T): T => { objects.set(object.id, cloneObject(object)); version += 1; return cloneObject(object) as T }
const snapshot = (): DraftSnapshot => ({ id, label, workingPlane: clonePlane(workingPlane), grid: { ...grid }, layers: [...layers.values()].map((layer) => ({ ...layer })), objects: [...objects.values()].map(cloneObject), version })
const setWorkingPlane = (plane: DraftWorkingPlane) => { validatePlane(plane); workingPlane = clonePlane(plane); version += 1; return snapshot() }
const setGrid = (spacing: number, enabled = true) => { if (!Number.isFinite(spacing) || spacing <= 0) throw new RangeError('Draft grid spacing must be positive and finite.'); grid = { spacing, enabled }; version += 1; return snapshot() }
const snap = (point: DraftPoint) => { finitePoint(point, 'Draft snap point'); return grid.enabled ? { x: Math.round(point.x / grid.spacing) * grid.spacing, y: Math.round(point.y / grid.spacing) * grid.spacing } : clonePoint(point) }
const mapToWorld = (point: DraftPoint) => { finitePoint(point, 'Draft plane point'); return { x: workingPlane.origin.x + workingPlane.xAxis.x * point.x + workingPlane.yAxis.x * point.y, y: workingPlane.origin.y + workingPlane.xAxis.y * point.x + workingPlane.yAxis.y * point.y, z: workingPlane.origin.z + workingPlane.xAxis.z * point.x + workingPlane.yAxis.z * point.y } }
const addLayer = (layer: DraftLayer) => { if (!layer.id || !layer.label || !/^#[0-9a-f]{6}$/i.test(layer.color)) throw new RangeError('Draft layer requires an id, label and six-digit color.'); if (layers.has(layer.id)) throw new RangeError(`Draft layer already exists: ${layer.id}`); layers.set(layer.id, { ...layer }); version += 1; return { ...layer } }
const createLine = (objectId: string, start: DraftPoint, end: DraftPoint, layerId = 'default') => { requireLayer(layerId); finitePoint(start, 'Draft line start'); finitePoint(end, 'Draft line end'); if (Math.hypot(end.x - start.x, end.y - start.y) <= 1e-9) throw new RangeError('Draft line must have non-zero length.'); return putObject({ id: objectId, label: objectId, layerId, kind: 'line', start: clonePoint(start), end: clonePoint(end) }) }
const createWire = (objectId: string, points: DraftPoint[], closed = false, layerId = 'default') => { requireLayer(layerId); if (points.length < (closed ? 3 : 2)) throw new RangeError('Draft wire has too few points.'); points.forEach((point) => finitePoint(point, 'Draft wire point')); return putObject({ id: objectId, label: objectId, layerId, kind: 'wire', points: points.map(clonePoint), closed }) }
const createCircle = (objectId: string, center: DraftPoint, radius: number, layerId = 'default') => { requireLayer(layerId); finitePoint(center, 'Draft circle center'); if (!Number.isFinite(radius) || radius <= 0) throw new RangeError('Draft circle radius must be positive and finite.'); return putObject({ id: objectId, label: objectId, layerId, kind: 'circle', center: clonePoint(center), radius }) }
const move = (objectId: string, delta: DraftPoint) => { finitePoint(delta, 'Draft move delta'); const object = requireObject(objectId); if (object.kind === 'clone') return replaceObject({ ...object, translation: { x: object.translation.x + delta.x, y: object.translation.y + delta.y } }); if (object.kind === 'line') return replaceObject({ ...object, start: { x: object.start.x + delta.x, y: object.start.y + delta.y }, end: { x: object.end.x + delta.x, y: object.end.y + delta.y } }); if (object.kind === 'wire') return replaceObject({ ...object, points: object.points.map((point) => ({ x: point.x + delta.x, y: point.y + delta.y })) }); return replaceObject({ ...object, center: { x: object.center.x + delta.x, y: object.center.y + delta.y } }) }
const rotate = (objectId: string, angle: number, center: DraftPoint = { x: 0, y: 0 }) => { if (!Number.isFinite(angle)) throw new RangeError('Draft rotation must be finite.'); finitePoint(center, 'Draft rotation center'); const object = requireObject(objectId); if (object.kind === 'clone') return replaceObject({ ...object, rotation: object.rotation + angle }); if (object.kind === 'line') return replaceObject({ ...object, start: transformPoint(object.start, center, angle), end: transformPoint(object.end, center, angle) }); if (object.kind === 'wire') return replaceObject({ ...object, points: object.points.map((point) => transformPoint(point, center, angle)) }); return replaceObject({ ...object, center: transformPoint(object.center, center, angle) }) }
const scale = (objectId: string, factor: number, center: DraftPoint = { x: 0, y: 0 }) => { if (!Number.isFinite(factor) || factor <= 0) throw new RangeError('Draft scale factor must be positive and finite.'); finitePoint(center, 'Draft scale center'); const object = requireObject(objectId); if (object.kind === 'clone') return replaceObject({ ...object, scale: object.scale * factor }); if (object.kind === 'line') return replaceObject({ ...object, start: transformPoint(object.start, center, 0, factor), end: transformPoint(object.end, center, 0, factor) }); if (object.kind === 'wire') return replaceObject({ ...object, points: object.points.map((point) => transformPoint(point, center, 0, factor)) }); return replaceObject({ ...object, center: transformPoint(object.center, center, 0, factor), radius: object.radius * factor }) }
const offsetLine = (objectId: string, distance: number, outputId: string) => { const object = requireObject(objectId); if (object.kind !== 'line') throw new TypeError('Draft offset currently supports line objects only.'); if (!Number.isFinite(distance) || distance === 0) throw new RangeError('Draft offset distance must be finite and non-zero.'); const dx = object.end.x - object.start.x; const dy = object.end.y - object.start.y; const length = Math.hypot(dx, dy); const offset = { x: -dy / length * distance, y: dx / length * distance }; return createLine(outputId, { x: object.start.x + offset.x, y: object.start.y + offset.y }, { x: object.end.x + offset.x, y: object.end.y + offset.y }, object.layerId) }
const trimLine = (objectId: string, startParameter: number, endParameter: number) => {
const object = requireObject(objectId)
if (object.kind !== 'line') throw new TypeError('Draft trim currently supports line objects only.')
if (!Number.isFinite(startParameter) || !Number.isFinite(endParameter) || startParameter < 0 || endParameter > 1 || startParameter >= endParameter) throw new RangeError('Draft trim parameters must satisfy 0 <= start < end <= 1.')
const pointAt = (parameter: number): DraftPoint => ({ x: object.start.x + (object.end.x - object.start.x) * parameter, y: object.start.y + (object.end.y - object.start.y) * parameter })
return replaceObject({ ...object, start: pointAt(startParameter), end: pointAt(endParameter) })
}
const clone = (sourceId: string, outputId: string, translation: DraftPoint = { x: 0, y: 0 }) => { requireObject(sourceId); finitePoint(translation, 'Draft clone translation'); return putObject({ id: outputId, label: outputId, layerId: requireObject(sourceId).layerId, kind: 'clone', sourceId, translation: clonePoint(translation), rotation: 0, scale: 1 }) }
const array = (sourceId: string, options: { columns: number; rows: number; columnSpacing: number; rowSpacing: number; idPrefix: string }) => { requireObject(sourceId); if (!Number.isInteger(options.columns) || !Number.isInteger(options.rows) || options.columns < 1 || options.rows < 1 || options.columns * options.rows > 1000) throw new RangeError('Draft array dimensions must create between 1 and 1000 instances.'); if (![options.columnSpacing, options.rowSpacing].every(Number.isFinite) || !options.idPrefix) throw new RangeError('Draft array spacing and prefix are invalid.'); const result: DraftClone[] = []; for (let row = 0; row < options.rows; row += 1) for (let column = 0; column < options.columns; column += 1) result.push(clone(sourceId, `${options.idPrefix}-${row + 1}-${column + 1}`, { x: column * options.columnSpacing, y: row * options.rowSpacing })); return result }
const assignLayer = (objectId: string, layerId: string) => { requireLayer(layerId); return replaceObject({ ...requireObject(objectId), layerId } as DraftObject) }
return { snapshot, setWorkingPlane, setGrid, snap, mapToWorld, addLayer, createLine, createWire, createCircle, move, rotate, scale, offsetLine, trimLine, clone, array, assignLayer }
}

135
src/facade/elementMap.ts Normal file
View File

@@ -0,0 +1,135 @@
import type { ElementMapEntry, ElementMapSnapshot, SubshapeRef, TopologyHistoryResult, TopologySnapshotEntry } from './types'
const elementKinds: SubshapeRef['kind'][] = ['vertex', 'edge', 'face']
const prefixes: Record<SubshapeRef['kind'], string> = { vertex: 'Vertex', edge: 'Edge', face: 'Face' }
const elementNamePattern = /^(Face|Edge|Vertex)([1-9][0-9]*)$/
export const formatElementMapName = (kind: SubshapeRef['kind'], index: number): string => {
if (!elementKinds.includes(kind)) throw new RangeError(`Unsupported ElementMap kind: ${kind}`)
if (!Number.isSafeInteger(index) || index < 1) throw new RangeError('ElementMap indexes are one-based positive integers.')
return `${prefixes[kind]}${index}`
}
export const parseElementMapName = (name: string): { kind: SubshapeRef['kind']; index: number } | null => {
if (typeof name !== 'string') return null
const match = elementNamePattern.exec(name.trim())
if (!match) return null
const kind = match[1].toLowerCase() as SubshapeRef['kind']
const index = Number(match[2])
return Number.isSafeInteger(index) && index > 0 ? { kind, index } : null
}
const cloneCandidate = (candidate: NonNullable<ElementMapEntry['candidates']>[number]) => ({ ...candidate })
const cloneEntry = (entry: ElementMapEntry): ElementMapEntry => ({ ...entry, ...(entry.candidates ? { candidates: entry.candidates.map(cloneCandidate) } : {}) })
const sourceKey = (objectId: string, persistentId: string) => `${objectId}\u0000${persistentId}`
type HistorySourceCandidate = { objectId: string; persistentId: string; sourceStageId?: string }
/**
* Builds the Web-side equivalent of FreeCAD's ElementMap names. Names are
* inherited only from an unambiguous source relation; all other cases remain
* explicit so a downstream LinkSub repair can present candidates instead of
* silently binding a repeated face/edge.
*/
export const createElementMapSnapshot = (
previous: ElementMapSnapshot | undefined,
outputObjectId: string,
output: TopologySnapshotEntry[],
history: TopologyHistoryResult,
sourceMaps: ReadonlyMap<string, ElementMapSnapshot | undefined> = new Map(),
nativeNamingEvidence?: ElementMapSnapshot['nativeNamingEvidence'],
): ElementMapSnapshot => {
const priorByKey = new Map<string, ElementMapEntry>()
for (const entry of previous?.entries ?? []) priorByKey.set(sourceKey(entry.objectId, entry.persistentId), entry)
for (const [objectId, map] of sourceMaps) for (const entry of map?.entries ?? []) priorByKey.set(sourceKey(objectId, entry.persistentId), entry)
const expandHistorySource = (candidate: HistorySourceCandidate, visited = new Set<string>()): HistorySourceCandidate[] => {
if (!candidate.sourceStageId) return [candidate]
const key = `${candidate.sourceStageId}\u0000${sourceKey(candidate.objectId, candidate.persistentId)}`
if (visited.has(key)) return [candidate]
const nextVisited = new Set(visited).add(key)
const predecessors = history.relations.filter((relation) => relation.resultStageId === candidate.sourceStageId && relation.resultPersistentId === candidate.persistentId && relation.relation !== 'deleted')
if (predecessors.length === 0) return [candidate]
const expanded = predecessors.flatMap((relation): HistorySourceCandidate[] => {
if (relation.relation === 'ambiguous') return (relation.candidates ?? []).map((source) => ({ objectId: source.sourceObjectId, persistentId: source.persistentId, sourceStageId: relation.sourceStageId }))
if (!relation.sourceObjectId || !relation.sourcePersistentId) return []
return [{ objectId: relation.sourceObjectId, persistentId: relation.sourcePersistentId, sourceStageId: relation.sourceStageId }]
}).flatMap((source) => expandHistorySource(source, nextVisited))
return expanded.length > 0 ? expanded : [candidate]
}
const usedNames = new Set<string>()
const nextIndex = new Map<SubshapeRef['kind'], number>(elementKinds.map((kind) => [kind, 1]))
const allocate = (kind: SubshapeRef['kind']) => {
let index = nextIndex.get(kind) ?? 1
let name = formatElementMapName(kind, index)
while (usedNames.has(name)) { index += 1; name = formatElementMapName(kind, index) }
nextIndex.set(kind, index + 1)
usedNames.add(name)
return name
}
const outputEntries: ElementMapEntry[] = []
for (const entry of output) {
const relations = history.relations.filter((relation) => relation.resultPersistentId === entry.ref.persistentId && (relation.relation === 'preserved' || relation.relation === 'modified' || relation.relation === 'generated' || relation.relation === 'ambiguous'))
const sourceCandidates = relations.flatMap((relation): HistorySourceCandidate[] => {
if (relation.relation === 'ambiguous') return (relation.candidates ?? []).map((candidate) => ({ objectId: candidate.sourceObjectId, persistentId: candidate.persistentId, sourceStageId: relation.sourceStageId }))
if (!relation.sourceObjectId || !relation.sourcePersistentId) return []
return [{ objectId: relation.sourceObjectId, persistentId: relation.sourcePersistentId, sourceStageId: relation.sourceStageId }]
}).flatMap((candidate) => expandHistorySource(candidate))
.map(({ objectId, persistentId }) => ({ objectId, persistentId }))
.filter((candidate, index, all) => all.findIndex((other) => other.objectId === candidate.objectId && other.persistentId === candidate.persistentId) === index)
const sourceNames = sourceCandidates.map((candidate) => {
const name = priorByKey.get(sourceKey(candidate.objectId, candidate.persistentId))?.name
return name ? { ...candidate, name } : null
}).filter((candidate): candidate is { objectId: string; persistentId: string; name: string } => Boolean(candidate))
const uniqueNames = [...new Set(sourceNames.map((candidate) => candidate.name as string))]
const uniqueSources = new Set(sourceCandidates.map((candidate) => sourceKey(candidate.objectId, candidate.persistentId)))
const hasAmbiguousSource = relations.some((relation) => relation.relation === 'ambiguous') || uniqueSources.size > 1
const generatedWithoutSource = relations.some((relation) => relation.relation === 'generated') && uniqueSources.size === 0
const status = entry.ref.status === 'ambiguous' || hasAmbiguousSource ? 'ambiguous' : generatedWithoutSource || entry.ref.status === 'new' ? 'new' : 'stable'
const canInheritName = !hasAmbiguousSource && sourceCandidates.length === 1 && sourceNames.length === 1 && uniqueNames.length === 1
const name = canInheritName && !usedNames.has(uniqueNames[0]) ? (usedNames.add(uniqueNames[0]), uniqueNames[0]) : allocate(entry.ref.kind)
outputEntries.push({
name,
objectId: outputObjectId,
kind: entry.ref.kind,
persistentId: entry.ref.persistentId,
status,
...(sourceNames.length > 0 || (status === 'ambiguous' && sourceCandidates.length > 0) ? { candidates: sourceCandidates.map((candidate) => ({ ...candidate, name: priorByKey.get(sourceKey(candidate.objectId, candidate.persistentId))?.name })) } : {}),
})
}
const deletedEntries: ElementMapEntry[] = []
for (const relation of history.relations.filter((candidate) => candidate.relation === 'deleted' && candidate.sourceObjectId && candidate.sourcePersistentId)) {
const key = sourceKey(relation.sourceObjectId!, relation.sourcePersistentId!)
const source = priorByKey.get(key)
if (!source || usedNames.has(source.name)) continue
usedNames.add(source.name)
deletedEntries.push({ ...cloneEntry(source), status: 'deleted', objectId: relation.sourceObjectId!, persistentId: relation.sourcePersistentId! })
}
return {
schemaVersion: 1,
entries: [...outputEntries, ...deletedEntries],
...(nativeNamingEvidence && nativeNamingEvidence.length > 0 ? { nativeNamingEvidence: nativeNamingEvidence.map((evidence) => ({
...evidence,
mappedNames: evidence.mappedNames?.map((mapped) => ({
...mapped,
reference: { ...mapped.reference, stringIds: mapped.reference.stringIds ? [...mapped.reference.stringIds] : undefined },
sourceRefs: mapped.sourceRefs?.map((source) => ({ ...source })),
candidates: mapped.candidates?.map((candidate) => ({ ...candidate })),
})),
stringHasher: evidence.stringHasher ? { schemaVersion: evidence.stringHasher.schemaVersion, nativeVersion: evidence.stringHasher.nativeVersion, entries: evidence.stringHasher.entries.map((entry) => ({ ...entry, relatedIds: [...entry.relatedIds] })) } : undefined,
})) } : {}),
}
}
export const cloneElementMapSnapshot = (snapshot: ElementMapSnapshot): ElementMapSnapshot => ({
schemaVersion: 1,
entries: snapshot.entries.map(cloneEntry),
...(snapshot.nativeNamingEvidence ? { nativeNamingEvidence: snapshot.nativeNamingEvidence.map((evidence) => ({
...evidence,
mappedNames: evidence.mappedNames?.map((mapped) => ({ ...mapped, reference: { ...mapped.reference, stringIds: mapped.reference.stringIds ? [...mapped.reference.stringIds] : undefined }, sourceRefs: mapped.sourceRefs?.map((source) => ({ ...source })), candidates: mapped.candidates?.map((candidate) => ({ ...candidate })) })),
stringHasher: evidence.stringHasher ? { schemaVersion: evidence.stringHasher.schemaVersion, nativeVersion: evidence.stringHasher.nativeVersion, entries: evidence.stringHasher.entries.map((entry) => ({ ...entry, relatedIds: [...entry.relatedIds] })) } : undefined,
})) } : {}),
})

647
src/facade/elementMap2.ts Normal file
View File

@@ -0,0 +1,647 @@
import { assertNativeNamingEvidence, type NativeStageNamingEvidence } from './nativeNamingEvidence'
/**
* FreeCAD 1.x native ElementMap resource codec.
*
* The on-disk resource is intentionally kept lossless at token level. This
* lets the facade inspect and migrate maps without inventing WebCAD names or
* discarding FreeCAD's opaque postfix/string-id tokens.
*/
export type ElementMap2SectionName = 'Edge' | 'Face' | 'Vertex' | string;
export interface ElementMap2NameToken {
raw: string;
marker: ':' | '$' | ';';
postfixIndex?: number;
elementIndex?: number;
name?: string;
suffix: string[];
}
export interface ElementMap2NameEntry {
tokens: ElementMap2NameToken[];
trailing: '0';
raw?: string;
}
/**
* The information FreeCAD's ElementMap::save() receives from MappedNameRef.
* It is deliberately provenance based: a token cannot be manufactured from
* final geometry alone, because its postfix and StringIDs come from naming
* history and the document StringHasher.
*/
export interface ElementMap2MappedNameReference {
name: string;
postfix?: string;
stringIds?: number[];
prefixStringId?: number;
indexedName?: {
type: string;
index: number;
};
}
export interface ElementMap2ChildRecord {
childIndex: number;
offset: number;
count: number;
tag: number;
mapIndex: number;
postfix: string;
stringIds: number[];
raw?: string;
}
export interface ElementMap2Section {
name: ElementMap2SectionName;
children: ElementMap2ChildRecord[];
names: ElementMap2NameEntry[];
}
export interface ElementMap2Map {
index: number;
id: number;
typeCount: number;
sections: ElementMap2Section[];
}
export interface ElementMap2DocumentV1 {
schemaVersion: 1;
nativeVersion: 1;
rootId: number;
postfixes: string[];
maps: ElementMap2Map[];
rootMapIndex: number;
}
export interface ElementMap2Document {
schemaVersion: 2;
nativeVersion: 1;
rootId: number;
postfixes: string[];
maps: ElementMap2Map[];
rootMapIndex: number;
}
export interface ElementMap2StageNameEntry {
kind: 'face' | 'edge' | 'vertex'
resultPersistentId: string
token: ElementMap2NameToken
status: 'stable' | 'ambiguous' | 'generated' | 'deleted'
sourceRefs: Array<{ objectId: string; persistentId: string; stageId?: string }>
candidates?: Array<{ objectId: string; persistentId: string; stageId?: string }>
}
export interface ElementMap2StageNameMap {
stageId: string
resultObjectId: string
inputStageIds: string[]
entries: ElementMap2StageNameEntry[]
}
export interface ElementMap2MultiStageNameMapping {
schemaVersion: 1
stages: ElementMap2StageNameMap[]
}
export type AnyElementMap2Document = ElementMap2DocumentV1 | ElementMap2Document;
export interface ElementMap2ValidationIssue {
code: 'NATIVE_VERSION' | 'ROOT_MAP' | 'MAP_INDEX' | 'TYPE_COUNT' | 'SECTION_NAME' | 'CHILD_MAP' | 'CHILD_POSTFIX' | 'NAME_TOKEN';
path: string;
message: string;
}
export interface ElementMap2ValidationReport {
valid: boolean;
mapCount: number;
childCount: number;
nameCount: number;
tokenCount: number;
issues: ElementMap2ValidationIssue[];
}
/** Converts native stage evidence into a lossless, multi-stage ElementMap2 view. */
export function createElementMap2MultiStageNameMapping(stages: readonly NativeStageNamingEvidence[], postfixes: readonly string[]): ElementMap2MultiStageNameMapping {
const seen = new Set<string>()
const output: ElementMap2StageNameMap[] = []
for (const rawEvidence of stages) {
const evidence = assertNativeNamingEvidence(rawEvidence)
if (seen.has(evidence.stageId)) throw new RangeError(`Duplicate ElementMap2 naming stage ${evidence.stageId}.`)
seen.add(evidence.stageId)
const entries = (evidence.mappedNames ?? []).map((mapped) => {
const status = mapped.relation === 'deleted' ? 'deleted' as const : mapped.relation === 'ambiguous' || (mapped.candidates?.length ?? 0) > 1 ? 'ambiguous' as const : mapped.relation === 'generated' ? 'generated' as const : 'stable' as const
return {
kind: mapped.kind,
resultPersistentId: mapped.resultPersistentId,
token: createElementMap2NameToken(mapped.reference, postfixes),
status,
sourceRefs: mapped.sourceRefs?.map((source) => ({ ...source })) ?? [],
...(mapped.candidates && mapped.candidates.length > 0 ? { candidates: mapped.candidates.map((candidate) => ({ ...candidate })) } : {}),
}
})
output.push({ stageId: evidence.stageId, resultObjectId: evidence.resultObjectId, inputStageIds: [...new Set(entries.flatMap((entry) => entry.sourceRefs.map((source) => source.stageId).filter((stageId): stageId is string => Boolean(stageId))))], entries })
}
return { schemaVersion: 1, stages: output }
}
export type ElementMap2MultiStageNameMappingIssue = {
code: 'SCHEMA_VERSION' | 'STAGE_ID' | 'RESULT_OBJECT_ID' | 'DUPLICATE_STAGE' | 'SOURCE_STAGE' | 'ENTRY' | 'TOKEN'
path: string
message: string
}
export function validateElementMap2MultiStageNameMapping(mapping: ElementMap2MultiStageNameMapping): ElementMap2MultiStageNameMappingIssue[] {
const issues: ElementMap2MultiStageNameMappingIssue[] = []
if (mapping.schemaVersion !== 1) issues.push({ code: 'SCHEMA_VERSION', path: 'schemaVersion', message: `unsupported schema version ${mapping.schemaVersion}` })
const stages = new Map<string, number>()
for (const [index, stage] of mapping.stages.entries()) {
if (!stage.stageId.trim()) issues.push({ code: 'STAGE_ID', path: `stages[${index}].stageId`, message: 'stageId must be non-empty' })
if (stages.has(stage.stageId)) issues.push({ code: 'DUPLICATE_STAGE', path: `stages[${index}].stageId`, message: `duplicate stage ${stage.stageId}` })
stages.set(stage.stageId, index)
if (!stage.resultObjectId.trim()) issues.push({ code: 'RESULT_OBJECT_ID', path: `stages[${index}].resultObjectId`, message: 'resultObjectId must be non-empty' })
}
for (const [stageIndex, stage] of mapping.stages.entries()) {
for (const [sourceIndex, sourceStageId] of stage.inputStageIds.entries()) {
const sourceOrdinal = stages.get(sourceStageId)
if (sourceOrdinal === undefined || sourceOrdinal >= stageIndex) issues.push({ code: 'SOURCE_STAGE', path: `stages[${stageIndex}].inputStageIds[${sourceIndex}]`, message: `source stage ${sourceStageId} must exist earlier than ${stage.stageId}` })
}
const keys = new Set<string>()
for (const [entryIndex, entry] of stage.entries.entries()) {
const key = `${entry.kind}:${entry.resultPersistentId}`
if (keys.has(key)) issues.push({ code: 'ENTRY', path: `stages[${stageIndex}].entries[${entryIndex}]`, message: `duplicate result mapping ${key}` })
keys.add(key)
if (!entry.resultPersistentId.trim() || !['stable', 'ambiguous', 'generated', 'deleted'].includes(entry.status)) issues.push({ code: 'ENTRY', path: `stages[${stageIndex}].entries[${entryIndex}]`, message: 'invalid result mapping status or persistent ID' })
try {
if (formatElementMap2NameToken(entry.token) !== entry.token.raw) issues.push({ code: 'TOKEN', path: `stages[${stageIndex}].entries[${entryIndex}].token`, message: 'token writer is not lossless' })
} catch (error) {
issues.push({ code: 'TOKEN', path: `stages[${stageIndex}].entries[${entryIndex}].token`, message: error instanceof Error ? error.message : String(error) })
}
if (entry.status === 'ambiguous' && (entry.candidates?.length ?? 0) < 2) issues.push({ code: 'ENTRY', path: `stages[${stageIndex}].entries[${entryIndex}].candidates`, message: 'ambiguous mappings require at least two candidates' })
}
}
return issues
}
export function writeElementMap2MultiStageNameMapping(mapping: ElementMap2MultiStageNameMapping): string {
const issues = validateElementMap2MultiStageNameMapping(mapping)
if (issues.length > 0) throw new Error(`ElementMap2 multi-stage mapping is invalid: ${issues[0].path}: ${issues[0].message}`)
return `${JSON.stringify(mapping)}\n`
}
export function parseElementMap2MultiStageNameMapping(text: string): ElementMap2MultiStageNameMapping {
let value: unknown
try { value = JSON.parse(text) } catch (error) { throw new Error(`ElementMap2 multi-stage mapping JSON is invalid: ${error instanceof Error ? error.message : String(error)}`) }
if (!value || typeof value !== 'object') throw new Error('ElementMap2 multi-stage mapping must be an object.')
const mapping = value as ElementMap2MultiStageNameMapping
const issues = validateElementMap2MultiStageNameMapping(mapping)
if (issues.length > 0) throw new Error(`ElementMap2 multi-stage mapping is invalid: ${issues[0].path}: ${issues[0].message}`)
return mapping
}
const MAX_LINES = 2_000_000;
const MAX_COUNT = 1_000_000;
function fail(message: string): never {
throw new Error(`ElementMap2 parse error: ${message}`);
}
function integer(value: string, label: string): number {
if (!/^(?:0|[1-9]\d*)$/.test(value)) fail(`${label} must be a decimal integer`);
const result = Number(value);
if (!Number.isSafeInteger(result) || result < 0 || result > MAX_COUNT) {
fail(`${label} is out of range`);
}
return result;
}
function hex(value: string, label: string): number {
if (!/^[0-9a-fA-F]+$/.test(value)) fail(`${label} must be hexadecimal`);
const result = Number.parseInt(value, 16);
if (!Number.isSafeInteger(result) || result < 0 || result > MAX_COUNT) {
fail(`${label} is out of range`);
}
return result;
}
function checkedHex(value: number, label: string): string {
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_COUNT) {
throw new RangeError(`ElementMap2 ${label} is out of range`);
}
return value.toString(16);
}
function postfixIndex(postfixes: readonly string[], postfix: string, label: string): number {
if (!postfix) return 0;
const index = postfixes.indexOf(postfix);
if (index < 0) throw new RangeError(`ElementMap2 ${label} ${JSON.stringify(postfix)} is absent from the postfix table`);
return index + 1;
}
/** Format the structured fields using the std::hex rules in ElementMap::save(). */
export function formatElementMap2NameToken(token: ElementMap2NameToken): string {
if (token.marker === ':') {
if (token.postfixIndex === undefined || token.elementIndex === undefined) return token.raw;
const head = `:${checkedHex(token.postfixIndex, 'indexed-name postfix index')}.${checkedHex(token.elementIndex, 'element index')}`;
return token.suffix.length ? `${head}.${token.suffix.join('.')}` : head;
}
if (!token.name) return token.raw;
const head = `${token.marker}${token.name}`;
return token.suffix.length ? `${head}.${token.suffix.join('.')}` : head;
}
/**
* Port of the name-token branch in FreeCAD 1.1.1 ElementMap::save(). The
* caller must provide the MappedName/StringHasher evidence produced while the
* feature builders execute; no geometric inference is performed here.
*/
export function createElementMap2NameToken(reference: ElementMap2MappedNameReference, postfixes: readonly string[]): ElementMap2NameToken {
if (!reference.name || /[.\s]/.test(reference.name)) throw new TypeError('ElementMap2 mapped name must be non-empty and contain neither dots nor whitespace.');
const markedIds = [...(reference.stringIds ?? [])];
for (const [index, id] of markedIds.entries()) checkedHex(id, `StringID ${index}`);
const mappedPostfixIndex = postfixIndex(postfixes, reference.postfix ?? '', 'mapped-name postfix');
let token: ElementMap2NameToken;
if (reference.indexedName) {
if (!reference.indexedName.type || /[.\s]/.test(reference.indexedName.type)) throw new TypeError('ElementMap2 indexed-name type must be a non-empty token.');
const typePostfixIndex = postfixIndex(postfixes, reference.indexedName.type, 'indexed-name type');
if (typePostfixIndex === 0) throw new RangeError('ElementMap2 indexed-name type cannot use postfix index zero.');
token = {
raw: '',
marker: ':',
postfixIndex: typePostfixIndex,
elementIndex: reference.indexedName.index,
suffix: [checkedHex(mappedPostfixIndex, 'mapped-name postfix index'), ...markedIds.map((id) => checkedHex(id, 'StringID'))],
};
} else {
const prefixIsMarked = reference.prefixStringId !== undefined && markedIds.includes(reference.prefixStringId);
const marker: '$' | ';' = prefixIsMarked ? '$' : ';';
const suffixIds = prefixIsMarked ? markedIds.filter((id) => id !== reference.prefixStringId) : markedIds;
token = {
raw: '',
marker,
name: reference.name,
suffix: [checkedHex(mappedPostfixIndex, 'mapped-name postfix index'), ...suffixIds.map((id) => checkedHex(id, 'StringID'))],
};
}
token.raw = formatElementMap2NameToken(token);
return token;
}
export function createElementMap2NameEntry(references: readonly ElementMap2MappedNameReference[], postfixes: readonly string[]): ElementMap2NameEntry {
return { tokens: references.map((reference) => createElementMap2NameToken(reference, postfixes)), trailing: '0' };
}
/** Recover the persisted MappedNameRef evidence needed to regenerate a token. */
export function elementMap2NameTokenToReference(token: ElementMap2NameToken, postfixes: readonly string[]): ElementMap2MappedNameReference {
if (token.suffix.length < 1 || token.suffix.some((part) => !/^[0-9a-fA-F]+$/.test(part))) {
throw new TypeError(`ElementMap2 token has no valid postfix/StringID evidence: ${JSON.stringify(token.raw)}`);
}
const mappedPostfixIndex = hex(token.suffix[0], 'mapped-name postfix index');
if (mappedPostfixIndex > postfixes.length) throw new RangeError(`ElementMap2 token references unavailable postfix ${mappedPostfixIndex}`);
const postfix = mappedPostfixIndex === 0 ? '' : postfixes[mappedPostfixIndex - 1];
const suffixStringIds = token.suffix.slice(1).map((value, index) => hex(value, `StringID ${index}`));
if (token.marker === ':') {
if (token.postfixIndex === undefined || token.postfixIndex <= 0 || token.postfixIndex > postfixes.length || token.elementIndex === undefined) {
throw new RangeError(`ElementMap2 indexed token has unavailable type evidence: ${JSON.stringify(token.raw)}`);
}
const type = postfixes[token.postfixIndex - 1];
return { name: `${type}${token.elementIndex}`, postfix, stringIds: suffixStringIds, indexedName: { type, index: token.elementIndex } };
}
if (!token.name) throw new TypeError(`ElementMap2 named token has no mapped name: ${JSON.stringify(token.raw)}`);
if (token.marker === '$') {
const prefix = token.name.match(/^#([0-9a-fA-F]+)(?::[0-9a-fA-F]+)?$/);
if (!prefix) throw new TypeError(`ElementMap2 StringID token has an invalid prefix: ${JSON.stringify(token.raw)}`);
const prefixStringId = hex(prefix[1], 'prefix StringID');
return { name: token.name, postfix, prefixStringId, stringIds: [prefixStringId, ...suffixStringIds] };
}
return { name: token.name, postfix, stringIds: suffixStringIds };
}
function parseNameToken(raw: string): ElementMap2NameToken {
if (!raw || !/^[;:$]/.test(raw)) fail(`invalid name token ${JSON.stringify(raw)}`);
const marker = raw[0] as ':' | '$' | ';';
const parts = raw.split('.');
const first = parts[0].slice(1);
const suffix = parts.slice(1);
if (marker === ':') {
// Native indexed tokens are :<postfix>.<element>[.<suffix>...].
if (parts.length >= 2 && /^[0-9a-fA-F]+$/.test(first) && /^[0-9a-fA-F]+$/.test(parts[1])) {
return {
raw,
marker,
postfixIndex: hex(first, 'postfix index'),
elementIndex: hex(parts[1], 'element index'),
suffix: parts.slice(2),
};
}
if (!first) fail('indexed token is empty');
return { raw, marker, suffix: [first, ...suffix] };
}
if (!first) fail(`named token ${JSON.stringify(raw)} is empty`);
return { raw, marker, name: first, suffix };
}
function readNonEmpty(lines: string[], cursor: { value: number }): string {
while (cursor.value < lines.length && lines[cursor.value].trim() === '') cursor.value += 1;
if (cursor.value >= lines.length) fail('unexpected end of resource');
return lines[cursor.value++].trim();
}
function peekNonEmpty(lines: string[], cursor: { value: number }): string {
let index = cursor.value;
while (index < lines.length && lines[index].trim() === '') index += 1;
return index < lines.length ? lines[index].trim() : '';
}
function parseCountLine(lines: string[], cursor: { value: number }, label: string): number {
const line = readNonEmpty(lines, cursor);
const match = line.match(new RegExp(`^${label}\\s+(\\d+)$`));
if (!match) fail(`expected "${label} <count>", got ${JSON.stringify(line)}`);
return integer(match[1], label);
}
export function parseElementMap2(text: string): ElementMap2DocumentV1 {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
if (lines.length > MAX_LINES) fail('resource is too large');
const cursor = { value: 0 };
const begin = readNonEmpty(lines, cursor).match(/^BeginElementMap\s+v(\d+)$/);
if (!begin || Number(begin[1]) !== 1) fail('expected BeginElementMap v1');
const header = readNonEmpty(lines, cursor).match(/^(\d+)\s+PostfixCount\s+(\d+)$/);
if (!header) fail('expected root id and PostfixCount');
const rootId = integer(header[1], 'root id');
const postfixCount = integer(header[2], 'postfix count');
const postfixes: string[] = [];
for (let i = 0; i < postfixCount; i += 1) {
if (cursor.value >= lines.length) fail('postfix list is truncated');
postfixes.push(lines[cursor.value++]);
}
const mapCount = parseCountLine(lines, cursor, 'MapCount');
if (mapCount === 0) fail('MapCount must be positive');
const maps: ElementMap2Map[] = [];
for (let mapOrdinal = 0; mapOrdinal < mapCount; mapOrdinal += 1) {
const mapHeader = readNonEmpty(lines, cursor).match(/^ElementMap\s+(\d+)\s+(\d+)\s+(\d+)$/);
if (!mapHeader) fail('expected ElementMap header');
const index = integer(mapHeader[1], 'map index');
const id = integer(mapHeader[2], 'map id');
const typeCount = integer(mapHeader[3], 'type count');
if (maps.some((map) => map.index === index)) fail(`duplicate map index ${index}`);
const sections: ElementMap2Section[] = [];
// Older WebCAD fixtures emitted a zero type count followed by one
// section. Accept that representation while writing the canonical count.
for (let typeOrdinal = 0; typeOrdinal < typeCount || (typeCount === 0 && /^[A-Za-z][A-Za-z0-9_]*$/.test(peekNonEmpty(lines, cursor))); typeOrdinal += 1) {
const name = readNonEmpty(lines, cursor);
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) fail(`invalid section name ${name}`);
const childCount = parseCountLine(lines, cursor, 'ChildCount');
const children: ElementMap2ChildRecord[] = [];
for (let childOrdinal = 0; childOrdinal < childCount; childOrdinal += 1) {
const raw = readNonEmpty(lines, cursor);
const fields = raw.split(/\s+/);
if (fields.length < 6) fail(`invalid child record ${JSON.stringify(raw)}`);
if (fields.length < 7) fail(`child record has no StringID sentinel: ${JSON.stringify(raw)}`);
const childStringIdToken = fields.slice(6).join('.');
const childStringIdParts = childStringIdToken.split('.');
if (childStringIdParts[0] !== '0' || childStringIdParts.slice(1).some((value) => !/^(?:0|[1-9]\d*)$/.test(value))) {
fail(`invalid child StringID token ${JSON.stringify(childStringIdToken)}`);
}
const child: ElementMap2ChildRecord = {
childIndex: integer(fields[0], 'child index'),
offset: integer(fields[1], 'child offset'),
count: integer(fields[2], 'child count'),
tag: integer(fields[3], 'child tag'),
mapIndex: integer(fields[4], 'child map index'),
postfix: fields[5],
// FreeCAD intentionally restores child StringIDs as decimal for
// backward compatibility with the original writer accident.
stringIds: childStringIdParts.slice(1).map((value, i) => integer(value, `child string id ${i}`)),
raw,
};
if (child.mapIndex >= index) fail(`child map index ${child.mapIndex} must precede map ${index}`);
children.push(child);
}
const nameCount = parseCountLine(lines, cursor, 'NameCount');
const names: ElementMap2NameEntry[] = [];
for (let nameOrdinal = 0; nameOrdinal < nameCount; nameOrdinal += 1) {
if (cursor.value >= lines.length) fail('name list is truncated');
const raw = lines[cursor.value++].trim();
const fields = raw ? raw.split(/\s+/) : [];
if (fields.length === 0) fail(`name entry is empty: ${JSON.stringify(raw)}`);
const hasTerminator = fields[fields.length - 1] === '0';
if (!hasTerminator && !fields[0].startsWith(';') && !fields[0].startsWith('$') && !fields[0].startsWith(':')) fail(`name entry must end in 0: ${JSON.stringify(raw)}`);
names.push({
tokens: fields.slice(0, hasTerminator ? -1 : undefined).map(parseNameToken),
trailing: '0',
raw,
});
}
sections.push({ name, children, names });
}
// A pre-v1 WebCAD fixture may contain an orphaned opaque name token after
// its declared names. Keep parsing deterministic, but consume only
// marker-prefixed tokens before the map terminator.
while (peekNonEmpty(lines, cursor) !== 'EndMap') {
const orphan = readNonEmpty(lines, cursor);
if (!/^[;:$]/.test(orphan)) fail(`unexpected map content ${JSON.stringify(orphan)}`);
}
const end = readNonEmpty(lines, cursor);
if (end !== 'EndMap') fail(`expected EndMap, got ${JSON.stringify(end)}`);
maps.push({ index, id, typeCount, sections });
}
const trailing = lines.slice(cursor.value).filter((line) => line.trim() !== '');
if (trailing.length > 0 || readLastEnd(lines) !== 'EndMap') {
// The native writer terminates with EndMap. readLastEnd() is separate so
// resources with a final newline are accepted without ambiguity.
fail('resource must terminate with EndMap');
}
const rootMapIndex = maps.reduce((max, map) => Math.max(max, map.index), 0);
return { schemaVersion: 1, nativeVersion: 1, rootId, postfixes, maps, rootMapIndex };
}
function readLastEnd(lines: string[]): string {
for (let index = lines.length - 1; index >= 0; index -= 1) {
const value = lines[index].trim();
if (value) return value;
}
return '';
}
export function migrateElementMap2Schema(document: AnyElementMap2Document): ElementMap2Document {
return {
schemaVersion: 2,
nativeVersion: document.nativeVersion,
rootId: document.rootId,
postfixes: [...document.postfixes],
maps: document.maps.map((map) => ({
index: map.index,
id: map.id,
typeCount: map.typeCount,
sections: map.sections.map((section) => ({
name: section.name,
children: section.children.map((child) => ({ ...child, stringIds: [...child.stringIds] })),
names: section.names.map((entry) => ({ ...entry, tokens: entry.tokens.map((token) => ({ ...token, suffix: [...token.suffix] })) })),
})),
})),
rootMapIndex: document.rootMapIndex,
};
}
export function cloneElementMap2(document: ElementMap2Document): ElementMap2Document {
return migrateElementMap2Schema(document);
}
export function validateElementMap2(document: AnyElementMap2Document): ElementMap2ValidationReport {
const value = migrateElementMap2Schema(document);
const issues: ElementMap2ValidationIssue[] = [];
const add = (issue: ElementMap2ValidationIssue) => issues.push(issue);
if (value.nativeVersion !== 1) add({ code: 'NATIVE_VERSION', path: 'nativeVersion', message: `unsupported native version ${value.nativeVersion}` });
const mapIndexes = new Set<number>();
let childCount = 0;
let nameCount = 0;
let tokenCount = 0;
for (const [mapOrdinal, map] of value.maps.entries()) {
const mapPath = `maps[${mapOrdinal}]`;
if (!Number.isSafeInteger(map.index) || map.index < 0 || mapIndexes.has(map.index)) add({ code: 'MAP_INDEX', path: `${mapPath}.index`, message: `invalid or duplicate map index ${map.index}` });
mapIndexes.add(map.index);
if (map.typeCount !== 0 && map.typeCount !== map.sections.length) add({ code: 'TYPE_COUNT', path: `${mapPath}.typeCount`, message: `declared ${map.typeCount} sections but parsed ${map.sections.length}` });
const sectionNames = new Set<string>();
for (const [sectionOrdinal, section] of map.sections.entries()) {
const sectionPath = `${mapPath}.sections[${sectionOrdinal}]`;
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(section.name) || sectionNames.has(section.name)) add({ code: 'SECTION_NAME', path: `${sectionPath}.name`, message: `invalid or duplicate section name ${section.name}` });
sectionNames.add(section.name);
childCount += section.children.length;
nameCount += section.names.length;
for (const [childOrdinal, child] of section.children.entries()) {
const childPath = `${sectionPath}.children[${childOrdinal}]`;
if (!Number.isSafeInteger(child.mapIndex) || child.mapIndex < 0 || child.mapIndex >= map.index) add({ code: 'CHILD_MAP', path: `${childPath}.mapIndex`, message: `child map ${child.mapIndex} must precede map ${map.index}` });
if (!child.postfix || /\s/.test(child.postfix)) add({ code: 'CHILD_POSTFIX', path: `${childPath}.postfix`, message: 'child postfix must be one non-empty token' });
}
for (const [nameOrdinal, entry] of section.names.entries()) {
tokenCount += entry.tokens.length;
for (const [tokenOrdinal, token] of entry.tokens.entries()) {
const tokenPath = `${sectionPath}.names[${nameOrdinal}].tokens[${tokenOrdinal}]`;
if (!token.raw || token.raw[0] !== token.marker || !/^[;:$]/.test(token.raw) || formatElementMap2NameToken(token) !== token.raw) {
add({ code: 'NAME_TOKEN', path: tokenPath, message: `invalid native name token ${JSON.stringify(token.raw)}` });
continue;
}
if (token.marker === ':' && (token.postfixIndex === undefined || token.postfixIndex <= 0 || token.postfixIndex > value.postfixes.length || token.elementIndex === undefined)) {
add({ code: 'NAME_TOKEN', path: tokenPath, message: 'indexed token references an unavailable indexed-name type' });
}
if (token.suffix.length < 1 || token.suffix.some((part) => !/^[0-9a-fA-F]+$/.test(part))) {
add({ code: 'NAME_TOKEN', path: tokenPath, message: 'token postfix/StringID suffix is missing or is not hexadecimal' });
continue;
}
const mappedPostfix = Number.parseInt(token.suffix[0], 16);
if (!Number.isSafeInteger(mappedPostfix) || mappedPostfix < 0 || mappedPostfix > value.postfixes.length) {
add({ code: 'NAME_TOKEN', path: tokenPath, message: `token references unavailable mapped-name postfix ${token.suffix[0]}` });
}
}
}
}
}
if (value.maps.length === 0 || !mapIndexes.has(value.rootMapIndex)) add({ code: 'ROOT_MAP', path: 'rootMapIndex', message: `root map ${value.rootMapIndex} does not exist` });
for (const [mapOrdinal, map] of value.maps.entries()) {
for (const [sectionOrdinal, section] of map.sections.entries()) {
for (const [childOrdinal, child] of section.children.entries()) {
if (child.mapIndex !== 0 && !mapIndexes.has(child.mapIndex)) add({ code: 'CHILD_MAP', path: `maps[${mapOrdinal}].sections[${sectionOrdinal}].children[${childOrdinal}].mapIndex`, message: `child map ${child.mapIndex} does not exist` });
}
}
}
return { valid: issues.length === 0, mapCount: value.maps.length, childCount, nameCount, tokenCount, issues };
}
export function canonicalizeElementMap2(document: AnyElementMap2Document): ElementMap2Document {
const value = migrateElementMap2Schema(document);
const canonical: ElementMap2Document = {
...value,
postfixes: [...value.postfixes],
maps: [...value.maps]
.sort((left, right) => left.index - right.index)
.map((map) => ({
...map,
typeCount: map.sections.length,
sections: map.sections.map((section) => ({
name: section.name,
children: section.children.map((child) => ({ ...child, stringIds: [...child.stringIds] })),
names: section.names.map((entry) => ({ ...entry, tokens: entry.tokens.map((token) => ({ ...token, suffix: [...token.suffix] })) })),
})),
})),
};
const report = validateElementMap2(canonical);
if (!report.valid) throw new Error(`ElementMap2 validation error: ${report.issues[0].path}: ${report.issues[0].message}`);
return canonical;
}
/** Stable semantic identity used to verify that native naming history survives a codec round trip. */
export function elementMap2SemanticDigest(document: AnyElementMap2Document): string {
const value = canonicalizeElementMap2(document);
return JSON.stringify({
nativeVersion: value.nativeVersion,
rootId: value.rootId,
rootMapIndex: value.rootMapIndex,
postfixes: value.postfixes,
maps: value.maps.map((map) => ({
index: map.index,
id: map.id,
sections: map.sections.map((section) => ({
name: section.name,
children: section.children.map(({ childIndex, offset, count, tag, mapIndex, postfix, stringIds }) => ({ childIndex, offset, count, tag, mapIndex, postfix, stringIds })),
names: section.names.map((entry) => entry.tokens.map((token) => token.raw)),
})),
})),
});
}
/**
* Adds a new native name entry while preserving all existing postfix indexes.
* Missing section/indexed types are appended before mapped-name postfixes,
* matching the discovery categories used by ElementMap::collectChildMaps().
*/
export function appendElementMap2NameEntry(
document: AnyElementMap2Document,
mapIndex: number,
sectionName: ElementMap2SectionName,
references: readonly ElementMap2MappedNameReference[],
): ElementMap2Document {
const value = migrateElementMap2Schema(document)
const map = value.maps.find((candidate) => candidate.index === mapIndex)
if (!map) throw new RangeError(`ElementMap2 map ${mapIndex} does not exist`)
const section = map.sections.find((candidate) => candidate.name === sectionName)
if (!section) throw new RangeError(`ElementMap2 section ${JSON.stringify(sectionName)} does not exist in map ${mapIndex}`)
const appendPostfix = (postfix: string) => {
if (postfix && !value.postfixes.includes(postfix)) value.postfixes.push(postfix)
}
for (const currentMap of [...value.maps].sort((left, right) => left.index - right.index)) {
for (const currentSection of currentMap.sections) appendPostfix(currentSection.name)
}
for (const reference of references) if (reference.indexedName) appendPostfix(reference.indexedName.type)
for (const reference of references) appendPostfix(reference.postfix ?? '')
section.names.push(createElementMap2NameEntry(references, value.postfixes))
return canonicalizeElementMap2(value)
}
export function writeElementMap2(document: AnyElementMap2Document): string {
const value = canonicalizeElementMap2(document);
const lines: string[] = ['BeginElementMap v1', `${value.rootId} PostfixCount ${value.postfixes.length}`];
lines.push(...value.postfixes);
lines.push(`MapCount ${value.maps.length}`, '');
for (const map of value.maps) {
lines.push(`ElementMap ${map.index} ${map.id} ${map.sections.length}`);
for (const section of map.sections) {
lines.push(section.name, `ChildCount ${section.children.length}`);
for (const child of section.children) {
// Child StringIDs are the one numeric field that FreeCAD 1.1.1 keeps
// decimal; the leading zero belongs to the native token grammar.
const ids = `0${child.stringIds.map((id) => `.${id}`).join('')}`;
lines.push([child.childIndex, child.offset, child.count, child.tag, child.mapIndex, child.postfix, ids].join(' '));
}
lines.push(`NameCount ${section.names.length}`);
for (const entry of section.names) {
lines.push(entry.tokens.length ? `${entry.tokens.map((token) => token.raw).join(' ')} 0` : '0');
}
}
lines.push('EndMap', '');
}
return `${lines.join('\n')}\n`;
}

View File

@@ -0,0 +1,8 @@
export type EngineeringKind = 'Assembly' | 'BIM' | 'Mesh' | 'Surface'
export type EngineeringArtifact = { id: string; kind: EngineeringKind; sourceRefs: string[]; payload: Record<string, unknown>; version: number }
export type EngineeringIntegrity = { complete: boolean; kinds: EngineeringKind[]; missingKinds: EngineeringKind[]; missingRefs: string[]; artifacts: number }
export type EngineeringSnapshot = { id: string; label: string; artifacts: EngineeringArtifact[]; version: number }
export type EngineeringProjectApi = { add(input: Omit<EngineeringArtifact, 'version'> & { version?: number }): EngineeringArtifact; update(id: string, patch: Partial<Pick<EngineeringArtifact, 'sourceRefs' | 'payload'>>): EngineeringArtifact; integrity(): EngineeringIntegrity; snapshot(): EngineeringSnapshot; save(): string; load(serialized: string): EngineeringSnapshot }
const kinds: EngineeringKind[] = ['Assembly', 'BIM', 'Mesh', 'Surface']
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const createEngineeringProject = (id = 'engineering', label = 'Engineering project'): EngineeringProjectApi => { const artifacts = new Map<string, EngineeringArtifact>(); let version = 0; const add = (input: Omit<EngineeringArtifact, 'version'> & { version?: number }) => { if (!input.id.trim() || artifacts.has(input.id)) throw new RangeError(`Engineering artifact already exists: ${input.id}`); if (!kinds.includes(input.kind)) throw new RangeError(`Unsupported engineering artifact kind: ${input.kind}`); const artifact: EngineeringArtifact = { id: input.id, kind: input.kind, sourceRefs: [...new Set(input.sourceRefs)], payload: clone(input.payload), version: input.version ?? 1 }; artifacts.set(artifact.id, artifact); version += 1; return clone(artifact) }; const update = (artifactId: string, patch: Partial<Pick<EngineeringArtifact, 'sourceRefs' | 'payload'>>) => { const artifact = artifacts.get(artifactId); if (!artifact) throw new RangeError(`Engineering artifact does not exist: ${artifactId}`); if (patch.sourceRefs) artifact.sourceRefs = [...new Set(patch.sourceRefs)]; if (patch.payload) artifact.payload = clone(patch.payload); artifact.version += 1; version += 1; return clone(artifact) }; const integrity = (): EngineeringIntegrity => { const present = [...new Set([...artifacts.values()].map((artifact) => artifact.kind))]; const missingKinds = kinds.filter((kind) => !present.includes(kind)); const missingRefs = [...new Set([...artifacts.values()].flatMap((artifact) => artifact.sourceRefs.filter((ref) => !artifacts.has(ref)).map((ref) => `${artifact.id}->${ref}`)))].sort(); return { complete: missingKinds.length === 0 && missingRefs.length === 0, kinds: present.sort(), missingKinds, missingRefs, artifacts: artifacts.size } }; const snapshot = (): EngineeringSnapshot => ({ id, label, artifacts: [...artifacts.values()].map(clone), version }); const save = () => `${JSON.stringify(snapshot(), null, 2)}\n`; const load = (serialized: string) => { let parsed: unknown; try { parsed = JSON.parse(serialized) } catch { throw new Error('Engineering project is not valid JSON.') }; if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as { artifacts?: unknown }).artifacts)) throw new Error('Engineering project artifacts are missing.'); const source = parsed as EngineeringSnapshot; artifacts.clear(); for (const artifact of source.artifacts) add(artifact); version = source.version; return snapshot() }; return { add, update, integrity, snapshot, save, load } }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
export type FcstdRoundTripDirection = 'freecad-web-freecad' | 'web-freecad-web'
export type FcstdRoundTripDifference = { path: string; expected: string; actual: string; classification: 'unknown' }
export type FcstdRoundTripResult = { schemaVersion: 1; id: string; direction: FcstdRoundTripDirection; status: 'pass' | 'fail-unknown-difference'; differences: FcstdRoundTripDifference[]; sourceFingerprint: string; receivedFingerprint: string; domains: string[] }
const normalize = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(normalize)
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, normalize(entry)]))
return value
}
export const fingerprintRoundTrip = (value: unknown) => JSON.stringify(normalize(value))
export const compareFcstdRoundTrip = (id: string, direction: FcstdRoundTripDirection, expected: unknown, received: unknown, domains: string[]): FcstdRoundTripResult => {
const sourceFingerprint = fingerprintRoundTrip(expected)
const receivedFingerprint = fingerprintRoundTrip(received)
const differences = sourceFingerprint === receivedFingerprint ? [] : [{ path: id, expected: sourceFingerprint, actual: receivedFingerprint, classification: 'unknown' as const }]
return { schemaVersion: 1, id, direction, status: differences.length === 0 ? 'pass' : 'fail-unknown-difference', differences, sourceFingerprint, receivedFingerprint, domains: [...new Set(domains)] }
}

11
src/facade/fem.ts Normal file
View File

@@ -0,0 +1,11 @@
export type FemNode = { id: number; position: [number, number, number] }
export type FemMaterial = { id: string; youngsModulus: number; poissonRatio: number }
export type FemLoad = { nodeId: number; force: number }
export type FemResult = { nodeId: number; displacement: number; stress: number }
export type FemNodeSet = { id: string; nodeIds: number[]; role: 'constraint' | 'load' | 'result' }
export type FemResultField = { name: 'displacement' | 'stress'; unit: 'mm' | 'MPa'; values: Array<{ nodeId: number; value: number }> }
export type FemSnapshot = { id: string; label: string; material?: FemMaterial; nodes: FemNode[]; nodeSets: FemNodeSet[]; fixedNodeIds: number[]; loads: FemLoad[]; results: FemResult[]; resultFields: FemResultField[]; solverStrategy: 'local-reference' | 'remote-required'; status: 'draft' | 'solved' | 'invalid'; version: number }
export type FemApi = { snapshot(): FemSnapshot; setMaterial(material: FemMaterial): FemMaterial; setMesh(nodes: FemNode[]): FemSnapshot; addNodeSet(set: FemNodeSet): FemNodeSet; fixNode(nodeId: number): FemSnapshot; addLoad(load: FemLoad): FemLoad; solve(input: { length: number; area: number }): FemSnapshot; exportResultsCsv(): string }
const finite = (value: number, label: string) => { if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`) }
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const createFemAnalysis = (id = 'fem', label = 'FEM analysis'): FemApi => { let material: FemMaterial | undefined; let nodes: FemNode[] = []; const nodeSets = new Map<string, FemNodeSet>(); const fixed = new Set<number>(); const loads: FemLoad[] = []; let results: FemResult[] = []; let status: FemSnapshot['status'] = 'draft'; let version = 0; const fields = (): FemResultField[] => results.length ? [{ name: 'displacement', unit: 'mm', values: results.map((entry) => ({ nodeId: entry.nodeId, value: entry.displacement })) }, { name: 'stress', unit: 'MPa', values: results.map((entry) => ({ nodeId: entry.nodeId, value: entry.stress })) }] : []; const snapshot = (): FemSnapshot => ({ id, label, material: material ? clone(material) : undefined, nodes: clone(nodes), nodeSets: [...nodeSets.values()].map(clone), fixedNodeIds: [...fixed].sort((a, b) => a - b), loads: clone(loads), results: clone(results), resultFields: fields(), solverStrategy: 'local-reference', status, version }); const node = (nodeId: number) => { const value = nodes.find((entry) => entry.id === nodeId); if (!value) throw new RangeError(`FEM node does not exist: ${nodeId}`); return value }; const setMaterial = (input: FemMaterial) => { if (!input.id.trim()) throw new RangeError('FEM material id is required.'); if (!Number.isFinite(input.youngsModulus) || input.youngsModulus <= 0) throw new RangeError('FEM Young modulus must be positive.'); if (!Number.isFinite(input.poissonRatio) || input.poissonRatio < 0 || input.poissonRatio >= 0.5) throw new RangeError('FEM Poisson ratio must be in [0,0.5).'); material = clone(input); status = 'draft'; version += 1; return clone(input) }; const setMesh = (input: FemNode[]) => { if (input.length < 2 || new Set(input.map((entry) => entry.id)).size !== input.length) throw new RangeError('FEM mesh needs at least two unique nodes.'); input.forEach((entry) => { if (!Number.isSafeInteger(entry.id)) throw new RangeError('FEM node id must be an integer.'); entry.position.forEach((value) => finite(value, 'FEM node position')) }); nodes = clone(input); nodeSets.clear(); fixed.clear(); loads.splice(0); results = []; status = 'draft'; version += 1; return snapshot() }; const addNodeSet = (input: FemNodeSet) => { if (!input.id.trim() || nodeSets.has(input.id) || input.nodeIds.length === 0) throw new RangeError(`FEM node set is invalid or already exists: ${input.id}`); input.nodeIds.forEach(node); const value = { ...input, nodeIds: [...new Set(input.nodeIds)].sort((a, b) => a - b) }; nodeSets.set(value.id, value); version += 1; return clone(value) }; const fixNode = (nodeId: number) => { node(nodeId); fixed.add(nodeId); status = 'draft'; version += 1; return snapshot() }; const addLoad = (input: FemLoad) => { node(input.nodeId); finite(input.force, 'FEM force'); loads.push(clone(input)); status = 'draft'; version += 1; return clone(input) }; const solve = (input: { length: number; area: number }) => { if (!material || nodes.length < 2 || fixed.size === 0 || loads.length === 0) { status = 'invalid'; results = []; version += 1; return snapshot() } if (![input.length, input.area].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError('FEM reference length and area must be positive.'); const totalForce = loads.reduce((sum, entry) => sum + entry.force, 0); const displacement = totalForce * input.length / (input.area * material.youngsModulus); const stress = totalForce / input.area; results = nodes.map((entry) => ({ nodeId: entry.id, displacement: fixed.has(entry.id) ? 0 : displacement, stress: fixed.has(entry.id) ? 0 : stress })); status = 'solved'; version += 1; return snapshot() }; const exportResultsCsv = () => [['nodeId', 'displacement', 'stress'], ...results.map((entry) => [String(entry.nodeId), entry.displacement.toFixed(9), entry.stress.toFixed(9)])].map((row) => row.join(',')).join('\n') + (results.length ? '\n' : ''); return { snapshot, setMaterial, setMesh, addNodeSet, fixNode, addLoad, solve, exportResultsCsv } }

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,76 @@ const createWebCadPlugins = (occt: MainModule) => ({
}
},
},
io: {
exportBrep: (inputs: { shape: TopoDS_Shape }) => occt.WriteBREPToString(inputs.shape),
exportIges: (inputs: { shape: TopoDS_Shape }) => occt.WriteIGESToString(inputs.shape),
importShape: (inputs: { format: 'step' | 'iges' | 'brep'; text: string }) => {
if (!inputs.text.trim()) throw new Error('Geometry import payload is empty.')
if (inputs.format === 'step') return occt.ReadSTEPFromString(inputs.text)
if (inputs.format === 'iges') return occt.ReadIGESFromString(inputs.text)
if (inputs.format === 'brep') return occt.ReadBREPFromString(inputs.text)
throw new Error(`Unsupported geometry import format: ${String(inputs.format)}`)
},
},
quality: {
massProperties: (inputs: { shape: TopoDS_Shape }) => {
const volume = occt.ComputeVolumeProperties(inputs.shape)
const surface = occt.ComputeSurfaceProperties(inputs.shape)
const center = volume.CentreOfMass
const result = { volume: volume.Mass, surfaceArea: surface.Mass, centerOfMass: [center.X(), center.Y(), center.Z()] as [number, number, number] }
volume.delete()
surface.delete()
return result
},
},
topology: {
describe: (inputs: { shape: TopoDS_Shape }) => {
const parse = (value: string) => JSON.parse(value) as Record<string, unknown>
const faceInfo = parse(occt.BRepGraphFaceInfo(inputs.shape)) as { faces: Array<Record<string, unknown>> }
const edgeInfo = parse(occt.BRepGraphEdgeInfo(inputs.shape)) as { edges: Array<Record<string, unknown>> }
const adjacency = parse(occt.BRepGraphFaceAdjacency(inputs.shape)) as { faces: Array<Record<string, unknown>> }
const edgeFaceMap = parse(occt.BRepGraphEdgeFaceMap(inputs.shape)) as { edges: Array<Record<string, unknown>> }
const vertexEdgeMap = parse(occt.BRepGraphVertexEdgeMap(inputs.shape)) as { vertices: Array<Record<string, unknown>> }
const faceMetrics = new Map<number, { area: number; centroid: [number, number, number] }>()
const faceExplorer = new occt.TopExp_Explorer(inputs.shape, occt.TopAbs_ShapeEnum.FACE)
while (faceExplorer.More()) {
const face = occt.CastToFace(faceExplorer.Current())
const properties = occt.ComputeSurfaceProperties(face)
const center = properties.CentreOfMass
faceMetrics.set(faceMetrics.size, { area: properties.Mass, centroid: [center.X(), center.Y(), center.Z()] })
properties.delete()
faceExplorer.Next()
}
faceExplorer.delete()
const edgeMetrics = new Map<number, { length: number; centroid: [number, number, number] }>()
const edgeExplorer = new occt.TopExp_Explorer(inputs.shape, occt.TopAbs_ShapeEnum.EDGE)
while (edgeExplorer.More()) {
const edge = occt.CastToEdge(edgeExplorer.Current())
const properties = occt.ComputeLinearProperties(edge)
const center = properties.CentreOfMass
edgeMetrics.set(edgeMetrics.size, { length: properties.Mass, centroid: [center.X(), center.Y(), center.Z()] })
properties.delete()
edgeExplorer.Next()
}
edgeExplorer.delete()
const surfaces: Array<Record<string, unknown>> = faceInfo.faces.map((face, index) => ({ ...face, ...(faceMetrics.get(index) ?? {}), adjacentFaceCount: (adjacency.faces[index]?.adjacent as unknown[] | undefined)?.length ?? 0, edgeCount: (adjacency.faces[index]?.edges as unknown[] | undefined)?.length ?? Number(face.nbWires ?? 0) }))
const surfaceByFace = new Map(surfaces.map((face) => [Number(face.index), String(face.surfaceType ?? 'Other')]))
const curves: Array<Record<string, unknown>> = edgeInfo.edges.map((edge, index) => ({ ...edge, ...(edgeMetrics.get(index) ?? {}), adjacentFaceTypes: ((edgeFaceMap.edges[index]?.faces as unknown[] | undefined) ?? []).map((faceIndex) => surfaceByFace.get(Number(faceIndex)) ?? 'Other') }))
const vertices: Array<Record<string, unknown>> = (vertexEdgeMap.vertices ?? []).map((vertex) => ({ ...vertex, point: vertex.point, incidentEdgeCount: (vertex.edges as unknown[] | undefined)?.length ?? 0 }))
return {
faces: surfaces,
edges: curves,
vertices,
adjacency: {
faceNeighbors: adjacency.faces.map((face) => (face.adjacent as number[] | undefined) ?? []),
faceEdges: adjacency.faces.map((face) => (face.edges as number[] | undefined) ?? []),
edgeFaces: edgeFaceMap.edges.map((edge) => (edge.faces as number[] | undefined) ?? []),
edgeVertices: edgeFaceMap.edges.map((edge) => [edge.startVertex, edge.endVertex].filter((index): index is number => Number.isSafeInteger(index))),
vertexEdges: (vertexEdgeMap.vertices ?? []).map((vertex) => (vertex.edges as number[] | undefined) ?? []),
},
}
},
},
})
const initialize = async () => {

View File

@@ -1,30 +1,101 @@
export { createMockFacade } from './mockFacade'
export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export { ThreeViewportAdapter } from './threeViewport'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validateMirrorInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
export type { SqliteProjectPersistenceOptions } from './projectStore'
export { resolveMeshSubshape, ThreeViewportAdapter } from './threeViewport'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, classifyPlanarProfile, collectGeometryImportText, MAX_GEOMETRY_IMPORT_TEXT_BYTES, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validateDraftInput, validateEllipsoidInput, validateGeometryFileImport, validateGrooveInput, validateHelixInput, validateLoftInput, validateMirrorInput, validatePadInput, validatePipeInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput, validateThicknessInput, validateTorusInput, validateWedgeInput } from './geometryRuntime'
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from './projectSchema'
export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema'
export type { CreateEllipsoidInput, CreateHelixInput, CreatePrismInput, CreateWedgeInput, ExtrudeInput } from './types'
export type { DraftInput, ThicknessInput } from './types'
export type { LinkSubValue } from './types'
export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, ModelTreeItem, MultiTransformStep, MultiTransformValue, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue } from './types'
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { ApplyPlacementInput, AttachmentSupportValue, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, CreateTorusInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, ElementMapEntry, ElementMapSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, GeometryFileImport, GrooveInput, LinearFeatureParameters, LoftInput, MeshAsset, MirrorInput, ModelTreeItem, MultiTransformStep, MultiTransformValue, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PathCommandValue, PathPropertyValue, PersistenceCapabilities, PipeInput, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProfileClassification, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RemoveObjectInput, ReorderBodyFeatureInput, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, ShapeMassProperties, ShapeQualityReport, SubshapeRef, SubshapeSelection, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyAdjacency, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue, ViewportInteractionHandlers, ViewportMeshAsset } from './types'
export { createAnalyticSubshapeRefs, createEdgeSubshapeRefs, createSubshapeRefs, createTopologyAdjacency, createVertexSubshapeRefs, matchSubshapes, matchSubshapesWithAdjacency, scoreAdjacencyCompatibility, signatureForAnalyticEdge, signatureForAnalyticFace, signatureForAnalyticVertex, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export type { AnalyticEdgeInput, AnalyticFaceInput, AnalyticVertexInput, TopologyAdjacencyMatch } from './topologyNaming'
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef, validateTopologySnapshotForPersistence } from './topologyReferences'
export { cloneElementMapSnapshot, createElementMapSnapshot, formatElementMapName, parseElementMapName } from './elementMap'
export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences'
export { captureNativeTopologyHistory, captureSignatureTopologyHistory } from './topologyHistory'
export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory'
export { runTopologyMutationReplay } from './topologyReplay'
export type { TopologyMutationReplayOptions, TopologyMutationReplayReport } from './topologyReplay'
export { createAssembly } from './assembly'
export type { AssemblyApi, AssemblyAxis, AssemblyBounds, AssemblyBomRow, AssemblyCollision, AssemblyComponent, AssemblyConnector, AssemblyDiagnostic, AssemblyJoint, AssemblyJointKind, AssemblyPlacement, AssemblyPoint, AssemblySnapshot, AssemblyVariant } from './assembly'
export { createMeshDocument } from './mesh'
export type { MeshApi, MeshPoint, MeshQuality, MeshSnapshot, MeshTriangle } from './mesh'
export { createInspection } from './inspection'
export type { InspectionApi, InspectionMeasurement, InspectionPoint, InspectionShape, InspectionSnapshot, InspectionVector } from './inspection'
export { createBimModel } from './bim'
export type { BimApi, BimElement, BimLevel, BimMaterial, BimSnapshot, BimSpace } from './bim'
export { createCamJob } from './cam'
export type { CamApi, CamAxisConfiguration, CamAxisMotion, CamCollisionEvent, CamCollisionFixture, CamCollisionKind, CamCollisionReport, CamCoolantMode, CamCutDirection, CamDressup, CamDressupKind, CamJobAsset, CamJobMetadata, CamCompound, CamPropertyValue, CamKinematicOptions, CamKinematicResult, CamKinematicViolation, CamMachinePose, CamMaterialRemoval, CamMaterialRemovalEvent, CamMaterialRemovalOptions, CamMaterialRemovalTimeline, CamOperation, CamOperationInput, CamOperationKind, CamPoint, CamPostprocessor, CamRotaryAxis, CamSanityIssue, CamSetupSheet, CamSimulationDiagnostic, CamSimulationResult, CamSnapshot, CamSpindleDirection, CamStock, CamStockMode, CamTool, CamToolBitAsset, CamToolController, CamToolShape } from './cam'
export { CAM_PIPELINE_ORDER, CAMOTICS_SOURCE_PATH, CAMOTICS_SOURCE_REVISION, CAMOTICS_SOURCE_URL, camoticsSourceEvidence, createLinuxcncIframeAdapter, prepareCamPipeline, submitCamPipelineToLinuxcnc } from './camPipeline'
export { CAMOTICS_WASM_ARTIFACT_SHA256, CAMOTICS_WASM_ARTIFACT_URL, CAMOTICS_WASM_SOURCE_REVISION, createCamoticsSweepKernel, loadCamoticsSweepKernel } from './camoticsWasm'
export type { CamPipelineOptions, CamPipelineResult, CamPipelineStage, CamoticsStageEvidence, LinuxcncIframeAdapterOptions, LinuxcncWasmAdapter, LinuxcncWasmSubmission, OpenCamLibPipelineRunner, OpenCamLibStageEvidence } from './camPipeline'
export { generateCamoticsSourceStageGcode, nativeCamCapabilities, runOpenCamLibDropCutter, sampleFlatEndPath, simulateOcctSolidRemoval } from './camNativeSimulation'
export type { CamoticsSourceMove, CamoticsSourceStageResult, OcctSolidRemovalInput, OcctSolidRemovalResult, NativeCamCapabilities, OpenCamLibDropCutterInput, OpenCamLibDropCutterResult, OpenCamLibTriangle } from './camNativeSimulation'
export { createFemAnalysis } from './fem'
export type { FemApi, FemLoad, FemMaterial, FemNode, FemResult, FemSnapshot } from './fem'
export { createRobot } from './robot'
export type { RobotApi, RobotJoint, RobotPose, RobotSnapshot, RobotWaypoint } from './robot'
export { createDataModules } from './dataModules'
export type { AdapterStatus, CylinderFitResult, DataAdapterDescriptor, DataModulesApi, DataModulesSnapshot, DataRecord, PlaneFitResult, Point3 as DataPoint3, PointCloud, PointCloudBounds, PointCloudFormat, PointCloudSegment, Polygon2, PolynomialFitResult, ReverseEngineeringResult, SpecialistModule, SphereFitResult, StructuredPointCloud } from './dataModules'
export { createAddonManager, createSignedAddonPackage } from './addonGovernance'
export type { AddonManagerApi, AddonManagerOptions, AddonManifest, AddonPackage, AddonPermission, AddonSnapshot, AddonVerification, InstalledAddon } from './addonGovernance'
export { createScriptSandbox } from './scriptSandbox'
export type { FacadeCommand, ScriptArgument, ScriptCommand, ScriptReplay, ScriptSandboxApi, ScriptSandboxOptions, ScriptSandboxSnapshot } from './scriptSandbox'
export { createSurfaceDocument } from './surface'
export type { SurfaceApi, SurfaceContinuity, SurfaceKind, SurfacePatch, SurfacePoint, SurfaceQuality, SurfaceSnapshot } from './surface'
export { checkAccessibilitySemantics, formatLength, localeSnapshot, supportedLocales, translate } from './locale'
export type { AccessibilityElement, AccessibilityReport, LocaleMessageKey, WorkspaceLocale } from './locale'
export { createSecondaryFormats } from './secondaryFormats'
export type { SecondaryFormat, SecondaryFormatDescriptor, SecondaryFormatsApi, SecondaryFormatsSnapshot, SecondaryRecord } from './secondaryFormats'
export { runPerformanceBudget } from './performanceBudget'
export type { PerformanceBudget, PerformanceReport } from './performanceBudget'
export { createProductionDocument } from './productionDocument'
export type { ProductionArtifact, ProductionClosure, ProductionDocumentApi, ProductionDocumentSnapshot, ProductionKind } from './productionDocument'
export { createEngineeringProject } from './engineeringProject'
export type { EngineeringArtifact, EngineeringIntegrity, EngineeringKind, EngineeringProjectApi, EngineeringSnapshot } from './engineeringProject'
export { createSecurityPreflight } from './securityPreflight'
export type { ArchiveEntry, SecurityPreflightApi, SecurityPreflightReport } from './securityPreflight'
export { compareFcstdRoundTrip, fingerprintRoundTrip } from './fcstdRoundTrip'
export type { FcstdRoundTripDifference, FcstdRoundTripDirection, FcstdRoundTripResult } from './fcstdRoundTrip'
export { captureNativeTopologyHistory, captureNativeTopologyHistoryStages, captureSignatureTopologyHistory, composeNativeTopologyHistoryLineage } from './topologyHistory'
export type { NativeTopologyHistoryLineageSource, NativeTopologyHistoryStageCapture, TopologyHistoryEntry, TopologyHistoryInput, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory'
export type {
NativeFeatureHistorySide,
NativeTopologyHistoryInput,
NativeTopologyHistoryRecord,
NativeTopologyHistoryStage,
} from './types'
export { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from './nativeHistoryProvider'
export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStage, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol'
export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from './nativeHistoryProtocol'
export type { NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest } from './nativeHistoryProtocol'
export type { NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryInputTransport, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest, NativeOcctHistoryStageTransport } from './nativeHistoryProtocol'
export { assertNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence'
export type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence'
export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient'
export type { NativeOcctHistoryWorkerOptions } from './nativeHistoryWorkerClient'
export { BasicSketchSolverAdapter, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchExternalGeometry, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
export { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay } from './sketchSolverProtocol'
export { applySketchAutoConstraints, BasicSketchSolverAdapter, carbonCopySketchGeometry, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, projectSketchGeometry, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, validateSketchSnapshot } from './sketcher'
export type { BsplineGeometryPatch, SketchAutoConstraintSuggestion, SketchConstraint, SketchDiagnostic, SketchEditorEvent, SketchEditorReplayResult, SketchExternalGeometry, SketchExternalMode, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
export { assertSketchSolverRequest, assertSketchSolverResponse, BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay } from './sketchSolverProtocol'
export type { SketchSolverCapabilities, SketchSolverCompatibility, SketchSolverExecution, SketchSolverProvider, SketchSolverReplayCase, SketchSolverReplayResult, SketchSolverRequest, SketchSolverResponse } from './sketchSolverProtocol'
export { PLANEGCS_WASM_CAPABILITIES, PlanegcsSubsetError, solvePlanegcsSubset } from './planegcsAdapter'
export type { PlanegcsDoubleVector, PlanegcsWasmModule } from './planegcsAdapter'
export { PlanegcsWorkerProvider } from './planegcsWorkerClient'
export type { PlanegcsWorkerOptions } from './planegcsWorkerClient'
export { runSketchStress } from './sketchStress'
export type { SketchStressCategory, SketchStressOptions, SketchStressReport } from './sketchStress'
export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
export { ATTACHMENT_MAP_MODES, composeAttachmentPlacement, identityAttachmentOffset, validateAttachmentMapMode, validateAttachmentOffset, validateAttachmentSupport } from './attachment'
export type { AttachmentFrame, AttachmentMapMode, AttachmentSupport } from './attachment'
export { redirectBodyTips, resolveBodyTip } from './bodyRules'
export type { BodyFeatureState } from './bodyRules'
export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeGeometryRuntime, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine'
export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd'
export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport, FcstdPropertySummary } from './fcstd'
export { DEFAULT_FCSTD_LIMITS, decodeFcstdPathProperty, decodeFcstdPropertyValue, encodeFcstdPathResource, extractFcstdShapeResources, inspectFcstdArchive, rewriteFcstdMetadataArchive, rewriteFcstdPathProperty, serializeElementMap2Resource, serializeFcstdMetadataArchive, serializeStringHasherTableResource, storeFcstdShapeResources } from './fcstd'
export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdDecodedPropertyValue, FcstdElementMapResource, FcstdEntryMetadata, FcstdEntryRole, FcstdGuiInspection, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport, FcstdPathEdit, FcstdPropertySummary, FcstdShapeResource, FcstdShapeResourcePayload, FcstdStoredShapeResource, FcstdStringHasherResource, FcstdWriteOptions } from './fcstd'
export { cloneStringHasherTable, migrateStringHasherSchema, parseStringHasherTable, StringHasherFlag, validateElementMap2StringHasherEvidence, validateStringHasherTable, writeStringHasherTable } from './stringHasher'
export type { AnyStringHasherTable, StringHasherEntry, StringHasherTable, StringHasherTableV1, StringHasherValidationIssue, StringHasherValidationReport, ElementMap2StringHasherEvidenceIssue } from './stringHasher'
export { appendElementMap2NameEntry, canonicalizeElementMap2, cloneElementMap2, createElementMap2MultiStageNameMapping, createElementMap2NameEntry, createElementMap2NameToken, elementMap2NameTokenToReference, elementMap2SemanticDigest, formatElementMap2NameToken, migrateElementMap2Schema, parseElementMap2, parseElementMap2MultiStageNameMapping, validateElementMap2, validateElementMap2MultiStageNameMapping, writeElementMap2, writeElementMap2MultiStageNameMapping } from './elementMap2'
export type { AnyElementMap2Document, ElementMap2ChildRecord, ElementMap2Document, ElementMap2DocumentV1, ElementMap2Map, ElementMap2MappedNameReference, ElementMap2MultiStageNameMapping, ElementMap2MultiStageNameMappingIssue, ElementMap2NameEntry, ElementMap2NameToken, ElementMap2Section, ElementMap2SectionName, ElementMap2StageNameEntry, ElementMap2StageNameMap, ElementMap2ValidationIssue, ElementMap2ValidationReport } from './elementMap2'

42
src/facade/inspection.ts Normal file
View File

@@ -0,0 +1,42 @@
export type InspectionPoint = [number, number, number]
export type InspectionVector = InspectionPoint
export type InspectionTopoRef = { persistentId: string; kind: 'face' | 'edge' | 'vertex'; status: 'stable' | 'ambiguous' | 'deleted' }
export type InspectionShape = { id: string; label: string; volume: number; area: number; structuralValid: boolean; bounds: { min: InspectionPoint; max: InspectionPoint }; dependencies?: string[]; topoRefs?: InspectionTopoRef[] }
export type InspectionMeasurement = { id: string; kind: 'distance' | 'angle' | 'area' | 'volume' | 'deviation'; value: number; unit: 'mm' | 'deg' | 'mm2' | 'mm3'; status: 'resolved' | 'invalid' }
export type InspectionSection = { shapeId: string; axis: 'z'; position: number; width: number; height: number; area: number }
export type InspectionSnapshot = { id: string; label: string; shapes: InspectionShape[]; measurements: InspectionMeasurement[]; dependencyIssues: string[]; version: number }
export type InspectionApi = {
snapshot(): InspectionSnapshot
registerShape(shape: InspectionShape): InspectionShape
measureDistance(id: string, first: InspectionPoint, second: InspectionPoint): InspectionMeasurement
measureAngle(id: string, first: InspectionVector, second: InspectionVector): InspectionMeasurement
measureShape(id: string, shapeId: string, kind: 'area' | 'volume'): InspectionMeasurement
measureDeviation(id: string, nominal: number, actual: number, tolerance: number): InspectionMeasurement
section(shapeId: string, position: number): InspectionSection
topoRefReport(shapeId: string): { total: number; stable: number; unresolved: string[] }
checkDependencies(): string[]
exportCsv(): string
}
const finitePoint = (point: InspectionPoint, label: string) => { if (point.length !== 3 || point.some((value) => !Number.isFinite(value))) throw new RangeError(`${label} must contain three finite coordinates.`); return [point[0], point[1], point[2]] as InspectionPoint }
const vectorLength = (vector: InspectionVector) => Math.hypot(vector[0], vector[1], vector[2])
const distance = (first: InspectionPoint, second: InspectionPoint) => Math.hypot(first[0] - second[0], first[1] - second[1], first[2] - second[2])
const cloneShape = (shape: InspectionShape): InspectionShape => ({ ...shape, bounds: { min: [...shape.bounds.min] as InspectionPoint, max: [...shape.bounds.max] as InspectionPoint }, dependencies: shape.dependencies ? [...shape.dependencies] : undefined, topoRefs: shape.topoRefs?.map((reference) => ({ ...reference })) })
const cloneMeasurement = (measurement: InspectionMeasurement): InspectionMeasurement => ({ ...measurement })
const csvEscape = (value: string) => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
export const createInspection = (id = 'inspection', label = 'Inspection'): InspectionApi => {
const shapes = new Map<string, InspectionShape>(); const measurements = new Map<string, InspectionMeasurement>(); let version = 0
const snapshot = (): InspectionSnapshot => ({ id, label, shapes: [...shapes.values()].map(cloneShape), measurements: [...measurements.values()].map(cloneMeasurement), dependencyIssues: checkDependencies(), version })
const registerShape = (shape: InspectionShape) => { if (!shape.id.trim() || shapes.has(shape.id)) throw new RangeError(`Inspection shape already exists: ${shape.id}`); if (![shape.volume, shape.area].every((value) => Number.isFinite(value) && value >= 0)) throw new RangeError('Inspection shape mass values must be finite and non-negative.'); finitePoint(shape.bounds.min, 'Inspection bounds.min'); finitePoint(shape.bounds.max, 'Inspection bounds.max'); if (!shape.structuralValid) throw new RangeError(`Inspection shape is structurally invalid: ${shape.id}`); shapes.set(shape.id, cloneShape(shape)); version += 1; return cloneShape(shape) }
const setMeasurement = (measurement: InspectionMeasurement) => { if (measurements.has(measurement.id)) throw new RangeError(`Inspection measurement already exists: ${measurement.id}`); measurements.set(measurement.id, measurement); version += 1; return cloneMeasurement(measurement) }
const measureDistance = (id: string, first: InspectionPoint, second: InspectionPoint) => { finitePoint(first, 'Inspection distance first'); finitePoint(second, 'Inspection distance second'); return setMeasurement({ id, kind: 'distance', value: distance(first, second), unit: 'mm', status: 'resolved' }) }
const measureAngle = (id: string, first: InspectionVector, second: InspectionVector) => { finitePoint(first, 'Inspection angle first'); finitePoint(second, 'Inspection angle second'); const firstLength = vectorLength(first); const secondLength = vectorLength(second); if (firstLength <= 1e-12 || secondLength <= 1e-12) return setMeasurement({ id, kind: 'angle', value: NaN, unit: 'deg', status: 'invalid' }); const cosine = Math.max(-1, Math.min(1, (first[0] * second[0] + first[1] * second[1] + first[2] * second[2]) / (firstLength * secondLength))); return setMeasurement({ id, kind: 'angle', value: Math.acos(cosine) * 180 / Math.PI, unit: 'deg', status: 'resolved' }) }
const measureShape = (id: string, shapeId: string, kind: 'area' | 'volume') => { const shape = shapes.get(shapeId); if (!shape) throw new RangeError(`Inspection shape does not exist: ${shapeId}`); return setMeasurement({ id, kind, value: kind === 'area' ? shape.area : shape.volume, unit: kind === 'area' ? 'mm2' : 'mm3', status: 'resolved' }) }
const measureDeviation = (id: string, nominal: number, actual: number, tolerance: number) => { if (![nominal, actual, tolerance].every(Number.isFinite) || tolerance < 0) throw new RangeError('Inspection deviation inputs must be finite and tolerance non-negative.'); const value = actual - nominal; return setMeasurement({ id, kind: 'deviation', value, unit: 'mm', status: Math.abs(value) <= tolerance ? 'resolved' : 'invalid' }) }
const section = (shapeId: string, position: number): InspectionSection => { const shape = shapes.get(shapeId); if (!shape) throw new RangeError(`Inspection shape does not exist: ${shapeId}`); if (!Number.isFinite(position) || position < shape.bounds.min[2] || position > shape.bounds.max[2]) throw new RangeError('Inspection section position is outside shape bounds.'); const width = shape.bounds.max[0] - shape.bounds.min[0]; const height = shape.bounds.max[1] - shape.bounds.min[1]; return { shapeId, axis: 'z', position, width, height, area: width * height } }
const topoRefReport = (shapeId: string) => { const shape = shapes.get(shapeId); if (!shape) throw new RangeError(`Inspection shape does not exist: ${shapeId}`); const references = shape.topoRefs ?? []; return { total: references.length, stable: references.filter((reference) => reference.status === 'stable').length, unresolved: references.filter((reference) => reference.status !== 'stable').map((reference) => reference.persistentId).sort() } }
const checkDependencies = () => { const issues: string[] = []; for (const shape of shapes.values()) for (const dependency of shape.dependencies || []) if (!shapes.has(dependency)) issues.push(`${shape.id}->${dependency}`); return issues.sort() }
const exportCsv = () => [['id', 'kind', 'value', 'unit', 'status'], ...[...measurements.values()].map((measurement) => [measurement.id, measurement.kind, Number.isFinite(measurement.value) ? measurement.value.toFixed(6) : '', measurement.unit, measurement.status])].map((row) => row.map(csvEscape).join(',')).join('\n') + '\n'
return { snapshot, registerShape, measureDistance, measureAngle, measureShape, measureDeviation, section, topoRefReport, checkDependencies, exportCsv }
}

15
src/facade/locale.ts Normal file
View File

@@ -0,0 +1,15 @@
export type WorkspaceLocale = 'en-US' | 'zh-CN' | 'de-DE'
export type LocaleMessageKey = 'workspace' | 'save' | 'unsupported' | 'longLabel'
export type AccessibilityElement = { id: string; role: string; accessibleName?: string; focusable?: boolean; visible?: boolean }
export type AccessibilityReport = { checked: number; missingNames: string[]; duplicateIds: string[]; unnamedFocusable: string[]; pass: boolean }
const messages: Record<WorkspaceLocale, Record<LocaleMessageKey, string>> = {
'en-US': { workspace: 'Workspace', save: 'Save project', unsupported: 'This command is not supported in the browser.', longLabel: 'Parametric feature history and document dependency inspector' },
'zh-CN': { workspace: '工作区', save: '保存项目', unsupported: '此命令在浏览器中暂不支持。', longLabel: '参数化特征历史与文档依赖及拓扑引用检查器工作流' },
'de-DE': { workspace: 'Arbeitsbereich', save: 'Projekt speichern', unsupported: 'Dieser Befehl wird im Browser nicht unterstützt.', longLabel: 'Verlauf parametrischer Features und Dokumentabhängigkeitsprüfung' },
}
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const supportedLocales = (): WorkspaceLocale[] => ['en-US', 'zh-CN', 'de-DE']
export const translate = (locale: WorkspaceLocale, key: LocaleMessageKey) => messages[locale][key]
export const formatLength = (value: number, locale: WorkspaceLocale, unit = 'mm') => { if (!Number.isFinite(value)) throw new RangeError('Locale quantity must be finite.'); return new Intl.NumberFormat(locale, { maximumFractionDigits: 3, minimumFractionDigits: 0 }).format(value) + ` ${unit}` }
export const checkAccessibilitySemantics = (elements: AccessibilityElement[]): AccessibilityReport => { const counts = new Map<string, number>(); const missingNames: string[] = []; const unnamedFocusable: string[] = []; for (const element of elements) { counts.set(element.id, (counts.get(element.id) ?? 0) + 1); if (element.visible !== false && !element.accessibleName?.trim()) missingNames.push(element.id); if (element.visible !== false && element.focusable === true && !element.accessibleName?.trim()) unnamedFocusable.push(element.id) }; return { checked: elements.length, missingNames, duplicateIds: [...counts.entries()].filter(([, count]) => count > 1).map(([id]) => id), unnamedFocusable, pass: missingNames.length === 0 && unnamedFocusable.length === 0 && [...counts.values()].every((count) => count === 1) } }
export const localeSnapshot = (locale: WorkspaceLocale) => clone({ locale, messages: messages[locale], samples: { zero: formatLength(0, locale), decimal: formatLength(12.3456, locale) } })

64
src/facade/mesh.ts Normal file
View File

@@ -0,0 +1,64 @@
export type MeshPoint = [number, number, number]
export type MeshTriangle = [number, number, number]
export type MeshSnapshot = { id: string; label: string; vertices: MeshPoint[]; triangles: MeshTriangle[]; version: number }
export type MeshQuality = { vertices: number; triangles: number; boundaryEdges: number; nonManifoldEdges: number; degenerateTriangles: number; selfIntersections: number; surfaceArea: number; bounds: { min: MeshPoint; max: MeshPoint } }
export type MeshLod = { level: number; vertices: number; triangles: number; indices: number[] }
export type MeshApi = {
snapshot(): MeshSnapshot
analyze(): MeshQuality
weldVertices(tolerance?: number): MeshSnapshot
removeDegenerate(tolerance?: number): MeshSnapshot
fillBoundaryTriangle(edge: [number, number], vertex: number): MeshSnapshot
detectSelfIntersections(): number
lod(targetTriangles: number): MeshLod
transform(input: { translation?: MeshPoint; scale?: number }): MeshSnapshot
exportObj(): string
exportPly(): string
exportStl(): string
}
const clonePoint = (point: MeshPoint): MeshPoint => [point[0], point[1], point[2]]
const cloneTriangle = (triangle: MeshTriangle): MeshTriangle => [triangle[0], triangle[1], triangle[2]]
const finitePoint = (point: MeshPoint, label: string) => { if (point.length !== 3 || point.some((value) => !Number.isFinite(value))) throw new RangeError(`${label} must contain three finite coordinates.`) }
const crossLength = (a: MeshPoint, b: MeshPoint, c: MeshPoint) => { const ab: MeshPoint = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; const ac: MeshPoint = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; return Math.hypot(ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]) }
const keyFor = (point: MeshPoint, tolerance: number) => point.map((value) => Math.round(value / tolerance)).join(':')
const orientation2d = (a: MeshPoint, b: MeshPoint, c: MeshPoint) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
const segmentsCross2d = (a: MeshPoint, b: MeshPoint, c: MeshPoint, d: MeshPoint) => { const ab1 = orientation2d(a, b, c); const ab2 = orientation2d(a, b, d); const cd1 = orientation2d(c, d, a); const cd2 = orientation2d(c, d, b); return ((ab1 > 1e-12 && ab2 < -1e-12) || (ab1 < -1e-12 && ab2 > 1e-12)) && ((cd1 > 1e-12 && cd2 < -1e-12) || (cd1 < -1e-12 && cd2 > 1e-12)) }
export const createMeshDocument = (id = 'mesh', label = 'Mesh', input?: { positions: ArrayLike<number>; indices: ArrayLike<number> }): MeshApi => {
const vertices: MeshPoint[] = []
const triangles: MeshTriangle[] = []
if (input) {
if (input.positions.length % 3 !== 0 || input.indices.length % 3 !== 0) throw new RangeError('Mesh positions and indices must be divisible by 3.')
for (let index = 0; index < input.positions.length; index += 3) { const point: MeshPoint = [Number(input.positions[index]), Number(input.positions[index + 1]), Number(input.positions[index + 2])]; finitePoint(point, 'Mesh vertex'); vertices.push(point) }
for (let index = 0; index < input.indices.length; index += 3) { const triangle: MeshTriangle = [Number(input.indices[index]), Number(input.indices[index + 1]), Number(input.indices[index + 2])]; if (triangle.some((value) => !Number.isSafeInteger(value) || value < 0 || value >= vertices.length)) throw new RangeError('Mesh triangle index is outside the vertex array.'); triangles.push(triangle) }
}
let version = 0
const snapshot = (): MeshSnapshot => ({ id, label, vertices: vertices.map(clonePoint), triangles: triangles.map(cloneTriangle), version })
const detectSelfIntersections = () => {
let intersections = 0
for (let first = 0; first < triangles.length; first += 1) for (let second = first + 1; second < triangles.length; second += 1) {
if (triangles[first].some((index) => triangles[second].includes(index))) continue
const a = triangles[first].map((index) => vertices[index]); const b = triangles[second].map((index) => vertices[index])
const edgesA = [[a[0], a[1]], [a[1], a[2]], [a[2], a[0]]] as const; const edgesB = [[b[0], b[1]], [b[1], b[2]], [b[2], b[0]]] as const
if (edgesA.some(([start, end]) => edgesB.some(([otherStart, otherEnd]) => segmentsCross2d(start, end, otherStart, otherEnd)))) intersections += 1
}
return intersections
}
const analyze = (): MeshQuality => {
const edgeCounts = new Map<string, number>(); let degenerateTriangles = 0; let surfaceArea = 0
const min: MeshPoint = [Infinity, Infinity, Infinity]; const max: MeshPoint = [-Infinity, -Infinity, -Infinity]
for (const point of vertices) for (let axis = 0; axis < 3; axis += 1) { min[axis] = Math.min(min[axis], point[axis]); max[axis] = Math.max(max[axis], point[axis]) }
for (const triangle of triangles) { const [a, b, c] = triangle.map((index) => vertices[index]); const doubled = crossLength(a, b, c); if (doubled <= 1e-12) degenerateTriangles += 1; surfaceArea += doubled / 2; for (const [first, second] of [[triangle[0], triangle[1]], [triangle[1], triangle[2]], [triangle[2], triangle[0]]]) { const edge = first < second ? `${first}:${second}` : `${second}:${first}`; edgeCounts.set(edge, (edgeCounts.get(edge) || 0) + 1) } }
return { vertices: vertices.length, triangles: triangles.length, boundaryEdges: [...edgeCounts.values()].filter((count) => count === 1).length, nonManifoldEdges: [...edgeCounts.values()].filter((count) => count > 2).length, degenerateTriangles, selfIntersections: detectSelfIntersections(), surfaceArea, bounds: { min: clonePoint(min), max: clonePoint(max) } }
}
const weldVertices = (tolerance = 1e-6) => { if (!Number.isFinite(tolerance) || tolerance <= 0) throw new RangeError('Mesh weld tolerance must be positive and finite.'); const indexByKey = new Map<string, number>(); const remap: number[] = []; const next: MeshPoint[] = []; vertices.forEach((point, index) => { const key = keyFor(point, tolerance); const existing = indexByKey.get(key); if (existing === undefined) { indexByKey.set(key, next.length); remap[index] = next.length; next.push(clonePoint(point)) } else remap[index] = existing }); vertices.splice(0, vertices.length, ...next); triangles.forEach((triangle, index) => { triangles[index] = [remap[triangle[0]], remap[triangle[1]], remap[triangle[2]]] }); version += 1; return snapshot() }
const removeDegenerate = (tolerance = 1e-12) => { if (!Number.isFinite(tolerance) || tolerance < 0) throw new RangeError('Mesh degenerate tolerance must be finite and non-negative.'); for (let index = triangles.length - 1; index >= 0; index -= 1) { const [a, b, c] = triangles[index].map((entry) => vertices[entry]); if (crossLength(a, b, c) <= tolerance || new Set(triangles[index]).size !== 3) triangles.splice(index, 1) } version += 1; return snapshot() }
const fillBoundaryTriangle = (edge: [number, number], vertex: number) => { if (![edge[0], edge[1], vertex].every((value) => Number.isSafeInteger(value) && value >= 0 && value < vertices.length)) throw new RangeError('Mesh boundary repair references an unknown vertex.'); const existing = triangles.some((triangle) => triangle.includes(edge[0]) && triangle.includes(edge[1])); if (existing) throw new RangeError('Mesh boundary repair edge is not available for this minimal repair contract.'); if (new Set([edge[0], edge[1], vertex]).size !== 3) throw new RangeError('Mesh boundary repair requires three distinct vertices.'); triangles.push([edge[0], edge[1], vertex]); version += 1; return snapshot() }
const lod = (targetTriangles: number): MeshLod => { if (!Number.isSafeInteger(targetTriangles) || targetTriangles < 1 || targetTriangles > triangles.length) throw new RangeError('Mesh LOD target must be a positive integer no greater than the triangle count.'); const stride = Math.max(1, Math.ceil(triangles.length / targetTriangles)); const selected = triangles.filter((_, index) => index % stride === 0).slice(0, targetTriangles); return { level: targetTriangles === triangles.length ? 0 : 1, vertices: vertices.length, triangles: selected.length, indices: selected.flatMap((triangle) => [...triangle]) } }
const transform = (input: { translation?: MeshPoint; scale?: number }) => { const translation = input.translation ?? [0, 0, 0] as MeshPoint; finitePoint(translation, 'Mesh translation'); const scale = input.scale ?? 1; if (!Number.isFinite(scale) || scale === 0) throw new RangeError('Mesh scale must be finite and non-zero.'); vertices.forEach((point) => { point[0] = point[0] * scale + translation[0]; point[1] = point[1] * scale + translation[1]; point[2] = point[2] * scale + translation[2] }); version += 1; return snapshot() }
const exportObj = () => { const lines = vertices.map((point) => `v ${point.map((value) => value.toFixed(6)).join(' ')}`); const faces = triangles.map((triangle) => `f ${triangle.map((value) => value + 1).join(' ')}`); return `${lines.concat(faces).join('\n')}\n` }
const exportPly = () => { const lines = ['ply', 'format ascii 1.0', `element vertex ${vertices.length}`, 'property float x', 'property float y', 'property float z', `element face ${triangles.length}`, 'property list uchar int vertex_indices', 'end_header']; return `${lines.concat(vertices.map((point) => point.map((value) => value.toFixed(6)).join(' ')), triangles.map((triangle) => `3 ${triangle.join(' ')}`)).join('\n')}\n` }
const exportStl = () => { const lines = ['solid bitbybit']; for (const triangle of triangles) { const [a, b, c] = triangle.map((index) => vertices[index]); const normal: MeshPoint = [0, 0, 0]; lines.push(` facet normal ${normal.join(' ')}`, ' outer loop', ` vertex ${a.join(' ')}`, ` vertex ${b.join(' ')}`, ` vertex ${c.join(' ')}`, ' endloop', ' endfacet') } lines.push('endsolid bitbybit'); return `${lines.join('\n')}\n` }
return { snapshot, analyze, weldVertices, removeDegenerate, fillBoundaryTriangle, detectSelfIntersections, lod, transform, exportObj, exportPly, exportStl }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider, NativeOcctMultiTransformStep } from './nativeHistoryProvider'
export const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const
@@ -19,8 +19,42 @@ export type NativeOcctHistoryRequest = {
documentVersion: number
operationId: string
operation: NativeOcctHistoryOperation
transformKind?: 'linear' | 'polar' | 'mirrored'
transforms?: NativeOcctMultiTransformStep[]
objectStep: string
toolStep: string
toolStep?: string
/** Explicit ordered inputs for multi-input and staged operations. */
inputs?: NativeOcctHistoryInputTransport[]
stages?: NativeOcctHistoryStageTransport[]
resultStepByStage?: Record<string, string>
direction?: [number, number, number]
axisOrigin?: [number, number, number]
angle?: number
radius?: number
distance?: number
depth?: number
position?: [number, number, number]
neutralPlaneDirection?: [number, number, number]
faceIndex?: number
reversed?: boolean
ruled?: boolean
offset?: number
joinType?: 'Arc' | 'Intersection'
}
export type NativeOcctHistoryInputTransport = {
inputId: string
role?: string
stageId?: string
step: string
}
export type NativeOcctHistoryStageTransport = {
stageId: string
operation?: NativeOcctHistoryOperation
inputIds: string[]
resultStageId?: string
ordinal: number
}
export type NativeOcctHistoryProtocolResponse = {
@@ -48,12 +82,101 @@ export class NativeOcctHistoryUnavailableError extends Error {
}
const abortError = () => new DOMException('Native OCCT history request cancelled.', 'AbortError')
const booleanOperations: readonly NativeOcctHistoryOperation[] = ['fuse', 'cut', 'common']
const assertRequest = (request: NativeOcctHistoryRequest) => {
if (request.protocolVersion !== NATIVE_OCCT_HISTORY_PROTOCOL_VERSION) throw new RangeError(`Unsupported native OCCT history protocol version: ${request.protocolVersion}.`)
if (!request.requestId.trim() || !request.documentId.trim() || !request.operationId.trim()) throw new TypeError('Native OCCT history request IDs must be non-empty strings.')
if (!Number.isSafeInteger(request.documentVersion) || request.documentVersion < 0) throw new RangeError('Native OCCT history documentVersion must be a non-negative integer.')
if (!request.objectStep.startsWith('ISO-10303-21;') || !request.toolStep.startsWith('ISO-10303-21;')) throw new TypeError('Native OCCT history transport requires ISO-10303-21 STEP text.')
if (!['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'].includes(request.operation)) throw new RangeError(`Unsupported native OCCT history operation: ${String(request.operation)}.`)
if (!request.objectStep.startsWith('ISO-10303-21;')) throw new TypeError('Native OCCT history transport requires ISO-10303-21 STEP text.')
if (request.inputs) {
if (request.inputs.length === 0 || request.inputs.length > 64) throw new RangeError('Native OCCT history inputs must contain between one and 64 entries.')
const ids = new Set<string>()
for (const input of request.inputs) {
if (!input.inputId.trim() || ids.has(input.inputId)) throw new TypeError('Native OCCT history input IDs must be unique and non-empty.')
ids.add(input.inputId)
if (!input.step.startsWith('ISO-10303-21;')) throw new TypeError(`Native OCCT history input '${input.inputId}' requires STEP text.`)
if (input.stageId !== undefined && !input.stageId.trim()) throw new TypeError('Native OCCT history input stageId must be non-empty.')
}
}
if (request.stages) {
if (request.stages.length === 0 || request.stages.length > 64) throw new RangeError('Native OCCT history stages must contain between one and 64 entries.')
const ids = new Set<string>()
const inputIds = new Set((request.inputs ?? []).map((input) => input.inputId))
for (const stage of request.stages) {
if (!stage.stageId.trim() || ids.has(stage.stageId)) throw new TypeError('Native OCCT history stage IDs must be unique and non-empty.')
ids.add(stage.stageId)
if (!Number.isSafeInteger(stage.ordinal) || stage.ordinal < 0) throw new RangeError('Native OCCT history stage ordinal must be a non-negative integer.')
if (stage.inputIds.length === 0) throw new TypeError(`Native OCCT history stage '${stage.stageId}' requires at least one input.`)
if (request.inputs && stage.inputIds.some((id) => !inputIds.has(id))) throw new TypeError(`Native OCCT history stage '${stage.stageId}' references an unknown input.`)
if (stage.resultStageId !== undefined && !stage.resultStageId.trim()) throw new TypeError('Native OCCT history resultStageId must be non-empty.')
}
}
if (request.resultStepByStage) {
for (const [stageId, step] of Object.entries(request.resultStepByStage)) {
if (!request.stages?.some((stage) => stage.stageId === stageId)) throw new TypeError(`Native OCCT result step references unknown stage '${stageId}'.`)
if (!step.startsWith('ISO-10303-21;')) throw new TypeError(`Native OCCT result step for '${stageId}' requires STEP text.`)
}
}
if (booleanOperations.includes(request.operation)) {
if (!request.toolStep?.startsWith('ISO-10303-21;')) throw new TypeError('Native Boolean history transport requires object and tool STEP text.')
} else if (request.operation === 'pocket') {
if (!request.toolStep?.startsWith('ISO-10303-21;')) throw new TypeError('Native Pocket history transport requires base and profile STEP text.')
if (!request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0) throw new TypeError('Native Pocket history requires a finite non-zero direction vector.')
} else if (request.operation === 'loft') {
if (!request.toolStep?.startsWith('ISO-10303-21;')) throw new TypeError('Native Loft history transport requires two section STEP texts.')
if (request.ruled !== undefined && typeof request.ruled !== 'boolean') throw new TypeError('Native Loft history ruled flag must be boolean.')
} else if (request.operation === 'pipe') {
if (!request.toolStep?.startsWith('ISO-10303-21;')) throw new TypeError('Native Pipe history transport requires profile and spine STEP texts.')
} else if (request.operation === 'rotate') {
const angle = request.angle
if (!request.axisOrigin || request.axisOrigin.some((value) => !Number.isFinite(value)) || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0 || typeof angle !== 'number' || !Number.isFinite(angle) || angle === 0 || Math.abs(angle) > 360) throw new TypeError('Native Rotate history requires a finite axis and a non-zero angle within 360 degrees.')
} else if (request.operation === 'revolution') {
const angle = request.angle
if (!request.axisOrigin || request.axisOrigin.some((value) => !Number.isFinite(value)) || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0 || typeof angle !== 'number' || !Number.isFinite(angle) || angle <= 0 || angle > 360) throw new TypeError('Native Revolution history requires a finite axis and an angle in (0, 360].')
} else if (request.operation === 'groove') {
const angle = request.angle
if (!request.toolStep?.startsWith('ISO-10303-21;')) throw new TypeError('Native Groove history transport requires base and profile STEP text.')
if (!request.axisOrigin || request.axisOrigin.some((value) => !Number.isFinite(value)) || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0 || typeof angle !== 'number' || !Number.isFinite(angle) || angle <= 0 || angle > 360) throw new TypeError('Native Groove history requires a finite axis and an angle in (0, 360].')
} else if (request.operation === 'fillet') {
if (typeof request.radius !== 'number' || !Number.isFinite(request.radius) || request.radius <= 0) throw new TypeError('Native Fillet history requires a finite positive radius.')
} else if (request.operation === 'chamfer') {
if (typeof request.distance !== 'number' || !Number.isFinite(request.distance) || request.distance <= 0) throw new TypeError('Native Chamfer history requires a finite positive distance.')
} else if (request.operation === 'hole') {
if (typeof request.radius !== 'number' || !Number.isFinite(request.radius) || request.radius <= 0 || typeof request.depth !== 'number' || !Number.isFinite(request.depth) || request.depth <= 0 || !request.position || request.position.some((value) => !Number.isFinite(value)) || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0) throw new TypeError('Native Hole history requires finite positive radius/depth, position and direction.')
} else if (request.operation === 'draft') {
if (typeof request.faceIndex !== 'number' || !Number.isSafeInteger(request.faceIndex) || request.faceIndex < 0 || typeof request.angle !== 'number' || !Number.isFinite(request.angle) || request.angle === 0 || request.angle <= -89.999 || request.angle >= 89.999 || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0 || !request.axisOrigin || request.axisOrigin.some((value) => !Number.isFinite(value)) || !request.neutralPlaneDirection || request.neutralPlaneDirection.some((value) => !Number.isFinite(value)) || Math.hypot(...request.neutralPlaneDirection) <= 0) throw new TypeError('Native Draft history requires one face index, finite angle, direction and neutral plane.')
} else if (request.operation === 'thickness') {
if (typeof request.faceIndex !== 'number' || !Number.isSafeInteger(request.faceIndex) || request.faceIndex < 0 || typeof request.offset !== 'number' || !Number.isFinite(request.offset) || request.offset === 0 || (request.joinType !== undefined && request.joinType !== 'Arc' && request.joinType !== 'Intersection')) throw new TypeError('Native Thickness history requires one face index, finite non-zero offset and a supported join type.')
} else if (request.operation === 'linear-pattern') {
if (!request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0) throw new TypeError('Native LinearPattern history requires a finite non-zero translation vector.')
} else if (request.operation === 'polar-pattern') {
if (!request.axisOrigin || request.axisOrigin.some((value) => !Number.isFinite(value)) || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0 || typeof request.angle !== 'number' || !Number.isFinite(request.angle) || request.angle === 0 || Math.abs(request.angle) > 360) throw new TypeError('Native PolarPattern history requires a finite axis and a non-zero angle within 360 degrees.')
} else if (request.operation === 'mirrored') {
if (!request.axisOrigin || request.axisOrigin.some((value) => !Number.isFinite(value)) || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0) throw new TypeError('Native Mirrored history requires a finite mirror plane.')
} else if (request.operation === 'multi-transform') {
if (request.transforms) {
if (request.transforms.length < 2 || request.transforms.length > 6) throw new TypeError('Native ordered MultiTransform history requires between two and six steps.')
for (const step of request.transforms) {
if (step.type === 'linear') {
if (!step.direction || step.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...step.direction) <= 0) throw new TypeError('Native MultiTransform linear history requires a finite non-zero translation vector.')
} else if (step.type === 'polar') {
if (!step.axisOrigin || step.axisOrigin.some((value) => !Number.isFinite(value)) || !step.direction || step.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...step.direction) <= 0 || typeof step.angle !== 'number' || !Number.isFinite(step.angle) || step.angle === 0 || Math.abs(step.angle) > 360) throw new TypeError('Native MultiTransform polar history requires a finite axis and a non-zero angle within 360 degrees.')
} else if (step.type === 'mirrored') {
if (!step.axisOrigin || step.axisOrigin.some((value) => !Number.isFinite(value)) || !step.direction || step.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...step.direction) <= 0) throw new TypeError('Native MultiTransform mirrored history requires a finite mirror plane.')
} else throw new TypeError('Native MultiTransform history contains an unsupported transform step.')
}
} else if (request.transformKind === 'linear') {
if (!request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0) throw new TypeError('Native MultiTransform linear history requires a finite non-zero translation vector.')
} else if (request.transformKind === 'polar') {
if (!request.axisOrigin || request.axisOrigin.some((value) => !Number.isFinite(value)) || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0 || typeof request.angle !== 'number' || !Number.isFinite(request.angle) || request.angle === 0 || Math.abs(request.angle) > 360) throw new TypeError('Native MultiTransform polar history requires a finite axis and a non-zero angle within 360 degrees.')
} else if (request.transformKind === 'mirrored') {
if (!request.axisOrigin || request.axisOrigin.some((value) => !Number.isFinite(value)) || !request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0) throw new TypeError('Native MultiTransform mirrored history requires a finite mirror plane.')
} else throw new TypeError('Native MultiTransform history requires one supported transform step.')
} else if (!request.direction || request.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...request.direction) <= 0) {
throw new TypeError('Native Pad history requires a finite non-zero direction vector.')
}
}
const assertResponse = (request: NativeOcctHistoryRequest, response: NativeOcctHistoryProtocolResponse) => {
@@ -62,6 +185,31 @@ const assertResponse = (request: NativeOcctHistoryRequest, response: NativeOcctH
return response
}
const attachStageMetadata = (request: NativeOcctHistoryRequest, history: NativeOcctHistoryResponse): NativeOcctHistoryResponse => {
if (!request.stages?.length) return history
const orderedStages = [...request.stages].sort((left, right) => left.ordinal - right.ordinal)
const resultStageId = orderedStages[orderedStages.length - 1].stageId
const records = history.records.map((record) => {
const sourceInput = request.inputs?.find((input) => input.inputId === record.sourceId || input.inputId === record.source || input.role === record.source)
return {
...record,
...(record.sourceStageId || sourceInput?.stageId ? { sourceStageId: record.sourceStageId ?? sourceInput?.stageId } : {}),
resultStageId: record.resultStageId ?? resultStageId,
}
})
return {
...history,
records,
stages: orderedStages.map((stage) => ({
stageId: stage.stageId,
operation: stage.operation,
inputIds: [...stage.inputIds],
resultStageId: stage.resultStageId,
ordinal: stage.ordinal,
})),
}
}
export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvider {
constructor(private readonly module: NativeOcctHistoryStepProvider, private readonly providerVersion = '8.0.0-embind') {}
@@ -72,7 +220,7 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide
providerVersion: this.providerVersion,
occtVersion,
availability: 'available',
operations: ['fuse', 'cut', 'common'],
operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'],
transport: 'step-text',
}
}
@@ -81,9 +229,82 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide
assertRequest(request)
if (signal.aborted) throw abortError()
const provider = this.capabilities()
const history = await Promise.resolve().then(() => this.module.booleanHistoryFromStep(request.objectStep, request.toolStep, request.operation))
if (!provider.operations.includes(request.operation)) throw new RangeError(`Native OCCT history provider does not declare operation '${request.operation}'.`)
const history = await Promise.resolve().then(() => {
if (request.operation === 'pad') {
if (!this.module.prismHistoryFromStep || !request.direction) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.prismHistoryFromStep(request.objectStep, ...request.direction)
}
if (request.operation === 'pocket') {
if (!this.module.pocketHistoryFromStep || !request.direction || !request.toolStep) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.pocketHistoryFromStep(request.objectStep, request.toolStep, ...request.direction)
}
if (request.operation === 'loft') {
if (!this.module.loftHistoryFromStep || !request.toolStep) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.loftHistoryFromStep(request.objectStep, request.toolStep, request.ruled === true)
}
if (request.operation === 'pipe') {
if (!this.module.pipeHistoryFromStep || !request.toolStep) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.pipeHistoryFromStep(request.objectStep, request.toolStep)
}
if (request.operation === 'rotate') {
if (!this.module.rotateHistoryFromStep || !request.axisOrigin || !request.direction || request.angle === undefined) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.rotateHistoryFromStep(request.objectStep, ...request.axisOrigin, ...request.direction, request.angle)
}
if (request.operation === 'revolution') {
if (!this.module.revolutionHistoryFromStep || !request.axisOrigin || !request.direction || request.angle === undefined) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.revolutionHistoryFromStep(request.objectStep, ...request.axisOrigin, ...request.direction, request.angle)
}
if (request.operation === 'groove') {
if (!this.module.grooveHistoryFromStep || !request.toolStep || !request.axisOrigin || !request.direction || request.angle === undefined) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.grooveHistoryFromStep(request.objectStep, request.toolStep, ...request.axisOrigin, ...request.direction, request.angle)
}
if (request.operation === 'fillet') {
if (!this.module.filletHistoryFromStep || request.radius === undefined) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.filletHistoryFromStep(request.objectStep, request.radius)
}
if (request.operation === 'chamfer') {
if (!this.module.chamferHistoryFromStep || request.distance === undefined) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.chamferHistoryFromStep(request.objectStep, request.distance)
}
if (request.operation === 'hole') {
if (!this.module.holeHistoryFromStep || request.radius === undefined || request.depth === undefined || !request.position || !request.direction) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.holeHistoryFromStep(request.objectStep, request.radius, request.depth, ...request.position, ...request.direction)
}
if (request.operation === 'draft') {
if (!this.module.draftHistoryFromStep || request.faceIndex === undefined || request.angle === undefined || !request.direction || !request.axisOrigin || !request.neutralPlaneDirection) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.draftHistoryFromStep(request.objectStep, request.faceIndex, request.angle, ...request.direction, ...request.axisOrigin, ...request.neutralPlaneDirection, request.reversed === true)
}
if (request.operation === 'thickness') {
if (!this.module.thicknessHistoryFromStep || request.faceIndex === undefined || request.offset === undefined) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.thicknessHistoryFromStep(request.objectStep, request.faceIndex, request.offset, request.joinType === 'Intersection')
}
if (request.operation === 'linear-pattern') {
if (!this.module.linearPatternHistoryFromStep || !request.direction) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.linearPatternHistoryFromStep(request.objectStep, ...request.direction)
}
if (request.operation === 'polar-pattern') {
if (!this.module.polarPatternHistoryFromStep || !request.axisOrigin || !request.direction || request.angle === undefined) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.polarPatternHistoryFromStep(request.objectStep, ...request.axisOrigin, ...request.direction, request.angle)
}
if (request.operation === 'mirrored') {
if (!this.module.mirroredHistoryFromStep || !request.axisOrigin || !request.direction) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.mirroredHistoryFromStep(request.objectStep, ...request.axisOrigin, ...request.direction)
}
if (request.operation === 'multi-transform') {
if (request.transforms && request.transforms.length >= 2) {
if (!this.module.multiTransformHistoryFromStep) throw new NativeOcctHistoryUnavailableError(provider)
return this.module.multiTransformHistoryFromStep(request.objectStep, request.transforms)
}
if (request.transformKind === 'linear' && this.module.linearPatternHistoryFromStep && request.direction) return this.module.linearPatternHistoryFromStep(request.objectStep, ...request.direction)
if (request.transformKind === 'polar' && this.module.polarPatternHistoryFromStep && request.axisOrigin && request.direction && request.angle !== undefined) return this.module.polarPatternHistoryFromStep(request.objectStep, ...request.axisOrigin, ...request.direction, request.angle)
if (request.transformKind === 'mirrored' && this.module.mirroredHistoryFromStep && request.axisOrigin && request.direction) return this.module.mirroredHistoryFromStep(request.objectStep, ...request.axisOrigin, ...request.direction)
throw new NativeOcctHistoryUnavailableError(provider)
}
return this.module.booleanHistoryFromStep(request.objectStep, request.toolStep!, request.operation)
})
if (signal.aborted) throw abortError()
return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history })
return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history: attachStageMetadata(request, history) })
}
}

View File

@@ -1,28 +1,78 @@
import type { NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types'
import type { NativeMultiTransformHistoryStep, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types'
import type { NativeStageNamingEvidence } from './nativeNamingEvidence'
export type NativeOcctMultiTransformStep = NativeMultiTransformHistoryStep
export type NativeOcctHistoryRecord = {
relation: 'modified' | 'generated' | 'deleted'
source: 'object' | 'tool'
/** Legacy values are object/tool; newer providers may use an inputId. */
source: string
sourceId?: string
sourceStageId?: string
resultStageId?: string
kind: string
resultKind?: string
sourceIndex: number
resultIndex: number
resultIndex?: number
resultIndexes?: number[]
}
export type NativeOcctHistoryStage = {
stageId: string
operation?: NativeOcctHistoryOperation
inputIds: string[]
resultStageId?: string
ordinal: number
}
export type NativeOcctHistoryResponse = {
provider: 'occt-native'
occtVersion: string
resultStep?: string
/** Native OCCT BREP preserves pcurves that STEP round-trips may omit. */
resultBrep?: string
summary?: {
shapeType: string
isNull: boolean
isValid: boolean
solids: number
faces: number
edges: number
vertices: number
volume: number
area: number
boundingBox: { min: [number, number, number]; max: [number, number, number] }
}
records: NativeOcctHistoryRecord[]
hasModified: boolean
hasGenerated: boolean
hasDeleted: boolean
stages?: NativeOcctHistoryStage[]
/** Optional FreeCAD MappedNameRef/StringHasher evidence from a native provider. */
namingEvidence?: NativeStageNamingEvidence
}
export type NativeOcctHistoryOperation = 'fuse' | 'cut' | 'common'
export type NativeOcctHistoryOperation = 'fuse' | 'cut' | 'common' | 'rotate' | 'pad' | 'pocket' | 'loft' | 'pipe' | 'revolution' | 'groove' | 'fillet' | 'chamfer' | 'hole' | 'draft' | 'thickness' | 'linear-pattern' | 'polar-pattern' | 'mirrored' | 'multi-transform'
export type NativeOcctHistoryStepProvider = {
occtVersion(): string
booleanHistoryFromStep(objectStep: string, toolStep: string, operation: NativeOcctHistoryOperation): NativeOcctHistoryResponse
rotateHistoryFromStep?(shapeStep: string, axisOriginX: number, axisOriginY: number, axisOriginZ: number, axisDirectionX: number, axisDirectionY: number, axisDirectionZ: number, angleDegrees: number): NativeOcctHistoryResponse
prismHistoryFromStep?(profileStep: string, dx: number, dy: number, dz: number): NativeOcctHistoryResponse
pocketHistoryFromStep?(baseStep: string, profileStep: string, dx: number, dy: number, dz: number): NativeOcctHistoryResponse
loftHistoryFromStep?(firstSectionStep: string, secondSectionStep: string, ruled: boolean): NativeOcctHistoryResponse
pipeHistoryFromStep?(profileStep: string, spineStep: string): NativeOcctHistoryResponse
revolutionHistoryFromStep?(profileStep: string, axisOriginX: number, axisOriginY: number, axisOriginZ: number, axisDirectionX: number, axisDirectionY: number, axisDirectionZ: number, angleDegrees: number): NativeOcctHistoryResponse
grooveHistoryFromStep?(baseStep: string, profileStep: string, axisOriginX: number, axisOriginY: number, axisOriginZ: number, axisDirectionX: number, axisDirectionY: number, axisDirectionZ: number, angleDegrees: number): NativeOcctHistoryResponse
filletHistoryFromStep?(baseStep: string, radius: number): NativeOcctHistoryResponse
chamferHistoryFromStep?(baseStep: string, distance: number): NativeOcctHistoryResponse
holeHistoryFromStep?(baseStep: string, radius: number, depth: number, positionX: number, positionY: number, positionZ: number, directionX: number, directionY: number, directionZ: number): NativeOcctHistoryResponse
draftHistoryFromStep?(baseStep: string, faceIndex: number, angle: number, directionX: number, directionY: number, directionZ: number, neutralPlaneOriginX: number, neutralPlaneOriginY: number, neutralPlaneOriginZ: number, neutralPlaneDirectionX: number, neutralPlaneDirectionY: number, neutralPlaneDirectionZ: number, reversed: boolean): NativeOcctHistoryResponse
thicknessHistoryFromStep?(baseStep: string, faceIndex: number, offset: number, intersectionJoin: boolean): NativeOcctHistoryResponse
linearPatternHistoryFromStep?(baseStep: string, dx: number, dy: number, dz: number): NativeOcctHistoryResponse
polarPatternHistoryFromStep?(baseStep: string, axisOriginX: number, axisOriginY: number, axisOriginZ: number, axisDirectionX: number, axisDirectionY: number, axisDirectionZ: number, angleDegrees: number): NativeOcctHistoryResponse
mirroredHistoryFromStep?(baseStep: string, planeOriginX: number, planeOriginY: number, planeOriginZ: number, planeNormalX: number, planeNormalY: number, planeNormalZ: number): NativeOcctHistoryResponse
multiTransformHistoryFromStep?(baseStep: string, steps: NativeOcctMultiTransformStep[]): NativeOcctHistoryResponse
}
export type NativeOcctHistoryStepGeometry = {
@@ -34,25 +84,71 @@ const isKind = (value: string): value is SubshapeRef['kind'] => kinds.includes(v
export const mapNativeOcctHistoryRecords = (
response: NativeOcctHistoryResponse,
sourceObjectIds: { object: string; tool: string },
sourceObjectIds: { object: string; tool: string } | Record<string, string>,
stageContext?: Pick<NativeTopologyHistoryInput, 'inputs' | 'stages'>,
): NativeTopologyHistoryRecord[] => {
if (response.provider !== 'occt-native' || !/^8\./.test(response.occtVersion)) throw new Error('Unsupported native OCCT history provider response.')
return response.records.map((record) => {
const observed = new Set(response.records.map((record) => record.relation))
if (observed.has('modified') && response.hasModified !== true) throw new Error('Native OCCT history modified flag is inconsistent with records.')
if (observed.has('generated') && response.hasGenerated !== true) throw new Error('Native OCCT history generated flag is inconsistent with records.')
if (observed.has('deleted') && response.hasDeleted !== true) throw new Error('Native OCCT history deleted flag is inconsistent with records.')
const mapped: NativeTopologyHistoryRecord[] = []
const grouped = new Map<string, NativeTopologyHistoryRecord>()
for (const record of response.records) {
if (!isKind(record.kind)) throw new Error(`Unsupported native OCCT subshape kind: ${record.kind}`)
if (!Number.isSafeInteger(record.sourceIndex) || record.sourceIndex < 0) throw new RangeError('Native OCCT sourceIndex must be a non-negative safe integer.')
const sourceObjectId = sourceObjectIds[record.source]
const sourceLookup = sourceObjectIds as Record<string, string>
const sourceObjectId = record.sourceId ? sourceLookup[record.sourceId] : sourceLookup[record.source]
if (!sourceObjectId) throw new Error(`Missing native OCCT source object ID for ${record.source}.`)
const resultIndexes = record.resultIndexes ?? (record.resultIndex === undefined ? undefined : [record.resultIndex])
if (record.relation === 'deleted') {
return { sourceObjectId, sourceKind: record.kind, sourceIndex: record.sourceIndex, relation: 'deleted' }
const deleted: NativeTopologyHistoryRecord = {
sourceObjectId,
sourceKind: record.kind,
sourceIndex: record.sourceIndex,
relation: 'deleted',
...(record.sourceStageId ? { sourceStageId: record.sourceStageId } : {}),
...(record.resultStageId ? { resultStageId: record.resultStageId } : {}),
}
const key = [deleted.relation, deleted.sourceObjectId, deleted.sourceKind, deleted.sourceIndex, deleted.sourceStageId ?? '', deleted.resultStageId ?? ''].join('|')
if (!grouped.has(key)) {
grouped.set(key, deleted)
mapped.push(deleted)
}
continue
}
if (!Number.isSafeInteger(record.resultIndex) || record.resultIndex < 0) throw new RangeError('Native OCCT resultIndex must be a non-negative safe integer.')
return {
if (!resultIndexes?.length || resultIndexes.some((index) => !Number.isSafeInteger(index) || index < 0)) throw new RangeError('Native OCCT resultIndexes must contain non-negative safe integers.')
if (record.resultKind !== undefined && !isKind(record.resultKind)) throw new Error(`Unsupported native OCCT result kind: ${record.resultKind}`)
const normalizedIndexes = [...new Set(resultIndexes)]
const current: NativeTopologyHistoryRecord = {
sourceObjectId,
sourceKind: record.kind,
sourceIndex: record.sourceIndex,
relation: record.relation,
resultKind: record.kind,
resultIndexes: [record.resultIndex],
resultKind: (record.resultKind as SubshapeRef['kind'] | undefined) ?? record.kind,
resultIndexes: normalizedIndexes,
...(record.sourceStageId ? { sourceStageId: record.sourceStageId } : {}),
...(record.resultStageId ? { resultStageId: record.resultStageId } : {}),
}
const key = [current.relation, current.sourceObjectId, current.sourceKind, current.sourceIndex, current.resultKind ?? '', current.sourceStageId ?? '', current.resultStageId ?? ''].join('|')
const previous = grouped.get(key)
if (previous && previous.relation !== 'deleted') {
previous.resultIndexes = [...new Set([...(previous.resultIndexes ?? []), ...normalizedIndexes])]
} else if (!previous) {
grouped.set(key, current)
mapped.push(current)
}
}
const resultStageId = stageContext?.stages?.length
? [...stageContext.stages].sort((left, right) => right.ordinal - left.ordinal)[0].stageId
: undefined
if (!resultStageId && !stageContext?.inputs.some((input) => input.stageId)) return mapped
return mapped.map((record) => {
const sourceStageId = record.sourceStageId ?? stageContext?.inputs.find((input) => input.objectId === record.sourceObjectId)?.stageId
return {
...record,
...(sourceStageId ? { sourceStageId } : {}),
...(record.resultStageId || resultStageId ? { resultStageId: record.resultStageId ?? resultStageId } : {}),
}
})
}
@@ -65,7 +161,188 @@ export const createNativeOcctStepHistoryBridge = (
geometry: NativeOcctHistoryStepGeometry,
provider: NativeOcctHistoryStepProvider,
) => async (input: NativeTopologyHistoryInput): Promise<NativeTopologyHistoryRecord[]> => {
if (!input.operation) throw new Error('Native OCCT STEP history requires a supported Boolean operation.')
if (!input.operation) throw new Error('Native OCCT STEP history requires a supported operation.')
if (input.operation === 'rotate') {
const axisOrigin = input.axisOrigin
const direction = input.direction
const angle = input.angle
if (input.inputs.length !== 1 || !axisOrigin || axisOrigin.some((value) => !Number.isFinite(value)) || !direction || direction.some((value) => !Number.isFinite(value)) || Math.hypot(...direction) <= 0 || typeof angle !== 'number' || !Number.isFinite(angle) || angle === 0 || Math.abs(angle) > 360) throw new Error('Native OCCT STEP Rotate history requires one shape, a finite axis and a non-zero angle within 360 degrees.')
if (!provider.rotateHistoryFromStep) throw new Error('Native OCCT history provider does not expose Rotate history.')
const [shapeInput] = input.inputs
const shapeStep = await geometry.exportStep(shapeInput.shape, `${shapeInput.objectId}.step`)
if (shapeStep.format !== 'step' || typeof shapeStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Rotate transport payload.')
const response = provider.rotateHistoryFromStep(shapeStep.text, ...axisOrigin, ...direction, angle)
return mapNativeOcctHistoryRecords(response, { object: shapeInput.objectId, tool: shapeInput.objectId }, input)
}
if (input.operation === 'pad') {
if (input.inputs.length !== 1 || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0) throw new Error('Native OCCT STEP Pad history requires one profile and a finite non-zero direction.')
if (!provider.prismHistoryFromStep) throw new Error('Native OCCT history provider does not expose Pad history.')
const [profileInput] = input.inputs
const profileStep = await geometry.exportStep(profileInput.shape, `${profileInput.objectId}.step`)
if (profileStep.format !== 'step' || typeof profileStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Pad profile transport payload.')
const response = provider.prismHistoryFromStep(profileStep.text, ...input.direction)
return mapNativeOcctHistoryRecords(response, { object: profileInput.objectId, tool: profileInput.objectId }, input)
}
if (input.operation === 'pocket') {
if (input.inputs.length !== 2 || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0) throw new Error('Native OCCT STEP Pocket history requires a base, profile and finite non-zero direction.')
if (!provider.pocketHistoryFromStep) throw new Error('Native OCCT history provider does not expose Pocket history.')
const [baseInput, profileInput] = input.inputs
const [baseStep, profileStep] = await Promise.all([
geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`),
geometry.exportStep(profileInput.shape, `${profileInput.objectId}.step`),
])
if (baseStep.format !== 'step' || profileStep.format !== 'step' || typeof baseStep.text !== 'string' || typeof profileStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Pocket transport payload.')
const response = provider.pocketHistoryFromStep(baseStep.text, profileStep.text, ...input.direction)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: profileInput.objectId }, input)
}
if (input.operation === 'loft') {
if (input.inputs.length !== 2) throw new Error('Native OCCT STEP Loft history requires exactly two section inputs.')
if (!provider.loftHistoryFromStep) throw new Error('Native OCCT history provider does not expose Loft history.')
const [firstInput, secondInput] = input.inputs
const [firstStep, secondStep] = await Promise.all([
geometry.exportStep(firstInput.shape, `${firstInput.objectId}.step`),
geometry.exportStep(secondInput.shape, `${secondInput.objectId}.step`),
])
if (firstStep.format !== 'step' || secondStep.format !== 'step' || typeof firstStep.text !== 'string' || typeof secondStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Loft transport payload.')
const response = provider.loftHistoryFromStep(firstStep.text, secondStep.text, input.ruled === true)
return mapNativeOcctHistoryRecords(response, { object: firstInput.objectId, tool: secondInput.objectId }, input)
}
if (input.operation === 'pipe') {
if (input.inputs.length !== 2) throw new Error('Native OCCT STEP Pipe history requires exactly one profile and one spine input.')
if (!provider.pipeHistoryFromStep) throw new Error('Native OCCT history provider does not expose Pipe history.')
const [profileInput, spineInput] = input.inputs
const [profileStep, spineStep] = await Promise.all([
geometry.exportStep(profileInput.shape, `${profileInput.objectId}.step`),
geometry.exportStep(spineInput.shape, `${spineInput.objectId}.step`),
])
if (profileStep.format !== 'step' || spineStep.format !== 'step' || typeof profileStep.text !== 'string' || typeof spineStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Pipe transport payload.')
const response = provider.pipeHistoryFromStep(profileStep.text, spineStep.text)
return mapNativeOcctHistoryRecords(response, { object: profileInput.objectId, tool: spineInput.objectId }, input)
}
if (input.operation === 'revolution') {
if (input.inputs.length !== 1) throw new Error('Native OCCT STEP Revolution history requires one profile, a finite axis and an angle in (0, 360].')
const axisOrigin = input.axisOrigin
const direction = input.direction
const angle = input.angle
if (!axisOrigin || axisOrigin.some((value) => !Number.isFinite(value))) throw new Error('Native OCCT STEP Revolution history requires a finite axis origin.')
if (!direction || direction.some((value) => !Number.isFinite(value)) || Math.hypot(...direction) <= 0) throw new Error('Native OCCT STEP Revolution history requires a finite non-zero axis direction.')
if (typeof angle !== 'number' || !Number.isFinite(angle) || angle <= 0 || angle > 360) throw new Error('Native OCCT STEP Revolution history requires an angle in (0, 360].')
if (!provider.revolutionHistoryFromStep) throw new Error('Native OCCT history provider does not expose Revolution history.')
const [profileInput] = input.inputs
const profileStep = await geometry.exportStep(profileInput.shape, `${profileInput.objectId}.step`)
if (profileStep.format !== 'step' || typeof profileStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Revolution profile transport payload.')
const response = provider.revolutionHistoryFromStep(profileStep.text, ...axisOrigin, ...direction, angle)
return mapNativeOcctHistoryRecords(response, { object: profileInput.objectId, tool: profileInput.objectId }, input)
}
if (input.operation === 'groove') {
if (input.inputs.length !== 2) throw new Error('Native OCCT STEP Groove history requires a base, profile, finite axis and angle in (0, 360].')
const axisOrigin = input.axisOrigin
const direction = input.direction
const angle = input.angle
if (!axisOrigin || axisOrigin.some((value) => !Number.isFinite(value))) throw new Error('Native OCCT STEP Groove history requires a finite axis origin.')
if (!direction || direction.some((value) => !Number.isFinite(value)) || Math.hypot(...direction) <= 0) throw new Error('Native OCCT STEP Groove history requires a finite non-zero axis direction.')
if (typeof angle !== 'number' || !Number.isFinite(angle) || angle <= 0 || angle > 360) throw new Error('Native OCCT STEP Groove history requires an angle in (0, 360].')
if (!provider.grooveHistoryFromStep) throw new Error('Native OCCT history provider does not expose Groove history.')
const [baseInput, profileInput] = input.inputs
const [baseStep, profileStep] = await Promise.all([
geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`),
geometry.exportStep(profileInput.shape, `${profileInput.objectId}.step`),
])
if (baseStep.format !== 'step' || profileStep.format !== 'step' || typeof baseStep.text !== 'string' || typeof profileStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Groove transport payload.')
const response = provider.grooveHistoryFromStep(baseStep.text, profileStep.text, ...axisOrigin, ...direction, angle)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: profileInput.objectId }, input)
}
if (input.operation === 'fillet') {
if (input.inputs.length !== 1 || typeof input.radius !== 'number' || !Number.isFinite(input.radius) || input.radius <= 0) throw new Error('Native OCCT STEP Fillet history requires one base and a finite positive radius.')
if (!provider.filletHistoryFromStep) throw new Error('Native OCCT history provider does not expose Fillet history.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
if (baseStep.format !== 'step' || typeof baseStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Fillet transport payload.')
const response = provider.filletHistoryFromStep(baseStep.text, input.radius)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.operation === 'chamfer') {
if (input.inputs.length !== 1 || typeof input.distance !== 'number' || !Number.isFinite(input.distance) || input.distance <= 0) throw new Error('Native OCCT STEP Chamfer history requires one base and a finite positive distance.')
if (!provider.chamferHistoryFromStep) throw new Error('Native OCCT history provider does not expose Chamfer history.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
if (baseStep.format !== 'step' || typeof baseStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Chamfer transport payload.')
const response = provider.chamferHistoryFromStep(baseStep.text, input.distance)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.operation === 'hole') {
const position = input.position
const direction = input.direction
if (input.inputs.length !== 1 || typeof input.radius !== 'number' || !Number.isFinite(input.radius) || input.radius <= 0 || typeof input.depth !== 'number' || !Number.isFinite(input.depth) || input.depth <= 0 || !position || position.some((value) => !Number.isFinite(value)) || !direction || direction.some((value) => !Number.isFinite(value)) || Math.hypot(...direction) <= 0) throw new Error('Native OCCT STEP Hole history requires one base, finite positive radius/depth, position and direction.')
if (!provider.holeHistoryFromStep) throw new Error('Native OCCT history provider does not expose Hole history.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
if (baseStep.format !== 'step' || typeof baseStep.text !== 'string') throw new Error('Bitbybit STEP export returned an invalid Hole transport payload.')
const response = provider.holeHistoryFromStep(baseStep.text, input.radius, input.depth, ...position, ...direction)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.operation === 'draft') {
if (input.inputs.length !== 1 || !input.faceIndexes || input.faceIndexes.length !== 1 || !Number.isSafeInteger(input.faceIndexes[0]) || input.faceIndexes[0] < 0 || typeof input.angle !== 'number' || !Number.isFinite(input.angle) || input.angle === 0 || input.angle <= -89.999 || input.angle >= 89.999 || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0 || !input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.neutralPlaneDirection || input.neutralPlaneDirection.some((value) => !Number.isFinite(value)) || Math.hypot(...input.neutralPlaneDirection) <= 0) throw new Error('Native OCCT STEP Draft history requires one base, one face index, finite angle, direction and neutral plane.')
if (!provider.draftHistoryFromStep) throw new Error('Native OCCT history provider does not expose Draft history.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
const response = provider.draftHistoryFromStep(baseStep.text, input.faceIndexes[0], input.angle, ...input.direction, ...input.axisOrigin, ...input.neutralPlaneDirection, input.reversed === true)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.operation === 'thickness') {
if (input.inputs.length !== 1 || !input.faceIndexes || input.faceIndexes.length !== 1 || !Number.isSafeInteger(input.faceIndexes[0]) || input.faceIndexes[0] < 0 || typeof input.offset !== 'number' || !Number.isFinite(input.offset) || input.offset === 0 || (input.joinType !== undefined && input.joinType !== 'Arc' && input.joinType !== 'Intersection')) throw new Error('Native OCCT STEP Thickness history requires one base, one face index, finite non-zero offset and a supported join type.')
if (!provider.thicknessHistoryFromStep) throw new Error('Native OCCT history provider does not expose Thickness history.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
const response = provider.thicknessHistoryFromStep(baseStep.text, input.faceIndexes[0], input.offset, input.joinType === 'Intersection')
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.operation === 'linear-pattern') {
if (input.inputs.length !== 1 || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0) throw new Error('Native OCCT STEP LinearPattern history requires one base and a finite non-zero translation vector.')
if (!provider.linearPatternHistoryFromStep) throw new Error('Native OCCT history provider does not expose LinearPattern history.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
const response = provider.linearPatternHistoryFromStep(baseStep.text, ...input.direction)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.operation === 'polar-pattern') {
if (input.inputs.length !== 1 || !input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0 || typeof input.angle !== 'number' || !Number.isFinite(input.angle) || input.angle === 0 || Math.abs(input.angle) > 360) throw new Error('Native OCCT STEP PolarPattern history requires one base, a finite axis and a non-zero angle within 360 degrees.')
if (!provider.polarPatternHistoryFromStep) throw new Error('Native OCCT history provider does not expose PolarPattern history.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
const response = provider.polarPatternHistoryFromStep(baseStep.text, ...input.axisOrigin, ...input.direction, input.angle)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.operation === 'mirrored') {
if (input.inputs.length !== 1 || !input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0) throw new Error('Native OCCT STEP Mirrored history requires one base and a finite mirror plane.')
if (!provider.mirroredHistoryFromStep) throw new Error('Native OCCT history provider does not expose Mirrored history.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
const response = provider.mirroredHistoryFromStep(baseStep.text, ...input.axisOrigin, ...input.direction)
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.operation === 'multi-transform') {
if (input.inputs.length !== 1) throw new Error('Native OCCT STEP MultiTransform history requires one base and supported transform steps.')
const [baseInput] = input.inputs
const baseStep = await geometry.exportStep(baseInput.shape, `${baseInput.objectId}.step`)
if (input.transforms && input.transforms.length >= 2) {
if (!provider.multiTransformHistoryFromStep) throw new Error('Native OCCT history provider does not expose ordered MultiTransform history.')
return mapNativeOcctHistoryRecords(provider.multiTransformHistoryFromStep(baseStep.text, input.transforms), { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (!input.transformKind || !['linear', 'polar', 'mirrored'].includes(input.transformKind)) throw new Error('Native OCCT STEP MultiTransform history requires one supported transform step.')
let response: NativeOcctHistoryResponse
if (input.transformKind === 'linear') {
if (!input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0 || !provider.linearPatternHistoryFromStep) throw new Error('Native OCCT STEP MultiTransform linear history requires a finite non-zero translation vector.')
response = provider.linearPatternHistoryFromStep(baseStep.text, ...input.direction)
} else if (input.transformKind === 'polar') {
if (!input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0 || typeof input.angle !== 'number' || !Number.isFinite(input.angle) || input.angle === 0 || Math.abs(input.angle) > 360 || !provider.polarPatternHistoryFromStep) throw new Error('Native OCCT STEP MultiTransform polar history requires a finite axis and a non-zero angle within 360 degrees.')
response = provider.polarPatternHistoryFromStep(baseStep.text, ...input.axisOrigin, ...input.direction, input.angle)
} else if (input.transformKind === 'mirrored') {
if (!input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0 || !provider.mirroredHistoryFromStep) throw new Error('Native OCCT STEP MultiTransform mirrored history requires a finite mirror plane.')
response = provider.mirroredHistoryFromStep(baseStep.text, ...input.axisOrigin, ...input.direction)
} else throw new Error('Native OCCT STEP MultiTransform history requires one supported transform step.')
return mapNativeOcctHistoryRecords(response, { object: baseInput.objectId, tool: baseInput.objectId }, input)
}
if (input.inputs.length !== 2) throw new Error('Native OCCT STEP history requires exactly an object and a tool input.')
const [objectInput, toolInput] = input.inputs
const [objectStep, toolStep] = await Promise.all([
@@ -76,5 +353,5 @@ export const createNativeOcctStepHistoryBridge = (
throw new Error('Bitbybit STEP export returned an invalid transport payload.')
}
const response = provider.booleanHistoryFromStep(objectStep.text, toolStep.text, input.operation)
return mapNativeOcctHistoryRecords(response, { object: objectInput.objectId, tool: toolInput.objectId })
return mapNativeOcctHistoryRecords(response, { object: objectInput.objectId, tool: toolInput.objectId }, input)
}

View File

@@ -21,7 +21,9 @@ export type NativeOcctHistoryWorkerOptions = {
const defaultWorkerFactory = () => new Worker(new URL('./nativeHistoryWorkerEntry.ts', import.meta.url), { type: 'module', name: 'occt-native-history' })
export class NativeOcctHistoryWorkerProvider implements NativeOcctHistoryProvider {
private readonly worker: WorkerLike
private worker: WorkerLike
private readonly workerFactory: () => WorkerLike
private disposed = false
private readonly options: Required<Pick<NativeOcctHistoryWorkerOptions, 'moduleUrl' | 'initializationTimeoutMs'>>
private readonly pending = new Map<string, { resolve: (response: NativeOcctHistoryProtocolResponse) => void; reject: (error: Error) => void }>()
private initialization: Promise<NativeOcctHistoryCapabilities> | null = null
@@ -52,16 +54,35 @@ export class NativeOcctHistoryWorkerProvider implements NativeOcctHistoryProvide
this.initializationReject?.(error)
this.initializationReject = null
this.current = { ...this.current, availability: 'unavailable', reason: error.message }
if (!this.disposed) this.replaceWorker()
}
private attachWorker(worker: WorkerLike) {
worker.addEventListener('message', this.onMessage)
worker.addEventListener('error', this.onError)
}
private detachWorker(worker: WorkerLike) {
worker.removeEventListener('message', this.onMessage)
worker.removeEventListener('error', this.onError)
}
private replaceWorker() {
const previous = this.worker
this.detachWorker(previous)
previous.terminate()
this.worker = this.workerFactory()
this.attachWorker(this.worker)
}
constructor(options: NativeOcctHistoryWorkerOptions = {}) {
this.worker = (options.workerFactory || defaultWorkerFactory)()
this.workerFactory = options.workerFactory || defaultWorkerFactory
this.worker = this.workerFactory()
this.options = {
moduleUrl: options.moduleUrl || '/native/occt-history/bitbybit-occt-history.js',
initializationTimeoutMs: options.initializationTimeoutMs ?? 120_000,
}
this.worker.addEventListener('message', this.onMessage)
this.worker.addEventListener('error', this.onError)
this.attachWorker(this.worker)
}
capabilities(): NativeOcctHistoryCapabilities { return { ...this.current, operations: [...this.current.operations] } }
@@ -95,9 +116,9 @@ export class NativeOcctHistoryWorkerProvider implements NativeOcctHistoryProvide
}
dispose() {
this.disposed = true
this.worker.postMessage({ type: 'dispose' })
this.worker.removeEventListener('message', this.onMessage)
this.worker.removeEventListener('error', this.onError)
this.detachWorker(this.worker)
this.worker.terminate()
this.initializationReject?.(new Error('Native OCCT history Worker disposed.'))
this.initializationReject = null

View File

@@ -12,6 +12,13 @@ const cancelled = new Set<string>()
const send = (message: WorkerResponse) => scope.postMessage(message)
const captureMultiTransform = (module: NativeOcctHistoryStepProvider, request: NativeOcctHistoryRequest) => {
if (request.transforms && request.transforms.length >= 2) return module.multiTransformHistoryFromStep?.(request.objectStep, request.transforms)
if (request.transformKind === 'linear') return module.linearPatternHistoryFromStep?.(request.objectStep, ...(request.direction || [0, 0, 0]))
if (request.transformKind === 'polar') return module.polarPatternHistoryFromStep?.(request.objectStep, ...(request.axisOrigin || [0, 0, 0]), ...(request.direction || [0, 0, 0]), request.angle || 0)
if (request.transformKind === 'mirrored') return module.mirroredHistoryFromStep?.(request.objectStep, ...(request.axisOrigin || [0, 0, 0]), ...(request.direction || [0, 0, 0]))
}
const initialize = async (moduleUrl: string) => {
const imported = await import(/* @vite-ignore */ moduleUrl) as { default?: () => Promise<NativeOcctHistoryStepProvider> }
if (typeof imported.default !== 'function') throw new Error('Native OCCT history module has no default Emscripten factory export.')
@@ -23,7 +30,7 @@ const initialize = async (moduleUrl: string) => {
providerVersion: '8.0.0-embind',
occtVersion: provider.occtVersion(),
availability: 'available',
operations: ['fuse', 'cut', 'common'],
operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'],
transport: 'step-text',
},
})
@@ -37,9 +44,43 @@ scope.onmessage = ({ data }: MessageEvent<WorkerRequest>) => {
if (data.type === 'cancel') { cancelled.add(data.requestId); return }
if (!provider) throw new Error('Native OCCT history Worker is not initialized.')
if (cancelled.delete(data.request.requestId)) return
const history = provider.booleanHistoryFromStep(data.request.objectStep, data.request.toolStep, data.request.operation)
const request = data.request
const history = request.operation === 'rotate'
? provider.rotateHistoryFromStep?.(request.objectStep, ...(request.axisOrigin || [0, 0, 0]), ...(request.direction || [0, 0, 0]), request.angle || 0)
: request.operation === 'pad'
? provider.prismHistoryFromStep?.(request.objectStep, ...(request.direction || [0, 0, 0]))
: request.operation === 'pocket'
? provider.pocketHistoryFromStep?.(request.objectStep, request.toolStep || '', ...(request.direction || [0, 0, 0]))
: request.operation === 'loft'
? provider.loftHistoryFromStep?.(request.objectStep, request.toolStep || '', request.ruled === true)
: request.operation === 'pipe'
? provider.pipeHistoryFromStep?.(request.objectStep, request.toolStep || '')
: request.operation === 'revolution'
? provider.revolutionHistoryFromStep?.(request.objectStep, ...(request.axisOrigin || [0, 0, 0]), ...(request.direction || [0, 0, 0]), request.angle || 0)
: request.operation === 'groove'
? provider.grooveHistoryFromStep?.(request.objectStep, request.toolStep || '', ...(request.axisOrigin || [0, 0, 0]), ...(request.direction || [0, 0, 0]), request.angle || 0)
: request.operation === 'fillet'
? provider.filletHistoryFromStep?.(request.objectStep, request.radius || 0)
: request.operation === 'chamfer'
? provider.chamferHistoryFromStep?.(request.objectStep, request.distance || 0)
: request.operation === 'hole'
? provider.holeHistoryFromStep?.(request.objectStep, request.radius || 0, request.depth || 0, ...(request.position || [0, 0, 0]), ...(request.direction || [0, 0, 0]))
: request.operation === 'draft'
? provider.draftHistoryFromStep?.(request.objectStep, request.faceIndex || 0, request.angle || 0, ...(request.direction || [0, 0, 0]), ...(request.axisOrigin || [0, 0, 0]), ...(request.neutralPlaneDirection || [0, 0, 0]), request.reversed === true)
: request.operation === 'thickness'
? provider.thicknessHistoryFromStep?.(request.objectStep, request.faceIndex || 0, request.offset || 0, request.joinType === 'Intersection')
: request.operation === 'linear-pattern'
? provider.linearPatternHistoryFromStep?.(request.objectStep, ...(request.direction || [0, 0, 0]))
: request.operation === 'polar-pattern'
? provider.polarPatternHistoryFromStep?.(request.objectStep, ...(request.axisOrigin || [0, 0, 0]), ...(request.direction || [0, 0, 0]), request.angle || 0)
: request.operation === 'mirrored'
? provider.mirroredHistoryFromStep?.(request.objectStep, ...(request.axisOrigin || [0, 0, 0]), ...(request.direction || [0, 0, 0]))
: request.operation === 'multi-transform'
? captureMultiTransform(provider, request)
: provider.booleanHistoryFromStep(request.objectStep, request.toolStep || '', request.operation)
if (!history) throw new Error('Native OCCT history provider does not expose the requested feature operation.')
if (cancelled.delete(data.request.requestId)) return
send({ type: 'response', response: { protocolVersion: data.request.protocolVersion, requestId: data.request.requestId, documentId: data.request.documentId, documentVersion: data.request.documentVersion, operationId: data.request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' }, history } })
send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text' }, history } })
} catch (error) {
send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) })
}

View File

@@ -0,0 +1,132 @@
import type { AnyElementMap2Document, ElementMap2MappedNameReference } from './elementMap2'
import { migrateStringHasherSchema, validateStringHasherTable, type AnyStringHasherTable } from './stringHasher'
export type NativeMappedNameRelation = 'preserved' | 'modified' | 'generated' | 'deleted' | 'ambiguous'
/** Runtime evidence emitted by a native builder for one result subshape. */
export type NativeMappedNameRef = {
kind: 'face' | 'edge' | 'vertex'
resultIndex: number
resultPersistentId: string
reference: ElementMap2MappedNameReference
relation?: NativeMappedNameRelation
sourceRefs?: Array<{
objectId: string
persistentId: string
stageId?: string
}>
candidates?: Array<{
objectId: string
persistentId: string
stageId?: string
}>
}
export type NativeNamingEvidenceStatus = 'native-evidence' | 'opaque-preserved' | 'final-shape-only' | 'ambiguous' | 'missing'
export type NativeStageNamingEvidence = {
schemaVersion: 1
stageId: string
resultObjectId: string
status: NativeNamingEvidenceStatus
mappedNames?: NativeMappedNameRef[]
stringHasher?: AnyStringHasherTable
elementMap2?: AnyElementMap2Document
reason?: string
}
export type NativeNamingEvidenceIssue = {
code: 'SCHEMA_VERSION' | 'STAGE_ID' | 'RESULT_OBJECT_ID' | 'STATUS' | 'MAPPED_NAME' | 'RESULT_INDEX' | 'STRING_HASHER' | 'STRING_ID' | 'SOURCE_REF' | 'AMBIGUOUS'
path: string
message: string
}
export type NativeNamingEvidenceReport = {
valid: boolean
status: NativeNamingEvidenceStatus
mappedNameCount: number
stringHasherEntryCount: number
issues: NativeNamingEvidenceIssue[]
}
const statuses = new Set<NativeNamingEvidenceStatus>(['native-evidence', 'opaque-preserved', 'final-shape-only', 'ambiguous', 'missing'])
const relationSet = new Set<NativeMappedNameRelation>(['preserved', 'modified', 'generated', 'deleted', 'ambiguous'])
const push = (issues: NativeNamingEvidenceIssue[], code: NativeNamingEvidenceIssue['code'], path: string, message: string) => issues.push({ code, path, message })
export const validateNativeNamingEvidence = (evidence: NativeStageNamingEvidence): NativeNamingEvidenceReport => {
const issues: NativeNamingEvidenceIssue[] = []
if (evidence.schemaVersion !== 1) push(issues, 'SCHEMA_VERSION', 'schemaVersion', `unsupported runtime naming evidence schema ${evidence.schemaVersion}`)
if (typeof evidence.stageId !== 'string' || !evidence.stageId.trim()) push(issues, 'STAGE_ID', 'stageId', 'stageId must be a non-empty string')
if (typeof evidence.resultObjectId !== 'string' || !evidence.resultObjectId.trim()) push(issues, 'RESULT_OBJECT_ID', 'resultObjectId', 'resultObjectId must be a non-empty string')
if (!statuses.has(evidence.status)) push(issues, 'STATUS', 'status', `unsupported naming evidence status ${String(evidence.status)}`)
if (evidence.mappedNames !== undefined && !Array.isArray(evidence.mappedNames)) push(issues, 'MAPPED_NAME', 'mappedNames', 'mappedNames must be an array')
const mappedNames = Array.isArray(evidence.mappedNames) ? evidence.mappedNames : []
const resultIndexes = new Set<string>()
let stringHasher: ReturnType<typeof migrateStringHasherSchema> | undefined
if (evidence.stringHasher) {
try {
stringHasher = migrateStringHasherSchema(evidence.stringHasher)
} catch (error) {
push(issues, 'STRING_HASHER', 'stringHasher', error instanceof Error ? error.message : String(error))
}
}
const stringIds = new Set(stringHasher?.entries.map((entry) => entry.id) ?? [])
if (stringHasher) {
const validation = validateStringHasherTable(stringHasher)
for (const issue of validation.issues) push(issues, 'STRING_HASHER', issue.path, issue.message)
}
for (const [index, mapped] of mappedNames.entries()) {
const path = `mappedNames[${index}]`
if (!mapped || typeof mapped !== 'object') {
push(issues, 'MAPPED_NAME', path, 'mapped name entry must be an object')
continue
}
if (!['face', 'edge', 'vertex'].includes(mapped.kind)) push(issues, 'MAPPED_NAME', `${path}.kind`, `unsupported mapped name kind ${String(mapped.kind)}`)
if (!Number.isSafeInteger(mapped.resultIndex) || mapped.resultIndex < 0) push(issues, 'RESULT_INDEX', `${path}.resultIndex`, 'resultIndex must be a non-negative safe integer')
const key = `${mapped.kind}:${mapped.resultIndex}`
if (resultIndexes.has(key)) push(issues, 'MAPPED_NAME', `${path}.resultIndex`, `duplicate result subshape index ${key}`)
resultIndexes.add(key)
if (typeof mapped.resultPersistentId !== 'string' || !mapped.resultPersistentId.trim()) push(issues, 'MAPPED_NAME', `${path}.resultPersistentId`, 'resultPersistentId must be a non-empty string')
const reference = mapped.reference
if (!reference || typeof reference !== 'object') {
push(issues, 'MAPPED_NAME', `${path}.reference`, 'mapped name reference must be an object')
} else if (typeof reference.name !== 'string' || !reference.name || /[.\s]/.test(reference.name)) {
push(issues, 'MAPPED_NAME', `${path}.reference.name`, 'mapped name must be a non-empty string and contain no dots or whitespace')
}
for (const [sidIndex, sid] of (reference && typeof reference === 'object' && Array.isArray(reference.stringIds) ? reference.stringIds : []).entries()) {
if (!Number.isSafeInteger(sid) || sid < 0) push(issues, 'STRING_ID', `${path}.reference.stringIds[${sidIndex}]`, `invalid StringID ${sid}`)
else if (stringHasher && !stringIds.has(sid)) push(issues, 'STRING_ID', `${path}.reference.stringIds[${sidIndex}]`, `missing StringHasher ID ${sid}`)
}
if (reference && typeof reference === 'object' && reference.prefixStringId !== undefined && stringHasher && !stringIds.has(reference.prefixStringId)) push(issues, 'STRING_ID', `${path}.reference.prefixStringId`, `missing StringHasher prefix ID ${reference.prefixStringId}`)
if (mapped.relation !== undefined && !relationSet.has(mapped.relation)) push(issues, 'MAPPED_NAME', `${path}.relation`, `unsupported relation ${String(mapped.relation)}`)
for (const [sourceIndex, source] of (Array.isArray(mapped.sourceRefs) ? mapped.sourceRefs : []).entries()) {
if (!source || typeof source !== 'object' || typeof source.objectId !== 'string' || !source.objectId.trim() || typeof source.persistentId !== 'string' || !source.persistentId.trim()) push(issues, 'SOURCE_REF', `${path}.sourceRefs[${sourceIndex}]`, 'source objectId and persistentId are required')
}
for (const [candidateIndex, candidate] of (Array.isArray(mapped.candidates) ? mapped.candidates : []).entries()) {
if (!candidate || typeof candidate !== 'object' || typeof candidate.objectId !== 'string' || !candidate.objectId.trim() || typeof candidate.persistentId !== 'string' || !candidate.persistentId.trim()) push(issues, 'SOURCE_REF', `${path}.candidates[${candidateIndex}]`, 'candidate objectId and persistentId are required')
}
if ((mapped.relation === 'ambiguous' || (Array.isArray(mapped.candidates) && mapped.candidates.length > 0)) && (!Array.isArray(mapped.candidates) || mapped.candidates.length < 2)) push(issues, 'AMBIGUOUS', path, 'ambiguous mapped names must retain at least two candidates')
}
if (evidence.status === 'native-evidence' && mappedNames.length === 0) push(issues, 'MAPPED_NAME', 'mappedNames', 'native-evidence status requires mapped name references')
if (evidence.status === 'final-shape-only' && mappedNames.length > 0) push(issues, 'STATUS', 'mappedNames', 'final-shape-only stages cannot carry generated mapped-name evidence')
if (evidence.status === 'ambiguous' && mappedNames.every((mapped) => (mapped.candidates?.length ?? 0) < 2)) push(issues, 'AMBIGUOUS', 'mappedNames', 'ambiguous status requires persisted candidate sets')
return { valid: issues.length === 0, status: evidence.status, mappedNameCount: mappedNames.length, stringHasherEntryCount: stringHasher?.entries.length ?? 0, issues }
}
export const assertNativeNamingEvidence = <T extends NativeStageNamingEvidence>(evidence: T): T => {
const report = validateNativeNamingEvidence(evidence)
if (!report.valid) throw new Error(`Invalid native naming evidence: ${report.issues[0].path}: ${report.issues[0].message}`)
return evidence
}
export const createFinalShapeOnlyNamingEvidence = (stageId: string, resultObjectId: string, reason = 'Native provider returned final Shape without MappedNameRef/StringHasher evidence.'): NativeStageNamingEvidence => ({
schemaVersion: 1,
stageId,
resultObjectId,
status: 'final-shape-only',
reason,
})
export const createNativeStageNamingEvidence = (input: Omit<NativeStageNamingEvidence, 'schemaVersion'>): NativeStageNamingEvidence => assertNativeNamingEvidence({ schemaVersion: 1, ...input })
export const hasNativeMappedNameEvidence = (evidence: NativeStageNamingEvidence | undefined): boolean => Boolean(evidence && (evidence.status === 'native-evidence' || evidence.status === 'opaque-preserved') && (evidence.mappedNames?.length ?? 0) > 0)

View File

@@ -0,0 +1,5 @@
export type PerformanceBudget = { objectCount: number; triangleCount: number; tableCells: number; maxObjectMs: number; maxTriangleMs: number; maxTableMs: number; maxHeapBytes: number }
export type PerformanceReport = { objects: { count: number; durationMs: number }; triangles: { count: number; checksum: number; durationMs: number }; table: { cells: number; checksum: number; durationMs: number }; heapBytes: number | null; pass: boolean; budget: PerformanceBudget }
const defaultBudget: PerformanceBudget = { objectCount: 1000, triangleCount: 1_000_000, tableCells: 100_000, maxObjectMs: 2_000, maxTriangleMs: 4_000, maxTableMs: 2_000, maxHeapBytes: 512 * 1024 * 1024 }
const now = () => typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now()
export const runPerformanceBudget = (budget: Partial<PerformanceBudget> = {}): PerformanceReport => { const selected = { ...defaultBudget, ...budget }; for (const key of ['objectCount', 'triangleCount', 'tableCells'] as const) if (!Number.isSafeInteger(selected[key]) || selected[key] <= 0) throw new RangeError(`Performance ${key} must be a positive integer.`); const objectStart = now(); const objects = new Array(selected.objectCount); for (let index = 0; index < selected.objectCount; index += 1) objects[index] = { id: `Object${index}`, dependencies: index === 0 ? [] : [`Object${index - 1}`], value: index % 17 }; const objectDuration = now() - objectStart; const triangleStart = now(); let triangleChecksum = 0; for (let index = 0; index < selected.triangleCount; index += 1) triangleChecksum = (triangleChecksum + ((index * 31) ^ (index >>> 3))) >>> 0; const triangleDuration = now() - triangleStart; const tableStart = now(); let tableChecksum = 0; for (let index = 0; index < selected.tableCells; index += 1) tableChecksum = (tableChecksum + ((index * 13) % 997)) % 1_000_003; const tableDuration = now() - tableStart; const heapBytes = typeof performance !== 'undefined' && 'memory' in performance ? Number((performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory?.usedJSHeapSize ?? 0) || null : null; return { objects: { count: objects.length, durationMs: objectDuration }, triangles: { count: selected.triangleCount, checksum: triangleChecksum, durationMs: triangleDuration }, table: { cells: selected.tableCells, checksum: tableChecksum, durationMs: tableDuration }, heapBytes, pass: objectDuration <= selected.maxObjectMs && triangleDuration <= selected.maxTriangleMs && tableDuration <= selected.maxTableMs && (heapBytes === null || heapBytes <= selected.maxHeapBytes), budget: selected } }

View File

@@ -4,7 +4,7 @@ import { assessResourceQuota, planResourceSweep, type ResourceSweepRecord } from
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSummary } from './types'
type PersistenceRequest =
| { id: number; type: 'initialize' }
| { id: number; type: 'initialize'; databasePath?: string; migrationFailureVersion?: number; migrationInterruptAfterVersion?: number }
| { id: number; type: 'list-projects' }
| { id: number; type: 'save-document'; document: DocumentSnapshot }
| { id: number; type: 'load-document'; documentId: string }
@@ -37,6 +37,7 @@ type WorkerScope = {
const workerScope = globalThis as unknown as WorkerScope
let sqlite3: Sqlite3Static | undefined
let database: Database | undefined
let databasePath = '/bitbybit-project.sqlite3'
let assetsDirectory: FileSystemDirectoryHandle | undefined
const transientResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>()
let capabilities: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: 'SQLite WASM has not been initialized.' }
@@ -56,23 +57,31 @@ const hashBytes = async (bytes: ArrayBuffer) => {
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join('')
}
const initialize = async (): Promise<PersistenceCapabilities> => {
const initialize = async (requestedPath?: string, migrationFailureVersion?: number, migrationInterruptAfterVersion?: number): Promise<PersistenceCapabilities> => {
if (database) return capabilities
if (requestedPath !== undefined) {
if (!/^\/[A-Za-z0-9._-]+\.sqlite3$/.test(requestedPath)) throw new Error('Persistence databasePath must be an absolute OPFS SQLite filename.')
databasePath = requestedPath
}
sqlite3 = await sqlite3InitModule()
const opfsAvailable = Boolean(sqlite3.oo1.OpfsDb)
try {
database = opfsAvailable ? new sqlite3.oo1.OpfsDb('/bitbybit-project.sqlite3') : new sqlite3.oo1.DB(':memory:', 'c')
database = opfsAvailable ? new sqlite3.oo1.OpfsDb(databasePath) : new sqlite3.oo1.DB(':memory:', 'c')
capabilities = { mode: opfsAvailable ? 'sqlite-opfs' : 'sqlite-memory', sqliteWasm: true, opfs: opfsAvailable, schemaVersion: PROJECT_SCHEMA_VERSION, reason: opfsAvailable ? undefined : 'OPFS VFS is unavailable; persistence is transient until export.' }
} catch (error) {
database = new sqlite3.oo1.DB(':memory:', 'c')
capabilities = { mode: 'sqlite-memory', sqliteWasm: true, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: error instanceof Error ? `OPFS initialization failed: ${error.message}` : 'OPFS initialization failed.' }
}
database.exec('PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);')
let currentMigrationVersion: number | undefined
runProjectSchemaMigrations({
begin: () => { database?.exec('BEGIN;') },
isApplied: (version) => (database?.exec({ sql: 'SELECT version FROM schema_migrations WHERE version = ?', bind: [version], returnValue: 'resultRows' }) as unknown[]).length > 0,
execute: (sql) => { database?.exec(sql) },
markApplied: (version, appliedAt) => { database?.exec({ sql: 'INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)', bind: [version, appliedAt] }) },
isApplied: (version) => { currentMigrationVersion = version; return (database?.exec({ sql: 'SELECT version FROM schema_migrations WHERE version = ?', bind: [version], returnValue: 'resultRows' }) as unknown[]).length > 0 },
execute: (sql) => { if (migrationFailureVersion !== undefined && currentMigrationVersion === migrationFailureVersion) throw new Error(`Injected migration failure at schema version ${migrationFailureVersion}.`); database?.exec(sql) },
markApplied: (version, appliedAt) => {
database?.exec({ sql: 'INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)', bind: [version, appliedAt] })
if (migrationInterruptAfterVersion === version) throw new Error(`Injected migration interruption after schema version ${version}.`)
},
commit: () => { database?.exec('COMMIT;') },
rollback: () => { database?.exec('ROLLBACK;') },
}, PROJECT_SCHEMA_MIGRATIONS)
@@ -282,7 +291,7 @@ const releaseResource = async (hash: string) => {
const handle = async (request: PersistenceRequest): Promise<PersistenceResponse> => {
try {
if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() }
if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize(request.databasePath, request.migrationFailureVersion, request.migrationInterruptAfterVersion) }
if (request.type === 'dispose') { database?.close(); database = undefined; transientResources.clear(); return { id: request.id, ok: true, type: 'disposed' } }
await initialize()
if (request.type === 'list-projects') return { id: request.id, ok: true, type: 'projects-listed', projects: listProjects() }

View File

@@ -0,0 +1,420 @@
import { cloneSketch, type SketchConstraint, type SketchSnapshot, type SketchSolveResult } from './sketcher'
import type { SketchSolverCapabilities } from './sketchSolverProtocol'
export type PlanegcsDoubleVector = {
size(): number
get(index: number): number
delete(): void
}
export type PlanegcsWasmModule = {
solveHorizontalDistance(startX: number, startY: number, endX: number, endY: number, targetLength: number): PlanegcsDoubleVector
solveVerticalDistance(startX: number, startY: number, endX: number, endY: number, targetLength: number): PlanegcsDoubleVector
solveDistanceX(startX: number, startY: number, endX: number, endY: number, targetDistance: number): PlanegcsDoubleVector
solveDistanceY(startX: number, startY: number, endX: number, endY: number, targetDistance: number): PlanegcsDoubleVector
solveAngle(startX: number, startY: number, endX: number, endY: number, targetLength: number, targetAngle: number): PlanegcsDoubleVector
solveCircleRadius(centerX: number, centerY: number, radius: number, targetRadius: number): PlanegcsDoubleVector
solveCircleDiameter(centerX: number, centerY: number, radius: number, targetDiameter: number): PlanegcsDoubleVector
solveEqualLines(firstStartX: number, firstStartY: number, firstEndX: number, firstEndY: number, secondStartX: number, secondStartY: number, secondEndX: number, secondEndY: number): PlanegcsDoubleVector
solveEqualCircles(firstCenterX: number, firstCenterY: number, firstRadius: number, secondCenterX: number, secondCenterY: number, secondRadius: number): PlanegcsDoubleVector
solveTangentCircles(firstCenterX: number, firstCenterY: number, firstRadius: number, secondCenterX: number, secondCenterY: number, secondRadius: number): PlanegcsDoubleVector
solvePointSymmetry?: (firstX: number, firstY: number, secondX: number, secondY: number, centerX: number, centerY: number) => PlanegcsDoubleVector
solvePointOnLine?: (pointX: number, pointY: number, startX: number, startY: number, endX: number, endY: number, vertical: boolean) => PlanegcsDoubleVector
solvePointOnCircle?: (pointX: number, pointY: number, centerX: number, centerY: number, radius: number) => PlanegcsDoubleVector
solvePointOnArc?: (pointX: number, pointY: number, centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number) => PlanegcsDoubleVector
solvePointOnEllipse?: (pointX: number, pointY: number, centerX: number, centerY: number, focusX: number, focusY: number, minorRadius: number) => PlanegcsDoubleVector
solvePointOnCubicBspline?: (pointX: number, pointY: number, pole0X: number, pole0Y: number, pole1X: number, pole1Y: number, pole2X: number, pole2Y: number, pole3X: number, pole3Y: number, pointParameter: number) => PlanegcsDoubleVector
solveParallelLines(firstStartX: number, firstStartY: number, firstEndX: number, firstEndY: number, secondStartX: number, secondStartY: number, secondEndX: number, secondEndY: number, secondLength: number): PlanegcsDoubleVector
solvePerpendicularLines(firstStartX: number, firstStartY: number, firstEndX: number, firstEndY: number, secondStartX: number, secondStartY: number, secondEndX: number, secondEndY: number, secondLength: number): PlanegcsDoubleVector
solveCoincidentLines(firstStartX: number, firstStartY: number, firstEndX: number, firstEndY: number, secondStartX: number, secondStartY: number, secondEndX: number, secondEndY: number, secondLength: number): PlanegcsDoubleVector
solveCoincidentLinePoints?: (firstStartX: number, firstStartY: number, firstEndX: number, firstEndY: number, secondStartX: number, secondStartY: number, secondEndX: number, secondEndY: number, secondLength: number, firstEnd: boolean, secondEnd: boolean) => PlanegcsDoubleVector
solveSnellsLawLines?: (firstStartX: number, firstStartY: number, firstEndX: number, firstEndY: number, secondStartX: number, secondStartY: number, secondEndX: number, secondEndY: number, boundaryStartX: number, boundaryStartY: number, boundaryEndX: number, boundaryEndY: number, refractiveRatio: number, firstEnd: boolean, secondEnd: boolean) => PlanegcsDoubleVector
solveEllipseInternalAlignment?: (centerX: number, centerY: number, majorRadius: number, minorRadius: number, rotation: number, alignmentType: number) => PlanegcsDoubleVector
solveEllipseInternalAlignmentSet?: (centerX: number, centerY: number, majorRadius: number, minorRadius: number, rotation: number) => PlanegcsDoubleVector
solveCubicBsplineWeight?: (pole0X: number, pole0Y: number, pole1X: number, pole1Y: number, pole2X: number, pole2Y: number, pole3X: number, pole3Y: number, weight0: number, weight1: number, weight2: number, weight3: number, controlPointIndex: number, targetWeight: number) => PlanegcsDoubleVector
}
export const PLANEGCS_WASM_CAPABILITIES: SketchSolverCapabilities = {
providerId: 'freecad.planegcs-wasm',
providerVersion: '1.1.1-embind-subset.23',
engine: 'planegcs-wasm',
availability: 'available',
compatibility: 'experimental',
supportedGeometry: ['point', 'line', 'circle', 'arc', 'ellipse', 'bspline'],
supportedConstraints: ['horizontal', 'vertical', 'parallel', 'perpendicular', 'distance', 'distanceX', 'distanceY', 'angle', 'radius', 'diameter', 'equal', 'tangent', 'coincident', 'symmetric', 'pointOnObject', 'snellsLaw', 'internalAlignment', 'weight', 'block'],
reason: 'Native adapter supports FreeCAD-style pre-solve Block, three-point Symmetric, horizontal/vertical Line and fixed Circle/Arc/Ellipse or monotonic clamped cubic B-spline PointOnObject, a three-Line Coincident/PointOnObject/SnellsLaw compound graph, Ellipse major/minor/focus InternalAlignment helpers, one clamped cubic B-spline Weight helper graph, one-line orientation/distance/distanceX/distanceY/angle, one-circle radius/diameter, two-line EqualLength and two-circle EqualRadius/Tangent subsets, plus two-line Parallel/Perpendicular and all four line-endpoint Coincident subsets.',
}
export class PlanegcsSubsetError extends Error {
readonly code = 'PLANEGCS_SUBSET_UNSUPPORTED'
constructor(message: string) {
super(message)
this.name = 'PlanegcsSubsetError'
}
}
const isLineEndpointDistance = (constraint: SketchConstraint, geometryId: string) => {
if (constraint.type !== 'distance' && constraint.type !== 'distanceX' && constraint.type !== 'distanceY') return false
if (constraint.first.geometryId !== geometryId || constraint.second.geometryId !== geometryId) return false
return (constraint.first.point === 'start' && constraint.second.point === 'end') || (constraint.first.point === 'end' && constraint.second.point === 'start')
}
const samePointRef = (first: { geometryId: string; point: string }, second: { geometryId: string; point: string }) => first.geometryId === second.geometryId && first.point === second.point
export const solvePlanegcsSubset = (module: PlanegcsWasmModule, input: SketchSnapshot): SketchSolveResult => {
const snapshot = cloneSketch(input)
if (snapshot.externalGeometry.length !== 0) throw new PlanegcsSubsetError('Native planegcs subset does not yet support external geometry.')
const driving = snapshot.constraints.filter((constraint) => constraint.driving !== false)
const blocks = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'block' }> => constraint.type === 'block')
if (snapshot.geometry.length === 1 && driving.length === 1 && blocks.length === 1) {
if (blocks[0].geometryId !== snapshot.geometry[0].id) throw new PlanegcsSubsetError('Native planegcs Block subset must reference its single geometry.')
return resultFor(snapshot, 0, 0, 0)
}
const symmetric = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'symmetric' }> => constraint.type === 'symmetric')
if (snapshot.geometry.length === 3 && snapshot.geometry.every((geometry) => geometry.type === 'point') && driving.length === 1 && symmetric.length === 1) {
const constraint = symmetric[0]
const refs = [constraint.first, constraint.second, constraint.center]
if (refs.some((ref) => ref.point !== 'position') || new Set(refs.map((ref) => ref.geometryId)).size !== 3) throw new PlanegcsSubsetError('Native planegcs Symmetric subset requires three distinct Point geometries referenced by position.')
const points = refs.map((ref) => snapshot.geometry.find((geometry) => geometry.id === ref.geometryId))
if (points.some((geometry) => !geometry || geometry.type !== 'point')) throw new PlanegcsSubsetError('Native planegcs Symmetric subset requires three distinct Point geometries.')
const [first, second, center] = points as Array<Extract<SketchSnapshot['geometry'][number], { type: 'point' }>>
if (!module.solvePointSymmetry) throw new PlanegcsSubsetError('Native planegcs Symmetric subset is unavailable in this module build.')
const vector = module.solvePointSymmetry(first.position.x, first.position.y, second.position.x, second.position.y, center.position.x, center.position.y)
const values = readVector(vector, 8)
const [solveStatus, firstX, firstY, secondX, secondY, centerX, centerY, residual] = values
first.position = { x: firstX, y: firstY }
second.position = { x: secondX, y: secondY }
center.position = { x: centerX, y: centerY }
return resultFor(snapshot, solveStatus, Math.abs(residual), 0)
}
const snellsLaw = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'snellsLaw' }> & { boundaryGeometryId: string } => constraint.type === 'snellsLaw' && 'boundaryGeometryId' in constraint)
if (snapshot.geometry.length === 3 && driving.length === 3 && snellsLaw.length === 1) {
const constraint = snellsLaw[0]
const coincident = driving.filter((entry): entry is Extract<SketchConstraint, { type: 'coincident' }> => entry.type === 'coincident')
const pointOnBoundary = driving.filter((entry): entry is Extract<SketchConstraint, { type: 'pointOnObject' }> => entry.type === 'pointOnObject')
if (coincident.length !== 1 || pointOnBoundary.length !== 1) throw new PlanegcsSubsetError('Native planegcs SnellsLaw subset requires one Coincident and one PointOnObject prerequisite constraint.')
if (!('first' in constraint) || !('second' in constraint)) throw new PlanegcsSubsetError('Native planegcs SnellsLaw subset requires endpoint references on both rays.')
if ((constraint.first.point !== 'start' && constraint.first.point !== 'end') || (constraint.second.point !== 'start' && constraint.second.point !== 'end')) throw new PlanegcsSubsetError('Native planegcs SnellsLaw subset requires start or end references on both rays.')
if (!Number.isFinite(constraint.value) || !(constraint.value > 0)) throw new PlanegcsSubsetError('Native planegcs SnellsLaw refractive ratio must be positive and finite.')
const first = snapshot.geometry.find((geometry) => geometry.id === constraint.first.geometryId)
const second = snapshot.geometry.find((geometry) => geometry.id === constraint.second.geometryId)
const boundary = snapshot.geometry.find((geometry) => geometry.id === constraint.boundaryGeometryId)
if (!first || !second || !boundary || first.type !== 'line' || second.type !== 'line' || boundary.type !== 'line' || new Set([first.id, second.id, boundary.id]).size !== 3) throw new PlanegcsSubsetError('Native planegcs SnellsLaw subset requires three distinct Line geometries.')
const coincidentMatches = (samePointRef(coincident[0].first, constraint.first) && samePointRef(coincident[0].second, constraint.second)) || (samePointRef(coincident[0].first, constraint.second) && samePointRef(coincident[0].second, constraint.first))
if (!coincidentMatches) throw new PlanegcsSubsetError('Native planegcs SnellsLaw Coincident prerequisite must connect the selected ray endpoints.')
if (!samePointRef(pointOnBoundary[0].point, constraint.first) || pointOnBoundary[0].geometryId !== boundary.id) throw new PlanegcsSubsetError('Native planegcs SnellsLaw PointOnObject prerequisite must place the first ray endpoint on the boundary.')
if (!module.solveSnellsLawLines) throw new PlanegcsSubsetError('Native planegcs SnellsLaw subset is unavailable in this module build.')
const vector = module.solveSnellsLawLines(first.start.x, first.start.y, first.end.x, first.end.y, second.start.x, second.start.y, second.end.x, second.end.y, boundary.start.x, boundary.start.y, boundary.end.x, boundary.end.y, constraint.value, constraint.first.point === 'end', constraint.second.point === 'end')
const values = readVector(vector, 14)
const [solveStatus, firstStartX, firstStartY, firstEndX, firstEndY, secondStartX, secondStartY, secondEndX, secondEndY, boundaryStartX, boundaryStartY, boundaryEndX, boundaryEndY, residual] = values
first.start = { x: firstStartX, y: firstStartY }
first.end = { x: firstEndX, y: firstEndY }
second.start = { x: secondStartX, y: secondStartY }
second.end = { x: secondEndX, y: secondEndY }
boundary.start = { x: boundaryStartX, y: boundaryStartY }
boundary.end = { x: boundaryEndX, y: boundaryEndY }
return resultFor(snapshot, solveStatus, Math.abs(residual), 8)
}
const internalAlignments = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'internalAlignment' }> => constraint.type === 'internalAlignment')
if (snapshot.geometry.length === 1 && internalAlignments.length > 0 && driving.length === internalAlignments.length) {
const ellipse = snapshot.geometry[0]
if (ellipse.type !== 'ellipse') throw new PlanegcsSubsetError('Native planegcs InternalAlignment subset supports Ellipse helper geometry only.')
if (!module.solveEllipseInternalAlignment) throw new PlanegcsSubsetError('Native planegcs Ellipse InternalAlignment subset is unavailable in this module build.')
const alignmentKeys = new Set(internalAlignments.map((constraint) => `${constraint.alignmentType}:${constraint.internalGeometryIndex}`))
const quartetKeys = ['ellipse-major:0', 'ellipse-minor:0', 'ellipse-focus:0', 'ellipse-focus:1']
if (internalAlignments.length === 4 && quartetKeys.every((key) => alignmentKeys.has(key))) {
if (internalAlignments.some((constraint) => constraint.geometryId !== ellipse.id)) throw new PlanegcsSubsetError('Native planegcs Ellipse InternalAlignment must reference its single geometry.')
if (!module.solveEllipseInternalAlignmentSet) throw new PlanegcsSubsetError('Native planegcs Ellipse InternalAlignment quartet is unavailable in this module build.')
const vector = module.solveEllipseInternalAlignmentSet(ellipse.center.x, ellipse.center.y, ellipse.majorRadius, ellipse.minorRadius, ellipse.rotation)
const values = readVector(vector, 10)
const [solveStatus, centerX, centerY, focusX, focusY, minorRadius, ...alignmentResiduals] = values
const focalDistance = Math.hypot(focusX - centerX, focusY - centerY)
const solvedMajorRadius = Math.hypot(focalDistance, minorRadius)
const solvedRotation = Math.atan2(focusY - centerY, focusX - centerX)
if (Math.abs(centerX - ellipse.center.x) > 1e-7 || Math.abs(centerY - ellipse.center.y) > 1e-7 || Math.abs(solvedMajorRadius - ellipse.majorRadius) > 1e-7 || Math.abs(minorRadius - ellipse.minorRadius) > 1e-7 || Math.abs(solvedRotation - ellipse.rotation) > 1e-7) throw new Error('FreeCAD planegcs Ellipse InternalAlignment quartet unexpectedly changed its parent geometry.')
return resultFor(snapshot, solveStatus, Math.max(...alignmentResiduals.map(Math.abs)), 5)
}
let solveStatus = 0
let residual = 0
for (const constraint of internalAlignments) {
if (constraint.geometryId !== ellipse.id) throw new PlanegcsSubsetError('Native planegcs Ellipse InternalAlignment must reference its single geometry.')
const alignmentType = constraint.alignmentType === 'ellipse-major' && constraint.internalGeometryIndex === 0 ? 1
: constraint.alignmentType === 'ellipse-minor' && constraint.internalGeometryIndex === 0 ? 2
: constraint.alignmentType === 'ellipse-focus' && constraint.internalGeometryIndex === 0 ? 3
: constraint.alignmentType === 'ellipse-focus' && constraint.internalGeometryIndex === 1 ? 4
: undefined
if (alignmentType === undefined) throw new PlanegcsSubsetError('Native planegcs Ellipse InternalAlignment supports major/minor index 0 and focus indexes 0 or 1.')
const vector = module.solveEllipseInternalAlignment(ellipse.center.x, ellipse.center.y, ellipse.majorRadius, ellipse.minorRadius, ellipse.rotation, alignmentType)
const values = readVector(vector, 11)
const [currentStatus, centerX, centerY, focusX, focusY, minorRadius, , , , , currentResidual] = values
const focalDistance = Math.hypot(focusX - centerX, focusY - centerY)
const solvedMajorRadius = Math.hypot(focalDistance, minorRadius)
const solvedRotation = Math.atan2(focusY - centerY, focusX - centerX)
if (Math.abs(centerX - ellipse.center.x) > 1e-7 || Math.abs(centerY - ellipse.center.y) > 1e-7 || Math.abs(solvedMajorRadius - ellipse.majorRadius) > 1e-7 || Math.abs(minorRadius - ellipse.minorRadius) > 1e-7 || Math.abs(solvedRotation - ellipse.rotation) > 1e-7) throw new Error('FreeCAD planegcs Ellipse InternalAlignment unexpectedly changed its parent geometry.')
solveStatus = Math.max(solveStatus, currentStatus)
residual = Math.max(residual, Math.abs(currentResidual))
}
return resultFor(snapshot, solveStatus, residual, 5)
}
const weights = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'weight' }> => constraint.type === 'weight')
if (snapshot.geometry.length === 1 && weights.length === 1 && (driving.length === 1 || driving.length === 2)) {
const bspline = snapshot.geometry[0]
const constraint = weights[0]
if (bspline.type !== 'bspline' || constraint.geometryId !== bspline.id) throw new PlanegcsSubsetError('Native planegcs Weight subset requires one referenced B-spline.')
const clampedKnots = bspline.knots?.length === 8 && bspline.knots.every((knot, index) => Math.abs(knot - (index < 4 ? 0 : 1)) <= 1e-12)
if (bspline.degree !== 3 || bspline.controlPoints.length !== 4 || bspline.weights?.length !== 4 || !clampedKnots || bspline.periodic === true) throw new PlanegcsSubsetError('Native planegcs Weight subset requires four control points, four weights, clamped 0/1 knots, degree 3 and non-periodic mode.')
if (!Number.isSafeInteger(constraint.controlPointIndex) || constraint.controlPointIndex < 0 || constraint.controlPointIndex >= 4 || !Number.isFinite(constraint.value) || !(constraint.value > 0)) throw new PlanegcsSubsetError('Native planegcs Weight requires a valid control-point index and positive finite value.')
if (driving.length === 2) {
const alignment = internalAlignments[0]
if (internalAlignments.length !== 1 || alignment.geometryId !== bspline.id || alignment.alignmentType !== 'bspline-control-point' || alignment.internalGeometryIndex !== constraint.controlPointIndex) throw new PlanegcsSubsetError('Native planegcs Weight compound graph requires the matching B-spline control-point InternalAlignment.')
}
if (!module.solveCubicBsplineWeight) throw new PlanegcsSubsetError('Native planegcs B-spline Weight subset is unavailable in this module build.')
const [pole0, pole1, pole2, pole3] = bspline.controlPoints
const [weight0, weight1, weight2, weight3] = bspline.weights
const vector = module.solveCubicBsplineWeight(pole0.x, pole0.y, pole1.x, pole1.y, pole2.x, pole2.y, pole3.x, pole3.y, weight0, weight1, weight2, weight3, constraint.controlPointIndex, constraint.value)
const values = readVector(vector, 10)
const [solveStatus, solvedWeight0, solvedWeight1, solvedWeight2, solvedWeight3, helperCenterX, helperCenterY, helperRadius, alignmentResidual, weightResidual] = values
const selectedPole = bspline.controlPoints[constraint.controlPointIndex]
if (Math.abs(helperCenterX - selectedPole.x) > 1e-7 || Math.abs(helperCenterY - selectedPole.y) > 1e-7 || Math.abs(helperRadius - constraint.value) > 1e-7) throw new Error('FreeCAD planegcs B-spline Weight returned an invalid control-point helper.')
bspline.weights = [solvedWeight0, solvedWeight1, solvedWeight2, solvedWeight3]
return resultFor(snapshot, solveStatus, Math.max(Math.abs(alignmentResidual), Math.abs(weightResidual)), 11)
}
const pointOnObject = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'pointOnObject' }> => constraint.type === 'pointOnObject')
if (snapshot.geometry.length === 2 && driving.length === 1 && pointOnObject.length === 1) {
const constraint = pointOnObject[0]
if (constraint.point.point !== 'position') throw new PlanegcsSubsetError('Native planegcs PointOnObject subset requires a Point position reference.')
const point = snapshot.geometry.find((geometry) => geometry.id === constraint.point.geometryId)
const target = snapshot.geometry.find((geometry) => geometry.id === constraint.geometryId)
if (!point || !target || point.type !== 'point' || point.id === target.id) throw new PlanegcsSubsetError('Native planegcs PointOnObject subset requires one distinct Point and target geometry.')
if (target.type === 'line') {
const dx = target.end.x - target.start.x
const dy = target.end.y - target.start.y
const vertical = Math.abs(dx) <= 1e-12 && Math.abs(dy) > 1e-12
const horizontal = Math.abs(dy) <= 1e-12 && Math.abs(dx) > 1e-12
if (!horizontal && !vertical) throw new PlanegcsSubsetError('Native planegcs PointOnObject subset requires a horizontal or vertical non-degenerate Line.')
if (!module.solvePointOnLine) throw new PlanegcsSubsetError('Native planegcs PointOnObject line subset is unavailable in this module build.')
const vector = module.solvePointOnLine(point.position.x, point.position.y, target.start.x, target.start.y, target.end.x, target.end.y, vertical)
const values = readVector(vector, 8)
const [solveStatus, pointX, pointY, startX, startY, endX, endY, residual] = values
point.position = { x: pointX, y: pointY }
target.start = { x: startX, y: startY }
target.end = { x: endX, y: endY }
return resultFor(snapshot, solveStatus, Math.abs(residual), 0)
}
if (target.type === 'circle') {
if (!(target.radius > 0)) throw new PlanegcsSubsetError('Native planegcs PointOnObject Circle requires a positive radius.')
if (!module.solvePointOnCircle) throw new PlanegcsSubsetError('Native planegcs PointOnObject circle subset is unavailable in this module build.')
const vector = module.solvePointOnCircle(point.position.x, point.position.y, target.center.x, target.center.y, target.radius)
const values = readVector(vector, 7)
const [solveStatus, pointX, pointY, centerX, centerY, radius, residual] = values
point.position = { x: pointX, y: pointY }
target.center = { x: centerX, y: centerY }
target.radius = radius
return resultFor(snapshot, solveStatus, Math.abs(residual), 0)
}
if (target.type === 'arc') {
if (!(target.radius > 0) || !Number.isFinite(target.startAngle) || !Number.isFinite(target.endAngle)) throw new PlanegcsSubsetError('Native planegcs PointOnObject Arc requires a positive radius and finite angles.')
if (!module.solvePointOnArc) throw new PlanegcsSubsetError('Native planegcs PointOnObject arc subset is unavailable in this module build.')
const vector = module.solvePointOnArc(point.position.x, point.position.y, target.center.x, target.center.y, target.radius, target.startAngle, target.endAngle)
const values = readVector(vector, 9)
const [solveStatus, pointX, pointY, centerX, centerY, radius, startAngle, endAngle, residual] = values
const tau = Math.PI * 2
const normalized = (angle: number) => ((angle % tau) + tau) % tau
const sweep = normalized(endAngle - startAngle)
const position = normalized(Math.atan2(pointY - centerY, pointX - centerX) - startAngle)
if (sweep <= 1e-12 || position > sweep + 1e-9) throw new PlanegcsSubsetError('Native planegcs PointOnObject arc solution lies outside the target sweep.')
point.position = { x: pointX, y: pointY }
target.center = { x: centerX, y: centerY }
target.radius = radius
target.startAngle = startAngle
target.endAngle = endAngle
return resultFor(snapshot, solveStatus, Math.abs(residual), 0)
}
if (target.type === 'bspline') {
const unitWeights = target.weights === undefined || target.weights.every((weight) => Math.abs(weight - 1) <= 1e-12)
const clampedKnots = target.knots === undefined || (target.knots.length === 8 && target.knots.every((knot, index) => Math.abs(knot - (index < 4 ? 0 : 1)) <= 1e-12))
const monotonicX = target.controlPoints.every((controlPoint, index) => index === 0 || controlPoint.x > target.controlPoints[index - 1].x)
if (target.degree !== 3 || target.controlPoints.length !== 4 || target.periodic === true || !unitWeights || !clampedKnots || !monotonicX) throw new PlanegcsSubsetError('Native planegcs PointOnObject B-spline requires four strictly X-monotonic cubic control points, unit weights, clamped 0/1 knots and non-periodic mode.')
const [pole0, pole1, pole2, pole3] = target.controlPoints
if (point.position.x < pole0.x - 1e-12 || point.position.x > pole3.x + 1e-12) throw new PlanegcsSubsetError('Native planegcs PointOnObject B-spline requires the fixed point X within the curve endpoint range.')
if (!module.solvePointOnCubicBspline) throw new PlanegcsSubsetError('Native planegcs PointOnObject B-spline subset is unavailable in this module build.')
const initialParameter = (point.position.x - pole0.x) / (pole3.x - pole0.x)
const vector = module.solvePointOnCubicBspline(point.position.x, point.position.y, pole0.x, pole0.y, pole1.x, pole1.y, pole2.x, pole2.y, pole3.x, pole3.y, initialParameter)
const values = readVector(vector, 6)
const [solveStatus, pointX, pointY, pointParameter, residualX, residualY] = values
if (pointParameter < -1e-9 || pointParameter > 1 + 1e-9) throw new PlanegcsSubsetError('Native planegcs PointOnObject B-spline solution lies outside the clamped parameter range.')
point.position = { x: pointX, y: pointY }
return resultFor(snapshot, solveStatus, Math.max(Math.abs(residualX), Math.abs(residualY)), 0)
}
if (target.type !== 'ellipse' || !(target.majorRadius > target.minorRadius) || !(target.minorRadius > 0) || !Number.isFinite(target.rotation)) throw new PlanegcsSubsetError('Native planegcs PointOnObject subset supports only a valid Circle, Arc, Ellipse or horizontal/vertical Line.')
if (!module.solvePointOnEllipse) throw new PlanegcsSubsetError('Native planegcs PointOnObject ellipse subset is unavailable in this module build.')
const focalDistance = Math.sqrt(Math.max(0, target.majorRadius * target.majorRadius - target.minorRadius * target.minorRadius))
const focusX = target.center.x + focalDistance * Math.cos(target.rotation)
const focusY = target.center.y + focalDistance * Math.sin(target.rotation)
const vector = module.solvePointOnEllipse(point.position.x, point.position.y, target.center.x, target.center.y, focusX, focusY, target.minorRadius)
const values = readVector(vector, 9)
const [solveStatus, pointX, pointY, centerX, centerY, solvedFocusX, solvedFocusY, minorRadius, residual] = values
point.position = { x: pointX, y: pointY }
target.center = { x: centerX, y: centerY }
target.minorRadius = minorRadius
const solvedFocalDistance = Math.hypot(solvedFocusX - centerX, solvedFocusY - centerY)
target.majorRadius = Math.hypot(solvedFocalDistance, minorRadius)
target.rotation = Math.atan2(solvedFocusY - centerY, solvedFocusX - centerX)
return resultFor(snapshot, solveStatus, Math.abs(residual), 0)
}
if (snapshot.geometry.length === 1 && snapshot.geometry[0].type === 'circle') {
const circle = snapshot.geometry[0]
const dimensions = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'radius' | 'diameter' }> => (constraint.type === 'radius' || constraint.type === 'diameter') && constraint.geometryId === circle.id)
if (driving.length !== 1 || dimensions.length !== 1 || !Number.isFinite(dimensions[0].value) || dimensions[0].value <= 0) throw new PlanegcsSubsetError('Native planegcs circle subset requires one driving positive Radius or Diameter constraint.')
const dimension = dimensions[0]
const vector = dimension.type === 'diameter'
? module.solveCircleDiameter(circle.center.x, circle.center.y, circle.radius, dimension.value)
: module.solveCircleRadius(circle.center.x, circle.center.y, circle.radius, dimension.value)
const values = readVector(vector, 5)
const [solveStatus, centerX, centerY, radius, residual] = values
circle.center = { x: centerX, y: centerY }
circle.radius = radius
return resultFor(snapshot, solveStatus, Math.abs(residual), 0)
}
if (snapshot.geometry.length === 1 && snapshot.geometry[0].type === 'line') {
const line = snapshot.geometry[0]
const orientations = driving.filter((constraint) => (constraint.type === 'horizontal' || constraint.type === 'vertical') && constraint.geometryId === line.id)
const distances = driving.filter((constraint) => isLineEndpointDistance(constraint, line.id))
const angles = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'angle' }> => constraint.type === 'angle' && constraint.geometryId === line.id)
if (driving.length !== 2 || distances.length !== 1 || (orientations.length + angles.length) !== 1) throw new PlanegcsSubsetError('Native planegcs one-line subset requires one driving orientation or Angle and one driving endpoint Distance, DistanceX or DistanceY constraint.')
const distanceConstraint = distances[0]
if ((distanceConstraint.type !== 'distance' && distanceConstraint.type !== 'distanceX' && distanceConstraint.type !== 'distanceY') || !Number.isFinite(distanceConstraint.value) || distanceConstraint.value <= 0) throw new PlanegcsSubsetError('Native planegcs endpoint Distance must be finite and greater than zero.')
if (angles.length === 1) {
if (distanceConstraint.type !== 'distance' || !Number.isFinite(angles[0].value)) throw new PlanegcsSubsetError('Native planegcs Angle requires a driving endpoint Distance and a finite angle in radians.')
const vector = module.solveAngle(line.start.x, line.start.y, line.end.x, line.end.y, distanceConstraint.value, angles[0].value)
const values = readVector(vector, 7)
const [solveStatus, startX, startY, endX, endY, lengthResidual, angleResidual] = values
line.start = { x: startX, y: startY }
line.end = { x: endX, y: endY }
return resultFor(snapshot, solveStatus, Math.max(Math.abs(lengthResidual), Math.abs(angleResidual)), 0)
}
const orientation = orientations[0]
const vector = distanceConstraint.type === 'distanceX'
? orientation.type === 'horizontal'
? module.solveDistanceX(line.start.x, line.start.y, line.end.x, line.end.y, distanceConstraint.value)
: (() => { throw new PlanegcsSubsetError('Native planegcs DistanceX requires a driving Horizontal constraint.') })()
: distanceConstraint.type === 'distanceY'
? orientation.type === 'vertical'
? module.solveDistanceY(line.start.x, line.start.y, line.end.x, line.end.y, distanceConstraint.value)
: (() => { throw new PlanegcsSubsetError('Native planegcs DistanceY requires a driving Vertical constraint.') })()
: orientation.type === 'vertical'
? module.solveVerticalDistance(line.start.x, line.start.y, line.end.x, line.end.y, distanceConstraint.value)
: module.solveHorizontalDistance(line.start.x, line.start.y, line.end.x, line.end.y, distanceConstraint.value)
const values = readVector(vector, 6)
const [solveStatus, startX, startY, endX, endY, signedResidual] = values
line.start = { x: startX, y: startY }
line.end = { x: endX, y: endY }
return resultFor(snapshot, solveStatus, Math.abs(signedResidual), 2)
}
const equal = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'equal' }> => constraint.type === 'equal')
const tangent = driving.filter((constraint): constraint is Extract<SketchConstraint, { type: 'tangent' }> => constraint.type === 'tangent')
if (driving.length === 1 && tangent.length === 1) {
const constraint = tangent[0]
const first = snapshot.geometry.find((geometry) => geometry.id === constraint.firstGeometryId)
const second = snapshot.geometry.find((geometry) => geometry.id === constraint.secondGeometryId)
if (!first || !second || first.id === second.id || first.type !== 'circle' || second.type !== 'circle') throw new PlanegcsSubsetError('Native planegcs Tangent subset supports two distinct circles only.')
const vector = module.solveTangentCircles(first.center.x, first.center.y, first.radius, second.center.x, second.center.y, second.radius)
const values = readVector(vector, 8)
const [solveStatus, firstCenterX, firstCenterY, firstRadius, secondCenterX, secondCenterY, secondRadius, residual] = values
first.center = { x: firstCenterX, y: firstCenterY }
first.radius = firstRadius
second.center = { x: secondCenterX, y: secondCenterY }
second.radius = secondRadius
return resultFor(snapshot, solveStatus, Math.abs(residual), 0)
}
if (driving.length === 1 && equal.length === 1) {
const constraint = equal[0]
const first = snapshot.geometry.find((geometry) => geometry.id === constraint.firstGeometryId)
const second = snapshot.geometry.find((geometry) => geometry.id === constraint.secondGeometryId)
if (!first || !second || first.id === second.id) throw new PlanegcsSubsetError('Native planegcs Equal constraint must reference two distinct geometries.')
if (first.type === 'line' && second.type === 'line') {
const vector = module.solveEqualLines(first.start.x, first.start.y, first.end.x, first.end.y, second.start.x, second.start.y, second.end.x, second.end.y)
const values = readVector(vector, 10)
const [solveStatus, firstStartX, firstStartY, firstEndX, firstEndY, secondStartX, secondStartY, secondEndX, secondEndY, residual] = values
first.start = { x: firstStartX, y: firstStartY }
first.end = { x: firstEndX, y: firstEndY }
second.start = { x: secondStartX, y: secondStartY }
second.end = { x: secondEndX, y: secondEndY }
return resultFor(snapshot, solveStatus, Math.abs(residual), 1)
}
if (first.type === 'circle' && second.type === 'circle') {
const vector = module.solveEqualCircles(first.center.x, first.center.y, first.radius, second.center.x, second.center.y, second.radius)
const values = readVector(vector, 8)
const [solveStatus, firstCenterX, firstCenterY, firstRadius, secondCenterX, secondCenterY, secondRadius, residual] = values
first.center = { x: firstCenterX, y: firstCenterY }
first.radius = firstRadius
second.center = { x: secondCenterX, y: secondCenterY }
second.radius = secondRadius
return resultFor(snapshot, solveStatus, Math.abs(residual), 0)
}
throw new PlanegcsSubsetError('Native planegcs Equal subset supports two lines or two circles only.')
}
if (snapshot.geometry.length !== 2 || snapshot.geometry.some((geometry) => geometry.type !== 'line')) throw new PlanegcsSubsetError('Native planegcs subset requires one or two lines.')
const coincident = driving.filter((constraint) => constraint.type === 'coincident')
if (driving.length === 1 && coincident.length === 1) {
const constraint = coincident[0]
const first = snapshot.geometry.find((geometry) => geometry.id === constraint.first.geometryId)
const second = snapshot.geometry.find((geometry) => geometry.id === constraint.second.geometryId)
if (!first || !second || first.type !== 'line' || second.type !== 'line' || first.id === second.id) throw new PlanegcsSubsetError('Native planegcs coincident constraint must reference two distinct lines.')
if ((constraint.first.point !== 'start' && constraint.first.point !== 'end') || (constraint.second.point !== 'start' && constraint.second.point !== 'end')) throw new PlanegcsSubsetError('Native planegcs coincident constraint requires start or end references on both lines.')
const secondLength = Math.hypot(second.end.x - second.start.x, second.end.y - second.start.y)
if (!(secondLength > 0)) throw new PlanegcsSubsetError('Native planegcs coincident constraint requires a non-degenerate second line.')
const args = [first.start.x, first.start.y, first.end.x, first.end.y, second.start.x, second.start.y, second.end.x, second.end.y, secondLength] as const
const vector = module.solveCoincidentLinePoints
? module.solveCoincidentLinePoints(...args, constraint.first.point === 'end', constraint.second.point === 'end')
: constraint.first.point === 'end' && constraint.second.point === 'start'
? module.solveCoincidentLines(...args)
: (() => { throw new PlanegcsSubsetError('Native planegcs artifact does not expose selectable Coincident line endpoints.') })()
const values = readVector(vector, 10)
const [solveStatus, firstStartX, firstStartY, firstEndX, firstEndY, secondStartX, secondStartY, secondEndX, secondEndY, residual] = values
first.start = { x: firstStartX, y: firstStartY }
first.end = { x: firstEndX, y: firstEndY }
second.start = { x: secondStartX, y: secondStartY }
second.end = { x: secondEndX, y: secondEndY }
return resultFor(snapshot, solveStatus, Math.abs(residual), 1)
}
const relations = driving.filter((constraint) => constraint.type === 'parallel' || constraint.type === 'perpendicular')
if (driving.length !== 1 || relations.length !== 1) throw new PlanegcsSubsetError('Native planegcs two-line subset requires exactly one driving Parallel, Perpendicular or Coincident constraint.')
const relation = relations[0]
if (relation.type !== 'parallel' && relation.type !== 'perpendicular') throw new PlanegcsSubsetError('Native planegcs line relation is invalid.')
const first = snapshot.geometry.find((geometry) => geometry.id === relation.firstGeometryId)
const second = snapshot.geometry.find((geometry) => geometry.id === relation.secondGeometryId)
if (!first || !second || first.type !== 'line' || second.type !== 'line' || first.id === second.id) throw new PlanegcsSubsetError('Native planegcs line relation must reference two distinct lines in the snapshot.')
const secondLength = Math.hypot(second.end.x - second.start.x, second.end.y - second.start.y)
if (!(secondLength > 0)) throw new PlanegcsSubsetError('Native planegcs line relation requires a non-degenerate second line.')
const args = [first.start.x, first.start.y, first.end.x, first.end.y, second.start.x, second.start.y, second.end.x, second.end.y, secondLength] as const
const vector = relation.type === 'perpendicular' ? module.solvePerpendicularLines(...args) : module.solveParallelLines(...args)
const values = readVector(vector, 10)
const [solveStatus, firstStartX, firstStartY, firstEndX, firstEndY, secondStartX, secondStartY, secondEndX, secondEndY, signedResidual] = values
first.start = { x: firstStartX, y: firstStartY }
first.end = { x: firstEndX, y: firstEndY }
second.start = { x: secondStartX, y: secondStartY }
second.end = { x: secondEndX, y: secondEndY }
return resultFor(snapshot, solveStatus, Math.abs(signedResidual), 7)
}
const readVector = (vector: PlanegcsDoubleVector, expectedSize: number): number[] => {
let values: number[]
try {
if (vector.size() !== expectedSize) throw new Error(`FreeCAD planegcs returned ${vector.size()} values; expected ${expectedSize}.`)
values = Array.from({ length: expectedSize }, (_, index) => vector.get(index))
} finally {
vector.delete()
}
if (values.some((value) => !Number.isFinite(value))) throw new Error('FreeCAD planegcs returned a non-finite solution.')
return values
}
const resultFor = (snapshot: SketchSnapshot, solveStatus: number, residual: number, degreesOfFreedom: number): SketchSolveResult => {
const converged = solveStatus <= 1
const diagnostics = converged ? [] : [{ code: 'SOLVER_NOT_CONVERGED' as const, message: `FreeCAD planegcs solve status ${solveStatus}.` }]
const status = converged ? (degreesOfFreedom === 0 ? 'solved' as const : 'under-constrained' as const) : 'conflicting' as const
snapshot.solver = { status, degreesOfFreedom, residual, iterations: 0, diagnostics }
return { snapshot, status, degreesOfFreedom, residual, iterations: 0, diagnostics }
}

View File

@@ -0,0 +1,124 @@
import { PLANEGCS_WASM_CAPABILITIES } from './planegcsAdapter'
import { assertSketchSolverRequest, assertSketchSolverResponse, type SketchSolverCapabilities, type SketchSolverProvider, type SketchSolverRequest, type SketchSolverResponse } from './sketchSolverProtocol'
type WorkerLike = Pick<Worker, 'postMessage' | 'terminate'> & {
addEventListener(type: 'message', listener: (event: MessageEvent) => void): void
addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void
removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void
removeEventListener(type: 'error', listener: (event: ErrorEvent) => void): void
}
type WorkerResponse = { type: 'ready'; capabilities: SketchSolverCapabilities } | { type: 'response'; response: SketchSolverResponse } | { type: 'error'; requestId?: string; error: string }
export type PlanegcsWorkerOptions = {
moduleUrl?: string
initializationTimeoutMs?: number
workerFactory?: () => WorkerLike
}
const unavailableCapabilities = (reason: string): SketchSolverCapabilities => ({ ...PLANEGCS_WASM_CAPABILITIES, availability: 'unavailable', reason })
const abortError = () => new DOMException('FreeCAD planegcs Worker request cancelled.', 'AbortError')
const defaultWorkerFactory = () => new Worker(new URL('./planegcsWorkerEntry.ts', import.meta.url), { type: 'module', name: 'freecad-planegcs' })
export class PlanegcsWorkerProvider implements SketchSolverProvider {
private worker: WorkerLike
private readonly workerFactory: () => WorkerLike
private readonly options: Required<Pick<PlanegcsWorkerOptions, 'moduleUrl' | 'initializationTimeoutMs'>>
private readonly pending = new Map<string, { resolve: (response: SketchSolverResponse) => void; reject: (error: Error) => void }>()
private initialization: Promise<SketchSolverCapabilities> | null = null
private initializationReject: ((error: Error) => void) | null = null
private disposed = false
private current = unavailableCapabilities('FreeCAD planegcs Worker has not been initialized.')
private readonly onMessage = (event: MessageEvent<WorkerResponse>) => {
if (event.data.type === 'ready') { this.current = event.data.capabilities; return }
if (event.data.type === 'error') {
const pending = event.data.requestId ? this.pending.get(event.data.requestId) : undefined
if (pending && event.data.requestId) { this.pending.delete(event.data.requestId); pending.reject(new Error(event.data.error)) }
if (!event.data.requestId) this.initializationReject?.(new Error(event.data.error))
return
}
const pending = this.pending.get(event.data.response.requestId)
if (!pending) return
this.pending.delete(event.data.response.requestId)
pending.resolve(event.data.response)
}
private readonly onError = (event: ErrorEvent) => this.replaceWorker(new Error(event.message || 'FreeCAD planegcs Worker failed.'))
constructor(options: PlanegcsWorkerOptions = {}) {
this.workerFactory = options.workerFactory || defaultWorkerFactory
this.options = { moduleUrl: options.moduleUrl || '/native/planegcs/freecad-planegcs.js', initializationTimeoutMs: options.initializationTimeoutMs ?? 120_000 }
this.worker = this.workerFactory()
this.attachWorker(this.worker)
}
private attachWorker(worker: WorkerLike) {
worker.addEventListener('message', this.onMessage)
worker.addEventListener('error', this.onError)
}
private detachWorker(worker: WorkerLike) {
worker.removeEventListener('message', this.onMessage)
worker.removeEventListener('error', this.onError)
}
private replaceWorker(error: Error) {
const previous = this.worker
this.detachWorker(previous)
previous.terminate()
this.initializationReject?.(error)
this.initializationReject = null
this.initialization = null
for (const pending of this.pending.values()) pending.reject(error)
this.pending.clear()
this.current = unavailableCapabilities(error.message)
if (!this.disposed) {
this.worker = this.workerFactory()
this.attachWorker(this.worker)
}
}
capabilities(): SketchSolverCapabilities { return { ...this.current, supportedGeometry: [...this.current.supportedGeometry], supportedConstraints: [...this.current.supportedConstraints] } }
initialize(): Promise<SketchSolverCapabilities> {
if (this.disposed) return Promise.reject(new Error('FreeCAD planegcs Worker provider is disposed.'))
if (this.current.availability === 'available') return Promise.resolve(this.capabilities())
if (this.initialization) return this.initialization
this.initialization = new Promise<SketchSolverCapabilities>((resolve, reject) => {
let settled = false
const timer = setTimeout(() => fail(new Error('FreeCAD planegcs Worker initialization timed out.')), this.options.initializationTimeoutMs)
const fail = (error: Error) => { if (settled) return; settled = true; clearTimeout(timer); this.worker.removeEventListener('message', waitForReady); this.initialization = null; this.initializationReject = null; this.current = unavailableCapabilities(error.message); reject(error) }
this.initializationReject = fail
const waitForReady = (event: MessageEvent<WorkerResponse>) => { if (event.data.type === 'error') return fail(new Error(event.data.error)); if (event.data.type !== 'ready') return; settled = true; clearTimeout(timer); this.worker.removeEventListener('message', waitForReady); this.initializationReject = null; resolve(this.capabilities()) }
this.worker.addEventListener('message', waitForReady)
this.worker.postMessage({ type: 'initialize', moduleUrl: this.options.moduleUrl })
})
return this.initialization
}
async solve(request: SketchSolverRequest, signal: AbortSignal): Promise<SketchSolverResponse> {
assertSketchSolverRequest(request)
if (signal.aborted) throw abortError()
const cancel = () => this.replaceWorker(abortError())
signal.addEventListener('abort', cancel, { once: true })
try {
await this.initialize()
if (signal.aborted) throw abortError()
const response = new Promise<SketchSolverResponse>((resolve, reject) => {
this.pending.set(request.requestId, { resolve, reject })
this.worker.postMessage({ type: 'solve', request })
})
return assertSketchSolverResponse(request, await response)
} finally {
signal.removeEventListener('abort', cancel)
}
}
dispose() {
if (this.disposed) return
this.disposed = true
this.worker.postMessage({ type: 'dispose' })
this.replaceWorker(new Error('FreeCAD planegcs Worker provider disposed.'))
}
}

View File

@@ -0,0 +1,35 @@
/// <reference lib="webworker" />
import { PLANEGCS_WASM_CAPABILITIES, solvePlanegcsSubset, type PlanegcsWasmModule } from './planegcsAdapter'
import { assertSketchSolverRequest, type SketchSolverRequest, type SketchSolverResponse } from './sketchSolverProtocol'
type WorkerRequest = { type: 'initialize'; moduleUrl: string } | { type: 'solve'; request: SketchSolverRequest } | { type: 'dispose' }
type WorkerResponse = { type: 'ready'; capabilities: typeof PLANEGCS_WASM_CAPABILITIES } | { type: 'response'; response: SketchSolverResponse } | { type: 'error'; requestId?: string; error: string }
const scope = self as DedicatedWorkerGlobalScope
let module: PlanegcsWasmModule | null = null
const send = (message: WorkerResponse) => scope.postMessage(message)
const initialize = async (moduleUrl: string) => {
const imported = await import(/* @vite-ignore */ moduleUrl) as { default?: () => Promise<PlanegcsWasmModule> }
if (typeof imported.default !== 'function') throw new Error('FreeCAD planegcs module has no default Emscripten factory export.')
module = await imported.default()
send({ type: 'ready', capabilities: PLANEGCS_WASM_CAPABILITIES })
}
scope.onmessage = ({ data }: MessageEvent<WorkerRequest>) => {
void (async () => {
try {
if (data.type === 'initialize') return await initialize(data.moduleUrl)
if (data.type === 'dispose') { module = null; scope.close(); return }
if (!module) throw new Error('FreeCAD planegcs Worker is not initialized.')
assertSketchSolverRequest(data.request)
const result = solvePlanegcsSubset(module, data.request.snapshot)
send({ type: 'response', response: { protocolVersion: data.request.protocolVersion, requestId: data.request.requestId, documentId: data.request.documentId, documentVersion: data.request.documentVersion, generation: data.request.generation, provider: PLANEGCS_WASM_CAPABILITIES, result } })
} catch (error) {
send({ type: 'error', requestId: data.type === 'solve' ? data.request.requestId : undefined, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) })
}
})()
}
export {}

101
src/facade/plot.ts Normal file
View File

@@ -0,0 +1,101 @@
export type PlotPoint = { x: number; y: number }
export type PlotSeriesStyle = { color: string; lineWidth: number; dash?: number[] }
export type PlotSeriesBinding = { sourceId: string; xRange: string; yRange: string }
export type PlotSeries = { id: string; label: string; points: PlotPoint[]; style: PlotSeriesStyle; binding?: PlotSeriesBinding }
export type PlotAxis = { label: string; minimum?: number; maximum?: number; scale: 'linear' | 'log' }
export type PlotSnapshot = { id: string; label: string; xAxis: PlotAxis; yAxis: PlotAxis; legend: boolean; series: PlotSeries[]; version: number }
export type PlotApi = {
snapshot(): PlotSnapshot
setAxes(input: { x: PlotAxis; y: PlotAxis }): PlotSnapshot
setLegend(visible: boolean): PlotSnapshot
setSeries(series: PlotSeries): PlotSeries
bindSeries(seriesId: string, binding: PlotSeriesBinding): PlotSeries
updateBoundSeries(seriesId: string, points: PlotPoint[]): PlotSeries
exportCsv(): string
exportSvg(width?: number, height?: number): string
}
const cloneAxis = (axis: PlotAxis): PlotAxis => ({ ...axis })
const cloneSeries = (series: PlotSeries): PlotSeries => ({ ...series, points: series.points.map((point) => ({ ...point })), style: { ...series.style, dash: series.style.dash ? [...series.style.dash] : undefined }, binding: series.binding ? { ...series.binding } : undefined })
const xmlEscape = (value: string) => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const csvEscape = (value: string) => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
const validateAxis = (axis: PlotAxis, name: string) => {
if (axis.minimum !== undefined && !Number.isFinite(axis.minimum)) throw new RangeError(`${name} axis minimum must be finite.`)
if (axis.maximum !== undefined && !Number.isFinite(axis.maximum)) throw new RangeError(`${name} axis maximum must be finite.`)
if (axis.minimum !== undefined && axis.maximum !== undefined && axis.minimum >= axis.maximum) throw new RangeError(`${name} axis minimum must be less than maximum.`)
if (axis.scale === 'log' && ((axis.minimum !== undefined && axis.minimum <= 0) || (axis.maximum !== undefined && axis.maximum <= 0))) throw new RangeError(`${name} log axis bounds must be greater than zero.`)
}
const validateSeries = (series: PlotSeries, xAxis?: PlotAxis, yAxis?: PlotAxis) => {
if (!series.id.trim() || !series.label.trim()) throw new RangeError('Plot series requires an id and label.')
if (series.points.length < 2 || series.points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) throw new RangeError('Plot series requires at least two finite points.')
if (!/^#[0-9a-f]{6}$/i.test(series.style.color)) throw new RangeError(`Plot series color must be a six-digit hex value: ${series.style.color}`)
if (!Number.isFinite(series.style.lineWidth) || series.style.lineWidth <= 0 || series.style.lineWidth > 20) throw new RangeError('Plot series line width must be greater than zero and no more than 20.')
if (series.style.dash?.some((value) => !Number.isFinite(value) || value <= 0)) throw new RangeError('Plot dash values must be positive finite numbers.')
if (xAxis?.scale === 'log' && series.points.some((point) => point.x <= 0)) throw new RangeError('Plot log X axis requires strictly positive series values.')
if (yAxis?.scale === 'log' && series.points.some((point) => point.y <= 0)) throw new RangeError('Plot log Y axis requires strictly positive series values.')
}
const bounds = (series: PlotSeries[], axis: 'x' | 'y', configured: PlotAxis) => {
const values = series.flatMap((entry) => entry.points.map((point) => point[axis]))
const minimum = configured.minimum ?? Math.min(...values)
const maximum = configured.maximum ?? Math.max(...values)
if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) return { minimum: 0, maximum: 1 }
if (configured.scale === 'log' && (minimum <= 0 || maximum <= 0)) throw new RangeError(`Plot log ${axis.toUpperCase()} axis requires strictly positive values.`)
if (minimum === maximum) return configured.scale === 'log' ? { minimum: minimum / 10, maximum: maximum * 10 } : { minimum: minimum - 0.5, maximum: maximum + 0.5 }
return { minimum, maximum }
}
export const createPlot = (id = 'plot', label = 'Plot'): PlotApi => {
let xAxis: PlotAxis = { label: 'X', scale: 'linear' }
let yAxis: PlotAxis = { label: 'Y', scale: 'linear' }
let legend = true
let version = 0
const seriesById = new Map<string, PlotSeries>()
const snapshot = (): PlotSnapshot => ({ id, label, xAxis: cloneAxis(xAxis), yAxis: cloneAxis(yAxis), legend, series: [...seriesById.values()].map(cloneSeries), version })
const setAxes = (input: { x: PlotAxis; y: PlotAxis }) => { validateAxis(input.x, 'X'); validateAxis(input.y, 'Y'); for (const series of seriesById.values()) validateSeries(series, input.x, input.y); xAxis = cloneAxis(input.x); yAxis = cloneAxis(input.y); version += 1; return snapshot() }
const setLegend = (visible: boolean) => { legend = visible; version += 1; return snapshot() }
const setSeries = (series: PlotSeries) => { validateSeries(series, xAxis, yAxis); const copy = cloneSeries(series); seriesById.set(copy.id, copy); version += 1; return cloneSeries(copy) }
const bindSeries = (seriesId: string, binding: PlotSeriesBinding) => {
const series = seriesById.get(seriesId)
if (!series) throw new RangeError(`Plot series does not exist: ${seriesId}`)
if (!binding.sourceId || !binding.xRange || !binding.yRange) throw new RangeError('Plot series binding requires source and ranges.')
series.binding = { ...binding }
version += 1
return cloneSeries(series)
}
const updateBoundSeries = (seriesId: string, points: PlotPoint[]) => {
const series = seriesById.get(seriesId)
if (!series?.binding) throw new RangeError(`Plot series is not bound: ${seriesId}`)
return setSeries({ ...cloneSeries(series), points })
}
const exportCsv = () => {
const rows = [['series', 'label', 'x', 'y']]
for (const series of seriesById.values()) for (const point of series.points) rows.push([series.id, series.label, String(point.x), String(point.y)])
return rows.map((row) => row.map(csvEscape).join(',')).join('\n')
}
const exportSvg = (width = 640, height = 360) => {
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 200 || height < 160 || width > 4096 || height > 4096) throw new RangeError('Plot SVG dimensions are outside the supported range.')
const series = [...seriesById.values()]
const x = bounds(series, 'x', xAxis)
const y = bounds(series, 'y', yAxis)
const margin = { left: 56, right: legend ? 144 : 24, top: 24, bottom: 48 }
const plotWidth = width - margin.left - margin.right
const plotHeight = height - margin.top - margin.bottom
const normalize = (value: number, axis: PlotAxis, range: { minimum: number; maximum: number }) => axis.scale === 'log'
? (Math.log10(value) - Math.log10(range.minimum)) / (Math.log10(range.maximum) - Math.log10(range.minimum))
: (value - range.minimum) / (range.maximum - range.minimum)
const mapX = (value: number) => margin.left + normalize(value, xAxis, x) * plotWidth
const mapY = (value: number) => margin.top + plotHeight - normalize(value, yAxis, y) * plotHeight
const paths = series.map((entry) => {
const path = entry.points.map((point, index) => `${index === 0 ? 'M' : 'L'}${mapX(point.x).toFixed(3)} ${mapY(point.y).toFixed(3)}`).join(' ')
const dash = entry.style.dash ? ` stroke-dasharray="${entry.style.dash.join(' ')}"` : ''
return `<path data-series="${xmlEscape(entry.id)}" d="${path}" fill="none" stroke="${entry.style.color}" stroke-width="${entry.style.lineWidth}"${dash}/>`
}).join('')
const legendItems = legend ? series.map((entry, index) => `<g transform="translate(${width - margin.right + 18} ${margin.top + 18 + index * 22})"><line x2="22" stroke="${entry.style.color}" stroke-width="${entry.style.lineWidth}"/><text x="30" y="4">${xmlEscape(entry.label)}</text></g>`).join('') : ''
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${xmlEscape(label)}" data-x-scale="${xAxis.scale}" data-y-scale="${yAxis.scale}"><rect width="100%" height="100%" fill="#ffffff"/><g font-family="sans-serif" font-size="12" fill="#202428"><line x1="${margin.left}" y1="${margin.top + plotHeight}" x2="${margin.left + plotWidth}" y2="${margin.top + plotHeight}" stroke="#59616a"/><line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotHeight}" stroke="#59616a"/>${paths}<text x="${margin.left + plotWidth / 2}" y="${height - 12}" text-anchor="middle">${xmlEscape(xAxis.label)}</text><text transform="translate(16 ${margin.top + plotHeight / 2}) rotate(-90)" text-anchor="middle">${xmlEscape(yAxis.label)}</text>${legendItems}</g></svg>`
}
return { snapshot, setAxes, setLegend, setSeries, bindSeries, updateBoundSeries, exportCsv, exportSvg }
}

View File

@@ -0,0 +1,9 @@
export type ProductionKind = 'TechDraw' | 'Spreadsheet' | 'Draft' | 'Plot'
export type ProductionArtifact = { id: string; kind: ProductionKind; version: number; dependencies: string[]; payload: Record<string, unknown> }
export type ProductionClosure = { complete: boolean; missingKinds: ProductionKind[]; missingDependencies: string[]; cycles: string[][]; artifacts: number }
export type ProductionDocumentSnapshot = { id: string; label: string; artifacts: ProductionArtifact[]; version: number }
export type ProductionDocumentApi = { add(input: Omit<ProductionArtifact, 'version'> & { version?: number }): ProductionArtifact; update(id: string, patch: Partial<Pick<ProductionArtifact, 'dependencies' | 'payload'>>): ProductionArtifact; closure(): ProductionClosure; snapshot(): ProductionDocumentSnapshot; save(): string; load(serialized: string): ProductionDocumentSnapshot }
const kinds: ProductionKind[] = ['TechDraw', 'Spreadsheet', 'Draft', 'Plot']
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const validKind = (value: string): value is ProductionKind => kinds.includes(value as ProductionKind)
export const createProductionDocument = (id = 'document', label = 'Production document'): ProductionDocumentApi => { const artifacts = new Map<string, ProductionArtifact>(); let version = 0; const add = (input: Omit<ProductionArtifact, 'version'> & { version?: number }) => { if (!input.id.trim() || artifacts.has(input.id)) throw new RangeError(`Production artifact already exists: ${input.id}`); if (!validKind(input.kind)) throw new RangeError(`Unsupported production artifact kind: ${input.kind}`); const artifact: ProductionArtifact = { id: input.id, kind: input.kind, version: input.version ?? 1, dependencies: [...new Set(input.dependencies)], payload: clone(input.payload) }; if (!Number.isSafeInteger(artifact.version) || artifact.version < 1) throw new RangeError('Production artifact version must be positive.'); artifacts.set(artifact.id, artifact); version += 1; return clone(artifact) }; const update = (artifactId: string, patch: Partial<Pick<ProductionArtifact, 'dependencies' | 'payload'>>) => { const artifact = artifacts.get(artifactId); if (!artifact) throw new RangeError(`Production artifact does not exist: ${artifactId}`); if (patch.dependencies) artifact.dependencies = [...new Set(patch.dependencies)]; if (patch.payload) artifact.payload = clone(patch.payload); artifact.version += 1; version += 1; return clone(artifact) }; const closure = (): ProductionClosure => { const present = new Set([...artifacts.values()].map((artifact) => artifact.kind)); const missingKinds = kinds.filter((kind) => !present.has(kind)); const missingDependencies: string[] = []; for (const artifact of artifacts.values()) for (const dependency of artifact.dependencies) if (!artifacts.has(dependency)) missingDependencies.push(`${artifact.id}->${dependency}`); const cycles: string[][] = []; const visiting = new Set<string>(); const visited = new Set<string>(); const walk = (id: string, path: string[]) => { if (visiting.has(id)) { const start = path.indexOf(id); cycles.push(path.slice(start).concat(id)); return }; if (visited.has(id)) return; visiting.add(id); const artifact = artifacts.get(id); for (const dependency of artifact?.dependencies ?? []) if (artifacts.has(dependency)) walk(dependency, [...path, dependency]); visiting.delete(id); visited.add(id) }; for (const artifact of artifacts.values()) walk(artifact.id, [artifact.id]); return { complete: missingKinds.length === 0 && missingDependencies.length === 0 && cycles.length === 0, missingKinds, missingDependencies: [...new Set(missingDependencies)].sort(), cycles, artifacts: artifacts.size } }; const snapshot = (): ProductionDocumentSnapshot => ({ id, label, artifacts: [...artifacts.values()].map(clone), version }); const save = () => `${JSON.stringify(snapshot(), null, 2)}\n`; const load = (serialized: string) => { let parsed: unknown; try { parsed = JSON.parse(serialized) } catch { throw new Error('Production document is not valid JSON.') }; if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as { artifacts?: unknown }).artifacts)) throw new Error('Production document artifacts are missing.'); const source = parsed as ProductionDocumentSnapshot; artifacts.clear(); for (const artifact of source.artifacts) add(artifact); version = source.version; return snapshot() }; return { add, update, closure, snapshot, save, load } }

View File

@@ -0,0 +1,42 @@
import sqlite3InitModule, { type Database } from '@sqlite.org/sqlite-wasm'
import { PROJECT_SCHEMA_MIGRATIONS } from './projectSchema'
type SeedRequest = { operation: 'seed'; databasePath: string; documentId: string; resourceHash: string; resourceBytes: ArrayBuffer } | { operation: 'inspect'; databasePath: string }
type SeedResponse = { ok: true; schemaVersion: 1; documentId?: string; resourceHash?: string; appliedVersions?: number[] } | { ok: false; error: string }
const scope = globalThis as unknown as { onmessage: ((event: MessageEvent<SeedRequest>) => void) | null; postMessage(message: SeedResponse): void }
scope.onmessage = (event) => {
void (async () => {
let database: Database | undefined
try {
const request = event.data
if (!/^\/[A-Za-z0-9._-]+\.sqlite3$/.test(request.databasePath)) throw new Error('Legacy seed database path is invalid.')
const sqlite3 = await sqlite3InitModule()
database = new sqlite3.oo1.OpfsDb(request.databasePath)
if (request.operation === 'inspect') {
const rows = database.exec({ sql: 'SELECT version FROM schema_migrations ORDER BY version', rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, number>>
scope.postMessage({ ok: true, schemaVersion: 1, appliedVersions: rows.map((row) => Number(row.version)) })
return
}
database.exec(PROJECT_SCHEMA_MIGRATIONS[0].sql)
const now = Date.now()
database.exec({ sql: 'INSERT INTO schema_migrations(version, applied_at) VALUES(1, ?)', bind: [now] })
database.exec({ sql: 'INSERT INTO projects(id, name, schema_version, created_at, updated_at) VALUES(?, ?, 1, ?, ?)', bind: [request.documentId, 'Legacy OPFS project', now, now] })
database.exec({ sql: 'INSERT INTO documents(id, project_id, label, version, dirty, read_only, units) VALUES(?, ?, ?, 3, 1, 0, ?)', bind: [request.documentId, request.documentId, 'Legacy OPFS project', 'mm'] })
database.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, 0)', bind: ['legacy-root', request.documentId, 'Legacy root', 'PartDesign::Feature', 'valid', 'seeded from schema v1', '[]'] })
database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [request.documentId, 'legacy-root', 'Label', JSON.stringify({ name: 'Label', label: 'Label', type: 'App::PropertyString', value: 'Legacy root', recompute: false }), 'App::PropertyString', now] })
database.exec({ sql: 'INSERT INTO resources(hash, path, byte_length, media_type, ref_count, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?)', bind: [request.resourceHash, `bitbybit-assets/${request.resourceHash}`, request.resourceBytes.byteLength, 'application/octet-stream', now, now] })
const root = await navigator.storage.getDirectory()
const assets = await root.getDirectoryHandle('bitbybit-assets', { create: true })
const handle = await assets.getFileHandle(request.resourceHash, { create: true })
const writable = await handle.createWritable()
await writable.write(request.resourceBytes)
await writable.close()
scope.postMessage({ ok: true, schemaVersion: 1, documentId: request.documentId, resourceHash: request.resourceHash })
} catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) })
} finally {
database?.close()
}
})()
}

View File

@@ -1,18 +1,21 @@
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types'
import { cloneSketch } from './sketcher'
import { cloneObjectTopologySnapshot } from './topologyReferences'
import { cloneObjectTopologySnapshot, validateTopologySnapshotForPersistence } from './topologyReferences'
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'load-checkpoint'; documentId: string; version?: number } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'load-checkpoint'; documentId: string; version?: number } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
export type SqliteProjectPersistenceOptions = { databasePath?: string; migrationFailureVersion?: number; migrationInterruptAfterVersion?: number }
type WorkerRequest = { id: number; type: 'initialize'; databasePath?: string; migrationFailureVersion?: number; migrationInterruptAfterVersion?: number } | { id: number; type: 'dispose' | 'list-projects' | 'sweep-resources' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'load-checkpoint'; documentId: string; version?: number } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
type WorkerInput = { type: 'initialize'; databasePath?: string; migrationFailureVersion?: number; migrationInterruptAfterVersion?: number } | { type: 'dispose' | 'list-projects' | 'sweep-resources' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'load-checkpoint'; documentId: string; version?: number } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded' | 'checkpoint-loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'resources-swept'; report: ProjectResourceSweepReport } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
const clonePropertyValue = (value: PropertyValue): PropertyValue => {
if (Array.isArray(value)) return [...value]
if (Array.isArray(value)) return value.every((entry) => typeof entry === 'number') ? [...value] as number[] : [...value] as string[]
if (!value || typeof value !== 'object') return value
if ('position' in value && 'rotation' in value) return { position: { ...value.position }, rotation: { axis: { ...value.rotation.axis }, angle: value.rotation.angle } }
if ('steps' in value && Array.isArray(value.steps)) return { steps: value.steps.map((step) => ({ ...step })) }
if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
if ('entries' in value && Array.isArray(value.entries)) return { ...value, entries: value.entries.map((entry) => ({ ...entry, subElement: entry.subElement && typeof entry.subElement === 'object' ? { ...entry.subElement, candidates: entry.subElement.candidates ? [...entry.subElement.candidates] : undefined } : entry.subElement })) }
if ('subElements' in value && Array.isArray(value.subElements)) return { ...value, subElements: value.subElements.map((entry) => typeof entry === 'string' ? entry : { ...entry, candidates: entry.candidates ? [...entry.candidates] : undefined }) }
if ('persistentId' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
return { ...value }
}
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
@@ -93,7 +96,14 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }>()
private readonly writeQueue = new PersistenceWriteQueue()
constructor() {
private readonly databasePath?: string
private readonly migrationFailureVersion?: number
private readonly migrationInterruptAfterVersion?: number
constructor(options: SqliteProjectPersistenceOptions = {}) {
this.databasePath = options.databasePath
this.migrationFailureVersion = options.migrationFailureVersion
this.migrationInterruptAfterVersion = options.migrationInterruptAfterVersion
this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' })
this.changeChannel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('bitbybit-project-changes')
;(this.changeChannel as (BroadcastChannel & { unref?: () => void }) | null)?.unref?.()
@@ -118,7 +128,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
initialize() {
if (!this.worker) return Promise.resolve(this.currentCapabilities)
if (!this.initialized) this.initialized = this.request({ type: 'initialize' }).then((response) => { if (!response.ok || response.type !== 'initialized') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error); this.currentCapabilities = { ...response.capabilities, crossTabWriteLock: this.crossTabWriteLockMode() }; return this.capabilities() })
if (!this.initialized) this.initialized = this.request({ type: 'initialize', databasePath: this.databasePath, migrationFailureVersion: this.migrationFailureVersion, migrationInterruptAfterVersion: this.migrationInterruptAfterVersion }).then((response) => { if (!response.ok || response.type !== 'initialized') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error); this.currentCapabilities = { ...response.capabilities, crossTabWriteLock: this.crossTabWriteLockMode() }; return this.capabilities() })
return this.initialized
}
@@ -133,6 +143,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
save(document: DocumentSnapshot) {
return this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
for (const object of document.objects) if (object.topology) validateTopologySnapshotForPersistence(object.topology)
await this.initialize()
if (!this.worker) {
const snapshot = cloneDocument(document)
@@ -264,4 +275,4 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
}
}
export const createSqliteProjectPersistence = () => new SqliteProjectPersistence()
export const createSqliteProjectPersistence = (options: SqliteProjectPersistenceOptions = {}) => new SqliteProjectPersistence(options)

File diff suppressed because it is too large Load Diff

9
src/facade/robot.ts Normal file
View File

@@ -0,0 +1,9 @@
export type RobotJoint = { id: string; label: string; minimum: number; maximum: number; linkLength?: number }
export type RobotWaypoint = { id: string; values: number[] }
export type RobotPose = { step: number; values: number[] }
export type RobotSnapshot = { id: string; label: string; joints: RobotJoint[]; waypoints: RobotWaypoint[]; trajectory: RobotPose[]; status: 'draft' | 'generated' | 'invalid'; version: number }
export type RobotKinematics = { position: [number, number, number]; maxReach: number }
export type RobotApi = { snapshot(): RobotSnapshot; addJoint(joint: RobotJoint): RobotJoint; addWaypoint(waypoint: RobotWaypoint): RobotWaypoint; generateLinearTrajectory(stepsPerSegment: number): RobotSnapshot; validateTrajectory(): { valid: boolean; violations: number[] }; forwardKinematics(values: number[]): RobotKinematics; workspace(): { maxReach: number; jointCount: number }; collisions(obstacles: Array<{ id: string; min: [number, number, number]; max: [number, number, number] }>): Array<{ obstacleId: string; pose: number }>; exportController(): string; exportCsv(): string }
const finite = (value: number, label: string) => { if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`) }
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const createRobot = (id = 'robot', label = 'Robot'): RobotApi => { const joints = new Map<string, RobotJoint>(); const waypoints: RobotWaypoint[] = []; let trajectory: RobotPose[] = []; let status: RobotSnapshot['status'] = 'draft'; let version = 0; const snapshot = (): RobotSnapshot => ({ id, label, joints: [...joints.values()].map(clone), waypoints: clone(waypoints), trajectory: clone(trajectory), status, version }); const addJoint = (input: RobotJoint) => { if (!input.id.trim() || joints.has(input.id)) throw new RangeError(`Robot joint already exists: ${input.id}`); if (!input.label.trim()) throw new RangeError('Robot joint label is required.'); finite(input.minimum, 'Robot joint minimum'); finite(input.maximum, 'Robot joint maximum'); if (input.minimum >= input.maximum) throw new RangeError('Robot joint minimum must be less than maximum.'); if (input.linkLength !== undefined && (!Number.isFinite(input.linkLength) || input.linkLength <= 0)) throw new RangeError('Robot link length must be positive and finite.'); joints.set(input.id, clone(input)); status = 'draft'; version += 1; return clone(input) }; const addWaypoint = (input: RobotWaypoint) => { if (!input.id.trim() || waypoints.some((entry) => entry.id === input.id)) throw new RangeError(`Robot waypoint already exists: ${input.id}`); if (input.values.length !== joints.size || input.values.some((value) => !Number.isFinite(value))) throw new RangeError('Robot waypoint values must match joint count and be finite.'); const jointList = [...joints.values()]; input.values.forEach((value, index) => { if (value < jointList[index].minimum || value > jointList[index].maximum) throw new RangeError(`Robot waypoint exceeds joint limit: ${jointList[index].id}`) }); waypoints.push(clone(input)); status = 'draft'; version += 1; return clone(input) }; const validateTrajectory = () => { const violations: number[] = []; const jointList = [...joints.values()]; trajectory.forEach((pose, poseIndex) => pose.values.forEach((value, jointIndex) => { if (value < jointList[jointIndex].minimum || value > jointList[jointIndex].maximum) violations.push(poseIndex) })); return { valid: violations.length === 0, violations: [...new Set(violations)] } }; const generateLinearTrajectory = (stepsPerSegment: number) => { if (!Number.isSafeInteger(stepsPerSegment) || stepsPerSegment < 1 || stepsPerSegment > 1000) throw new RangeError('Robot trajectory steps must be a positive integer no greater than 1000.'); if (waypoints.length < 2) { status = 'invalid'; trajectory = []; version += 1; return snapshot() }; trajectory = []; let step = 0; for (let segment = 0; segment < waypoints.length - 1; segment += 1) { const first = waypoints[segment].values; const second = waypoints[segment + 1].values; for (let index = 0; index <= stepsPerSegment; index += 1) { if (segment > 0 && index === 0) continue; const t = index / stepsPerSegment; trajectory.push({ step: step++, values: first.map((value, jointIndex) => value + (second[jointIndex] - value) * t) }) } } const validation = validateTrajectory(); status = validation.valid ? 'generated' : 'invalid'; version += 1; return snapshot() }; const forwardKinematics = (values: number[]): RobotKinematics => { if (values.length !== joints.size || values.some((value) => !Number.isFinite(value))) throw new RangeError('Robot kinematics values must match joint count.'); let angle = 0; let x = 0; let y = 0; const jointList = [...joints.values()]; for (let index = 0; index < jointList.length; index += 1) { angle += values[index] * Math.PI / 180; const length = jointList[index].linkLength ?? 1; x += Math.cos(angle) * length; y += Math.sin(angle) * length } return { position: [x, y, 0], maxReach: jointList.reduce((sum, joint) => sum + (joint.linkLength ?? 1), 0) } }; const workspace = () => ({ maxReach: [...joints.values()].reduce((sum, joint) => sum + (joint.linkLength ?? 1), 0), jointCount: joints.size }); const collisions = (obstacles: Array<{ id: string; min: [number, number, number]; max: [number, number, number] }>) => { const output: Array<{ obstacleId: string; pose: number }> = []; for (const pose of trajectory) { const endpoint = forwardKinematics(pose.values).position; for (const obstacle of obstacles) if (endpoint.every((value, axisIndex) => value >= obstacle.min[axisIndex] && value <= obstacle.max[axisIndex])) output.push({ obstacleId: obstacle.id, pose: pose.step }) } return output }; const exportController = () => `${JSON.stringify({ schemaVersion: 1, robot: id, joints: [...joints.values()], waypoints, trajectory }, null, 2)}\n`; const exportCsv = () => [['step', ...[...joints.values()].map((joint) => joint.id)], ...trajectory.map((pose) => [String(pose.step), ...pose.values.map((value) => value.toFixed(6))])].map((row) => row.join(',')).join('\n') + (trajectory.length ? '\n' : ''); return { snapshot, addJoint, addWaypoint, generateLinearTrajectory, validateTrajectory, forwardKinematics, workspace, collisions, exportController, exportCsv } }

View File

@@ -0,0 +1,26 @@
export type FacadeCommand = 'project.list' | 'project.open' | 'project.save' | 'geometry.createBox' | 'geometry.createCylinder' | 'geometry.boolean' | 'sketch.solve' | 'spreadsheet.evaluate'
export type ScriptArgument = string | number | boolean | null | ScriptArgument[] | { [key: string]: ScriptArgument }
export type ScriptCommand = { sequence: number; command: FacadeCommand; arguments: ScriptArgument }
export type ScriptReplay = { status: 'replayed' | 'rejected'; executed: number; rejected: Array<{ sequence: number; reason: string }> }
export type ScriptCapability = 'file' | 'network' | 'time' | 'resource'
export type ScriptSandboxSnapshot = { commands: ScriptCommand[]; version: number; policy: { allowedCommands: FacadeCommand[]; allowedCapabilities: ScriptCapability[]; maxCommands: number; maxArgumentBytes: number } }
export type ScriptSandboxApi = { record(command: FacadeCommand, args?: ScriptArgument): ScriptCommand; replay(commands?: ScriptCommand[]): ScriptReplay; requestCapability(capability: ScriptCapability): { capability: ScriptCapability; granted: boolean; reason?: string }; clear(): void; snapshot(): ScriptSandboxSnapshot; exportMacro(): string; importMacro(serialized: string): ScriptCommand[] }
export type ScriptSandboxOptions = { allowedCommands?: FacadeCommand[]; allowedCapabilities?: ScriptCapability[]; maxCommands?: number; maxArgumentBytes?: number }
const allFacadeCommands: FacadeCommand[] = ['project.list', 'project.open', 'project.save', 'geometry.createBox', 'geometry.createCylinder', 'geometry.boolean', 'sketch.solve', 'spreadsheet.evaluate']
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const argumentBytes = (value: ScriptArgument) => new TextEncoder().encode(JSON.stringify(value ?? null)).byteLength
const isCommand = (value: unknown): value is FacadeCommand => typeof value === 'string' && allFacadeCommands.includes(value as FacadeCommand)
export const createScriptSandbox = (options: ScriptSandboxOptions = {}): ScriptSandboxApi => {
const allowedCommands = [...new Set(options.allowedCommands ?? allFacadeCommands)]; const allowedCapabilities = [...new Set(options.allowedCapabilities ?? [])]; const maxCommands = options.maxCommands ?? 1000; const maxArgumentBytes = options.maxArgumentBytes ?? 16 * 1024; const commands: ScriptCommand[] = []; let version = 0
const validateCommand = (command: FacadeCommand, args: ScriptArgument) => { if (!isCommand(command) || !allowedCommands.includes(command)) throw new Error(`Script command is not allowed: ${command}`); if (argumentBytes(args) > maxArgumentBytes) throw new RangeError('Script argument quota exceeded.'); if (commands.length >= maxCommands) throw new RangeError('Script command quota exceeded.') }
const record = (command: FacadeCommand, args: ScriptArgument = null) => { validateCommand(command, args); const entry = { sequence: commands.length + 1, command, arguments: clone(args) }; commands.push(entry); version += 1; return clone(entry) }
const normalize = (input: ScriptCommand[]) => input.map((entry, index) => { if (!entry || !Number.isSafeInteger(entry.sequence) || entry.sequence !== index + 1 || !isCommand(entry.command)) throw new Error(`Invalid macro command at sequence ${index + 1}.`); if (!allowedCommands.includes(entry.command)) throw new Error(`Script command is not allowed: ${entry.command}`); if (argumentBytes(entry.arguments) > maxArgumentBytes) throw new RangeError('Script argument quota exceeded.'); return clone(entry) })
const replay = (input = commands): ScriptReplay => { const rejected: Array<{ sequence: number; reason: string }> = []; let executed = 0; for (const entry of input) { if (!isCommand(entry.command) || !allowedCommands.includes(entry.command)) rejected.push({ sequence: entry.sequence, reason: 'command not allowed' }); else if (argumentBytes(entry.arguments) > maxArgumentBytes) rejected.push({ sequence: entry.sequence, reason: 'argument quota exceeded' }); else if (executed >= maxCommands) rejected.push({ sequence: entry.sequence, reason: 'command quota exceeded' }); else executed += 1 }; return { status: rejected.length === 0 ? 'replayed' : 'rejected', executed, rejected } }
const requestCapability = (capability: ScriptCapability) => allowedCapabilities.includes(capability) ? { capability, granted: true } : { capability, granted: false, reason: `${capability} capability is denied by policy` }
const clear = () => { commands.length = 0; version += 1 }
const snapshot = (): ScriptSandboxSnapshot => ({ commands: clone(commands), version, policy: { allowedCommands: [...allowedCommands], allowedCapabilities: [...allowedCapabilities], maxCommands, maxArgumentBytes } })
const importMacro = (serialized: string) => { let parsed: unknown; try { parsed = JSON.parse(serialized) } catch { throw new Error('Macro is not valid JSON.') }; if (!Array.isArray(parsed)) throw new Error('Macro must contain a command array.'); return normalize(parsed as ScriptCommand[]) }
return { record, replay, requestCapability, clear, snapshot, exportMacro: () => `${JSON.stringify(commands, null, 2)}\n`, importMacro }
}

View File

@@ -0,0 +1,84 @@
export type SecondaryFormat = 'DXF' | 'SVG' | 'OBJ' | 'PLY' | 'STL' | 'PDF' | 'IFC' | 'CSV'
export type SecondaryFormatDescriptor = { format: SecondaryFormat; category: '2d' | 'mesh' | 'bim' | 'data'; mime: string; importMode: 'native' | 'proxy'; exportMode: 'native' | 'proxy'; deterministic: boolean; maxBytes: number }
export type SecondaryRecord = { id: string; format: SecondaryFormat; byteLength: number; detected: boolean; proxy: boolean; hash: string }
export type SecondaryFormatsSnapshot = { descriptors: SecondaryFormatDescriptor[]; records: SecondaryRecord[]; version: number }
export type SecondaryFormatsApi = { descriptors(): SecondaryFormatDescriptor[]; detect(format: SecondaryFormat, bytes: Uint8Array): boolean; importBytes(input: { id: string; format: SecondaryFormat; bytes: Uint8Array }): SecondaryRecord; exportBytes(format: SecondaryFormat, input?: { label?: string; width?: number; height?: number }): Uint8Array; exportRecord(id: string): Uint8Array; snapshot(): SecondaryFormatsSnapshot; remove(id: string): void }
const descriptors: SecondaryFormatDescriptor[] = [
{ format: 'DXF', category: '2d', mime: 'image/vnd.dxf', importMode: 'proxy', exportMode: 'proxy', deterministic: true, maxBytes: 16 * 1024 * 1024 },
{ format: 'SVG', category: '2d', mime: 'image/svg+xml', importMode: 'proxy', exportMode: 'native', deterministic: true, maxBytes: 8 * 1024 * 1024 },
{ format: 'OBJ', category: 'mesh', mime: 'model/obj', importMode: 'proxy', exportMode: 'native', deterministic: true, maxBytes: 32 * 1024 * 1024 },
{ format: 'PLY', category: 'mesh', mime: 'model/ply', importMode: 'proxy', exportMode: 'proxy', deterministic: true, maxBytes: 32 * 1024 * 1024 },
{ format: 'STL', category: 'mesh', mime: 'model/stl', importMode: 'proxy', exportMode: 'native', deterministic: true, maxBytes: 32 * 1024 * 1024 },
{ format: 'PDF', category: '2d', mime: 'application/pdf', importMode: 'proxy', exportMode: 'proxy', deterministic: true, maxBytes: 16 * 1024 * 1024 },
{ format: 'IFC', category: 'bim', mime: 'application/x-step', importMode: 'proxy', exportMode: 'proxy', deterministic: true, maxBytes: 64 * 1024 * 1024 },
{ format: 'CSV', category: 'data', mime: 'text/csv', importMode: 'proxy', exportMode: 'proxy', deterministic: true, maxBytes: 16 * 1024 * 1024 },
]
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const descriptor = (format: SecondaryFormat) => descriptors.find((entry) => entry.format === format)!
const hash = (bytes: Uint8Array) => { let value = 2166136261; for (const byte of bytes) value = Math.imul(value ^ byte, 16777619); return (value >>> 0).toString(16).padStart(8, '0') }
const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes.slice(0, Math.min(bytes.byteLength, 4096))).trimStart()
const detects = (format: SecondaryFormat, bytes: Uint8Array) => {
const prefix = text(bytes)
if (format === 'DXF') return /(^|\n)0\s*\nSECTION/.test(prefix)
if (format === 'SVG') return /^<svg(?:\s|>)/i.test(prefix)
if (format === 'OBJ') return /(^|\n)v\s+[-+0-9.]/.test(prefix)
if (format === 'PLY') return /^ply\s/.test(prefix)
if (format === 'STL') return /^solid(?:\s|\n)/i.test(prefix) || bytes.byteLength >= 84
if (format === 'PDF') return /^%PDF-/.test(prefix)
if (format === 'IFC') return /^ISO-10303-21;/.test(prefix) && /FILE_SCHEMA\s*\(\s*\(\s*'IFC/i.test(prefix)
return /^Label,Width,Height(?:\r?\n)[^,\r\n]+,[^,\r\n]+,[^,\r\n]+(?:\r?\n|$)/.test(prefix)
}
export const createSecondaryFormats = (): SecondaryFormatsApi => {
const records = new Map<string, SecondaryRecord>()
const payloads = new Map<string, Uint8Array>()
let version = 0
return {
descriptors: () => clone(descriptors),
detect: (format, bytes) => {
if (!(bytes instanceof Uint8Array)) throw new TypeError('Secondary format bytes must be Uint8Array.')
return detects(format, bytes)
},
importBytes: ({ id, format, bytes }) => {
const selected = descriptor(format)
if (!id.trim() || records.has(id)) throw new RangeError(`Secondary format record already exists: ${id}`)
if (bytes.byteLength <= 0 || bytes.byteLength > selected.maxBytes) throw new RangeError(`${format} payload exceeds the supported limit.`)
const detected = detects(format, bytes)
if (!detected) throw new RangeError(`${format} payload signature was not recognized.`)
const record: SecondaryRecord = { id, format, byteLength: bytes.byteLength, detected, proxy: selected.importMode === 'proxy', hash: hash(bytes) }
records.set(id, record)
payloads.set(id, Uint8Array.from(bytes))
version += 1
return clone(record)
},
exportBytes: (format, input = {}) => {
const rawLabel = input.label ?? 'BitBybit CAD'
const label = rawLabel.replace(/[<>\r\n,']/g, ' ').trim() || 'BitBybit CAD'
const width = Number.isFinite(input.width) ? input.width! : 100
const height = Number.isFinite(input.height) ? input.height! : 80
if (width <= 0 || height <= 0) throw new RangeError('Secondary export dimensions must be positive.')
const output = format === 'SVG' ? `<svg xmlns="http://www.w3.org/2000/svg" width="${width}mm" height="${height}mm" role="img" aria-label="${label}"><title>${label}</title><rect width="100%" height="100%" fill="none" stroke="#222"/></svg>\n`
: format === 'OBJ' ? `# ${label}\nv 0 0 0\nv ${width} 0 0\nv ${width} ${height} 0\nv 0 ${height} 0\nf 1 2 3 4\n`
: format === 'STL' ? `solid ${label}\nendsolid ${label}\n`
: format === 'PLY' ? 'ply\nformat ascii 1.0\nelement vertex 0\nend_header\n'
: format === 'DXF' ? '0\nSECTION\n2\nENTITIES\n0\nENDSEC\n0\nEOF\n'
: format === 'PDF' ? `%PDF-1.4\n% BitBybit ${label}\n%%EOF\n`
: format === 'IFC' ? `ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('${label}'),'2;1');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n`
: `Label,Width,Height\n${label},${width},${height}\n`
return new TextEncoder().encode(output)
},
exportRecord: (id) => {
const bytes = payloads.get(id)
if (!bytes) throw new RangeError(`Secondary format record does not exist: ${id}`)
return Uint8Array.from(bytes)
},
snapshot: () => ({ descriptors: clone(descriptors), records: [...records.values()].map(clone), version }),
remove: (id) => {
if (!records.delete(id)) throw new RangeError(`Secondary format record does not exist: ${id}`)
payloads.delete(id)
version += 1
},
}
}

View File

@@ -0,0 +1,5 @@
export type ArchiveEntry = { path: string; compressedBytes: number; uncompressedBytes: number }
export type SecurityPreflightReport = { pathChecks: number; archiveEntries: number; xmlBytes: number; permissionChecks: number; pass: boolean; errors: string[] }
export type SecurityPreflightApi = { path(path: string): string; archive(entries: ArchiveEntry[], limits?: { maxEntries?: number; maxTotalBytes?: number; maxRatio?: number }): void; xml(xml: string, maxBytes?: number): void; permissions(permissions: string[], allowed?: string[]): void; report(): SecurityPreflightReport }
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const createSecurityPreflight = (): SecurityPreflightApi => { const errors: string[] = []; let pathChecks = 0; let archiveEntries = 0; let xmlBytes = 0; let permissionChecks = 0; const path = (value: string) => { pathChecks += 1; if (!value.trim() || value.includes('\0') || value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || value.split(/[\\/]+/).some((part) => part === '..')) { errors.push(`unsafe path: ${value}`); throw new Error('Unsafe resource path.') } return value.replaceAll('\\', '/') }; const archive = (entries: ArchiveEntry[], limits: { maxEntries?: number; maxTotalBytes?: number; maxRatio?: number } = {}) => { const maxEntries = limits.maxEntries ?? 10_000; const maxTotalBytes = limits.maxTotalBytes ?? 512 * 1024 * 1024; const maxRatio = limits.maxRatio ?? 100; archiveEntries += entries.length; if (entries.length > maxEntries) { errors.push('archive entry count exceeded'); throw new Error('Archive entry count exceeds the security limit.') } let total = 0; for (const entry of entries) { path(entry.path); if (!Number.isSafeInteger(entry.compressedBytes) || !Number.isSafeInteger(entry.uncompressedBytes) || entry.compressedBytes <= 0 || entry.uncompressedBytes < entry.compressedBytes) { errors.push('invalid archive sizes'); throw new Error('Archive entry sizes are invalid.') } total += entry.uncompressedBytes; if (entry.uncompressedBytes / entry.compressedBytes > maxRatio) { errors.push('archive ratio exceeded'); throw new Error('Archive compression ratio exceeds the security limit.') } } if (total > maxTotalBytes) { errors.push('archive total exceeded'); throw new Error('Archive uncompressed size exceeds the security limit.') } }; const xml = (value: string, maxBytes = 8 * 1024 * 1024) => { xmlBytes += new TextEncoder().encode(value).byteLength; if (xmlBytes > maxBytes || /<!DOCTYPE|<!ENTITY|SYSTEM\s+['"]/i.test(value)) { errors.push('unsafe XML'); throw new Error('XML contains a forbidden declaration or exceeds the size limit.') } }; const permissions = (values: string[], allowed = ['geometry.read', 'project.read']) => { permissionChecks += values.length; const accepted = new Set(allowed); if (values.some((value) => !accepted.has(value))) { errors.push('permission exceeded'); throw new Error('Permission is outside the security allowlist.') } }; const report = () => clone({ pathChecks, archiveEntries, xmlBytes, permissionChecks, pass: errors.length === 0, errors }); return { path, archive, xml, permissions, report } }

View File

@@ -49,13 +49,18 @@ export class SketchSolverUnavailableError extends Error {
}
}
const assertRequest = (request: SketchSolverRequest) => {
export const assertSketchSolverRequest = (request: SketchSolverRequest) => {
if (request.protocolVersion !== SKETCH_SOLVER_PROTOCOL_VERSION) throw new RangeError(`Unsupported sketch solver protocol version: ${request.protocolVersion}.`)
if (!request.requestId || !request.documentId) throw new TypeError('Sketch solver request requires requestId and documentId.')
if (!Number.isSafeInteger(request.documentVersion) || request.documentVersion < 0) throw new RangeError('Sketch solver documentVersion must be a non-negative integer.')
if (!Number.isSafeInteger(request.generation) || request.generation < 1) throw new RangeError('Sketch solver generation must be a positive integer.')
}
export const assertSketchSolverResponse = (request: SketchSolverRequest, response: SketchSolverResponse) => {
if (response.protocolVersion !== request.protocolVersion || response.requestId !== request.requestId || response.documentId !== request.documentId || response.documentVersion !== request.documentVersion || response.generation !== request.generation) throw new Error('Sketch solver response does not match its request context.')
return response
}
const abortError = () => new DOMException('Sketch solve cancelled.', 'AbortError')
export class BasicSketchSolverProvider implements SketchSolverProvider {
@@ -67,16 +72,16 @@ export class BasicSketchSolverProvider implements SketchSolverProvider {
availability: 'available',
compatibility: 'experimental',
supportedGeometry: ['point', 'line', 'circle', 'arc'],
supportedConstraints: ['coincident', 'horizontal', 'vertical', 'distance', 'distanceX', 'distanceY', 'radius', 'diameter', 'angle', 'equal', 'symmetric', 'tangent', 'block'],
supportedConstraints: ['coincident', 'horizontal', 'vertical', 'parallel', 'perpendicular', 'tangent', 'distance', 'distanceX', 'distanceY', 'angle', 'radius', 'equal', 'pointOnObject', 'symmetric', 'block', 'diameter'],
}
}
async solve(request: SketchSolverRequest, signal: AbortSignal): Promise<SketchSolverResponse> {
assertRequest(request)
assertSketchSolverRequest(request)
if (signal.aborted) throw abortError()
const result = solveSketch(cloneSketch(request.snapshot), request.options)
if (signal.aborted) throw abortError()
return {
return assertSketchSolverResponse(request, {
protocolVersion: request.protocolVersion,
requestId: request.requestId,
documentId: request.documentId,
@@ -84,7 +89,7 @@ export class BasicSketchSolverProvider implements SketchSolverProvider {
generation: request.generation,
provider: this.capabilities(),
result,
}
})
}
}

108
src/facade/sketchStress.ts Normal file
View File

@@ -0,0 +1,108 @@
import { createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSolverStatus } from './sketcher'
export type SketchStressCategory = 'constraints' | 'conflict' | 'redundant' | 'reference' | 'drag'
export type SketchStressReport = {
seed: number
models: number
solves: number
durationMs: number
averageSolveMs: number
maxIterations: number
maxResidual: number
deterministicMismatches: number
unexpectedResults: number
digest: string
categories: Record<SketchStressCategory, number>
statuses: Record<SketchSolverStatus, number>
}
export type SketchStressOptions = { seed?: number; models?: number }
const randomGenerator = (seed: number) => {
let state = seed >>> 0
return () => {
state += 0x6d2b79f5
let value = state
value = Math.imul(value ^ value >>> 15, value | 1)
value ^= value + Math.imul(value ^ value >>> 7, value | 61)
return ((value ^ value >>> 14) >>> 0) / 0x1_0000_0000
}
}
const digestText = (current: number, value: string) => {
let digest = current
for (let index = 0; index < value.length; index += 1) digest = Math.imul(digest ^ value.charCodeAt(index), 16777619)
return digest >>> 0
}
const fixtureFor = (index: number, random: () => number) => {
const category = (['constraints', 'conflict', 'redundant', 'reference', 'drag'] as const)[index % 5]
const offset = Math.round(random() * 1000) / 100
let geometry: SketchGeometry[]
let constraints: SketchConstraint[]
let expected: SketchSolverStatus
if (category === 'constraints') {
geometry = [
{ id: 'axis', type: 'line', start: { x: offset, y: 0 }, end: { x: offset + 4, y: 0 } },
{ id: 'parallel', type: 'line', start: { x: 0, y: 2 }, end: { x: 1, y: 3 } },
{ id: 'perpendicular', type: 'line', start: { x: 2, y: 0 }, end: { x: 4, y: 1 } },
]
constraints = [
{ id: 'parallel', type: 'parallel', firstGeometryId: 'axis', secondGeometryId: 'parallel' },
{ id: 'perpendicular', type: 'perpendicular', firstGeometryId: 'axis', secondGeometryId: 'perpendicular' },
]
expected = 'under-constrained'
} else if (category === 'conflict') {
geometry = [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1 + offset, y: 1 } }]
constraints = [{ id: 'block', type: 'block', geometryId: 'line' }, { id: 'horizontal', type: 'horizontal', geometryId: 'line' }]
expected = 'conflicting'
} else if (category === 'redundant') {
geometry = [{ id: 'line', type: 'line', start: { x: 0, y: offset }, end: { x: 2, y: offset + 1 } }]
constraints = [{ id: 'horizontal', type: 'horizontal', geometryId: 'line' }, { id: 'duplicate', type: 'horizontal', geometryId: 'line' }]
expected = 'under-constrained'
} else if (category === 'reference') {
geometry = [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 2 + offset, y: 1 } }]
constraints = [
{ id: 'horizontal', type: 'horizontal', geometryId: 'line' },
{ id: 'reference', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 999, driving: false },
]
expected = 'under-constrained'
} else {
geometry = [{ id: 'circle', type: 'circle', center: { x: offset, y: 0 }, radius: 2 }, { id: 'point', type: 'point', position: { x: offset + 4, y: 1 } }]
constraints = [{ id: 'drag-on-circle', type: 'pointOnObject', point: { geometryId: 'point', point: 'position' }, geometryId: 'circle' }]
expected = 'under-constrained'
}
return { category, snapshot: createSketch(`stress-${index}`, geometry, constraints), expected }
}
export const runSketchStress = (options: SketchStressOptions = {}): SketchStressReport => {
const seed = options.seed ?? 0x20260803
const models = options.models ?? 500
if (!Number.isSafeInteger(models) || models < 1) throw new RangeError('Sketch stress models must be a positive safe integer.')
const random = randomGenerator(seed)
const categories: SketchStressReport['categories'] = { constraints: 0, conflict: 0, redundant: 0, reference: 0, drag: 0 }
const statuses: SketchStressReport['statuses'] = { solved: 0, 'under-constrained': 0, conflicting: 0, invalid: 0 }
let deterministicMismatches = 0
let unexpectedResults = 0
let maxIterations = 0
let maxResidual = 0
let digest = 2166136261
const startedAt = performance.now()
for (let index = 0; index < models; index += 1) {
const fixture = fixtureFor(index, random)
categories[fixture.category] += 1
const first = solveSketch(fixture.snapshot)
const second = solveSketch(fixture.snapshot)
statuses[first.status] += 1
maxIterations = Math.max(maxIterations, first.iterations)
if (fixture.expected !== 'conflicting' && Number.isFinite(first.residual)) maxResidual = Math.max(maxResidual, first.residual)
const firstSnapshot = JSON.stringify(first.snapshot)
if (firstSnapshot !== JSON.stringify(second.snapshot)) deterministicMismatches += 1
const requiredDiagnostic = fixture.category === 'redundant' ? first.diagnostics.some((diagnostic) => diagnostic.code === 'REDUNDANT_CONSTRAINT') : fixture.category === 'conflict' ? first.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED') : true
if (first.status !== fixture.expected || !requiredDiagnostic) unexpectedResults += 1
digest = digestText(digest, firstSnapshot)
}
const durationMs = performance.now() - startedAt
return { seed, models, solves: models * 2, durationMs, averageSolveMs: durationMs / (models * 2), maxIterations, maxResidual, deterministicMismatches, unexpectedResults, digest: digest.toString(16).padStart(8, '0'), categories, statuses }
}

View File

@@ -12,11 +12,19 @@ export type SketchGeometry =
export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' }
export type SketchExternalMode = 'projection' | 'intersection' | 'both'
export type SketchExternalGeometry = {
id: string
source: TopoRefValue
projection: SketchGeometry
construction: true
mode?: SketchExternalMode
defining?: boolean
frozen?: boolean
detached?: boolean
missing?: boolean
sync?: boolean
}
export type SketchConstraint =
@@ -27,14 +35,20 @@ export type SketchConstraint =
| { id: string; type: 'diameter'; geometryId: string; value: number; driving?: boolean }
| { id: string; type: 'angle'; geometryId: string; value: number; driving?: boolean }
| { id: string; type: 'equal'; firstGeometryId: string; secondGeometryId: string; driving?: boolean }
| { id: string; type: 'parallel' | 'perpendicular'; firstGeometryId: string; secondGeometryId: string; driving?: boolean }
| { id: string; type: 'pointOnObject'; point: SketchPointRef; geometryId: string; driving?: boolean }
| { id: string; type: 'symmetric'; first: SketchPointRef; second: SketchPointRef; center: SketchPointRef; driving?: boolean }
| { id: string; type: 'tangent'; firstGeometryId: string; secondGeometryId: string; driving?: boolean }
| { id: string; type: 'block'; geometryId: string; driving?: boolean }
| { id: string; type: 'weight'; geometryId: string; controlPointIndex: number; value: number; driving?: boolean }
| { id: string; type: 'snellsLaw'; first: SketchPointRef; second: SketchPointRef; boundaryGeometryId: string; value: number; driving?: boolean }
| { id: string; type: 'snellsLaw'; firstGeometryId: string; secondGeometryId: string; value: number; driving?: boolean }
| { id: string; type: 'internalAlignment'; geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'bspline-control-point' | 'bspline-knot'; driving?: boolean }
export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid'
export type SketchDiagnostic = {
code: 'UNKNOWN_GEOMETRY' | 'UNKNOWN_POINT' | 'INVALID_VALUE' | 'UNSUPPORTED_GEOMETRY' | 'CONSTRAINT_CONFLICT' | 'SOLVER_NOT_CONVERGED'
code: 'UNKNOWN_GEOMETRY' | 'UNKNOWN_POINT' | 'INVALID_VALUE' | 'UNSUPPORTED_GEOMETRY' | 'UNSUPPORTED_CONSTRAINT' | 'REFERENCE_DIMENSION' | 'CONSTRAINT_CONFLICT' | 'REDUNDANT_CONSTRAINT' | 'SOLVER_NOT_CONVERGED'
geometryId?: string
constraintId?: string
message: string
@@ -59,6 +73,8 @@ export type SketchSolveOptions = {
maxIterations?: number
}
export type SketchAutoConstraintSuggestion = { id: string; constraint: SketchConstraint; reason: 'horizontal' | 'vertical' | 'coincident' }
export type SketchSolveResult = {
snapshot: SketchSnapshot
status: SketchSolverStatus
@@ -68,34 +84,442 @@ export type SketchSolveResult = {
diagnostics: SketchDiagnostic[]
}
const assertFinitePoint = (point: SketchPoint, label: string) => {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) throw new RangeError(`${label} coordinates must be finite.`)
}
export const validateSketchGeometry = (geometry: SketchGeometry): void => {
if (!geometry.id.trim()) throw new TypeError('Sketch geometry IDs must be non-empty.')
if (geometry.type === 'point') assertFinitePoint(geometry.position, `${geometry.id}.position`)
if (geometry.type === 'line') { assertFinitePoint(geometry.start, `${geometry.id}.start`); assertFinitePoint(geometry.end, `${geometry.id}.end`) }
if (geometry.type === 'circle' || geometry.type === 'arc') {
assertFinitePoint(geometry.center, `${geometry.id}.center`)
if (!Number.isFinite(geometry.radius) || geometry.radius <= 0) throw new RangeError(`${geometry.id} radius must be finite and greater than zero.`)
if (geometry.type === 'arc' && (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle))) throw new RangeError(`${geometry.id} angles must be finite.`)
}
if (geometry.type === 'ellipse') {
assertFinitePoint(geometry.center, `${geometry.id}.center`)
if (!Number.isFinite(geometry.majorRadius) || geometry.majorRadius <= 0 || !Number.isFinite(geometry.minorRadius) || geometry.minorRadius <= 0) throw new RangeError(`${geometry.id} radii must be finite and greater than zero.`)
if (geometry.majorRadius < geometry.minorRadius || !Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid axis parameters.`)
}
if (geometry.type === 'bspline') {
if (!Number.isSafeInteger(geometry.degree) || geometry.degree < 1) throw new RangeError(`${geometry.id} degree must be a positive integer.`)
if (geometry.controlPoints.length < geometry.degree + 1) throw new RangeError(`${geometry.id} requires at least degree + 1 control points.`)
geometry.controlPoints.forEach((point, index) => assertFinitePoint(point, `${geometry.id}.controlPoints[${index}]`))
if (geometry.weights && (geometry.weights.length !== geometry.controlPoints.length || geometry.weights.some((weight) => !Number.isFinite(weight) || weight <= 0))) throw new RangeError(`${geometry.id} weights must be positive finite values matching control points.`)
if (geometry.knots && (geometry.knots.length !== geometry.controlPoints.length + geometry.degree + 1 || geometry.knots.some((knot, index) => !Number.isFinite(knot) || (index > 0 && knot < geometry.knots![index - 1])))) throw new RangeError(`${geometry.id} knots must be non-decreasing and have controlPointCount + degree + 1 entries.`)
if (geometry.periodic && geometry.controlPoints.length <= geometry.degree) throw new RangeError(`${geometry.id} periodic curves require more control points than degree.`)
}
}
export const validateSketchSnapshot = (sketch: SketchSnapshot): void => {
if (!sketch.id.trim()) throw new TypeError('Sketch IDs must be non-empty.')
const geometryIds = new Set<string>()
for (const geometry of sketch.geometry) { validateSketchGeometry(geometry); if (geometryIds.has(geometry.id)) throw new TypeError(`Duplicate sketch geometry ID '${geometry.id}'.`); geometryIds.add(geometry.id) }
const constraintIds = new Set<string>()
for (const constraint of sketch.constraints) { if (!constraint.id.trim()) throw new TypeError('Sketch constraint IDs must be non-empty.'); if (constraintIds.has(constraint.id)) throw new TypeError(`Duplicate sketch constraint ID '${constraint.id}'.`); constraintIds.add(constraint.id) }
const externalIds = new Set<string>()
const projectionIds = new Set(geometryIds)
const externalGroupState = new Map<string, string>()
for (const external of sketch.externalGeometry) {
if (!external.id.trim()) throw new TypeError('External geometry IDs must be non-empty.')
if (externalIds.has(external.id)) throw new TypeError(`Duplicate external geometry ID '${external.id}'.`)
externalIds.add(external.id)
if (external.construction !== true) throw new TypeError(`External geometry '${external.id}' must be construction geometry.`)
if (external.mode !== undefined && external.mode !== 'projection' && external.mode !== 'intersection' && external.mode !== 'both') throw new TypeError(`External geometry '${external.id}' has an invalid mode.`)
for (const flag of ['defining', 'frozen', 'detached', 'missing', 'sync'] as const) if (external[flag] !== undefined && typeof external[flag] !== 'boolean') throw new TypeError(`External geometry '${external.id}' ${flag} flag must be boolean.`)
if (external.sync && !external.frozen) throw new TypeError(`External geometry '${external.id}' can only synchronize while frozen.`)
validateSketchGeometry(external.projection)
if (projectionIds.has(external.projection.id)) throw new TypeError(`Duplicate sketch projection geometry ID '${external.projection.id}'.`)
projectionIds.add(external.projection.id)
const groupKey = `${external.source.objectId}\0${external.source.kind}\0${external.source.persistentId}`
const stateKey = JSON.stringify({ mode: external.mode ?? 'projection', defining: external.defining ?? false, frozen: external.frozen ?? false, detached: external.detached ?? false, missing: external.missing ?? false, sync: external.sync ?? false })
const existingState = externalGroupState.get(groupKey)
if (existingState !== undefined && existingState !== stateKey) throw new TypeError(`External geometry group '${external.source.objectId}.${external.source.persistentId}' has conflicting mode or state flags.`)
externalGroupState.set(groupKey, stateKey)
}
}
export const sketchGeometrySignature = (geometry: SketchGeometry): string => {
validateSketchGeometry(geometry)
return JSON.stringify(geometry)
}
export const cloneSketchGeometry = (geometry: SketchGeometry): SketchGeometry => {
validateSketchGeometry(geometry)
if (geometry.type === 'line') return { ...geometry, start: { ...geometry.start }, end: { ...geometry.end } }
if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') return { ...geometry, center: { ...geometry.center } }
if (geometry.type === 'bspline') return { ...geometry, controlPoints: geometry.controlPoints.map((point) => ({ ...point })), weights: geometry.weights ? [...geometry.weights] : undefined, knots: geometry.knots ? [...geometry.knots] : undefined }
return { ...geometry, position: { ...geometry.position } }
}
export const projectSketchGeometry = (geometry: SketchGeometry, projectedId = `projection-${geometry.id}`): SketchGeometry => ({ ...cloneSketchGeometry(geometry), id: projectedId, construction: true })
export const carbonCopySketchGeometry = (geometry: SketchGeometry, idPrefix = 'carboncopy'): SketchGeometry => ({ ...cloneSketchGeometry(geometry), id: `${idPrefix}-${geometry.id}` })
export type BsplineGeometryPatch = Partial<Pick<Extract<SketchGeometry, { type: 'bspline' }>, 'degree' | 'controlPoints' | 'weights' | 'knots' | 'periodic' | 'construction'>>
export const editBsplineGeometry = (geometry: Extract<SketchGeometry, { type: 'bspline' }>, patch: BsplineGeometryPatch): Extract<SketchGeometry, { type: 'bspline' }> => {
const edited: Extract<SketchGeometry, { type: 'bspline' }> = {
...geometry,
...patch,
id: geometry.id,
controlPoints: (patch.controlPoints ?? geometry.controlPoints).map((point) => ({ ...point })),
weights: patch.weights ? [...patch.weights] : patch.weights === undefined && geometry.weights ? [...geometry.weights] : undefined,
knots: patch.knots ? [...patch.knots] : patch.knots === undefined && geometry.knots ? [...geometry.knots] : undefined,
}
validateSketchGeometry(edited)
return edited
}
export const editSketchBspline = (sketch: SketchSnapshot, geometryId: string, patch: BsplineGeometryPatch): SketchSnapshot => {
const edited = cloneSketch(sketch)
const index = edited.geometry.findIndex((geometry) => geometry.id === geometryId)
if (index < 0) throw new RangeError(`Sketch B-spline '${geometryId}' does not exist.`)
const geometry = edited.geometry[index]
if (geometry.type !== 'bspline') throw new TypeError(`Sketch geometry '${geometryId}' is not a B-spline.`)
edited.geometry[index] = editBsplineGeometry(geometry, patch)
validateSketchSnapshot(edited)
return edited
}
export const cloneSketchConstraint = (constraint: SketchConstraint): SketchConstraint => {
if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') return { ...constraint, first: { ...constraint.first }, second: { ...constraint.second } }
if (constraint.type === 'snellsLaw' && 'boundaryGeometryId' in constraint) return { ...constraint, first: { ...constraint.first }, second: { ...constraint.second } }
if (constraint.type === 'pointOnObject') return { ...constraint, point: { ...constraint.point } }
if (constraint.type === 'symmetric') return { ...constraint, first: { ...constraint.first }, second: { ...constraint.second }, center: { ...constraint.center } }
return { ...constraint }
}
export const cloneSketch = (sketch: SketchSnapshot): SketchSnapshot => ({
...sketch,
geometry: sketch.geometry.map(cloneSketchGeometry),
externalGeometry: (sketch.externalGeometry ?? []).map((external) => ({ ...external, source: { ...external.source, candidates: external.source.candidates ? [...external.source.candidates] : undefined }, projection: cloneSketchGeometry(external.projection), construction: true })),
constraints: sketch.constraints.map(cloneSketchConstraint),
solver: { ...sketch.solver, diagnostics: sketch.solver.diagnostics.map((diagnostic) => ({ ...diagnostic })) },
})
export const cloneSketch = (sketch: SketchSnapshot): SketchSnapshot => {
validateSketchSnapshot(sketch)
return {
...sketch,
geometry: sketch.geometry.map(cloneSketchGeometry),
externalGeometry: (sketch.externalGeometry ?? []).map((external) => ({ ...external, source: { ...external.source, candidates: external.source.candidates ? [...external.source.candidates] : undefined }, projection: cloneSketchGeometry(external.projection), construction: true })),
constraints: sketch.constraints.map(cloneSketchConstraint),
solver: { ...sketch.solver, diagnostics: sketch.solver.diagnostics.map((diagnostic) => ({ ...diagnostic })) },
}
}
export const createSketch = (id: string, geometry: SketchGeometry[] = [], constraints: SketchConstraint[] = []): SketchSnapshot => ({
id,
geometry: geometry.map(cloneSketchGeometry),
externalGeometry: [],
constraints: constraints.map(cloneSketchConstraint),
solver: { status: geometry.length === 0 ? 'solved' : 'under-constrained', degreesOfFreedom: 0, residual: 0, iterations: 0, diagnostics: [] },
})
export const createSketch = (id: string, geometry: SketchGeometry[] = [], constraints: SketchConstraint[] = []): SketchSnapshot => {
const sketch: SketchSnapshot = { id, geometry: geometry.map(cloneSketchGeometry), externalGeometry: [], constraints: constraints.map(cloneSketchConstraint), solver: { status: geometry.length === 0 ? 'solved' : 'under-constrained', degreesOfFreedom: 0, residual: 0, iterations: 0, diagnostics: [] } }
validateSketchSnapshot(sketch)
return sketch
}
export const dragSketchPoint = (sketch: SketchSnapshot, pointRef: SketchPointRef, target: SketchPoint, options: SketchSolveOptions = {}): SketchSolveResult => {
assertFinitePoint(target, 'Sketch drag target')
const edited = cloneSketch(sketch)
const geometry = edited.geometry.find((candidate) => candidate.id === pointRef.geometryId)
if (!geometry) throw new RangeError(`Sketch geometry '${pointRef.geometryId}' does not exist.`)
const current = pointFor(geometry, pointRef.point, 'drag', [])
if (!current) throw new RangeError(`Point '${pointRef.point}' is not valid for ${geometry.type} '${geometry.id}'.`)
adjustPoint(geometry, pointRef.point, target, new Set())
if (geometry.type === 'line' && (pointRef.point === 'start' || pointRef.point === 'end')) {
const other = pointRef.point === 'start' ? geometry.end : geometry.start
if (edited.constraints.some((constraint) => constraint.type === 'horizontal' && constraint.geometryId === geometry.id)) other.y = target.y
if (edited.constraints.some((constraint) => constraint.type === 'vertical' && constraint.geometryId === geometry.id)) other.x = target.x
}
return solveSketch(edited, options)
}
const constraintReferencesGeometry = (constraint: SketchConstraint, geometryId: string) => {
if (constraint.type === 'snellsLaw' && 'boundaryGeometryId' in constraint && constraint.boundaryGeometryId === geometryId) return true
if ('geometryId' in constraint && constraint.geometryId === geometryId) return true
if ('firstGeometryId' in constraint && constraint.firstGeometryId === geometryId) return true
if ('secondGeometryId' in constraint && constraint.secondGeometryId === geometryId) return true
if ('first' in constraint && constraint.first.geometryId === geometryId) return true
if ('second' in constraint && constraint.second.geometryId === geometryId) return true
if ('center' in constraint && constraint.center.geometryId === geometryId) return true
if ('point' in constraint && constraint.point.geometryId === geometryId) return true
return false
}
export const splitSketchLine = (sketch: SketchSnapshot, geometryId: string, splitPoint: SketchPoint, newGeometryId: string, tolerance = 1e-7): SketchSnapshot => {
assertFinitePoint(splitPoint, 'Sketch line split point')
if (!newGeometryId.trim()) throw new TypeError('Split geometry ID must be non-empty.')
if (!Number.isFinite(tolerance) || tolerance <= 0) throw new RangeError('Split tolerance must be finite and greater than zero.')
const edited = cloneSketch(sketch)
if (edited.geometry.some((geometry) => geometry.id === newGeometryId)) throw new RangeError(`Sketch geometry '${newGeometryId}' already exists.`)
const index = edited.geometry.findIndex((geometry) => geometry.id === geometryId)
if (index < 0) throw new RangeError(`Sketch geometry '${geometryId}' does not exist.`)
const geometry = edited.geometry[index]
if (geometry.type !== 'line') throw new TypeError(`Sketch geometry '${geometryId}' is not a line.`)
if (edited.constraints.some((constraint) => constraintReferencesGeometry(constraint, geometryId))) throw new Error(`Cannot split constrained sketch line '${geometryId}' without an explicit constraint migration.`)
const dx = geometry.end.x - geometry.start.x
const dy = geometry.end.y - geometry.start.y
const lengthSquared = dx * dx + dy * dy
if (lengthSquared <= tolerance * tolerance) throw new RangeError(`Sketch line '${geometryId}' is degenerate.`)
const parameter = ((splitPoint.x - geometry.start.x) * dx + (splitPoint.y - geometry.start.y) * dy) / lengthSquared
const projected = { x: geometry.start.x + parameter * dx, y: geometry.start.y + parameter * dy }
if (distance(projected, splitPoint) > tolerance || parameter <= tolerance || parameter >= 1 - tolerance) throw new RangeError('Split point must lie strictly inside the sketch line.')
const originalEnd = { ...geometry.end }
geometry.end = projected
edited.geometry.splice(index + 1, 0, { id: newGeometryId, type: 'line', start: { ...projected }, end: originalEnd, construction: geometry.construction })
edited.constraints.push({ id: `split-coincident-${geometryId}-${newGeometryId}`, type: 'coincident', first: { geometryId, point: 'end' }, second: { geometryId: newGeometryId, point: 'start' } })
validateSketchSnapshot(edited)
return edited
}
export const extendSketchLine = (sketch: SketchSnapshot, geometryId: string, endpoint: 'start' | 'end', target: SketchPoint, tolerance = 1e-7): SketchSnapshot => {
assertFinitePoint(target, 'Sketch line extension target')
if (!Number.isFinite(tolerance) || tolerance <= 0) throw new RangeError('Extension tolerance must be finite and greater than zero.')
const edited = cloneSketch(sketch)
const geometry = edited.geometry.find((candidate) => candidate.id === geometryId)
if (!geometry) throw new RangeError(`Sketch geometry '${geometryId}' does not exist.`)
if (geometry.type !== 'line') throw new TypeError(`Sketch geometry '${geometryId}' is not a line.`)
if (edited.constraints.some((constraint) => constraintReferencesGeometry(constraint, geometryId))) throw new Error(`Cannot extend constrained sketch line '${geometryId}' without an explicit constraint migration.`)
const dx = geometry.end.x - geometry.start.x
const dy = geometry.end.y - geometry.start.y
const lengthSquared = dx * dx + dy * dy
if (lengthSquared <= tolerance * tolerance) throw new RangeError(`Sketch line '${geometryId}' is degenerate.`)
const parameter = ((target.x - geometry.start.x) * dx + (target.y - geometry.start.y) * dy) / lengthSquared
const projected = { x: geometry.start.x + parameter * dx, y: geometry.start.y + parameter * dy }
const extendsEndpoint = endpoint === 'start' ? parameter < -tolerance : parameter > 1 + tolerance
if (distance(projected, target) > tolerance || !extendsEndpoint) throw new RangeError(`Extension target must be collinear and beyond the line ${endpoint}.`)
geometry[endpoint] = { ...target }
validateSketchSnapshot(edited)
return edited
}
export const trimSketchLine = (sketch: SketchSnapshot, geometryId: string, endpoint: 'start' | 'end', target: SketchPoint, tolerance = 1e-7): SketchSnapshot => {
assertFinitePoint(target, 'Sketch line trim target')
if (!Number.isFinite(tolerance) || tolerance <= 0) throw new RangeError('Trim tolerance must be finite and greater than zero.')
const edited = cloneSketch(sketch)
const geometry = edited.geometry.find((candidate) => candidate.id === geometryId)
if (!geometry) throw new RangeError(`Sketch geometry '${geometryId}' does not exist.`)
if (geometry.type !== 'line') throw new TypeError(`Sketch geometry '${geometryId}' is not a line.`)
if (edited.constraints.some((constraint) => constraintReferencesGeometry(constraint, geometryId))) throw new Error(`Cannot trim constrained sketch line '${geometryId}' without an explicit constraint migration.`)
const dx = geometry.end.x - geometry.start.x
const dy = geometry.end.y - geometry.start.y
const lengthSquared = dx * dx + dy * dy
if (lengthSquared <= tolerance * tolerance) throw new RangeError(`Sketch line '${geometryId}' is degenerate.`)
const parameter = ((target.x - geometry.start.x) * dx + (target.y - geometry.start.y) * dy) / lengthSquared
const projected = { x: geometry.start.x + parameter * dx, y: geometry.start.y + parameter * dy }
if (distance(projected, target) > tolerance || parameter <= tolerance || parameter >= 1 - tolerance) throw new RangeError('Trim target must lie strictly inside the sketch line.')
geometry[endpoint] = { ...target }
const cuttingGeometry = edited.geometry.find((candidate) => candidate.id !== geometryId && candidate.type === 'line' && (() => {
const cuttingDx = candidate.end.x - candidate.start.x
const cuttingDy = candidate.end.y - candidate.start.y
const cuttingLengthSquared = cuttingDx * cuttingDx + cuttingDy * cuttingDy
if (cuttingLengthSquared <= tolerance * tolerance) return false
const cuttingParameter = ((target.x - candidate.start.x) * cuttingDx + (target.y - candidate.start.y) * cuttingDy) / cuttingLengthSquared
const cuttingProjection = { x: candidate.start.x + cuttingParameter * cuttingDx, y: candidate.start.y + cuttingParameter * cuttingDy }
return cuttingParameter >= -tolerance && cuttingParameter <= 1 + tolerance && distance(cuttingProjection, target) <= tolerance
})())
if (cuttingGeometry) edited.constraints.push({ id: `trim-point-on-object-${geometryId}-${endpoint}-${cuttingGeometry.id}`, type: 'pointOnObject', point: { geometryId, point: endpoint }, geometryId: cuttingGeometry.id })
validateSketchSnapshot(edited)
return edited
}
export const deleteSketchGeometry = (sketch: SketchSnapshot, geometryId: string): SketchSnapshot => {
const edited = cloneSketch(sketch)
const index = edited.geometry.findIndex((geometry) => geometry.id === geometryId)
if (index < 0) throw new RangeError(`Sketch geometry '${geometryId}' does not exist.`)
if (edited.constraints.some((constraint) => constraintReferencesGeometry(constraint, geometryId))) throw new Error(`Cannot delete constrained sketch geometry '${geometryId}'.`)
if (edited.externalGeometry.some((external) => external.projection.id === geometryId || external.source.persistentId === geometryId)) throw new Error(`Cannot delete sketch geometry '${geometryId}' referenced by external geometry.`)
edited.geometry.splice(index, 1)
validateSketchSnapshot(edited)
return edited
}
export const setSketchConstruction = (sketch: SketchSnapshot, geometryId: string, construction: boolean): SketchSnapshot => {
const edited = cloneSketch(sketch)
const geometry = edited.geometry.find((candidate) => candidate.id === geometryId)
if (!geometry) throw new RangeError(`Sketch geometry '${geometryId}' does not exist.`)
geometry.construction = construction
validateSketchSnapshot(edited)
return edited
}
export const suggestSketchAutoConstraints = (sketch: SketchSnapshot, geometryId: string, tolerance = 1e-3): SketchAutoConstraintSuggestion[] => {
validateSketchSnapshot(sketch)
if (!Number.isFinite(tolerance) || tolerance <= 0) throw new RangeError('Autoconstraint tolerance must be finite and greater than zero.')
const geometry = sketch.geometry.find((candidate) => candidate.id === geometryId)
if (!geometry) throw new RangeError(`Sketch geometry '${geometryId}' does not exist.`)
const suggestions: SketchAutoConstraintSuggestion[] = []
const existing = new Set(sketch.constraints.map(constraintSignature))
const add = (reason: SketchAutoConstraintSuggestion['reason'], constraint: SketchConstraint) => {
if (!existing.has(constraintSignature(constraint))) suggestions.push({ id: constraint.id, constraint, reason })
}
if (geometry.type === 'line') {
if (Math.abs(geometry.end.y - geometry.start.y) <= tolerance) add('horizontal', { id: `auto-horizontal-${geometry.id}`, type: 'horizontal', geometryId: geometry.id })
if (Math.abs(geometry.end.x - geometry.start.x) <= tolerance) add('vertical', { id: `auto-vertical-${geometry.id}`, type: 'vertical', geometryId: geometry.id })
for (const point of ['start', 'end'] as const) {
const current = geometry[point]
for (const other of sketch.geometry) {
if (other.id === geometry.id || other.type !== 'line') continue
for (const otherPoint of ['start', 'end'] as const) {
if (distance(current, other[otherPoint]) > tolerance) continue
const ids = [`${geometry.id}.${point}`, `${other.id}.${otherPoint}`].sort()
add('coincident', { id: `auto-coincident-${ids.join('-')}`, type: 'coincident', first: { geometryId: geometry.id, point }, second: { geometryId: other.id, point: otherPoint } })
}
}
}
}
return suggestions.sort((left, right) => left.id.localeCompare(right.id))
}
export const applySketchAutoConstraints = (sketch: SketchSnapshot, suggestions: readonly SketchAutoConstraintSuggestion[]): SketchSnapshot => {
const edited = cloneSketch(sketch)
const ids = new Set(edited.constraints.map((constraint) => constraint.id))
const signatures = new Set(edited.constraints.map(constraintSignature))
for (const suggestion of suggestions) {
if (!suggestion.id.trim() || suggestion.id !== suggestion.constraint.id) throw new TypeError('Autoconstraint suggestion ID must match its constraint ID.')
if (ids.has(suggestion.id)) throw new RangeError(`Sketch constraint '${suggestion.id}' already exists.`)
const signature = constraintSignature(suggestion.constraint)
if (signatures.has(signature)) throw new RangeError(`Sketch already contains the suggested ${suggestion.reason} constraint.`)
ids.add(suggestion.id)
signatures.add(signature)
edited.constraints.push(cloneSketchConstraint(suggestion.constraint))
}
validateSketchSnapshot(edited)
return edited
}
export type SketchEditorEvent =
| { type: 'drag'; point: SketchPointRef; target: SketchPoint; options?: SketchSolveOptions }
| { type: 'split'; geometryId: string; point: SketchPoint; newGeometryId: string; tolerance?: number }
| { type: 'extend' | 'trim'; geometryId: string; endpoint: 'start' | 'end'; target: SketchPoint; tolerance?: number }
| { type: 'delete'; geometryId: string }
| { type: 'construction'; geometryId: string; construction: boolean }
| { type: 'autoconstraint'; geometryId: string; tolerance?: number; suggestionIds?: string[] }
| { type: 'undo' | 'redo' }
export type SketchEditorReplayResult = { snapshot: SketchSnapshot; applied: number; undone: number; redone: number }
export const replaySketchEditorEvents = (initial: SketchSnapshot, events: readonly SketchEditorEvent[]): SketchEditorReplayResult => {
const history: SketchSnapshot[] = [cloneSketch(initial)]
let cursor = 0
let applied = 0
let undone = 0
let redone = 0
for (const event of events) {
if (event.type === 'undo') { if (cursor > 0) { cursor -= 1; undone += 1 } continue }
if (event.type === 'redo') { if (cursor < history.length - 1) { cursor += 1; redone += 1 } continue }
const current = history[cursor]
let next: SketchSnapshot
if (event.type === 'drag') next = dragSketchPoint(current, event.point, event.target, event.options).snapshot
else if (event.type === 'split') next = splitSketchLine(current, event.geometryId, event.point, event.newGeometryId, event.tolerance)
else if (event.type === 'extend') next = extendSketchLine(current, event.geometryId, event.endpoint, event.target, event.tolerance)
else if (event.type === 'trim') next = trimSketchLine(current, event.geometryId, event.endpoint, event.target, event.tolerance)
else if (event.type === 'delete') next = deleteSketchGeometry(current, event.geometryId)
else if (event.type === 'construction') next = setSketchConstruction(current, event.geometryId, event.construction)
else if (event.type === 'autoconstraint') {
const suggestions = suggestSketchAutoConstraints(current, event.geometryId, event.tolerance)
next = applySketchAutoConstraints(current, event.suggestionIds ? suggestions.filter((suggestion) => event.suggestionIds?.includes(suggestion.id)) : suggestions)
} else throw new TypeError(`Unsupported sketch editor event: ${(event as { type: string }).type}`)
history.splice(cursor + 1)
history.push(next)
cursor += 1
applied += 1
}
return { snapshot: cloneSketch(history[cursor]), applied, undone, redone }
}
export type SketchEditorTool = 'select' | 'drag' | 'split' | 'extend' | 'trim'
export type SketchEditorPointerTarget = { geometryId: string; point?: SketchPointRef['point']; endpoint?: 'start' | 'end'; newGeometryId?: string }
export type SketchEditorPointerInput = { position: SketchPoint; target?: SketchEditorPointerTarget }
export type SketchEditorKeyboardInput = { key: string; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
export class SketchEditorInteractionSession {
private history: SketchSnapshot[]
private cursor = 0
private activePointer: SketchEditorPointerInput | null = null
private selectedGeometryId: string | null = null
private splitSequence = 0
private appliedCount = 0
private undoneCount = 0
private redoneCount = 0
private tool: SketchEditorTool = 'select'
constructor(initial: SketchSnapshot) {
this.history = [cloneSketch(initial)]
}
setTool(tool: SketchEditorTool) {
this.tool = tool
this.activePointer = null
}
getTool() { return this.tool }
getSnapshot() { return cloneSketch(this.history[this.cursor]) }
getState() {
return {
snapshot: this.getSnapshot(),
tool: this.tool,
pointerActive: this.activePointer !== null,
selectedGeometryId: this.selectedGeometryId,
applied: this.appliedCount,
undone: this.undoneCount,
redone: this.redoneCount,
}
}
pointerDown(input: SketchEditorPointerInput) {
assertFinitePoint(input.position, 'Sketch editor pointer position')
if (input.target && !this.history[this.cursor].geometry.some((geometry) => geometry.id === input.target?.geometryId)) throw new RangeError(`Sketch geometry '${input.target.geometryId}' does not exist.`)
this.activePointer = { position: { ...input.position }, target: input.target ? { ...input.target } : undefined }
this.selectedGeometryId = input.target?.geometryId ?? null
}
pointerUp(input: SketchEditorPointerInput) {
assertFinitePoint(input.position, 'Sketch editor pointer position')
const started = this.activePointer
this.activePointer = null
if (!started?.target || this.tool === 'select') return false
const target = started.target
if (this.tool === 'drag') {
if (!target.point) throw new TypeError('Drag pointer target requires a sketch point reference.')
this.apply({ type: 'drag', point: { geometryId: target.geometryId, point: target.point }, target: input.position })
} else if (this.tool === 'split') {
this.apply({ type: 'split', geometryId: target.geometryId, point: input.position, newGeometryId: target.newGeometryId ?? `${target.geometryId}-split-${++this.splitSequence}` })
} else if (this.tool === 'extend' || this.tool === 'trim') {
const endpoint = target.endpoint ?? (target.point === 'start' || target.point === 'end' ? target.point : undefined)
if (!endpoint) throw new TypeError(`${this.tool} pointer target requires a line endpoint.`)
this.apply({ type: this.tool, geometryId: target.geometryId, endpoint, target: input.position })
}
return true
}
keyDown(input: SketchEditorKeyboardInput) {
const key = input.key.toLowerCase()
if ((input.ctrlKey || input.metaKey) && key === 'z') return input.shiftKey ? this.redo() : this.undo()
if ((input.ctrlKey || input.metaKey) && key === 'y') return this.redo()
if (key === 'escape') { this.activePointer = null; this.tool = 'select'; return true }
if ((key === 'delete' || key === 'backspace') && this.selectedGeometryId) {
this.apply({ type: 'delete', geometryId: this.selectedGeometryId })
this.selectedGeometryId = null
return true
}
return false
}
apply(event: Exclude<SketchEditorEvent, { type: 'undo' | 'redo' }>) {
const result = replaySketchEditorEvents(this.history[this.cursor], [event])
this.history.splice(this.cursor + 1)
this.history.push(result.snapshot)
this.cursor += 1
this.appliedCount += 1
}
undo() {
if (this.cursor === 0) return false
this.cursor -= 1
this.undoneCount += 1
return true
}
redo() {
if (this.cursor >= this.history.length - 1) return false
this.cursor += 1
this.redoneCount += 1
return true
}
}
const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: string, diagnostics: SketchDiagnostic[]) => {
const result = geometry.find((candidate) => candidate.id === id)
@@ -124,6 +548,35 @@ const lineCircleTangentResidual = (line: SketchGeometry, circle: SketchGeometry)
return Math.abs(((circle.center.x - line.start.x) * dy - (circle.center.y - line.start.y) * dx) / length) - circle.radius
}
const lineRelationResidual = (first: SketchGeometry, second: SketchGeometry, relation: 'parallel' | 'perpendicular') => {
if (first.type !== 'line' || second.type !== 'line') return Infinity
const firstDx = first.end.x - first.start.x
const firstDy = first.end.y - first.start.y
const secondDx = second.end.x - second.start.x
const secondDy = second.end.y - second.start.y
const scale = Math.hypot(firstDx, firstDy) * Math.hypot(secondDx, secondDy)
if (scale === 0) return Infinity
return relation === 'parallel' ? Math.abs(firstDx * secondDy - firstDy * secondDx) / scale : Math.abs(firstDx * secondDx + firstDy * secondDy) / scale
}
const pointOnObjectProjection = (point: SketchPoint, object: SketchGeometry): SketchPoint | null => {
if (object.type === 'line') {
const dx = object.end.x - object.start.x
const dy = object.end.y - object.start.y
const lengthSquared = dx * dx + dy * dy
if (lengthSquared === 0) return null
const parameter = ((point.x - object.start.x) * dx + (point.y - object.start.y) * dy) / lengthSquared
return { x: object.start.x + parameter * dx, y: object.start.y + parameter * dy }
}
if (object.type === 'circle' || object.type === 'arc') {
const dx = point.x - object.center.x
const dy = point.y - object.center.y
const length = Math.hypot(dx, dy)
return length === 0 ? { x: object.center.x + object.radius, y: object.center.y } : { x: object.center.x + dx / length * object.radius, y: object.center.y + dy / length * object.radius }
}
return null
}
const isBlocked = (geometryId: string, blocked: Set<string>) => blocked.has(geometryId)
const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], next: SketchPoint, blocked: Set<string>) => {
@@ -135,8 +588,26 @@ const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], n
const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => {
if ('value' in constraint && (!Number.isFinite(constraint.value) || constraint.value < 0)) diagnostics.push({ code: 'INVALID_VALUE', constraintId: constraint.id, message: `Constraint '${constraint.id}' requires a finite non-negative value.` })
if (constraint.type === 'weight' && constraint.value <= 0) diagnostics.push({ code: 'INVALID_VALUE', constraintId: constraint.id, message: `Weight constraint '${constraint.id}' requires a positive value.` })
if ((constraint.type === 'weight' || constraint.type === 'internalAlignment') && (!Number.isSafeInteger(constraint.type === 'weight' ? constraint.controlPointIndex : constraint.internalGeometryIndex) || (constraint.type === 'weight' ? constraint.controlPointIndex : constraint.internalGeometryIndex) < 0)) diagnostics.push({ code: 'INVALID_VALUE', constraintId: constraint.id, message: `Constraint '${constraint.id}' requires a non-negative integer index.` })
}
const constraintSignature = (constraint: SketchConstraint) => {
if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') return `${constraint.type}:${pointKey(constraint.first)}:${pointKey(constraint.second)}:${'value' in constraint ? constraint.value : ''}`
if (constraint.type === 'symmetric') return `${constraint.type}:${pointKey(constraint.first)}:${pointKey(constraint.second)}:${pointKey(constraint.center)}`
if (constraint.type === 'equal' || constraint.type === 'tangent' || constraint.type === 'parallel' || constraint.type === 'perpendicular') return `${constraint.type}:${[constraint.firstGeometryId, constraint.secondGeometryId].sort().join(':')}`
if (constraint.type === 'pointOnObject') return `${constraint.type}:${pointKey(constraint.point)}:${constraint.geometryId}`
if (constraint.type === 'weight') return `${constraint.type}:${constraint.geometryId}:${constraint.controlPointIndex}:${constraint.value}`
if (constraint.type === 'snellsLaw') return 'boundaryGeometryId' in constraint
? `${constraint.type}:${pointKey(constraint.first)}:${pointKey(constraint.second)}:${constraint.boundaryGeometryId}:${constraint.value}`
: `${constraint.type}:${[constraint.firstGeometryId, constraint.secondGeometryId].sort().join(':')}:${constraint.value}`
if (constraint.type === 'internalAlignment') return `${constraint.type}:${constraint.geometryId}:${constraint.internalGeometryIndex}:${constraint.alignmentType}`
if (constraint.type === 'horizontal' || constraint.type === 'vertical' || constraint.type === 'block') return `${constraint.type}:${constraint.geometryId}`
return `${constraint.type}:${'geometryId' in constraint ? constraint.geometryId : ''}:${'value' in constraint ? constraint.value : ''}`
}
const hasFatalDiagnostics = (diagnostics: SketchDiagnostic[]) => diagnostics.some((diagnostic) => diagnostic.code !== 'REDUNDANT_CONSTRAINT' && diagnostic.code !== 'REFERENCE_DIMENSION' && diagnostic.code !== 'CONSTRAINT_CONFLICT')
const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], diagnostics: SketchDiagnostic[]): number => {
if (constraint.type === 'horizontal' || constraint.type === 'vertical') {
const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics)
@@ -161,6 +632,19 @@ const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], d
const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics)
return first && second ? Math.abs(lineLength(first) - lineLength(second)) : Infinity
}
if (constraint.type === 'parallel' || constraint.type === 'perpendicular') {
const first = findGeometry(geometry, constraint.firstGeometryId, constraint.id, diagnostics)
const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics)
return first && second ? lineRelationResidual(first, second, constraint.type) : Infinity
}
if (constraint.type === 'pointOnObject') {
const pointGeometry = findGeometry(geometry, constraint.point.geometryId, constraint.id, diagnostics)
const objectGeometry = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics)
if (!pointGeometry || !objectGeometry) return Infinity
const point = pointFor(pointGeometry, constraint.point.point, constraint.id, diagnostics)
const projection = point ? pointOnObjectProjection(point, objectGeometry) : null
return point && projection ? distance(point, projection) : Infinity
}
if (constraint.type === 'symmetric') {
const firstGeometry = findGeometry(geometry, constraint.first.geometryId, constraint.id, diagnostics)
const secondGeometry = findGeometry(geometry, constraint.second.geometryId, constraint.id, diagnostics)
@@ -202,13 +686,37 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
for (const geometry of snapshot.geometry) {
if (geometry.type === 'ellipse' || geometry.type === 'bspline') diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` })
}
for (const constraint of snapshot.constraints) if (constraint.type === 'weight' || constraint.type === 'snellsLaw' || constraint.type === 'internalAlignment') diagnostics.push({ code: 'UNSUPPORTED_CONSTRAINT', constraintId: constraint.id, message: `The typescript-basic solver does not solve ${constraint.type} constraint '${constraint.id}'.` })
const geometryById = new Map(snapshot.geometry.map((geometry) => [geometry.id, geometry]))
const blocked = new Set(snapshot.constraints.filter((constraint) => constraint.type === 'block').map((constraint) => constraint.geometryId))
snapshot.constraints.forEach((constraint) => validateConstraintValues(constraint, diagnostics))
const seenConstraints = new Set<string>()
const dimensionalConstraints = new Map<string, { id: string; value: number }>()
for (const constraint of snapshot.constraints.filter((candidate) => candidate.driving !== false)) {
const signature = constraintSignature(constraint)
if (seenConstraints.has(signature)) diagnostics.push({ code: 'REDUNDANT_CONSTRAINT', constraintId: constraint.id, message: `Constraint '${constraint.id}' duplicates an existing driving constraint.` })
else seenConstraints.add(signature)
if (constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY' || constraint.type === 'radius' || constraint.type === 'diameter' || constraint.type === 'angle') {
const key = constraint.type === 'radius' || constraint.type === 'diameter' || constraint.type === 'angle'
? `${constraint.type}:${constraint.geometryId}`
: `${constraint.type}:${pointKey(constraint.first)}:${pointKey(constraint.second)}`
const previous = dimensionalConstraints.get(key)
if (previous && Number.isFinite(constraint.value) && Number.isFinite(previous.value)) {
if (Math.abs(previous.value - constraint.value) <= tolerance) diagnostics.push({ code: 'REDUNDANT_CONSTRAINT', constraintId: constraint.id, message: `Dimensional constraint '${constraint.id}' duplicates '${previous.id}'.` })
else diagnostics.push({ code: 'CONSTRAINT_CONFLICT', constraintId: constraint.id, message: `Dimensional constraint '${constraint.id}' conflicts with '${previous.id}'.` })
} else if (Number.isFinite(constraint.value)) dimensionalConstraints.set(key, { id: constraint.id, value: constraint.value })
}
}
for (const constraint of snapshot.constraints) {
if (constraint.driving !== false || !('value' in constraint)) continue
if (!['distance', 'distanceX', 'distanceY', 'radius', 'diameter', 'angle'].includes(constraint.type)) continue
diagnostics.push({ code: 'REFERENCE_DIMENSION', constraintId: constraint.id, message: `Reference dimension '${constraint.id}' is read-only and does not affect the solve or degrees of freedom.` })
}
let residual = Infinity
let iterations = 0
for (; iterations < maxIterations && residual > tolerance && diagnostics.length === 0; iterations += 1) {
for (; iterations < maxIterations && residual > tolerance && !hasFatalDiagnostics(diagnostics); iterations += 1) {
for (const constraint of snapshot.constraints) {
if (constraint.driving === false) continue
if (constraint.type === 'block') continue
if (constraint.type === 'horizontal' || constraint.type === 'vertical') {
const candidate = geometryById.get(constraint.geometryId)
@@ -225,6 +733,23 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
const candidate = geometryById.get(constraint.geometryId)
if (!candidate || (candidate.type !== 'circle' && candidate.type !== 'arc')) { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue }
if (!isBlocked(candidate.id, blocked)) candidate.radius = constraint.value / 2
} else if (constraint.type === 'parallel' || constraint.type === 'perpendicular') {
const first = geometryById.get(constraint.firstGeometryId)
const second = geometryById.get(constraint.secondGeometryId)
if (!first || !second) { findGeometry(snapshot.geometry, !first ? constraint.firstGeometryId : constraint.secondGeometryId, constraint.id, diagnostics); continue }
if (first.type !== 'line' || second.type !== 'line') continue
if (!isBlocked(second.id, blocked)) {
const length = lineLength(second)
const angle = Math.atan2(first.end.y - first.start.y, first.end.x - first.start.x) + (constraint.type === 'perpendicular' ? Math.PI / 2 : 0)
second.end = { x: second.start.x + Math.cos(angle) * length, y: second.start.y + Math.sin(angle) * length }
}
} else if (constraint.type === 'pointOnObject') {
const pointGeometry = geometryById.get(constraint.point.geometryId)
const objectGeometry = geometryById.get(constraint.geometryId)
if (!pointGeometry || !objectGeometry) { findGeometry(snapshot.geometry, !pointGeometry ? constraint.point.geometryId : constraint.geometryId, constraint.id, diagnostics); continue }
const point = pointFor(pointGeometry, constraint.point.point, constraint.id, diagnostics)
const projection = point ? pointOnObjectProjection(point, objectGeometry) : null
if (projection && !isBlocked(pointGeometry.id, blocked)) adjustPoint(pointGeometry, constraint.point.point, projection, blocked)
} else if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') {
const firstGeometry = geometryById.get(constraint.first.geometryId)
const secondGeometry = geometryById.get(constraint.second.geometryId)
@@ -297,19 +822,33 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
if (!isBlocked(candidate.id, blocked)) { const length = lineLength(candidate); candidate.end = { x: candidate.start.x + Math.cos(constraint.value) * length, y: candidate.start.y + Math.sin(constraint.value) * length } }
}
}
residual = Math.max(0, ...snapshot.constraints.map((constraint) => residualFor(constraint, snapshot.geometry, diagnostics)))
residual = Math.max(0, ...snapshot.constraints.filter((constraint) => constraint.driving !== false).map((constraint) => residualFor(constraint, snapshot.geometry, diagnostics)))
}
const variableCount = snapshot.geometry.reduce((count, geometry) => {
if (geometry.type === 'point') return count + 2
if (geometry.type === 'line') return count + 4
if (geometry.type === 'circle') return count + 3
if (geometry.type === 'arc' || geometry.type === 'ellipse') return count + 5
return count + geometry.controlPoints.length * 2 + (geometry.weights?.length ?? 0)
const variableCountForGeometry = (geometry: SketchGeometry) => {
if (geometry.type === 'point') return 2
if (geometry.type === 'line') return 4
if (geometry.type === 'circle') return 3
if (geometry.type === 'arc' || geometry.type === 'ellipse') return 5
return geometry.controlPoints.length * 2 + (geometry.weights?.length ?? 0)
}
const variableCount = snapshot.geometry.reduce((count, geometry) => count + variableCountForGeometry(geometry), 0)
const rankConstraints = new Set(snapshot.constraints.filter((constraint) => constraint.driving !== false && constraint.type !== 'block').map(constraintSignature))
const blockedVariableCount = [...blocked].reduce((count, geometryId) => {
const geometry = geometryById.get(geometryId)
return geometry ? count + variableCountForGeometry(geometry) : count
}, 0)
const rank = Math.min(variableCount, snapshot.constraints.filter((constraint) => constraint.type !== 'block' || !isBlocked(constraint.geometryId, blocked)).length + blocked.size * 2)
const rank = Math.min(variableCount, rankConstraints.size + blockedVariableCount)
const degreesOfFreedom = Math.max(0, variableCount - rank)
const status: SketchSolverStatus = diagnostics.length > 0 ? 'invalid' : residual <= tolerance ? degreesOfFreedom === 0 ? 'solved' : 'under-constrained' : 'conflicting'
if (status === 'conflicting') diagnostics.push({ code: 'SOLVER_NOT_CONVERGED', message: `Sketch solver residual ${residual} exceeded tolerance ${tolerance}.` })
const hasConstraintConflict = diagnostics.some((diagnostic) => diagnostic.code === 'CONSTRAINT_CONFLICT')
const status: SketchSolverStatus = hasFatalDiagnostics(diagnostics) ? 'invalid' : hasConstraintConflict || residual > tolerance ? 'conflicting' : degreesOfFreedom === 0 ? 'solved' : 'under-constrained'
if (status === 'conflicting') {
for (const constraint of snapshot.constraints) {
if (constraint.driving === false) continue
const constraintResidual = residualFor(constraint, snapshot.geometry, diagnostics)
if (Number.isFinite(constraintResidual) && constraintResidual > tolerance && !diagnostics.some((diagnostic) => diagnostic.code === 'CONSTRAINT_CONFLICT' && diagnostic.constraintId === constraint.id)) diagnostics.push({ code: 'CONSTRAINT_CONFLICT', constraintId: constraint.id, message: `Constraint '${constraint.id}' residual ${constraintResidual} exceeded tolerance ${tolerance}.` })
}
diagnostics.push({ code: 'SOLVER_NOT_CONVERGED', message: `Sketch solver residual ${residual} exceeded tolerance ${tolerance}.` })
}
snapshot.solver = { status, degreesOfFreedom, residual, iterations, diagnostics }
return { snapshot, status, degreesOfFreedom, residual, iterations, diagnostics }
}

258
src/facade/spreadsheet.ts Normal file
View File

@@ -0,0 +1,258 @@
import { convertQuantity, evaluateQuantityExpression, getUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units'
export type SpreadsheetCellInput = number | string | null
export type SpreadsheetErrorCode = 'INVALID_ADDRESS' | 'UNKNOWN_REFERENCE' | 'DEPENDENCY_CYCLE' | 'EXPRESSION_ERROR'
export type SpreadsheetCellStyle = { background?: string; color?: string; bold?: boolean; italic?: boolean; numberFormat?: string }
export type SpreadsheetCell = {
address: string
input: SpreadsheetCellInput
alias?: string
unit?: string
style?: SpreadsheetCellStyle
value?: Quantity
display?: string
error?: { code: SpreadsheetErrorCode; message: string }
}
export type SpreadsheetBinding = { alias: string; objectId: string; propertyName: string }
export type SpreadsheetNamedRange = { name: string; range: string }
export type SpreadsheetDependency = { source: string; target: string; reference: string }
export type SpreadsheetEvaluation = {
cells: Record<string, SpreadsheetCell>
dependencies: SpreadsheetDependency[]
cycles: string[][]
errors: Array<{ address: string; code: SpreadsheetErrorCode; message: string }>
}
export type SpreadsheetSnapshot = {
id: string
label: string
cells: SpreadsheetCell[]
bindings: SpreadsheetBinding[]
namedRanges: SpreadsheetNamedRange[]
mergedRanges: string[]
version: number
}
export type SpreadsheetApi = {
snapshot(): SpreadsheetSnapshot
setCell(address: string, input: SpreadsheetCellInput, options?: { alias?: string; unit?: string }): SpreadsheetCell
setAlias(address: string, alias: string): SpreadsheetCell
setStyle(range: string, style: SpreadsheetCellStyle): SpreadsheetCell[]
mergeCells(range: string): SpreadsheetSnapshot
setNamedRange(name: string, range: string): SpreadsheetNamedRange
insertRows(at: number, count?: number): SpreadsheetSnapshot
insertColumns(at: number, count?: number): SpreadsheetSnapshot
bindProperty(alias: string, objectId: string, propertyName: string): SpreadsheetBinding
evaluate(): SpreadsheetEvaluation
exportCsv(): string
}
type CellState = SpreadsheetCell & { address: string }
const addressPattern = /^([A-Z]+)([1-9]\d*)$/
const normalizeAddress = (address: string) => {
const normalized = address.trim().replace(/\$/g, '').toUpperCase()
if (!addressPattern.test(normalized)) throw new RangeError(`Invalid spreadsheet cell address: ${address}`)
return normalized
}
const parseAddress = (address: string) => {
const match = addressPattern.exec(address)
if (!match) throw new RangeError(`Invalid spreadsheet cell address: ${address}`)
let column = 0
for (const character of match[1]) column = column * 26 + character.charCodeAt(0) - 64
return { column, row: Number(match[2]) }
}
const columnName = (column: number) => { let value = ''; for (let current = column; current > 0; current = Math.floor((current - 1) / 26)) value = String.fromCharCode(65 + ((current - 1) % 26)) + value; return value }
const formatAddress = (column: number, row: number) => `${columnName(column)}${row}`
const parseRange = (range: string) => {
const [start, end = start] = range.split(':').map(normalizeAddress)
const first = parseAddress(start); const last = parseAddress(end)
return { start, end, minColumn: Math.min(first.column, last.column), maxColumn: Math.max(first.column, last.column), minRow: Math.min(first.row, last.row), maxRow: Math.max(first.row, last.row) }
}
const rangeAddress = (range: ReturnType<typeof parseRange>) => `${formatAddress(range.minColumn, range.minRow)}:${formatAddress(range.maxColumn, range.maxRow)}`
const isFormula = (input: SpreadsheetCellInput): input is string => typeof input === 'string' && input.trim().startsWith('=')
const formulaBody = (input: string) => input.trim().replace(/^=/, '').trim()
const referencePattern = /\b(?:[A-Za-z_][A-Za-z0-9_]*\.)?[A-Za-z]{1,3}[1-9]\d*\b|\b[A-Za-z_][A-Za-z0-9_]*\b/g
const functionNames = new Set(['abs', 'sin', 'cos', 'tan', 'atan2', 'min', 'max', 'clamp', 'round', 'pow', 'pi'])
const referencesFor = (expression: string) => [...new Set((expression.match(referencePattern) ?? []).filter((reference) => !functionNames.has(reference.toLowerCase()) && !getUnit(reference)))]
const cloneCell = (cell: SpreadsheetCell): SpreadsheetCell => ({ ...cell, value: cell.value ? { ...cell.value } : undefined, style: cell.style ? { ...cell.style } : undefined, error: cell.error ? { ...cell.error } : undefined })
const cloneSnapshot = (snapshot: SpreadsheetSnapshot): SpreadsheetSnapshot => ({ ...snapshot, cells: snapshot.cells.map(cloneCell), bindings: snapshot.bindings.map((binding) => ({ ...binding })), namedRanges: snapshot.namedRanges.map((namedRange) => ({ ...namedRange })), mergedRanges: [...snapshot.mergedRanges] })
const quantityForLiteral = (input: SpreadsheetCellInput, unit?: string): Quantity | undefined => {
if (typeof input === 'number') return unit ? quantityFromUnit(input, unit) : quantityFromNumber(input)
if (typeof input !== 'string' || !input.trim() || isFormula(input)) return undefined
const evaluated = evaluateQuantityExpression(input)
if (!unit) return evaluated.value
const definition = getUnit(unit)
if (!definition || evaluated.value.dimension !== definition.dimension) throw new TypeError(`Cell unit ${unit} does not match its value dimension.`)
return quantityFromUnit(evaluated.value.value / definition.factor, unit)
}
export const createSpreadsheet = (id = 'spreadsheet', label = 'Spreadsheet'): SpreadsheetApi => {
const cells = new Map<string, CellState>()
const bindings = new Map<string, SpreadsheetBinding>()
const namedRanges = new Map<string, SpreadsheetNamedRange>()
const mergedRanges = new Set<string>()
let version = 0
const setCell = (address: string, input: SpreadsheetCellInput, options: { alias?: string; unit?: string } = {}) => {
const normalized = normalizeAddress(address)
if (options.alias) {
const alias = options.alias.trim()
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) throw new RangeError(`Invalid spreadsheet alias: ${options.alias}`)
const existing = [...cells.values()].find((cell) => cell.alias === alias && cell.address !== normalized)
if (existing) throw new RangeError(`Spreadsheet alias is already used: ${alias}`)
}
const previous = cells.get(normalized)
const cell: CellState = { address: normalized, input, ...(options.alias ? { alias: options.alias.trim() } : previous?.alias ? { alias: previous.alias } : {}), ...(options.unit ? { unit: options.unit } : previous?.unit ? { unit: previous.unit } : {}), ...(previous?.style ? { style: { ...previous.style } } : {}) }
cells.set(normalized, cell)
version += 1
return cloneCell(cell)
}
const setAlias = (address: string, alias: string) => {
const normalized = normalizeAddress(address)
const cell = cells.get(normalized)
if (!cell) throw new RangeError(`Spreadsheet cell does not exist: ${normalized}`)
return setCell(normalized, cell.input, { alias, unit: cell.unit })
}
const setStyle = (range: string, style: SpreadsheetCellStyle) => {
const parsed = parseRange(range)
const validColor = (value: string | undefined) => value === undefined || /^#[0-9a-f]{6}$/i.test(value)
if (!validColor(style.background) || !validColor(style.color) || (style.numberFormat !== undefined && !style.numberFormat.trim())) throw new RangeError('Spreadsheet style contains an invalid color or number format.')
const updated: SpreadsheetCell[] = []
for (let row = parsed.minRow; row <= parsed.maxRow; row += 1) for (let column = parsed.minColumn; column <= parsed.maxColumn; column += 1) {
const address = formatAddress(column, row)
const current = cells.get(address) ?? { address, input: null }
const next = { ...current, style: { ...current.style, ...style } }
cells.set(address, next); updated.push(cloneCell(next))
}
version += 1
return updated
}
const mergeCells = (range: string) => { const parsed = parseRange(range); const normalized = rangeAddress(parsed); mergedRanges.add(normalized); version += 1; return snapshot() }
const setNamedRange = (name: string, range: string) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new RangeError(`Invalid spreadsheet named range: ${name}`)
const normalized = rangeAddress(parseRange(range))
const namedRange = { name, range: normalized }
namedRanges.set(name, namedRange); version += 1
return { ...namedRange }
}
const shiftRange = (range: string, axis: 'row' | 'column', at: number, count: number) => {
const parsed = parseRange(range)
if (axis === 'row' && parsed.maxRow < at) return rangeAddress(parsed)
if (axis === 'column' && parsed.maxColumn < at) return rangeAddress(parsed)
const delta = (value: number) => value >= at ? value + count : value
return axis === 'row' ? `${formatAddress(parsed.minColumn, delta(parsed.minRow))}:${formatAddress(parsed.maxColumn, delta(parsed.maxRow))}` : `${formatAddress(delta(parsed.minColumn), parsed.minRow)}:${formatAddress(delta(parsed.maxColumn), parsed.maxRow)}`
}
const insert = (axis: 'row' | 'column', at: number, count: number) => {
if (!Number.isInteger(at) || at < 1 || !Number.isInteger(count) || count < 1 || count > 1000) throw new RangeError(`Spreadsheet ${axis} insertion range is invalid.`)
const moved = new Map<string, CellState>()
for (const cell of cells.values()) {
const parsed = parseAddress(cell.address)
const shifted = axis === 'row' ? { column: parsed.column, row: parsed.row >= at ? parsed.row + count : parsed.row } : { column: parsed.column >= at ? parsed.column + count : parsed.column, row: parsed.row }
moved.set(formatAddress(shifted.column, shifted.row), { ...cell, address: formatAddress(shifted.column, shifted.row) })
}
cells.clear(); moved.forEach((cell, address) => cells.set(address, cell))
for (const [name, namedRange] of namedRanges) namedRanges.set(name, { ...namedRange, range: shiftRange(namedRange.range, axis, at, count) })
const shiftedMerges = [...mergedRanges].map((range) => shiftRange(range, axis, at, count)); mergedRanges.clear(); shiftedMerges.forEach((range) => mergedRanges.add(range))
version += 1
return snapshot()
}
const insertRows = (at: number, count = 1) => insert('row', at, count)
const insertColumns = (at: number, count = 1) => insert('column', at, count)
const bindProperty = (alias: string, objectId: string, propertyName: string) => {
const cell = [...cells.values()].find((candidate) => candidate.alias === alias)
if (!cell) throw new ReferenceError(`Spreadsheet alias does not exist: ${alias}`)
const binding = { alias, objectId, propertyName }
bindings.set(alias, binding)
version += 1
return { ...binding }
}
const evaluate = (): SpreadsheetEvaluation => {
const result = new Map<string, SpreadsheetCell>()
const dependencies: SpreadsheetDependency[] = []
const stack: string[] = []
const cycles: string[][] = []
const errors: Array<{ address: string; code: SpreadsheetErrorCode; message: string }> = []
const aliasToAddress = new Map([...cells.values()].filter((cell) => cell.alias).map((cell) => [cell.alias as string, cell.address]))
const resolve = (reference: string, sourceAddress?: string): Quantity => {
const address = cells.has(reference) ? reference : aliasToAddress.get(reference)
if (!address) throw Object.assign(new ReferenceError(`Unknown spreadsheet reference: ${reference}`), { code: 'UNKNOWN_REFERENCE' as const })
if (sourceAddress && sourceAddress !== address) dependencies.push({ source: sourceAddress, target: address, reference })
if (stack.includes(address)) {
const cycle = [...stack.slice(stack.indexOf(address)), address]
if (!cycles.some((candidate) => candidate.join('>') === cycle.join('>'))) cycles.push(cycle)
throw Object.assign(new Error(`Spreadsheet dependency cycle: ${cycle.join(' -> ')}`), { code: 'DEPENDENCY_CYCLE' as const })
}
const previous = result.get(address)
if (previous?.value) return previous.value
const cell = cells.get(address)
if (!cell) throw new ReferenceError(`Unknown spreadsheet cell: ${address}`)
stack.push(address)
try {
const references = isFormula(cell.input) ? referencesFor(formulaBody(cell.input)) : []
const variables = new Map<string, Quantity>()
for (const reference of references) variables.set(reference, resolve(reference, address))
const literal = isFormula(cell.input) ? undefined : quantityForLiteral(cell.input, cell.unit)
if (!isFormula(cell.input) && !literal) throw new TypeError(`Spreadsheet cell ${address} is not a quantity.`)
const evaluated = isFormula(cell.input) ? evaluateQuantityExpression(formulaBody(cell.input), variables) : { value: literal as Quantity }
const displayValue = cell.unit ? convertQuantity(evaluated.value, cell.unit) : evaluated.value.value
const updated = { ...cloneCell(cell), value: evaluated.value, display: `${displayValue} ${cell.unit ?? evaluated.value.dimension}`, error: undefined }
result.set(address, updated)
return evaluated.value
} catch (error) {
const code = (error as { code?: SpreadsheetErrorCode }).code ?? (error instanceof ReferenceError ? 'UNKNOWN_REFERENCE' : 'EXPRESSION_ERROR')
const message = error instanceof Error ? error.message : String(error)
const updated = { ...cloneCell(cell), error: { code, message } }
result.set(address, updated)
errors.push({ address, code, message })
throw error
} finally { stack.pop() }
}
for (const cell of cells.values()) { try { resolve(cell.address) } catch { /* diagnostics are returned with the cell */ } }
for (const cycle of cycles) for (const address of cycle) {
const cell = result.get(address) ?? cells.get(address)
if (cell) result.set(address, { ...cloneCell(cell), error: { code: 'DEPENDENCY_CYCLE', message: `Spreadsheet dependency cycle: ${cycle.join(' -> ')}` } })
}
return { cells: Object.fromEntries([...cells.keys()].map((address) => [address, cloneCell(result.get(address) ?? cells.get(address)!)])), dependencies, cycles, errors }
}
const snapshot = (): SpreadsheetSnapshot => cloneSnapshot({ id, label, cells: [...cells.values()].map(cloneCell), bindings: [...bindings.values()].map((binding) => ({ ...binding })), namedRanges: [...namedRanges.values()].map((namedRange) => ({ ...namedRange })), mergedRanges: [...mergedRanges], version })
const exportCsv = () => {
const evaluated = evaluate()
if (cells.size === 0) return ''
const coordinates = [...cells.keys()].map(parseAddress)
const maxRow = Math.max(...coordinates.map((coordinate) => coordinate.row))
const maxColumn = Math.max(...coordinates.map((coordinate) => coordinate.column))
return Array.from({ length: maxRow }, (_, rowIndex) => Array.from({ length: maxColumn }, (_, columnIndex) => {
const cell = evaluated.cells[formatAddress(columnIndex + 1, rowIndex + 1)]
const value = cell?.display ?? (cell?.input == null ? '' : String(cell.input))
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
}).join(',')).join('\n')
}
return { snapshot, setCell, setAlias, setStyle, mergeCells, setNamedRange, insertRows, insertColumns, bindProperty, evaluate, exportCsv }
}

297
src/facade/stringHasher.ts Normal file
View File

@@ -0,0 +1,297 @@
/**
* FreeCAD 1.1.1 StringHasher.Table.txt codec.
*
* IDs and related IDs are persisted as a delta-coded hexadecimal stream. The
* table is naming evidence, not a hash that can be reconstructed from a
* final shape; callers must preserve it as an opaque native resource.
*/
export const STRING_HASHER_NATIVE_VERSION = 1 as const
export const enum StringHasherFlag {
Binary = 1 << 0,
Hashed = 1 << 1,
PostfixEncoded = 1 << 2,
Postfixed = 1 << 3,
Indexed = 1 << 4,
PrefixId = 1 << 5,
PrefixIdIndex = 1 << 6,
Persistent = 1 << 7,
/** Internal mark is stripped from persisted tables but accepted by writer input. */
Marked = 1 << 8,
}
export type StringHasherEntry = {
id: number
flags: number
relatedIds: number[]
/** Stored data for non-postfixed entries or explicit postfixed prefixes. */
data: string
/** Stored postfix when PostfixEncoded is not set. */
postfix: string
raw?: string
}
export type StringHasherTableV1 = {
schemaVersion: 1
nativeVersion: 1
entries: StringHasherEntry[]
}
export type StringHasherTable = {
schemaVersion: 2
nativeVersion: 1
entries: StringHasherEntry[]
}
export type AnyStringHasherTable = StringHasherTableV1 | StringHasherTable
export type StringHasherValidationIssue = {
code: 'HEADER' | 'COUNT' | 'SCHEMA_VERSION' | 'NATIVE_VERSION' | 'ID' | 'FLAGS' | 'RELATED_ID' | 'PAYLOAD'
path: string
message: string
}
export type StringHasherValidationReport = {
valid: boolean
entryCount: number
issues: StringHasherValidationIssue[]
}
export type ElementMap2StringHasherEvidenceIssue = Omit<StringHasherValidationIssue, 'code'> & { code: StringHasherValidationIssue['code'] | 'TOKEN_ID' | 'PREFIX_ID' }
const MAX_ENTRIES = 2_000_000
const MAX_ID = 0x7fffffff
const KNOWN_FLAGS = StringHasherFlag.Binary | StringHasherFlag.Hashed | StringHasherFlag.PostfixEncoded | StringHasherFlag.Postfixed | StringHasherFlag.Indexed | StringHasherFlag.PrefixId | StringHasherFlag.PrefixIdIndex | StringHasherFlag.Persistent
const WRITABLE_FLAGS = KNOWN_FLAGS | StringHasherFlag.Marked
const hasFlag = (flags: number, flag: StringHasherFlag) => (flags & flag) !== 0
const fail = (message: string): never => { throw new Error(`StringHasher parse error: ${message}`) }
const parseHex = (value: string, label: string): number => {
if (!/^(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/.test(value)) fail(`${label} is not hexadecimal: ${value}`)
const result = Number.parseInt(value, 16)
if (!Number.isSafeInteger(result) || result < 0 || result > MAX_ID) fail(`${label} is out of range`)
return result
}
const parseSignedHex = (value: string, label: string): number => value.startsWith('-') ? -parseHex(value.slice(1), label) : parseHex(value, label)
const checkedHex = (value: number, label: string): string => {
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_ID) throw new RangeError(`StringHasher ${label} is out of range`)
return value.toString(16)
}
const migrateEntry = (entry: StringHasherEntry): StringHasherEntry => ({ ...entry, relatedIds: [...entry.relatedIds] })
export const migrateStringHasherSchema = (table: AnyStringHasherTable): StringHasherTable => ({
schemaVersion: 2,
nativeVersion: table.nativeVersion,
entries: table.entries.map(migrateEntry),
})
export const cloneStringHasherTable = (table: AnyStringHasherTable): StringHasherTable => migrateStringHasherSchema(table)
const readToken = (text: string, state: { offset: number }): string => {
while (state.offset < text.length && /\s/.test(text[state.offset])) state.offset += 1
const start = state.offset
while (state.offset < text.length && !/\s/.test(text[state.offset])) state.offset += 1
if (start === state.offset) fail('unexpected end of table')
return text.slice(start, state.offset)
}
const readLinePayload = (text: string, state: { offset: number }): string => {
while (state.offset < text.length && /[ \t]/.test(text[state.offset])) state.offset += 1
const start = state.offset
while (state.offset < text.length && /[0-9a-fA-F]/.test(text[state.offset])) state.offset += 1
if (state.offset === start || text[state.offset] !== ':') fail('invalid TextOutputStream payload prefix')
const lineCount = Number.parseInt(text.slice(start, state.offset), 16)
state.offset += 1
let value = ''
for (let line = 0; line < lineCount; line += 1) {
const newline = text.indexOf('\n', state.offset)
if (newline < 0) fail('truncated multiline StringHasher payload')
value += text.slice(state.offset, newline + 1)
state.offset = newline + 1
}
const newline = text.indexOf('\n', state.offset)
if (newline < 0) fail('truncated StringHasher payload delimiter')
value += text.slice(state.offset, newline)
state.offset = newline + 1
return value
}
const readRawPayload = (text: string, state: { offset: number }): string => {
while (state.offset < text.length && /\s/.test(text[state.offset])) state.offset += 1
const start = state.offset
while (state.offset < text.length && !/\s/.test(text[state.offset])) state.offset += 1
if (start === state.offset) fail('missing postfixed StringHasher payload')
return text.slice(start, state.offset)
}
const decodeRelatedIds = (id: number, parts: string[], previous: StringHasherEntry | undefined, relative: boolean): number[] => {
const result: number[] = []
let position = 0
if (relative && previous) {
for (; position < parts.length && position < previous.relatedIds.length; position += 1) {
const value = previous.relatedIds[position] + parseSignedHex(parts[position], `related ID ${position}`)
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_ID) fail(`related ID ${value} is out of range`)
result.push(value)
}
}
for (; position < parts.length; position += 1) {
const value = relative ? id - parseHex(parts[position], `relative related ID ${position}`) : parseHex(parts[position], `related ID ${position}`)
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_ID) fail(`related ID ${value} is out of range`)
result.push(value)
}
return result
}
export const parseStringHasherTable = (text: string): StringHasherTableV1 => {
const normalized = text.replace(/\r\n/g, '\n')
const headerEnd = normalized.indexOf('\n')
if (headerEnd < 0) fail('missing StringTableStart header')
const header = normalized.slice(0, headerEnd).trim().match(/^StringTableStart\s+v(\d+)\s+(\d+)$/)
if (!header) throw new Error('StringHasher parse error: expected StringTableStart v1 <count>')
if (Number(header[1]) !== 1) fail('expected StringTableStart v1 <count>')
const count = Number(header[2])
if (!Number.isSafeInteger(count) || count < 0 || count > MAX_ENTRIES) fail('entry count is out of range')
const state = { offset: headerEnd + 1 }
const entries: StringHasherEntry[] = []
const entriesById = new Map<number, StringHasherEntry>()
let lastId = 0
let previous: StringHasherEntry | undefined
for (let ordinal = 0; ordinal < count; ordinal += 1) {
while (state.offset < normalized.length && /\s/.test(normalized[state.offset])) state.offset += 1
const rawStart = state.offset
const meta = readToken(normalized, state)
const parts = meta.split('.')
if (parts.length < 2) fail(`entry ${ordinal} has no flags`)
const relative = parts[0].startsWith('-')
const id = relative ? lastId + parseHex(parts[0].slice(1), `entry ${ordinal} relative id`) : parseHex(parts[0], `entry ${ordinal} id`)
if (id <= lastId || id <= 0) fail(`entry ${ordinal} ID ${id} is not strictly increasing`)
const flags = parseHex(parts[1], `entry ${ordinal} flags`)
if ((flags & ~KNOWN_FLAGS) !== 0) fail(`entry ${ordinal} has unknown flags 0x${flags.toString(16)}`)
const relatedIds = decodeRelatedIds(id, parts.slice(2), previous, relative)
let data = ''
let postfix = ''
if (!hasFlag(flags, StringHasherFlag.Postfixed)) {
data = readLinePayload(normalized, state)
} else {
if (!hasFlag(flags, StringHasherFlag.Indexed) && !hasFlag(flags, StringHasherFlag.PrefixId) && !hasFlag(flags, StringHasherFlag.PrefixIdIndex)) data = readRawPayload(normalized, state)
if (!hasFlag(flags, StringHasherFlag.PostfixEncoded)) postfix = readRawPayload(normalized, state)
while (state.offset < normalized.length && /\s/.test(normalized[state.offset])) state.offset += 1
const relatedOffset = hasFlag(flags, StringHasherFlag.PostfixEncoded) ? 1 : 0
if (hasFlag(flags, StringHasherFlag.PostfixEncoded)) {
const related = entriesById.get(relatedIds[0])
if (!related) throw new Error(`StringHasher parse error: entry ${ordinal} has no resolvable postfix StringID`)
postfix = related.data
}
if (hasFlag(flags, StringHasherFlag.Indexed)) {
const related = entriesById.get(relatedIds[relatedOffset])
if (!related) throw new Error(`StringHasher parse error: entry ${ordinal} has no resolvable indexed prefix StringID`)
data = related.data
} else if (hasFlag(flags, StringHasherFlag.PrefixId) || hasFlag(flags, StringHasherFlag.PrefixIdIndex)) {
const prefixId = relatedIds[relatedOffset]
if (!entriesById.has(prefixId)) fail(`entry ${ordinal} has no resolvable prefix StringID`)
data = `#${prefixId.toString(16)}${hasFlag(flags, StringHasherFlag.PrefixIdIndex) ? ':' : ''}`
}
}
const entry: StringHasherEntry = { id, flags, relatedIds, data, postfix, raw: normalized.slice(rawStart, state.offset).trim() }
entries.push(entry)
entriesById.set(id, entry)
lastId = id
previous = entry
}
const trailing = normalized.slice(state.offset).trim()
if (trailing) fail(`unexpected trailing table content: ${trailing.slice(0, 40)}`)
return { schemaVersion: 1, nativeVersion: 1, entries }
}
const encodedText = (value: string): string => {
const lineCount = [...value].filter((character) => character === '\n').length
return `${lineCount.toString(16)}:${value.replace(/\r\n/g, '\n')}\n`
}
export const writeStringHasherTable = (table: AnyStringHasherTable): string => {
const value = migrateStringHasherSchema(table)
const validation = validateStringHasherTable(value)
if (!validation.valid) throw new RangeError(`StringHasher validation error: ${validation.issues[0].path}: ${validation.issues[0].message}`)
let output = `StringTableStart v1 ${value.entries.length}\n`
let anchor = 0
let lastId = 0
let previous: StringHasherEntry | undefined
for (const entry of value.entries) {
const relative = entry.id - anchor < 1000
const idToken = relative ? `-${checkedHex(entry.id - lastId, 'relative ID')}` : checkedHex(entry.id, 'ID')
if (!relative) anchor = entry.id
const flags = entry.flags & ~0x100
if (!Number.isSafeInteger(flags) || flags < 0 || (flags & ~KNOWN_FLAGS) !== 0) throw new RangeError(`StringHasher flags out of range for ID ${entry.id}`)
const related: string[] = []
if (relative && previous) {
let position = 0
for (; position < entry.relatedIds.length && position < previous.relatedIds.length; position += 1) {
const delta = entry.relatedIds[position] - previous.relatedIds[position]
related.push(delta < 0 ? `-${checkedHex(-delta, 'related ID delta')}` : checkedHex(delta, 'related ID delta'))
}
for (; position < entry.relatedIds.length; position += 1) related.push(checkedHex(entry.id - entry.relatedIds[position], 'relative related ID'))
} else related.push(...entry.relatedIds.map((id) => checkedHex(id, 'related ID')))
let line = [idToken, checkedHex(flags, 'flags'), ...related].join('.')
if (!hasFlag(flags, StringHasherFlag.Postfixed)) {
output += `${line} ${encodedText(entry.data)}`
} else {
if (!hasFlag(flags, StringHasherFlag.Indexed) && !hasFlag(flags, StringHasherFlag.PrefixId) && !hasFlag(flags, StringHasherFlag.PrefixIdIndex)) line += ` ${entry.data}`
if (!hasFlag(flags, StringHasherFlag.PostfixEncoded)) line += ` ${entry.postfix}`
output += `${line}\n`
}
lastId = entry.id
previous = entry
}
return output
}
export const validateStringHasherTable = (table: AnyStringHasherTable): StringHasherValidationReport => {
const value = migrateStringHasherSchema(table)
const issues: StringHasherValidationIssue[] = []
const ids = new Set<number>()
let previous = 0
for (const [index, entry] of value.entries.entries()) {
const path = `entries[${index}]`
if (!Number.isSafeInteger(entry.id) || entry.id <= previous || entry.id > MAX_ID || ids.has(entry.id)) issues.push({ code: 'ID', path: `${path}.id`, message: `ID ${entry.id} must be strictly increasing and unique` })
ids.add(entry.id); previous = entry.id
if ((entry.flags & ~WRITABLE_FLAGS) !== 0) issues.push({ code: 'FLAGS', path: `${path}.flags`, message: `unknown flags 0x${entry.flags.toString(16)}` })
const prefixFlags = [StringHasherFlag.Indexed, StringHasherFlag.PrefixId, StringHasherFlag.PrefixIdIndex].filter((flag) => hasFlag(entry.flags, flag))
if (prefixFlags.length > 1) issues.push({ code: 'FLAGS', path: `${path}.flags`, message: 'Indexed, PrefixId and PrefixIdIndex are mutually exclusive' })
if (!hasFlag(entry.flags, StringHasherFlag.Postfixed) && (hasFlag(entry.flags, StringHasherFlag.PostfixEncoded) || prefixFlags.length > 0)) issues.push({ code: 'FLAGS', path: `${path}.flags`, message: 'postfix/prefix encoding flags require Postfixed' })
const relatedOffset = hasFlag(entry.flags, StringHasherFlag.PostfixEncoded) ? 1 : 0
if (hasFlag(entry.flags, StringHasherFlag.Postfixed) && hasFlag(entry.flags, StringHasherFlag.PostfixEncoded) && entry.relatedIds.length < 1) issues.push({ code: 'PAYLOAD', path, message: 'PostfixEncoded entry has no related postfix ID' })
if (hasFlag(entry.flags, StringHasherFlag.Postfixed) && prefixFlags.length > 0 && entry.relatedIds.length <= relatedOffset) issues.push({ code: 'PAYLOAD', path, message: 'encoded prefix entry has no related prefix ID' })
for (const [sidIndex, sid] of entry.relatedIds.entries()) if (!ids.has(sid)) issues.push({ code: 'RELATED_ID', path: `${path}.relatedIds[${sidIndex}]`, message: `missing prior StringID ${sid}` })
}
if (value.schemaVersion !== 2) issues.unshift({ code: 'SCHEMA_VERSION', path: 'schemaVersion', message: `unsupported schema version ${value.schemaVersion}` })
if (value.nativeVersion !== 1) issues.unshift({ code: 'NATIVE_VERSION', path: 'nativeVersion', message: `unsupported native version ${value.nativeVersion}` })
return { valid: issues.length === 0, entryCount: value.entries.length, issues }
}
export const validateElementMap2StringHasherEvidence = (document: import('./elementMap2').AnyElementMap2Document, table: AnyStringHasherTable): ElementMap2StringHasherEvidenceIssue[] => {
const ids = new Set(migrateStringHasherSchema(table).entries.map((entry) => entry.id))
const issues: ElementMap2StringHasherEvidenceIssue[] = []
for (const [mapIndex, map] of document.maps.entries()) for (const [sectionIndex, section] of map.sections.entries()) {
for (const [childIndex, child] of section.children.entries()) for (const [sidIndex, sid] of child.stringIds.entries()) if (!ids.has(sid)) issues.push({ code: 'TOKEN_ID', path: `maps[${mapIndex}].sections[${sectionIndex}].children[${childIndex}].stringIds[${sidIndex}]`, message: `missing StringHasher ID ${sid}` })
for (const [nameIndex, name] of section.names.entries()) for (const [tokenIndex, token] of name.tokens.entries()) {
const tokenPath = `maps[${mapIndex}].sections[${sectionIndex}].names[${nameIndex}].tokens[${tokenIndex}]`
// `$#<id>[:<index>]` stores the prefix StringID in the mapped name, not
// in the ElementMap suffix. It is still required native evidence.
if (token.marker === '$' && token.name) {
const prefix = token.name.match(/^#([0-9a-fA-F]+)(?::[0-9a-fA-F]+)?$/)
if (!prefix) issues.push({ code: 'PREFIX_ID', path: `${tokenPath}.name`, message: `invalid mapped-name prefix ${token.name}` })
else {
const sid = Number.parseInt(prefix[1], 16)
if (!ids.has(sid)) issues.push({ code: 'PREFIX_ID', path: `${tokenPath}.name`, message: `missing prefix StringHasher ID ${sid}` })
}
}
for (const [sidIndex, part] of token.suffix.slice(1).entries()) {
const sid = Number.parseInt(part, 16)
if (!ids.has(sid)) issues.push({ code: 'TOKEN_ID', path: `${tokenPath}.suffix[${sidIndex + 1}]`, message: `missing StringHasher ID ${sid}` })
}
}
}
return issues
}

33
src/facade/surface.ts Normal file
View File

@@ -0,0 +1,33 @@
export type SurfacePoint = [number, number, number]
export type SurfaceKind = 'bezier' | 'bspline' | 'loft'
export type SurfacePatch = { id: string; kind: SurfaceKind; poles: SurfacePoint[][]; degreeU: number; degreeV: number; knotsU: number[]; knotsV: number[]; trim?: { u: [number, number]; v: [number, number] }; tolerance: number }
export type SurfaceQuality = { patches: number; poles: number; bounds: { min: SurfacePoint; max: SurfacePoint }; trimmed: number; openEdges: number }
export type SurfaceContinuity = { relation: 'C0' | 'disconnected'; gap: number; tolerance: number }
export type SurfaceTopoRef = { patchId: string; edge: 'u0' | 'u1' | 'v0' | 'v1'; persistentId: string; status: 'stable' }
export type SurfaceSnapshot = { id: string; label: string; patches: SurfacePatch[]; sewn: string[][]; version: number }
export type SurfaceApi = { snapshot(): SurfaceSnapshot; addBezier(id: string, poles: SurfacePoint[][], tolerance?: number): SurfacePatch; addBspline(id: string, poles: SurfacePoint[][], options?: { degreeU?: number; degreeV?: number; knotsU?: number[]; knotsV?: number[]; tolerance?: number }): SurfacePatch; loft(id: string, profiles: SurfacePoint[][], options?: { ruled?: boolean; tolerance?: number }): SurfacePatch; fill(id: string, boundary: SurfacePoint[], tolerance?: number): SurfacePatch; offset(sourceId: string, outputId: string, distance: number): SurfacePatch; trim(id: string, u: [number, number], v: [number, number]): SurfacePatch; sew(ids: string[], tolerance?: number): string[]; continuity(firstId: string, secondId: string, tolerance?: number): SurfaceContinuity; topologyRefs(id: string): SurfaceTopoRef[]; analyze(): SurfaceQuality; exportObj(samples?: number): string }
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const point = (value: SurfacePoint, label: string): SurfacePoint => { if (value.length !== 3 || value.some((entry) => !Number.isFinite(entry))) throw new RangeError(`${label} must contain three finite coordinates.`); return [value[0], value[1], value[2]] }
const poles = (input: SurfacePoint[][]) => { if (input.length < 2 || input.some((row) => row.length < 2 || row.length !== input[0].length)) throw new RangeError('Surface poles require a rectangular grid of at least 2x2 points.'); return input.map((row) => row.map((entry) => point(entry, 'Surface pole'))) }
const distance = (a: SurfacePoint, b: SurfacePoint) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2])
const edge = (patch: SurfacePatch, side: 'start' | 'end') => patch.poles[side === 'start' ? 0 : patch.poles.length - 1]
export const createSurfaceDocument = (id = 'surface', label = 'Surface'): SurfaceApi => {
const patches = new Map<string, SurfacePatch>(); const sewn: string[][] = []; let version = 0
const add = (patch: SurfacePatch) => { if (patches.has(patch.id)) throw new RangeError(`Surface patch already exists: ${patch.id}`); patches.set(patch.id, patch); version += 1; return clone(patch) }
const addBezier = (patchId: string, input: SurfacePoint[][], tolerance = 1e-6) => { if (!Number.isFinite(tolerance) || tolerance <= 0) throw new RangeError('Surface tolerance must be positive.'); const grid = poles(input); return add({ id: patchId, kind: 'bezier', poles: grid, degreeU: grid.length - 1, degreeV: grid[0].length - 1, knotsU: [], knotsV: [], tolerance }) }
const addBspline = (patchId: string, input: SurfacePoint[][], options: { degreeU?: number; degreeV?: number; knotsU?: number[]; knotsV?: number[]; tolerance?: number } = {}) => { const grid = poles(input); const degreeU = options.degreeU ?? Math.min(3, grid.length - 1); const degreeV = options.degreeV ?? Math.min(3, grid[0].length - 1); if (!Number.isSafeInteger(degreeU) || degreeU < 1 || degreeU >= grid.length || !Number.isSafeInteger(degreeV) || degreeV < 1 || degreeV >= grid[0].length) throw new RangeError('BSpline degree must fit the pole grid.'); const knotsU = options.knotsU ? [...options.knotsU] : Array.from({ length: grid.length + degreeU + 1 }, (_, index) => index); const knotsV = options.knotsV ? [...options.knotsV] : Array.from({ length: grid[0].length + degreeV + 1 }, (_, index) => index); if (knotsU.length !== grid.length + degreeU + 1 || knotsV.length !== grid[0].length + degreeV + 1) throw new RangeError('BSpline knot vectors have an invalid length.'); return add({ id: patchId, kind: 'bspline', poles: grid, degreeU, degreeV, knotsU, knotsV, tolerance: options.tolerance ?? 1e-6 }) }
const loft = (patchId: string, profiles: SurfacePoint[][], options: { ruled?: boolean; tolerance?: number } = {}) => { if (profiles.length < 2 || profiles.some((profile) => profile.length < 2 || profile.length !== profiles[0].length)) throw new RangeError('Surface loft requires two or more profiles with equal point counts.'); const grid = poles(profiles); return add({ id: patchId, kind: 'loft', poles: grid, degreeU: options.ruled ? 1 : Math.min(3, grid.length - 1), degreeV: 1, knotsU: [], knotsV: [], tolerance: options.tolerance ?? 1e-6 }) }
const fill = (patchId: string, boundary: SurfacePoint[], tolerance = 1e-6) => { if (boundary.length !== 4) throw new RangeError('Surface fill requires four boundary corner points.'); const corners = boundary.map((entry) => point(entry, 'Surface fill boundary')); return addBezier(patchId, [[corners[0], corners[1]], [corners[3], corners[2]]], tolerance) }
const offset = (sourceId: string, outputId: string, distanceValue: number) => { const source = patches.get(sourceId); if (!source) throw new RangeError(`Surface patch does not exist: ${sourceId}`); if (!Number.isFinite(distanceValue) || distanceValue === 0) throw new RangeError('Surface offset distance must be finite and non-zero.'); return add({ ...clone(source), id: outputId, poles: source.poles.map((row) => row.map((entry) => [entry[0], entry[1], entry[2] + distanceValue] as SurfacePoint)) }) }
const trim = (patchId: string, u: [number, number], v: [number, number]) => { const patch = patches.get(patchId); if (!patch) throw new RangeError(`Surface patch does not exist: ${patchId}`); if (![...u, ...v].every((entry) => Number.isFinite(entry)) || u[0] < 0 || u[1] > 1 || u[0] >= u[1] || v[0] < 0 || v[1] > 1 || v[0] >= v[1]) throw new RangeError('Surface trim bounds must be ordered within [0, 1].'); patch.trim = { u: [...u], v: [...v] }; version += 1; return clone(patch) }
const sew = (ids: string[], tolerance = 1e-6) => { if (ids.length < 2 || ids.some((patchId) => !patches.has(patchId))) throw new RangeError('Surface sewing requires at least two existing patches.'); const group = [...new Set(ids)]; for (const previous of sewn) if (previous.join('|') === group.join('|')) return [...previous]; for (let index = 1; index < group.length; index += 1) { const relation = continuity(group[index - 1], group[index], tolerance); if (relation.relation === 'disconnected') throw new RangeError(`Surface patches are outside sewing tolerance: ${relation.gap}`) } sewn.push(group); version += 1; return [...group] }
const continuity = (firstId: string, secondId: string, tolerance = 1e-6): SurfaceContinuity => { const first = patches.get(firstId); const second = patches.get(secondId); if (!first || !second) throw new RangeError('Surface continuity requires existing patches.'); const a = edge(first, 'end'); const b = edge(second, 'start'); const gap = Math.max(...a.map((entry, index) => distance(entry, b[Math.min(index, b.length - 1)]))); return { relation: gap <= tolerance ? 'C0' : 'disconnected', gap, tolerance } }
const topologyRefs = (patchId: string): SurfaceTopoRef[] => { const patch = patches.get(patchId); if (!patch) throw new RangeError(`Surface patch does not exist: ${patchId}`); return (['u0', 'u1', 'v0', 'v1'] as const).map((edgeName) => ({ patchId, edge: edgeName, persistentId: `${patchId}:${edgeName}`, status: 'stable' as const })) }
const analyze = (): SurfaceQuality => { const min: SurfacePoint = [Infinity, Infinity, Infinity]; const max: SurfacePoint = [-Infinity, -Infinity, -Infinity]; let poleCount = 0; for (const patch of patches.values()) for (const row of patch.poles) for (const entry of row) { poleCount += 1; for (let axis = 0; axis < 3; axis += 1) { min[axis] = Math.min(min[axis], entry[axis]); max[axis] = Math.max(max[axis], entry[axis]) } } return { patches: patches.size, poles: poleCount, bounds: { min, max }, trimmed: [...patches.values()].filter((patch) => patch.trim !== undefined).length, openEdges: Math.max(0, patches.size * 4 - sewn.reduce((total, group) => total + (group.length - 1) * 2, 0)) } }
const sample = (patch: SurfacePatch, u: number, v: number): SurfacePoint => { const row = patch.poles[Math.min(patch.poles.length - 1, Math.round(u * (patch.poles.length - 1)))]; const next = patch.poles[Math.min(patch.poles.length - 1, Math.min(patch.poles.length - 1, Math.round(u * (patch.poles.length - 1)) + 1))]; const first = row[Math.min(row.length - 1, Math.round(v * (row.length - 1)))]; const second = next[Math.min(next.length - 1, Math.round(v * (next.length - 1)))]; return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2, (first[2] + second[2]) / 2] }
const exportObj = (samples = 2) => { if (!Number.isSafeInteger(samples) || samples < 2 || samples > 16) throw new RangeError('Surface export samples must be between 2 and 16.'); const vertices: SurfacePoint[] = []; const faces: string[] = []; for (const patch of patches.values()) { const offset = vertices.length; for (let u = 0; u < samples; u += 1) for (let v = 0; v < samples; v += 1) vertices.push(sample(patch, u / (samples - 1), v / (samples - 1))); for (let u = 0; u < samples - 1; u += 1) for (let v = 0; v < samples - 1; v += 1) { const base = offset + u * samples + v; faces.push(`f ${base + 1} ${base + samples + 1} ${base + samples + 2} ${base + 2}`) } } return `${vertices.map((entry) => `v ${entry.map((value) => value.toFixed(6)).join(' ')}`).concat(faces).join('\n')}\n` }
const snapshot = (): SurfaceSnapshot => ({ id, label, patches: [...patches.values()].map(clone), sewn: clone(sewn), version })
return { snapshot, addBezier, addBspline, loft, fill, offset, trim, sew, continuity, topologyRefs, analyze, exportObj }
}

100
src/facade/techDraw.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { TopoRefValue } from './types'
export type TechDrawPoint = { x: number; y: number; z: number }
export type TechDrawSourcePoint = { id: string; ref: TopoRefValue; point: TechDrawPoint }
export type TechDrawSourceEdge = { id: string; start: string; end: string }
export type TechDrawSource = { id: string; revision: number; points: TechDrawSourcePoint[]; edges: TechDrawSourceEdge[] }
export type TechDrawViewKind = 'projection' | 'section'
export type TechDrawView = { id: string; sourceId: string; kind: TechDrawViewKind; direction: TechDrawPoint; position: { x: number; y: number }; scale: number; visible: boolean; sourceRevision: number; projected: Record<string, { x: number; y: number }>; unresolved: string[]; section?: { normal: TechDrawPoint; offset: number } }
export type TechDrawDimension = { id: string; sourceId: string; first: TopoRefValue; second: TopoRefValue; label: string; value?: number; status: 'resolved' | 'unresolved' }
export type TechDrawAnnotation = { id: string; text: string; position: { x: number; y: number }; style: 'text' | 'callout' }
export type TechDrawGeometricTolerance = { id: string; sourceId: string; reference: TopoRefValue; characteristic: 'flatness' | 'parallelism' | 'perpendicularity' | 'position'; value: number; datum?: string; status: 'resolved' | 'unresolved' }
export type TechDrawSnapshot = { id: string; label: string; page: { width: number; height: number; unit: 'mm' }; sources: TechDrawSource[]; views: TechDrawView[]; dimensions: TechDrawDimension[]; annotations: TechDrawAnnotation[]; tolerances: TechDrawGeometricTolerance[]; version: number }
export type TechDrawApi = {
snapshot(): TechDrawSnapshot
addSource(source: TechDrawSource): TechDrawSource
updateSource(source: TechDrawSource): TechDrawSnapshot
addView(input: { id: string; sourceId: string; kind?: TechDrawViewKind; direction: TechDrawPoint; position: { x: number; y: number }; scale?: number; section?: { normal: TechDrawPoint; offset: number } }): TechDrawView
addDimension(input: { id: string; sourceId: string; first: TopoRefValue; second: TopoRefValue; label?: string }): TechDrawDimension
addAnnotation(input: { id: string; text: string; position: { x: number; y: number }; style?: 'text' | 'callout' }): TechDrawAnnotation
addGeometricTolerance(input: { id: string; sourceId: string; reference: TopoRefValue; characteristic: TechDrawGeometricTolerance['characteristic']; value: number; datum?: string }): TechDrawGeometricTolerance
exportSvg(): string
exportPdf(): Uint8Array
}
const clonePoint = (point: TechDrawPoint): TechDrawPoint => ({ ...point })
const cloneRef = (ref: TopoRefValue): TopoRefValue => ({ ...ref, candidates: ref.candidates ? [...ref.candidates] : undefined })
const cloneSource = (source: TechDrawSource): TechDrawSource => ({ ...source, points: source.points.map((point) => ({ ...point, ref: cloneRef(point.ref), point: clonePoint(point.point) })), edges: source.edges.map((edge) => ({ ...edge })) })
const cloneView = (view: TechDrawView): TechDrawView => ({ ...view, direction: clonePoint(view.direction), position: { ...view.position }, projected: Object.fromEntries(Object.entries(view.projected).map(([key, value]) => [key, { ...value }])), unresolved: [...view.unresolved], section: view.section ? { normal: clonePoint(view.section.normal), offset: view.section.offset } : undefined })
const cloneDimension = (dimension: TechDrawDimension): TechDrawDimension => ({ ...dimension, first: cloneRef(dimension.first), second: cloneRef(dimension.second) })
const cloneTolerance = (tolerance: TechDrawGeometricTolerance): TechDrawGeometricTolerance => ({ ...tolerance, reference: cloneRef(tolerance.reference) })
const finitePoint = (point: TechDrawPoint, label: string) => { if (![point.x, point.y, point.z].every(Number.isFinite)) throw new RangeError(`${label} must be finite.`) }
const norm = (point: TechDrawPoint) => { const length = Math.hypot(point.x, point.y, point.z); if (length <= 1e-12) throw new RangeError('TechDraw direction must be non-zero.'); return { x: point.x / length, y: point.y / length, z: point.z / length } }
const dot = (left: TechDrawPoint, right: TechDrawPoint) => left.x * right.x + left.y * right.y + left.z * right.z
const cross = (left: TechDrawPoint, right: TechDrawPoint) => ({ x: left.y * right.z - left.z * right.y, y: left.z * right.x - left.x * right.z, z: left.x * right.y - left.y * right.x })
const projectPoint = (point: TechDrawPoint, direction: TechDrawPoint) => {
const normal = norm(direction)
const seed = Math.abs(normal.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 }
const right = norm(cross(seed, normal))
const up = norm(cross(normal, right))
return { x: dot(point, right), y: dot(point, up) }
}
const xmlEscape = (value: string) => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
export const createTechDrawPage = (id = 'techdraw', label = 'TechDraw page', width = 297, height = 210): TechDrawApi => {
if (![width, height].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError('TechDraw page dimensions must be positive and finite.')
const sources = new Map<string, TechDrawSource>()
const views = new Map<string, TechDrawView>()
const dimensions = new Map<string, TechDrawDimension>()
const annotations = new Map<string, TechDrawAnnotation>()
const tolerances = new Map<string, TechDrawGeometricTolerance>()
let version = 0
const snapshot = (): TechDrawSnapshot => ({ id, label, page: { width, height, unit: 'mm' }, sources: [...sources.values()].map(cloneSource), views: [...views.values()].map(cloneView), dimensions: [...dimensions.values()].map(cloneDimension), annotations: [...annotations.values()].map((annotation) => ({ ...annotation, position: { ...annotation.position } })), tolerances: [...tolerances.values()].map(cloneTolerance), version })
const sourceById = (sourceId: string) => { const source = sources.get(sourceId); if (!source) throw new RangeError(`TechDraw source does not exist: ${sourceId}`); return source }
const addSource = (source: TechDrawSource) => { if (!source.id || sources.has(source.id)) throw new RangeError(`TechDraw source already exists: ${source.id}`); if (!Number.isInteger(source.revision) || source.revision < 0) throw new RangeError('TechDraw source revision must be a non-negative integer.'); const pointIds = new Set<string>(); for (const point of source.points) { if (!point.id || pointIds.has(point.id)) throw new RangeError('TechDraw source point IDs must be unique.'); pointIds.add(point.id); finitePoint(point.point, 'TechDraw source point') }; for (const edge of source.edges) if (!pointIds.has(edge.start) || !pointIds.has(edge.end)) throw new RangeError(`TechDraw edge ${edge.id} references an unknown point.`); sources.set(source.id, cloneSource(source)); version += 1; return cloneSource(source) }
const updateSource = (source: TechDrawSource) => { const current = sourceById(source.id); if (source.revision <= current.revision) throw new RangeError('TechDraw source revision must increase.'); sources.set(source.id, cloneSource(source)); for (const view of views.values()) if (view.sourceId === source.id) { const previousRefs = Object.keys(view.projected); view.sourceRevision = source.revision; view.projected = {}; view.unresolved = []; const refs = new Set(source.points.map((point) => point.ref.persistentId)); for (const point of source.points) view.projected[point.ref.persistentId] = projectPoint(point.point, view.direction); for (const reference of previousRefs) if (!refs.has(reference)) view.unresolved.push(reference) }; for (const dimension of dimensions.values()) if (dimension.sourceId === source.id) { const first = source.points.find((point) => point.ref.persistentId === dimension.first.persistentId); const second = source.points.find((point) => point.ref.persistentId === dimension.second.persistentId); dimension.status = first && second ? 'resolved' : 'unresolved'; dimension.value = first && second ? Math.hypot(second.point.x - first.point.x, second.point.y - first.point.y, second.point.z - first.point.z) : undefined }; for (const tolerance of tolerances.values()) if (tolerance.sourceId === source.id) tolerance.status = source.points.some((point) => point.ref.persistentId === tolerance.reference.persistentId) ? 'resolved' : 'unresolved'; version += 1; return snapshot() }
const addView = (input: { id: string; sourceId: string; kind?: TechDrawViewKind; direction: TechDrawPoint; position: { x: number; y: number }; scale?: number; section?: { normal: TechDrawPoint; offset: number } }) => { if (views.has(input.id)) throw new RangeError(`TechDraw view already exists: ${input.id}`); const source = sourceById(input.sourceId); const direction = norm(input.direction); if (!Number.isFinite(input.position.x) || !Number.isFinite(input.position.y)) throw new RangeError('TechDraw view position must be finite.'); const scale = input.scale ?? 1; if (!Number.isFinite(scale) || scale <= 0) throw new RangeError('TechDraw view scale must be positive and finite.'); const view: TechDrawView = { id: input.id, sourceId: input.sourceId, kind: input.kind ?? 'projection', direction, position: { ...input.position }, scale, visible: true, sourceRevision: source.revision, projected: Object.fromEntries(source.points.map((point) => [point.ref.persistentId, projectPoint(point.point, direction)])), unresolved: [], section: input.section ? { normal: norm(input.section.normal), offset: input.section.offset } : undefined }; views.set(view.id, view); version += 1; return cloneView(view) }
const addDimension = (input: { id: string; sourceId: string; first: TopoRefValue; second: TopoRefValue; label?: string }) => { if (dimensions.has(input.id)) throw new RangeError(`TechDraw dimension already exists: ${input.id}`); const source = sourceById(input.sourceId); const first = source.points.find((point) => point.ref.persistentId === input.first.persistentId); const second = source.points.find((point) => point.ref.persistentId === input.second.persistentId); const dimension: TechDrawDimension = { id: input.id, sourceId: input.sourceId, first: cloneRef(input.first), second: cloneRef(input.second), label: input.label ?? input.id, status: first && second ? 'resolved' : 'unresolved', value: first && second ? Math.hypot(second.point.x - first.point.x, second.point.y - first.point.y, second.point.z - first.point.z) : undefined }; dimensions.set(dimension.id, dimension); version += 1; return cloneDimension(dimension) }
const addAnnotation = (input: { id: string; text: string; position: { x: number; y: number }; style?: 'text' | 'callout' }) => { if (!input.id || annotations.has(input.id) || !input.text) throw new RangeError('TechDraw annotation id/text must be unique and non-empty.'); if (![input.position.x, input.position.y].every(Number.isFinite)) throw new RangeError('TechDraw annotation position must be finite.'); const annotation = { id: input.id, text: input.text, position: { ...input.position }, style: input.style ?? 'text' as const }; annotations.set(annotation.id, annotation); version += 1; return { ...annotation, position: { ...annotation.position } } }
const addGeometricTolerance = (input: { id: string; sourceId: string; reference: TopoRefValue; characteristic: TechDrawGeometricTolerance['characteristic']; value: number; datum?: string }) => { if (!input.id || tolerances.has(input.id)) throw new RangeError(`TechDraw tolerance already exists: ${input.id}`); if (!Number.isFinite(input.value) || input.value <= 0) throw new RangeError('TechDraw tolerance value must be positive and finite.'); if (input.datum !== undefined && !/^[A-Z][A-Z0-9]{0,2}$/.test(input.datum)) throw new RangeError('TechDraw tolerance datum must be an uppercase datum identifier.'); const source = sourceById(input.sourceId); const tolerance: TechDrawGeometricTolerance = { ...input, reference: cloneRef(input.reference), status: source.points.some((point) => point.ref.persistentId === input.reference.persistentId) ? 'resolved' : 'unresolved' }; tolerances.set(input.id, tolerance); version += 1; return cloneTolerance(tolerance) }
const exportSvg = () => {
const viewMarkup = [...views.values()].filter((view) => view.visible).map((view) => { const source = sourceById(view.sourceId); const edges = source.edges.map((edge) => { const start = view.projected[source.points.find((point) => point.id === edge.start)?.ref.persistentId ?? '']; const end = view.projected[source.points.find((point) => point.id === edge.end)?.ref.persistentId ?? '']; return start && end ? `<line x1="${(view.position.x + start.x * view.scale).toFixed(3)}" y1="${(view.position.y - start.y * view.scale).toFixed(3)}" x2="${(view.position.x + end.x * view.scale).toFixed(3)}" y2="${(view.position.y - end.y * view.scale).toFixed(3)}" stroke="#202428"/>` : '' }).join(''); const section = view.kind === 'section' ? `<text x="${view.position.x}" y="${view.position.y + 8}">${xmlEscape(`${view.id} section`)}</text>` : ''; return `<g data-view="${xmlEscape(view.id)}">${edges}${section}</g>` }).join('')
const dimensionMarkup = [...dimensions.values()].map((dimension) => `<text data-dimension="${xmlEscape(dimension.id)}" x="12" y="${24 + [...dimensions.values()].indexOf(dimension) * 12}">${xmlEscape(`${dimension.label}: ${dimension.status === 'resolved' ? `${dimension.value!.toFixed(3)} mm` : 'unresolved'}`)}</text>`).join('')
const annotationMarkup = [...annotations.values()].map((annotation) => `<text data-annotation="${xmlEscape(annotation.id)}" x="${annotation.position.x}" y="${annotation.position.y}">${xmlEscape(annotation.text)}</text>`).join('')
const toleranceMarkup = [...tolerances.values()].map((tolerance, index) => `<text data-tolerance="${xmlEscape(tolerance.id)}" x="12" y="${48 + dimensions.size * 12 + index * 12}">${xmlEscape(`${tolerance.characteristic} ${tolerance.value.toFixed(3)} mm${tolerance.datum ? ` | ${tolerance.datum}` : ''} | ${tolerance.status}`)}</text>`).join('')
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}mm" height="${height}mm" viewBox="0 0 ${width} ${height}" role="img" aria-label="${xmlEscape(label)}"><rect width="100%" height="100%" fill="#fff"/>${viewMarkup}${dimensionMarkup}${annotationMarkup}${toleranceMarkup}</svg>`
}
const exportPdf = () => {
const pointScale = 72 / 25.4
const clean = (value: string) => value.replace(/[^\x20-\x7e]/g, '?').replace(/([\\()])/g, '\\$1')
const commands: string[] = ['0 0 0 RG', '0.75 w']
for (const view of views.values()) {
const source = sourceById(view.sourceId)
for (const edge of source.edges) {
const startId = source.points.find((point) => point.id === edge.start)?.ref.persistentId
const endId = source.points.find((point) => point.id === edge.end)?.ref.persistentId
const start = startId ? view.projected[startId] : undefined
const end = endId ? view.projected[endId] : undefined
if (start && end) commands.push(`${((view.position.x + start.x * view.scale) * pointScale).toFixed(3)} ${((height - view.position.y + start.y * view.scale) * pointScale).toFixed(3)} m ${((view.position.x + end.x * view.scale) * pointScale).toFixed(3)} ${((height - view.position.y + end.y * view.scale) * pointScale).toFixed(3)} l S`)
}
}
const textLines = [label, ...[...dimensions.values()].map((dimension) => `${dimension.label}: ${dimension.status === 'resolved' ? `${dimension.value!.toFixed(3)} mm` : 'unresolved'}`), ...[...annotations.values()].map((annotation) => annotation.text), ...[...tolerances.values()].map((tolerance) => `${tolerance.characteristic} ${tolerance.value.toFixed(3)} mm${tolerance.datum ? ` ${tolerance.datum}` : ''} ${tolerance.status}`)]
textLines.forEach((line, index) => commands.push(`BT /F1 9 Tf 24 ${(height * pointScale - 24 - index * 12).toFixed(3)} Td (${clean(line)}) Tj ET`))
const content = `${commands.join('\n')}\n`
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${(width * pointScale).toFixed(3)} ${(height * pointScale).toFixed(3)}] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${new TextEncoder().encode(content).byteLength} >>\nstream\n${content}endstream`,
]
let output = '%PDF-1.4\n% BitBybit TechDraw\n'
const offsets = [0]
for (let index = 0; index < objects.length; index += 1) { offsets.push(new TextEncoder().encode(output).byteLength); output += `${index + 1} 0 obj\n${objects[index]}\nendobj\n` }
const xrefOffset = new TextEncoder().encode(output).byteLength
output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, '0')} 00000 n `).join('\n')}\ntrailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
return new TextEncoder().encode(output)
}
return { snapshot, addSource, updateSource, addView, addDimension, addAnnotation, addGeometricTolerance, exportSvg, exportPdf }
}

View File

@@ -1,5 +1,17 @@
import * as THREE from 'three'
import type { BitBybitViewportAdapter, MeshAsset } from './types'
import type { BitBybitViewportAdapter, MeshAsset, SubshapeRef, ViewportInteractionHandlers, ViewportMeshAsset } from './types'
type ScreenRect = { left: number; top: number; right: number; bottom: number }
export const resolveScreenBoxSelection = (candidates: Array<{ objectId: string; bounds: ScreenRect }>, selection: ScreenRect, mode: 'window' | 'crossing'): string[] => candidates.filter(({ bounds }) => mode === 'window'
? bounds.left >= selection.left && bounds.right <= selection.right && bounds.top >= selection.top && bounds.bottom <= selection.bottom
: bounds.right >= selection.left && bounds.left <= selection.right && bounds.bottom >= selection.top && bounds.top <= selection.bottom).map(({ objectId }) => objectId)
export const resolveMeshSubshape = (mesh: MeshAsset, triangleIndex: number): SubshapeRef | null => {
if (!Number.isSafeInteger(triangleIndex) || triangleIndex < 0) return null
const range = mesh.subshapeRanges?.find((entry) => triangleIndex >= entry.startTriangle && triangleIndex < entry.startTriangle + entry.triangleCount)
return range ? { ...range.ref, candidates: range.ref.candidates ? [...range.ref.candidates] : undefined } : null
}
/** Internal renderer boundary. React receives only the adapter contract. */
export class ThreeViewportAdapter implements BitBybitViewportAdapter {
@@ -7,35 +19,334 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter {
private renderer: THREE.WebGLRenderer | null = null
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private selection: THREE.Mesh | null = null
private subshapeHighlight: THREE.Mesh | THREE.Line | THREE.Points | null = null
private toolpath: THREE.Group | null = null
private toolpathSource: Array<Array<[number, number, number]>> = []
private readonly objectMeshes = new Map<string, { asset: MeshAsset; mesh: THREE.Mesh }>()
private interactionHandlers: ViewportInteractionHandlers = {}
private hoveredSubshapeId = ''
private selectedObjectId = ''
private selectedObjectIds = new Set<string>()
private selectionFlashObjectId = ''
private selectionFlashUntil = 0
private boxDrag: { startX: number; startY: number; pointerId: number; additive: boolean } | null = null
private selectionBox: HTMLDivElement | null = null
private suppressNextClick = false
private readonly raycaster = new THREE.Raycaster()
private readonly pointer = new THREE.Vector2()
private viewTarget = new THREE.Vector3(0, 0.8, 0)
private drag: { x: number; y: number; mode: 'pan' | 'orbit'; pointerId: number } | null = null
private frame = 0
private readonly handleWheel = (event: WheelEvent) => {
event.preventDefault()
this.zoomBy(Math.exp(Math.sign(event.deltaY) * 0.12))
}
private readonly handlePointerDown = (event: PointerEvent) => {
const additive = event.shiftKey || event.ctrlKey || event.metaKey
if (event.button === 0 && (additive || !this.pickHit(event.clientX, event.clientY))) {
event.preventDefault()
this.boxDrag = { startX: event.clientX, startY: event.clientY, pointerId: event.pointerId, additive }
this.showSelectionBox(event.clientX, event.clientY)
this.renderer?.domElement.setPointerCapture(event.pointerId)
return
}
if (event.button !== 1 && !(this.drag && (event.button === 0 || event.button === 2))) return
event.preventDefault()
const mode = this.drag && (event.button === 0 || event.button === 2) ? 'orbit' : 'pan'
this.drag = { x: event.clientX, y: event.clientY, mode, pointerId: event.pointerId }
this.renderer?.domElement.setPointerCapture(event.pointerId)
}
private readonly handlePointerMove = (event: PointerEvent) => {
if (this.boxDrag) {
this.showSelectionBox(event.clientX, event.clientY)
return
}
if (!this.drag) {
if (event.buttons === 0) this.updatePreselection(event)
return
}
if (!this.camera || (event.buttons & 4) === 0) return
const deltaX = event.clientX - this.drag.x
const deltaY = event.clientY - this.drag.y
this.drag.x = event.clientX
this.drag.y = event.clientY
this.drag.mode = (event.buttons & 3) !== 0 ? 'orbit' : 'pan'
if (this.drag.mode === 'orbit') {
const offset = this.camera.position.clone().sub(this.viewTarget)
const spherical = new THREE.Spherical().setFromVector3(offset)
spherical.theta -= deltaX * 0.006
spherical.phi = THREE.MathUtils.clamp(spherical.phi - deltaY * 0.006, 0.04, Math.PI - 0.04)
this.camera.position.copy(this.viewTarget).add(new THREE.Vector3().setFromSpherical(spherical))
this.camera.up.set(0, 1, 0)
} else {
const distance = this.camera.position.distanceTo(this.viewTarget)
const scale = distance * 0.0014
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0)
const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1)
const translation = right.multiplyScalar(-deltaX * scale).add(up.multiplyScalar(deltaY * scale))
this.camera.position.add(translation)
this.viewTarget.add(translation)
}
this.camera.lookAt(this.viewTarget)
}
private readonly handlePointerUp = (event: PointerEvent) => {
if (this.boxDrag) {
const box = this.boxDrag
const width = Math.abs(event.clientX - box.startX)
const height = Math.abs(event.clientY - box.startY)
if (this.renderer?.domElement.hasPointerCapture(box.pointerId)) this.renderer.domElement.releasePointerCapture(box.pointerId)
this.boxDrag = null
this.clearSelectionBox()
if (width >= 4 && height >= 4) {
this.suppressNextClick = true
const mode = event.clientX >= box.startX ? 'window' : 'crossing'
const objectIds = this.pickObjectsInBox(box.startX, box.startY, event.clientX, event.clientY, mode)
if (this.renderer) {
this.renderer.domElement.dataset.boxSelectionMode = mode
this.renderer.domElement.dataset.boxSelectionObjectIds = objectIds.join(',')
}
this.interactionHandlers.onBoxSelect?.({ objectIds, additive: box.additive, mode })
}
return
}
if (!this.drag) return
if ((event.buttons & 4) !== 0) {
this.drag.mode = 'pan'
this.drag.x = event.clientX
this.drag.y = event.clientY
return
}
if (this.renderer?.domElement.hasPointerCapture(this.drag.pointerId)) this.renderer.domElement.releasePointerCapture(this.drag.pointerId)
this.drag = null
}
private readonly handleContextMenu = (event: MouseEvent) => {
if (this.drag) event.preventDefault()
}
private readonly handleClick = (event: MouseEvent) => {
if (this.suppressNextClick) {
this.suppressNextClick = false
event.preventDefault()
event.stopPropagation()
return
}
const hit = this.pickHit(event.clientX, event.clientY)
const additive = event.shiftKey || event.ctrlKey || event.metaKey
if (hit && additive) {
event.stopPropagation()
this.interactionHandlers.onObjectClick?.(hit.objectId, { additive: true })
return
}
const picked = hit ? this.pickSubshape(event.clientX, event.clientY, hit) : null
if (picked) {
event.stopPropagation()
this.interactionHandlers.onSubshapeClick?.({ kind: picked.ref.kind, persistentId: picked.ref.persistentId, objectId: picked.objectId })
} else if (hit) {
event.stopPropagation()
this.interactionHandlers.onObjectClick?.(hit.objectId, { additive: false })
}
}
private readonly handlePointerLeave = () => this.updateHoveredSubshape(null)
private showSelectionBox(clientX: number, clientY: number) {
if (!this.boxDrag) return
if (!this.selectionBox) {
this.selectionBox = document.createElement('div')
Object.assign(this.selectionBox.style, { position: 'fixed', pointerEvents: 'none', zIndex: '1000' })
document.body.appendChild(this.selectionBox)
}
const windowMode = clientX >= this.boxDrag.startX
this.selectionBox.style.border = `1px ${windowMode ? 'solid #74a7ff' : 'dashed #6ee7d8'}`
this.selectionBox.style.background = windowMode ? 'rgba(116, 167, 255, 0.12)' : 'rgba(110, 231, 216, 0.12)'
const left = Math.min(this.boxDrag.startX, clientX)
const top = Math.min(this.boxDrag.startY, clientY)
this.selectionBox.style.left = `${left}px`
this.selectionBox.style.top = `${top}px`
this.selectionBox.style.width = `${Math.abs(clientX - this.boxDrag.startX)}px`
this.selectionBox.style.height = `${Math.abs(clientY - this.boxDrag.startY)}px`
}
private clearSelectionBox() {
this.selectionBox?.remove()
this.selectionBox = null
}
private pickObjectsInBox(startX: number, startY: number, endX: number, endY: number, mode: 'window' | 'crossing') {
if (!this.renderer || !this.camera) return []
const viewport = this.renderer.domElement.getBoundingClientRect()
const selection = { left: Math.min(startX, endX), top: Math.min(startY, endY), right: Math.max(startX, endX), bottom: Math.max(startY, endY) }
this.camera.updateMatrixWorld()
const candidates = [...this.objectMeshes.entries()].flatMap(([objectId, entry]) => {
entry.mesh.updateMatrixWorld()
if (!entry.mesh.geometry.boundingBox) entry.mesh.geometry.computeBoundingBox()
const bounds = entry.mesh.geometry.boundingBox
if (!bounds || bounds.isEmpty()) return []
const points = [
[bounds.min.x, bounds.min.y, bounds.min.z], [bounds.min.x, bounds.min.y, bounds.max.z],
[bounds.min.x, bounds.max.y, bounds.min.z], [bounds.min.x, bounds.max.y, bounds.max.z],
[bounds.max.x, bounds.min.y, bounds.min.z], [bounds.max.x, bounds.min.y, bounds.max.z],
[bounds.max.x, bounds.max.y, bounds.min.z], [bounds.max.x, bounds.max.y, bounds.max.z],
].map(([x, y, z]) => new THREE.Vector3(x, y, z).applyMatrix4(entry.mesh.matrixWorld).project(this.camera as THREE.Camera))
if (!points.some((point) => Number.isFinite(point.x) && Number.isFinite(point.y) && point.z >= -1 && point.z <= 1)) return []
const xs = points.map((point) => viewport.left + (point.x + 1) * 0.5 * viewport.width)
const ys = points.map((point) => viewport.top + (1 - point.y) * 0.5 * viewport.height)
return [{ objectId, bounds: { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) } }]
})
return resolveScreenBoxSelection(candidates, selection, mode)
}
private pickHit(clientX: number, clientY: number) {
if (!this.renderer || !this.camera || this.objectMeshes.size === 0) return null
const bounds = this.renderer.domElement.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) return null
this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1)
this.raycaster.setFromCamera(this.pointer, this.camera)
const hit = this.raycaster.intersectObjects([...this.objectMeshes.values()].map((entry) => entry.mesh), false)[0]
const faceIndex = hit?.faceIndex
if (!hit || !(hit.object instanceof THREE.Mesh)) return null
const entry = [...this.objectMeshes.entries()].find(([, candidate]) => candidate.mesh === hit.object)
if (!entry) return null
const projectedHit = hit.point.clone().project(this.camera)
return { bounds, hitDepth: projectedHit.z, faceIndex: typeof faceIndex === 'number' && Number.isSafeInteger(faceIndex) ? faceIndex : -1, objectId: entry[0], asset: entry[1].asset, mesh: entry[1].mesh }
}
private projectPoint(point: [number, number, number], bounds: DOMRect, mesh: THREE.Mesh) {
if (!this.camera) return null
const projected = new THREE.Vector3(...point).applyMatrix4(mesh.matrixWorld).project(this.camera)
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || !Number.isFinite(projected.z)) return null
return {
x: bounds.left + (projected.x + 1) * 0.5 * bounds.width,
y: bounds.top + (1 - projected.y) * 0.5 * bounds.height,
depth: projected.z,
}
}
private pickSubshape(clientX: number, clientY: number, hit = this.pickHit(clientX, clientY)): { ref: SubshapeRef; objectId: string; asset: MeshAsset; mesh: THREE.Mesh } | null {
if (!hit) return null
const pointer = { x: clientX, y: clientY }
const distanceToSegment = (point: { x: number; y: number }, start: { x: number; y: number }, end: { x: number; y: number }) => {
const dx = end.x - start.x
const dy = end.y - start.y
const lengthSquared = dx * dx + dy * dy
if (lengthSquared <= 1e-9) return Math.hypot(point.x - start.x, point.y - start.y)
const factor = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared))
return Math.hypot(point.x - (start.x + factor * dx), point.y - (start.y + factor * dy))
}
const vertices = (hit.asset.subshapeVertices ?? []).flatMap((candidate) => {
const projected = this.projectPoint(candidate.position, hit.bounds, hit.mesh)
return projected && projected.depth <= hit.hitDepth + 0.08 ? [{ ref: candidate.ref, distance: Math.hypot(pointer.x - projected.x, pointer.y - projected.y) }] : []
}).sort((left, right) => left.distance - right.distance)
if (vertices[0] && vertices[0].distance <= 10) return { ref: vertices[0].ref, objectId: hit.objectId, asset: hit.asset, mesh: hit.mesh }
const edges = (hit.asset.subshapeEdges ?? []).flatMap((candidate) => {
const start = this.projectPoint(candidate.start, hit.bounds, hit.mesh)
const end = this.projectPoint(candidate.end, hit.bounds, hit.mesh)
if (!start || !end || Math.min(start.depth, end.depth) > hit.hitDepth + 0.08) return []
return [{ ref: candidate.ref, distance: distanceToSegment(pointer, start, end) }]
}).sort((left, right) => left.distance - right.distance)
if (edges[0] && edges[0].distance <= 8) return { ref: edges[0].ref, objectId: hit.objectId, asset: hit.asset, mesh: hit.mesh }
const ref = resolveMeshSubshape(hit.asset, hit.faceIndex)
return ref ? { ref, objectId: hit.objectId, asset: hit.asset, mesh: hit.mesh } : null
}
private updatePreselection(event: PointerEvent) {
const picked = this.pickSubshape(event.clientX, event.clientY)
this.updateHoveredSubshape(picked)
}
private updateHoveredSubshape(picked: { ref: SubshapeRef; objectId: string; asset: MeshAsset; mesh: THREE.Mesh } | null) {
const nextId = picked ? `${picked.objectId}:${picked.ref.kind}:${picked.ref.persistentId}` : ''
if (nextId === this.hoveredSubshapeId) return
this.hoveredSubshapeId = nextId
this.renderSubshapeHighlight(picked)
this.interactionHandlers.onSubshapeHover?.(picked ? { kind: picked.ref.kind, persistentId: picked.ref.persistentId, objectId: picked.objectId } : null)
}
private clearSubshapeHighlight() {
if (!this.subshapeHighlight) return
this.subshapeHighlight.geometry.dispose()
if (this.subshapeHighlight.material instanceof THREE.Material) this.subshapeHighlight.material.dispose()
this.scene?.remove(this.subshapeHighlight)
this.subshapeHighlight = null
}
private renderSubshapeHighlight(picked: { ref: SubshapeRef; objectId: string; asset: MeshAsset; mesh: THREE.Mesh } | null) {
this.clearSubshapeHighlight()
if (!picked || !this.scene) return
const { ref, asset, mesh } = picked
if (ref.kind === 'edge') {
const edges = asset.subshapeEdges?.filter((entry) => entry.ref.persistentId === ref.persistentId) ?? []
if (edges.length === 0) return
const geometry = new THREE.BufferGeometry().setFromPoints(edges.flatMap((edge) => [new THREE.Vector3(...edge.start), new THREE.Vector3(...edge.end)]))
const material = new THREE.LineBasicMaterial({ color: 0xffd05a, transparent: true, opacity: 0.95, depthTest: false })
this.subshapeHighlight = new THREE.LineSegments(geometry, material)
} else if (ref.kind === 'vertex') {
const vertex = asset.subshapeVertices?.find((entry) => entry.ref.persistentId === ref.persistentId)
if (!vertex) return
const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(...vertex.position)])
const material = new THREE.PointsMaterial({ color: 0xffd05a, size: 12, sizeAttenuation: false, depthTest: false })
this.subshapeHighlight = new THREE.Points(geometry, material)
} else {
const range = asset.subshapeRanges?.find((entry) => entry.ref.kind === ref.kind && entry.ref.persistentId === ref.persistentId)
if (!range) return
const source = mesh.geometry
const sourceIndex = source.getIndex()
if (!sourceIndex) return
const start = range.startTriangle * 3
const count = range.triangleCount * 3
const indices = Array.from({ length: count }, (_, offset) => sourceIndex.getX(start + offset))
const geometry = new THREE.BufferGeometry()
const positions = source.getAttribute('position')
const normals = source.getAttribute('normal')
geometry.setAttribute('position', positions.clone())
if (normals) geometry.setAttribute('normal', normals.clone())
geometry.setIndex(indices)
const material = new THREE.MeshBasicMaterial({ color: 0xffd05a, transparent: true, opacity: 0.48, depthTest: false, side: THREE.DoubleSide })
this.subshapeHighlight = new THREE.Mesh(geometry, material)
}
this.subshapeHighlight.position.copy(mesh.position)
this.subshapeHighlight.rotation.copy(mesh.rotation)
this.subshapeHighlight.scale.copy(mesh.scale)
this.subshapeHighlight.renderOrder = 20
this.scene.add(this.subshapeHighlight)
}
mount(host: HTMLElement) {
this.host = host
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0x0e1417)
this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 1000)
this.camera.position.set(3.8, 3.1, 5.2)
this.camera.lookAt(0, 0, 0)
this.camera.lookAt(this.viewTarget)
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' })
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false)
this.renderer.domElement.dataset.geometrySource = 'three-fallback'
this.renderer.domElement.style.touchAction = 'none'
this.renderer.domElement.addEventListener('wheel', this.handleWheel, { passive: false })
this.renderer.domElement.addEventListener('pointerdown', this.handlePointerDown)
this.renderer.domElement.addEventListener('pointermove', this.handlePointerMove)
this.renderer.domElement.addEventListener('pointerup', this.handlePointerUp)
this.renderer.domElement.addEventListener('pointercancel', this.handlePointerUp)
this.renderer.domElement.addEventListener('pointerleave', this.handlePointerLeave)
this.renderer.domElement.addEventListener('click', this.handleClick)
this.renderer.domElement.addEventListener('contextmenu', this.handleContextMenu)
host.appendChild(this.renderer.domElement)
const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439)
grid.rotation.x = 0
this.scene.add(grid)
const geometry = new THREE.BoxGeometry(2.7, 1.6, 1.4)
const material = new THREE.MeshStandardMaterial({ color: 0x579a9c, roughness: 0.62, metalness: 0.1 })
this.selection = new THREE.Mesh(geometry, material)
this.selection.position.y = 0.8
this.scene.add(this.selection)
this.scene.add(new THREE.HemisphereLight(0xb8eeee, 0x142125, 2.1))
const key = new THREE.DirectionalLight(0xffffff, 2.4)
key.position.set(4, 6, 4)
this.scene.add(key)
const render = () => {
if (!this.renderer || !this.scene || !this.camera) return
this.updateSelectionFlash()
this.renderer.render(this.scene, this.camera)
this.frame = requestAnimationFrame(render)
}
@@ -44,31 +355,136 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter {
}
setMesh(mesh: MeshAsset | null) {
if (!this.selection) return
if (!mesh) {
this.selection.visible = false
this.setMeshes(mesh ? [{ objectId: this.selectedObjectId || mesh.shapeId, mesh }] : [])
}
private clearObjectMeshes() {
for (const { mesh } of this.objectMeshes.values()) {
mesh.geometry.dispose()
if (mesh.material instanceof THREE.Material) mesh.material.dispose()
this.scene?.remove(mesh)
}
this.objectMeshes.clear()
}
setMeshes(entries: ViewportMeshAsset[]) {
if (!this.scene) return
this.clearSubshapeHighlight()
this.updateHoveredSubshape(null)
this.clearObjectMeshes()
const uniqueEntries = [...new Map(entries.filter((entry) => entry.objectId).map((entry) => [entry.objectId, entry])).values()]
const verticalOffset = uniqueEntries.length > 0 ? -Math.min(...uniqueEntries.map((entry) => entry.mesh.bounds.min[1])) : 0
for (const { objectId, mesh: asset } of uniqueEntries) {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(asset.positions, 3))
geometry.setAttribute('normal', new THREE.BufferAttribute(asset.normals, 3))
geometry.setIndex(new THREE.BufferAttribute(asset.indices, 1))
if (!asset.normals.some((component) => component !== 0)) geometry.computeVertexNormals()
geometry.computeBoundingBox()
geometry.computeBoundingSphere()
const material = new THREE.MeshStandardMaterial({ color: 0x579a9c, roughness: 0.62, metalness: 0.1 })
const objectMesh = new THREE.Mesh(geometry, material)
objectMesh.name = objectId
objectMesh.position.set(0, verticalOffset, 0)
this.scene.add(objectMesh)
this.objectMeshes.set(objectId, { asset, mesh: objectMesh })
}
this.applySelectionMaterials()
this.renderToolpath()
if (uniqueEntries.length > 0) this.fitAll()
if (uniqueEntries.length === 0) {
if (this.renderer) {
this.renderer.domElement.dataset.geometrySource = 'none'
delete this.renderer.domElement.dataset.triangleCount
this.renderer.domElement.dataset.objectCount = '0'
this.renderer.domElement.dataset.topologySource = 'none'
this.renderer.domElement.dataset.faceTopologySource = 'none'
this.renderer.domElement.dataset.edgeTopologySource = 'none'
this.renderer.domElement.dataset.vertexTopologySource = 'none'
this.renderer.domElement.dataset.subshapeEdgeCount = '0'
this.renderer.domElement.dataset.subshapeVertexCount = '0'
}
return
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3))
geometry.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3))
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1))
if (!mesh.normals.some((component) => component !== 0)) geometry.computeVertexNormals()
geometry.computeBoundingSphere()
this.selection.geometry.dispose()
this.selection.geometry = geometry
this.selection.visible = true
this.selection.position.set(0, -mesh.bounds.min[1], 0)
if (this.renderer) {
this.renderer.domElement.dataset.geometrySource = 'bitbybit-occt'
this.renderer.domElement.dataset.triangleCount = String(mesh.indices.length / 3)
this.renderer.domElement.dataset.triangleCount = String(uniqueEntries.reduce((sum, entry) => sum + entry.mesh.indices.length / 3, 0))
this.renderer.domElement.dataset.objectCount = String(uniqueEntries.length)
const edgeRefs = new Set(uniqueEntries.flatMap((entry) => (entry.mesh.subshapeEdges ?? []).map((edge) => edge.ref.persistentId)))
const vertexRefs = new Set(uniqueEntries.flatMap((entry) => (entry.mesh.subshapeVertices ?? []).map((vertex) => vertex.ref.persistentId)))
const analyticFaces = uniqueEntries.every((entry) => (entry.mesh.subshapeRanges ?? []).every((range) => range.ref.signature?.includes('surface=')))
const analyticEdges = uniqueEntries.every((entry) => (entry.mesh.subshapeEdges ?? []).every((edge) => edge.ref.signature?.includes('curve=')))
const analyticVertices = uniqueEntries.every((entry) => (entry.mesh.subshapeVertices ?? []).every((vertex) => vertex.ref.signature?.startsWith('vertex|degree=')))
this.renderer.domElement.dataset.topologySource = analyticFaces && analyticEdges && analyticVertices ? 'occt-analytic' : 'mesh-fallback'
this.renderer.domElement.dataset.faceTopologySource = analyticFaces ? 'occt-analytic' : 'mesh-fallback'
this.renderer.domElement.dataset.edgeTopologySource = analyticEdges ? 'occt-analytic' : 'mesh-fallback'
this.renderer.domElement.dataset.vertexTopologySource = analyticVertices ? 'occt-analytic' : 'mesh-fallback'
this.renderer.domElement.dataset.subshapeEdgeCount = String(edgeRefs.size)
this.renderer.domElement.dataset.subshapeVertexCount = String(vertexRefs.size)
}
}
private clearToolpath() {
if (!this.toolpath) return
for (const child of this.toolpath.children) {
if (child instanceof THREE.Line) {
child.geometry.dispose()
if (child.material instanceof THREE.Material) child.material.dispose()
}
}
this.toolpath.clear()
}
private renderToolpath() {
if (!this.scene) return
if (!this.toolpath) {
this.toolpath = new THREE.Group()
this.toolpath.name = 'CAM Toolpath'
this.scene.add(this.toolpath)
}
this.clearToolpath()
const points = this.toolpathSource.flat()
const targetMesh = this.objectMeshes.get(this.selectedObjectId)?.mesh ?? this.objectMeshes.values().next().value?.mesh
if (points.length === 0 || !targetMesh) {
if (this.renderer) {
this.renderer.domElement.dataset.toolpathPoints = '0'
this.renderer.domElement.dataset.toolpathOperations = '0'
}
return
}
const sourceMin = [Math.min(...points.map((entry) => entry[0])), Math.min(...points.map((entry) => entry[1])), Math.min(...points.map((entry) => entry[2]))]
const sourceMax = [Math.max(...points.map((entry) => entry[0])), Math.max(...points.map((entry) => entry[1])), Math.max(...points.map((entry) => entry[2]))]
const target = new THREE.Box3().setFromObject(targetMesh)
const targetSize = target.getSize(new THREE.Vector3())
const map = (value: number, axis: number, min: number, size: number) => {
const span = sourceMax[axis] - sourceMin[axis]
return span <= 1e-9 ? min + size * 0.5 : min + (value - sourceMin[axis]) / span * size
}
const colors = [0xffd05a, 0x6ee7d8, 0xf79ac0, 0xa7d47b]
this.toolpathSource.forEach((path, index) => {
if (path.length < 2) return
const positions = path.map(([x, y, z]) => new THREE.Vector3(map(x, 0, target.min.x, targetSize.x), map(z, 2, target.min.y, targetSize.y) + 0.015, map(y, 1, target.min.z, targetSize.z)))
const geometry = new THREE.BufferGeometry().setFromPoints(positions)
const material = new THREE.LineBasicMaterial({ color: colors[index % colors.length], depthTest: false, transparent: true, opacity: 0.96 })
const line = new THREE.Line(geometry, material)
line.renderOrder = 10
this.toolpath?.add(line)
})
if (this.renderer) {
this.renderer.domElement.dataset.toolpathPoints = String(points.length)
this.renderer.domElement.dataset.toolpathOperations = String(this.toolpathSource.length)
}
}
setToolpath(paths: Array<Array<[number, number, number]>>) {
this.toolpathSource = paths.map((path) => path.map((entry) => [...entry] as [number, number, number]))
this.renderToolpath()
}
setInteractionHandlers(handlers: ViewportInteractionHandlers) {
this.interactionHandlers = { ...handlers }
}
resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) {
if (!this.renderer || !this.camera) return
this.camera.aspect = Math.max(width, 1) / Math.max(height, 1)
@@ -78,23 +494,133 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter {
}
setSelection(objectId: string) {
if (!this.selection) return
const material = this.selection.material as THREE.MeshStandardMaterial
material.color.set(objectId ? 0x5ed6d6 : 0x579a9c)
material.emissive.set(objectId ? 0x123c3c : 0x000000)
this.setSelectedObjects(objectId ? [objectId] : [])
}
setSelectedObjects(objectIds: string[]) {
const uniqueIds = [...new Set(objectIds)]
this.selectedObjectId = uniqueIds[0] ?? ''
this.selectedObjectIds = new Set(uniqueIds)
this.applySelectionMaterials()
}
private applySelectionMaterials() {
for (const [objectId, entry] of this.objectMeshes) {
if (!(entry.mesh.material instanceof THREE.MeshStandardMaterial)) continue
const selected = this.selectedObjectIds.has(objectId)
entry.mesh.material.color.set(selected ? 0x5ed6d6 : 0x579a9c)
entry.mesh.material.emissive.set(selected ? 0x123c3c : 0x000000)
entry.mesh.material.emissiveIntensity = selected ? 1 : 0
}
}
flashSelection() {
if (!this.objectMeshes.has(this.selectedObjectId)) return
this.selectionFlashObjectId = this.selectedObjectId
this.selectionFlashUntil = performance.now() + 1400
}
private updateSelectionFlash() {
if (this.selectionFlashUntil <= 0) return
const selection = this.objectMeshes.get(this.selectionFlashObjectId)?.mesh
if (!selection || !(selection.material instanceof THREE.MeshStandardMaterial)) {
this.selectionFlashUntil = 0
this.selectionFlashObjectId = ''
return
}
const material = selection.material
const remaining = this.selectionFlashUntil - performance.now()
if (remaining <= 0) {
this.selectionFlashUntil = 0
this.selectionFlashObjectId = ''
this.applySelectionMaterials()
return
}
const phase = (1400 - remaining) / 1400 * Math.PI * 6
material.emissive.set(0xffb347)
material.emissiveIntensity = 0.35 + (0.65 * (0.5 + 0.5 * Math.sin(phase)))
}
setView(orientation: 'axonometric' | 'front' | 'rear' | 'left' | 'right' | 'top' | 'bottom') {
if (!this.camera) return
const directions: Record<typeof orientation, THREE.Vector3> = {
axonometric: new THREE.Vector3(1, 1, 1),
front: new THREE.Vector3(0, 0, 1),
rear: new THREE.Vector3(0, 0, -1),
left: new THREE.Vector3(-1, 0, 0),
right: new THREE.Vector3(1, 0, 0),
top: new THREE.Vector3(0, 1, 0),
bottom: new THREE.Vector3(0, -1, 0),
}
const distance = Math.max(this.camera.position.distanceTo(this.viewTarget), 0.1)
const direction = directions[orientation].normalize()
this.camera.up.set(0, orientation === 'top' || orientation === 'bottom' ? 0 : 1, orientation === 'top' ? -1 : orientation === 'bottom' ? 1 : 0)
this.camera.position.copy(this.viewTarget).addScaledVector(direction, distance)
this.camera.lookAt(this.viewTarget)
this.camera.updateProjectionMatrix()
}
fitAll() {
if (!this.camera || this.objectMeshes.size === 0) return
const bounds = new THREE.Box3()
for (const { mesh } of this.objectMeshes.values()) bounds.expandByObject(mesh)
if (bounds.isEmpty()) return
const center = bounds.getCenter(new THREE.Vector3())
const size = bounds.getSize(new THREE.Vector3())
const radius = Math.max(size.x, size.y, size.z, 0.1) * 0.5
const distance = radius / Math.tan(THREE.MathUtils.degToRad(this.camera.fov * 0.5)) * 1.35
const direction = this.camera.position.clone().sub(this.viewTarget).normalize()
if (direction.lengthSq() === 0) direction.set(1, 1, 1).normalize()
this.viewTarget.copy(center)
this.camera.position.copy(center).addScaledVector(direction, distance)
this.camera.near = Math.max(distance / 1000, 0.01)
this.camera.far = Math.max(distance * 100, 100)
this.camera.lookAt(center)
this.camera.updateProjectionMatrix()
}
zoomBy(factor: number) {
if (!this.camera || !Number.isFinite(factor) || factor <= 0) return
const offset = this.camera.position.clone().sub(this.viewTarget)
const distance = THREE.MathUtils.clamp(offset.length() * factor, 0.1, 500)
this.camera.position.copy(this.viewTarget).addScaledVector(offset.normalize(), distance)
this.camera.lookAt(this.viewTarget)
this.camera.updateProjectionMatrix()
}
dispose() {
cancelAnimationFrame(this.frame)
this.selection?.geometry.dispose()
if (this.selection?.material instanceof THREE.Material) this.selection.material.dispose()
this.renderer?.domElement.removeEventListener('wheel', this.handleWheel)
this.renderer?.domElement.removeEventListener('pointerdown', this.handlePointerDown)
this.renderer?.domElement.removeEventListener('pointermove', this.handlePointerMove)
this.renderer?.domElement.removeEventListener('pointerup', this.handlePointerUp)
this.renderer?.domElement.removeEventListener('pointercancel', this.handlePointerUp)
this.renderer?.domElement.removeEventListener('pointerleave', this.handlePointerLeave)
this.renderer?.domElement.removeEventListener('click', this.handleClick)
this.renderer?.domElement.removeEventListener('contextmenu', this.handleContextMenu)
this.clearSubshapeHighlight()
this.clearSelectionBox()
this.clearObjectMeshes()
this.clearToolpath()
if (this.toolpath && this.scene) this.scene.remove(this.toolpath)
this.renderer?.dispose()
this.renderer?.domElement.remove()
this.host = null
this.renderer = null
this.scene = null
this.camera = null
this.selection = null
this.toolpath = null
this.toolpathSource = []
this.interactionHandlers = {}
this.hoveredSubshapeId = ''
this.selectedObjectId = ''
this.selectedObjectIds.clear()
this.selectionFlashObjectId = ''
this.selectionFlashUntil = 0
this.viewTarget.set(0, 0.8, 0)
this.drag = null
this.boxDrag = null
this.suppressNextClick = false
}
getBackend() { return 'webgl2' as const }

View File

@@ -1,7 +1,26 @@
import type { NativeTopologyHistoryRecord, SubshapeRef, TopologyHistoryRelation as StoredTopologyHistoryRelation, TopologyHistoryResult as StoredTopologyHistoryResult } from './types'
import { matchSubshapes, type SubshapeSignature } from './topologyNaming'
import type { NativeTopologyHistoryRecord, SubshapeRef, TopologyAdjacency, TopologyHistoryRelation as StoredTopologyHistoryRelation, TopologyHistoryResult as StoredTopologyHistoryResult } from './types'
import { assertNativeNamingEvidence, type NativeStageNamingEvidence } from './nativeNamingEvidence'
import { matchSubshapes, matchSubshapesWithAdjacency, type SubshapeSignature } from './topologyNaming'
export type TopologyHistoryEntry = { ref: SubshapeRef; signature: SubshapeSignature }
export type TopologyHistoryInput = { objectId: string; entries: TopologyHistoryEntry[]; sourceStageId?: string; adjacency?: TopologyAdjacency }
export type NativeTopologyHistoryStageCapture = {
stageId: string
operationId?: string
ordinal: number
inputs: TopologyHistoryInput[]
output: TopologyHistoryEntry[]
outputAdjacency?: TopologyAdjacency
records: NativeTopologyHistoryRecord[]
resultObjectId?: string
namingEvidence?: NativeStageNamingEvidence
}
export type NativeTopologyHistoryLineageSource = {
objectId: string
history: TopologyHistoryResult
}
export type TopologyHistoryRelation = StoredTopologyHistoryRelation
export type TopologyHistoryResult = StoredTopologyHistoryResult
@@ -10,11 +29,28 @@ const sourceKey = (objectId: string, persistentId: string) => `${encodeURICompon
const emptyCounts = (): TopologyHistoryResult['counts'] => ({ preserved: 0, modified: 0, generated: 0, deleted: 0, ambiguous: 0 })
const entriesOfKind = (entries: TopologyHistoryEntry[], kind: SubshapeRef['kind']) => entries.filter((entry) => entry.ref.kind === kind)
export const captureNativeTopologyHistory = (
type NativeCaptureOptions = {
unresolved: 'throw' | 'ambiguous'
resultStageId?: string
outputAdjacency?: TopologyAdjacency
}
const relationKey = (relation: TopologyHistoryRelation) => [
relation.sourceStageId ?? '',
relation.resultStageId ?? '',
relation.sourceObjectId ?? '',
relation.sourcePersistentId ?? '',
relation.relation,
relation.resultPersistentId ?? '',
...(relation.resultCandidates ?? []),
].join('|')
const captureNativeTopologyHistoryInternal = (
operationId: string,
inputs: Array<{ objectId: string; entries: TopologyHistoryEntry[] }>,
inputs: TopologyHistoryInput[],
output: TopologyHistoryEntry[],
records: NativeTopologyHistoryRecord[],
options: NativeCaptureOptions,
): TopologyHistoryResult => {
if (!operationId.trim()) throw new RangeError('Topology history operationId is required.')
const inputById = new Map(inputs.map((input) => [input.objectId, input]))
@@ -29,12 +65,14 @@ export const captureNativeTopologyHistory = (
const sources = entriesOfKind(input.entries, record.sourceKind)
if (!Number.isSafeInteger(record.sourceIndex) || record.sourceIndex < 0 || record.sourceIndex >= sources.length) throw new RangeError(`Native topology history source index is out of range for ${record.sourceObjectId} ${record.sourceKind}.`)
const source = sources[record.sourceIndex]
const sourceStageId = record.sourceStageId ?? input.sourceStageId
const resultStageId = record.resultStageId ?? options.resultStageId
if (record.relation === 'deleted') {
if (record.resultIndexes?.length) throw new RangeError('Deleted native topology history records cannot reference result indexes.')
const key = `${record.sourceObjectId}:${source.ref.persistentId}:deleted`
const key = [sourceStageId ?? '', resultStageId ?? '', record.sourceObjectId, source.ref.persistentId, 'deleted'].join('|')
if (!seen.has(key)) {
seen.add(key)
relations.push({ relation: 'deleted', sourceObjectId: record.sourceObjectId, sourcePersistentId: source.ref.persistentId, score: 1 })
relations.push({ relation: 'deleted', sourceObjectId: record.sourceObjectId, sourcePersistentId: source.ref.persistentId, sourceStageId, resultStageId, score: 1 })
counts.deleted += 1
}
continue
@@ -45,16 +83,211 @@ export const captureNativeTopologyHistory = (
for (const resultIndex of record.resultIndexes) {
if (!Number.isSafeInteger(resultIndex) || resultIndex < 0 || resultIndex >= results.length) throw new RangeError(`Native topology history result index is out of range for ${resultKind}.`)
const result = results[resultIndex]
const key = `${record.sourceObjectId}:${source.ref.persistentId}:${record.relation}:${result.ref.persistentId}`
const key = [sourceStageId ?? '', resultStageId ?? '', record.sourceObjectId, source.ref.persistentId, record.relation, result.ref.persistentId].join('|')
if (seen.has(key)) continue
seen.add(key)
relations.push({ relation: record.relation, sourceObjectId: record.sourceObjectId, sourcePersistentId: source.ref.persistentId, resultPersistentId: result.ref.persistentId, score: 1 })
relations.push({ relation: record.relation, sourceObjectId: record.sourceObjectId, sourcePersistentId: source.ref.persistentId, resultStageId, sourceStageId, resultPersistentId: result.ref.persistentId, score: 1 })
counts[record.relation] += 1
}
}
for (const input of inputs) {
for (const source of input.entries) {
const explained = relations.some((relation) => relation.sourceObjectId === input.objectId && relation.sourcePersistentId === source.ref.persistentId)
if (explained) continue
const candidates = output.filter((result) => result.ref.kind === source.ref.kind && result.signature.hash === source.signature.hash)
if (candidates.length !== 1 && input.adjacency && options.outputAdjacency) {
const adjacencyMatches = matchSubshapesWithAdjacency(input.entries, output, input.adjacency, options.outputAdjacency)
const resolved = adjacencyMatches.filter((match) => match.status === 'stable' && match.previousId === source.ref.persistentId)
if (resolved.length === 1) {
const resolvedEntry = output.find((entry) => entry.ref.persistentId === resolved[0].current.persistentId)
if (resolvedEntry) candidates.splice(0, candidates.length, resolvedEntry)
}
}
if (candidates.length !== 1) {
if (options.unresolved === 'throw') throw new Error(`Native topology history does not explain ${input.objectId}/${source.ref.persistentId}.`)
const ambiguous: TopologyHistoryRelation = {
relation: 'ambiguous',
sourceObjectId: input.objectId,
sourcePersistentId: source.ref.persistentId,
sourceStageId: input.sourceStageId,
resultStageId: options.resultStageId,
resultCandidates: candidates.map((candidate) => candidate.ref.persistentId),
score: 0,
}
const key = relationKey(ambiguous)
if (!seen.has(key)) {
seen.add(key)
relations.push(ambiguous)
counts.ambiguous += 1
}
continue
}
const result = candidates[0]
const key = [input.sourceStageId ?? '', options.resultStageId ?? '', input.objectId, source.ref.persistentId, 'preserved', result.ref.persistentId].join('|')
if (!seen.has(key)) {
seen.add(key)
relations.push({ relation: 'preserved', sourceObjectId: input.objectId, sourcePersistentId: source.ref.persistentId, resultPersistentId: result.ref.persistentId, sourceStageId: input.sourceStageId, resultStageId: options.resultStageId, score: 1 })
counts.preserved += 1
}
}
}
return { operationId, provider: 'occt-native', relations, counts }
}
export const captureNativeTopologyHistory = (
operationId: string,
inputs: TopologyHistoryInput[],
output: TopologyHistoryEntry[],
records: NativeTopologyHistoryRecord[],
): TopologyHistoryResult => captureNativeTopologyHistoryInternal(operationId, inputs, output, records, { unresolved: 'throw' })
/**
* Captures native history against each OCCT result stage before merging it.
* Result indexes are therefore interpreted in the stage that produced them,
* instead of being incorrectly applied to the final feature topology.
*/
export const captureNativeTopologyHistoryStages = (
operationId: string,
stages: NativeTopologyHistoryStageCapture[],
): TopologyHistoryResult => {
if (!operationId.trim()) throw new RangeError('Topology history operationId is required.')
if (stages.length === 0) throw new RangeError('Native topology history requires at least one stage.')
const ordered = [...stages].sort((left, right) => left.ordinal - right.ordinal)
const stageById = new Map<string, NativeTopologyHistoryStageCapture>()
const ordinalSet = new Set<number>()
for (const stage of ordered) {
if (!stage.stageId.trim()) throw new RangeError('Native topology history stageId is required.')
if (stageById.has(stage.stageId)) throw new RangeError(`Duplicate native topology history stage ${stage.stageId}.`)
if (!Number.isSafeInteger(stage.ordinal) || stage.ordinal < 0 || ordinalSet.has(stage.ordinal)) throw new RangeError(`Native topology history stage ${stage.stageId} has an invalid or duplicate ordinal.`)
if (!stage.operationId?.trim() && !operationId.trim()) throw new RangeError(`Native topology history stage ${stage.stageId} requires an operationId.`)
if (stage.namingEvidence) {
assertNativeNamingEvidence(stage.namingEvidence)
if (stage.namingEvidence.stageId !== stage.stageId) throw new RangeError(`Native naming evidence stage ${stage.namingEvidence.stageId} does not match ${stage.stageId}.`)
if (stage.resultObjectId && stage.namingEvidence.resultObjectId !== stage.resultObjectId) throw new RangeError(`Native naming evidence result ${stage.namingEvidence.resultObjectId} does not match ${stage.resultObjectId}.`)
}
stageById.set(stage.stageId, stage)
ordinalSet.add(stage.ordinal)
}
const relations: TopologyHistoryRelation[] = []
const seen = new Set<string>()
for (const stage of ordered) {
for (const input of stage.inputs) {
if (!input.sourceStageId) continue
const sourceStage = stageById.get(input.sourceStageId)
if (!sourceStage) throw new RangeError(`Native topology history stage ${stage.stageId} references unknown source stage ${input.sourceStageId}.`)
if (sourceStage.ordinal >= stage.ordinal) throw new RangeError(`Native topology history stage ${stage.stageId} must only consume an earlier stage.`)
if (sourceStage.resultObjectId && sourceStage.resultObjectId !== input.objectId) throw new RangeError(`Native topology history stage ${stage.stageId} input ${input.objectId} does not match source stage ${input.sourceStageId}.`)
}
const normalizedRecords = stage.records.map((record) => {
const input = stage.inputs.find((candidate) => candidate.objectId === record.sourceObjectId)
const sourceStageId = record.sourceStageId ?? input?.sourceStageId
const resultStageId = record.resultStageId ?? stage.stageId
if (sourceStageId && !stageById.has(sourceStageId)) throw new RangeError(`Native topology history record references unknown source stage ${sourceStageId}.`)
if (resultStageId !== stage.stageId) throw new RangeError(`Native topology history record for ${stage.stageId} targets result stage ${resultStageId}.`)
return { ...record, sourceStageId, resultStageId }
})
const captured = captureNativeTopologyHistoryInternal(
stage.operationId ?? `${operationId}:${stage.stageId}`,
stage.inputs,
stage.output,
normalizedRecords,
{ unresolved: 'ambiguous', resultStageId: stage.stageId, outputAdjacency: stage.outputAdjacency },
)
for (const relation of captured.relations) {
const key = relationKey(relation)
if (seen.has(key)) continue
seen.add(key)
relations.push(relation)
}
}
const counts = emptyCounts()
for (const relation of relations) counts[relation.relation] += 1
const result: TopologyHistoryResult = {
operationId,
provider: 'occt-native',
relations,
counts,
stages: ordered.map((stage) => ({
stageId: stage.stageId,
operationId: stage.operationId ?? `${operationId}:${stage.stageId}`,
inputObjectIds: stage.inputs.map((input) => input.objectId),
resultObjectId: stage.resultObjectId,
ordinal: stage.ordinal,
})),
}
const namingEvidence = ordered.flatMap((stage) => stage.namingEvidence ? [stage.namingEvidence] : [])
if (namingEvidence.length > 0) result.namingEvidence = namingEvidence.map((evidence) => ({ ...evidence, mappedNames: evidence.mappedNames?.map((mapped) => ({ ...mapped, reference: { ...mapped.reference }, sourceRefs: mapped.sourceRefs?.map((source) => ({ ...source })), candidates: mapped.candidates?.map((candidate) => ({ ...candidate })) })) }))
return result
}
/**
* Carries native stage lineage through feature boundaries. A downstream
* relation is linked to the last native stage that produced its source
* object, while every original per-stage relation remains available.
*/
export const composeNativeTopologyHistoryLineage = (
operationId: string,
current: TopologyHistoryResult,
sources: NativeTopologyHistoryLineageSource[],
): TopologyHistoryResult => {
if (!operationId.trim()) throw new RangeError('Topology history operationId is required.')
if (current.provider !== 'occt-native') return current
const sourceByObjectId = new Map<string, TopologyHistoryResult>()
for (const source of sources) {
if (!source.objectId.trim() || sourceByObjectId.has(source.objectId)) throw new RangeError('Native topology lineage source object IDs must be unique and non-empty.')
if (source.history.provider === 'occt-native') sourceByObjectId.set(source.objectId, source.history)
}
const stageById = new Map<string, NonNullable<TopologyHistoryResult['stages']>[number]>()
const orderedStages: NonNullable<TopologyHistoryResult['stages']> = []
const appendStages = (history: TopologyHistoryResult) => {
for (const stage of [...(history.stages ?? [])].sort((left, right) => left.ordinal - right.ordinal)) {
const previous = stageById.get(stage.stageId)
if (previous) {
if (previous.operationId !== stage.operationId || previous.resultObjectId !== stage.resultObjectId || previous.inputObjectIds.join('\0') !== stage.inputObjectIds.join('\0')) throw new RangeError(`Native topology lineage stage ${stage.stageId} has conflicting definitions.`)
continue
}
const cloned = { ...stage, inputObjectIds: [...stage.inputObjectIds], ordinal: orderedStages.length }
stageById.set(stage.stageId, cloned)
orderedStages.push(cloned)
}
}
for (const source of sources) {
const history = sourceByObjectId.get(source.objectId)
if (history) appendStages(history)
}
appendStages(current)
const lastSourceStage = new Map<string, string>()
for (const source of sources) {
const history = sourceByObjectId.get(source.objectId)
const last = history?.stages?.length ? [...history.stages].sort((left, right) => right.ordinal - left.ordinal)[0] : undefined
if (last) lastSourceStage.set(source.objectId, last.stageId)
}
const relations: TopologyHistoryRelation[] = []
const seen = new Set<string>()
const appendRelation = (relation: TopologyHistoryRelation) => {
const key = relationKey(relation)
if (seen.has(key)) return
seen.add(key)
relations.push({ ...relation, resultCandidates: relation.resultCandidates ? [...relation.resultCandidates] : undefined, candidates: relation.candidates?.map((candidate) => ({ ...candidate })) })
}
for (const source of sources) {
const history = sourceByObjectId.get(source.objectId)
if (history) for (const relation of history.relations) appendRelation(relation)
}
for (const relation of current.relations) appendRelation({
...relation,
sourceStageId: relation.sourceStageId ?? (relation.sourceObjectId ? lastSourceStage.get(relation.sourceObjectId) : undefined),
})
const counts = emptyCounts()
for (const relation of relations) counts[relation.relation] += 1
const namingEvidence = sources.flatMap((source) => sourceByObjectId.get(source.objectId)?.namingEvidence ?? []).concat(current.namingEvidence ?? [])
return { operationId, provider: 'occt-native', relations, counts, stages: orderedStages, ...(namingEvidence.length > 0 ? { namingEvidence } : {}) }
}
export const captureSignatureTopologyHistory = (
operationId: string,
inputs: Array<{ objectId: string; entries: TopologyHistoryEntry[] }>,

View File

@@ -1,4 +1,4 @@
import type { SubshapeRef, SubshapeSignature } from './types'
import type { SubshapeRef, SubshapeSignature, TopologyAdjacency } from './types'
export type { SubshapeSignature } from './types'
@@ -8,6 +8,36 @@ export type FaceMeshInput = {
triIndexes: number[]
}
export type AnalyticFaceInput = {
surfaceType: string
area: number
centroid?: [number, number, number]
tolerance?: number
adjacentFaceCount: number
edgeCount: number
wireCount?: number
naturalRestriction?: boolean
}
export type AnalyticEdgeInput = {
curveType: string
length: number
centroid?: [number, number, number]
bounds?: { min: [number, number, number]; max: [number, number, number] }
tolerance?: number
parameterRange?: [number, number]
adjacentFaceTypes: string[]
startVertex?: number
endVertex?: number
degenerated?: boolean
}
export type AnalyticVertexInput = {
point: [number, number, number]
tolerance?: number
incidentEdgeCount: number
}
export type SubshapeMatch = {
current: SubshapeRef
previousId?: string
@@ -15,6 +45,10 @@ export type SubshapeMatch = {
status: 'stable' | 'ambiguous' | 'new' | 'deleted'
}
export type TopologyAdjacencyMatch = SubshapeMatch & {
adjacencyScore: number
}
const quantize = (value: number, tolerance: number) => Math.round(value / tolerance)
const hashString = (value: string) => {
@@ -83,6 +117,44 @@ export const signatureForVertex = (point: [number, number, number], tolerance =
return { kind: 'vertex', canonical, hash: hashString(canonical), centroid: [...point] as [number, number, number], bounds: { min: [...point] as [number, number, number], max: [...point] as [number, number, number] }, area: 0, normal: [0, 0, 0] }
}
const analyticCanonical = (kind: string, fields: string[]) => [kind, ...fields].join('|')
export const signatureForAnalyticFace = (face: AnalyticFaceInput, tolerance = 1e-5): SubshapeSignature => {
if (!face.surfaceType.trim() || !Number.isFinite(face.area) || face.area < 0) throw new RangeError('An analytic face signature requires a surface type and non-negative area.')
if (!Number.isSafeInteger(face.adjacentFaceCount) || face.adjacentFaceCount < 0 || !Number.isSafeInteger(face.edgeCount) || face.edgeCount < 0) throw new RangeError('Analytic face adjacency counts must be non-negative integers.')
const canonical = analyticCanonical('face', [
`surface=${face.surfaceType.trim().toLowerCase()}`,
`area=${quantize(face.area, tolerance)}`,
`adjacent=${face.adjacentFaceCount}`,
`edges=${face.edgeCount}`,
`wires=${face.wireCount ?? 0}`,
`natural=${face.naturalRestriction === true ? 1 : 0}`,
])
const centroid: [number, number, number] = face.centroid ? [...face.centroid] as [number, number, number] : [0, 0, 0]
return { kind: 'face', canonical, hash: hashString(canonical), centroid, bounds: { min: [...centroid], max: [...centroid] }, area: face.area, normal: [0, 0, 0], analytic: { type: face.surfaceType.trim(), tolerance: face.tolerance ?? tolerance, adjacencyDegree: face.adjacentFaceCount, incidentCount: face.edgeCount } }
}
export const signatureForAnalyticEdge = (edge: AnalyticEdgeInput, tolerance = 1e-5): SubshapeSignature => {
if (!edge.curveType.trim() || !Number.isFinite(edge.length) || edge.length < 0) throw new RangeError('An analytic edge signature requires a curve type and non-negative length.')
const adjacent = [...edge.adjacentFaceTypes].map((type) => type.trim().toLowerCase()).sort().join(',')
const canonical = analyticCanonical('edge', [
`curve=${edge.curveType.trim().toLowerCase()}`,
`length=${quantize(edge.length, tolerance)}`,
`range=${edge.parameterRange ? [...edge.parameterRange].sort((left, right) => left - right).map((value) => quantize(value, tolerance)).join(',') : ''}`,
`adjacent=${adjacent}`,
`degenerated=${edge.degenerated === true ? 1 : 0}`,
])
const centroid: [number, number, number] = edge.centroid ? [...edge.centroid] as [number, number, number] : [0, 0, 0]
const bounds = edge.bounds ?? { min: [...centroid] as [number, number, number], max: [...centroid] as [number, number, number] }
return { kind: 'edge', canonical, hash: hashString(canonical), centroid, bounds, area: 0, normal: [0, 0, 0], analytic: { type: edge.curveType.trim(), tolerance: edge.tolerance ?? tolerance, parameterRange: edge.parameterRange, adjacencyDegree: edge.adjacentFaceTypes.length, incidentCount: edge.adjacentFaceTypes.length } }
}
export const signatureForAnalyticVertex = (vertex: AnalyticVertexInput, tolerance = 1e-5): SubshapeSignature => {
if (!vertex.point.every(Number.isFinite) || !Number.isSafeInteger(vertex.incidentEdgeCount) || vertex.incidentEdgeCount < 0) throw new RangeError('An analytic vertex signature requires a finite point and edge count.')
const canonical = analyticCanonical('vertex', [`degree=${vertex.incidentEdgeCount}`])
return { kind: 'vertex', canonical, hash: hashString(canonical), centroid: [...vertex.point], bounds: { min: [...vertex.point], max: [...vertex.point] }, area: 0, normal: [0, 0, 0], analytic: { type: 'vertex', tolerance: vertex.tolerance ?? tolerance, adjacencyDegree: vertex.incidentEdgeCount, incidentCount: vertex.incidentEdgeCount } }
}
const refsForSignatures = (shapeId: string, topologyVersion: number, signatures: SubshapeSignature[]): SubshapeRef[] => {
const occurrences = new Map<string, number>()
signatures.forEach((signature) => occurrences.set(`${signature.kind}:${signature.hash}`, (occurrences.get(`${signature.kind}:${signature.hash}`) ?? 0) + 1))
@@ -129,6 +201,172 @@ export const createSubshapeRefs = (shapeId: string, topologyVersion: number, fac
return { refs: refsForSignatures(shapeId, topologyVersion, signatures), signatures }
}
export const createAnalyticSubshapeRefs = (shapeId: string, topologyVersion: number, faces: AnalyticFaceInput[], edges: AnalyticEdgeInput[], vertices: AnalyticVertexInput[], tolerance = 1e-5): { faces: { refs: SubshapeRef[]; signatures: SubshapeSignature[] }; edges: { refs: SubshapeRef[]; signatures: SubshapeSignature[] }; vertices: { refs: SubshapeRef[]; signatures: SubshapeSignature[] } } => {
const faceSignatures = faces.map((face) => signatureForAnalyticFace(face, tolerance))
const edgeSignatures = edges.map((edge) => signatureForAnalyticEdge(edge, tolerance))
const vertexSignatures = vertices.map((vertex) => signatureForAnalyticVertex(vertex, tolerance))
return {
faces: { refs: refsForSignatures(shapeId, topologyVersion, faceSignatures), signatures: faceSignatures },
edges: { refs: refsForSignatures(shapeId, topologyVersion, edgeSignatures), signatures: edgeSignatures },
vertices: { refs: refsForSignatures(shapeId, topologyVersion, vertexSignatures), signatures: vertexSignatures },
}
}
type AdjacencyIndexInput = {
faceNeighbors?: number[][]
faceEdges?: number[][]
edgeFaces?: number[][]
edgeVertices?: number[][]
vertexEdges?: number[][]
}
const resolveIndexes = (indexes: number[] | undefined, refs: SubshapeRef[], label: string) => {
const values = indexes ?? []
if (!values.every((index) => Number.isSafeInteger(index) && index >= 0 && index < refs.length)) throw new RangeError(`${label} adjacency index is out of range.`)
return [...new Set(values)].map((index) => refs[index].persistentId).sort()
}
export const createTopologyAdjacency = (
topology: { faces: SubshapeRef[]; edges: SubshapeRef[]; vertices: SubshapeRef[] },
indexes: AdjacencyIndexInput,
): TopologyAdjacency => {
// Keep the source maps explicit: this prevents a transient OCCT index from entering a persisted graph.
return {
faceNeighbors: Object.fromEntries(topology.faces.map((ref, index) => [ref.persistentId, resolveIndexes(indexes.faceNeighbors?.[index], topology.faces, 'face')])),
faceEdges: Object.fromEntries(topology.faces.map((ref, index) => [ref.persistentId, resolveIndexes(indexes.faceEdges?.[index], topology.edges, 'face-edge')])),
edgeFaces: Object.fromEntries(topology.edges.map((ref, index) => [ref.persistentId, resolveIndexes(indexes.edgeFaces?.[index], topology.faces, 'edge-face')])),
edgeVertices: Object.fromEntries(topology.edges.map((ref, index) => [ref.persistentId, resolveIndexes(indexes.edgeVertices?.[index], topology.vertices, 'edge-vertex')])),
vertexEdges: Object.fromEntries(topology.vertices.map((ref, index) => [ref.persistentId, resolveIndexes(indexes.vertexEdges?.[index], topology.edges, 'vertex-edge')])),
}
}
type AdjacencyRelation = 'faceNeighbors' | 'faceEdges' | 'edgeFaces' | 'edgeVertices' | 'vertexEdges'
const adjacencyRelations: Array<{ kind: SubshapeRef['kind']; relation: AdjacencyRelation }> = [
{ kind: 'face', relation: 'faceNeighbors' },
{ kind: 'face', relation: 'faceEdges' },
{ kind: 'edge', relation: 'edgeFaces' },
{ kind: 'edge', relation: 'edgeVertices' },
{ kind: 'vertex', relation: 'vertexEdges' },
]
const adjacencyKey = (relation: AdjacencyRelation, persistentId: string) => `${relation}:${persistentId}`
export const scoreAdjacencyCompatibility = (
previousRef: SubshapeRef,
currentRef: SubshapeRef,
previous: TopologyAdjacency,
current: TopologyAdjacency,
currentToPrevious: ReadonlyMap<string, string>,
): number => {
if (previousRef.kind !== currentRef.kind) return 0
const expected = new Set<string>()
const observed = new Set<string>()
for (const relation of adjacencyRelations.filter((candidate) => candidate.kind === previousRef.kind)) {
const previousIds = previous[relation.relation][previousRef.persistentId] ?? []
const currentIds = current[relation.relation][currentRef.persistentId] ?? []
for (const id of previousIds) expected.add(adjacencyKey(relation.relation, id))
for (const id of currentIds) {
const mapped = currentToPrevious.get(id)
if (mapped) observed.add(adjacencyKey(relation.relation, mapped))
}
}
if (expected.size === 0 && observed.size === 0) return 1
let overlap = 0
for (const value of observed) if (expected.has(value)) overlap += 1
return overlap / Math.max(expected.size, observed.size, 1)
}
export const matchSubshapesWithAdjacency = (
previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
current: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
previousAdjacency: TopologyAdjacency | undefined,
currentAdjacency: TopologyAdjacency | undefined,
tolerance = 1e-4,
minimumAdjacencyScore = 0.5,
): TopologyAdjacencyMatch[] => {
const base = matchSubshapes(previous, current, tolerance)
if (!previousAdjacency || !currentAdjacency || current.length === 0 || previous.length === 0) return base.map((match) => ({ ...match, adjacencyScore: 1 }))
// Duplicate analytic signatures are common on boxes, cylinders and
// symmetric Boolean results. Resolve only when the neighboring persistent
// signatures form a one-to-one fingerprint; a symmetric graph remains
// ambiguous and is never guessed by array order.
const relationForKind: Record<SubshapeRef['kind'], Array<keyof TopologyAdjacency>> = {
face: ['faceNeighbors', 'faceEdges'],
edge: ['edgeFaces', 'edgeVertices'],
vertex: ['vertexEdges'],
}
const fingerprint = (
ref: SubshapeRef,
adjacency: TopologyAdjacency,
entries: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
) => {
const byId = new Map(entries.map((entry) => [entry.ref.persistentId, entry.signature.hash]))
return relationForKind[ref.kind].flatMap((relation) => (adjacency[relation][ref.persistentId] ?? []).map((id) => `${relation}:${byId.get(id) ?? id}`).sort()).join('|')
}
const resolvedBase = base.map((match) => ({ ...match }))
const groups = new Map<string, { previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>; current: Array<{ ref: SubshapeRef; signature: SubshapeSignature; index: number }> }>()
for (const entry of previous) {
const key = `${entry.signature.kind}:${entry.signature.hash}`
const group = groups.get(key) ?? { previous: [], current: [] }
group.previous.push(entry); groups.set(key, group)
}
for (let index = 0; index < current.length; index += 1) {
const entry = current[index]
const key = `${entry.signature.kind}:${entry.signature.hash}`
const group = groups.get(key)
if (group) group.current.push({ ...entry, index })
}
for (const group of groups.values()) {
if (group.previous.length < 2 || group.current.length !== group.previous.length) continue
const previousByFingerprint = new Map<string, typeof group.previous>()
const currentByFingerprint = new Map<string, typeof group.current>()
for (const entry of group.previous) {
const key = fingerprint(entry.ref, previousAdjacency, previous)
previousByFingerprint.set(key, [...(previousByFingerprint.get(key) ?? []), entry])
}
for (const entry of group.current) {
const key = fingerprint(entry.ref, currentAdjacency, current)
currentByFingerprint.set(key, [...(currentByFingerprint.get(key) ?? []), entry])
}
for (const [key, currentEntries] of currentByFingerprint) {
const previousEntries = previousByFingerprint.get(key)
if (!previousEntries || currentEntries.length !== 1 || previousEntries.length !== 1) continue
const currentEntry = currentEntries[0]
const previousEntry = previousEntries[0]
const match = resolvedBase[currentEntry.index]
if (match?.status !== 'ambiguous') continue
resolvedBase[currentEntry.index] = {
current: { ...currentEntry.ref, persistentId: previousEntry.ref.persistentId, status: 'stable', candidates: undefined },
previousId: previousEntry.ref.persistentId,
score: Math.max(match.score, 0.9),
status: 'stable',
}
}
}
const currentToPrevious = new Map<string, string>()
for (let index = 0; index < current.length; index += 1) {
const match = resolvedBase[index]
if (match?.status === 'stable' && match.previousId) currentToPrevious.set(current[index].ref.persistentId, match.previousId)
}
return resolvedBase.map((match, index) => {
if (index >= current.length || match.status !== 'stable' || !match.previousId) return { ...match, adjacencyScore: 1 }
const previousRef = previous.find((entry) => entry.ref.persistentId === match.previousId)?.ref
if (!previousRef) return { ...match, adjacencyScore: 0, status: 'ambiguous' as const, current: { ...match.current, status: 'ambiguous', candidates: [match.previousId] }, previousId: undefined }
const adjacencyScore = scoreAdjacencyCompatibility(previousRef, current[index].ref, previousAdjacency, currentAdjacency, currentToPrevious)
if (adjacencyScore < minimumAdjacencyScore) return {
...match,
adjacencyScore,
status: 'ambiguous' as const,
current: { ...match.current, status: 'ambiguous', candidates: [match.previousId] },
previousId: undefined,
score: Math.min(match.score, adjacencyScore),
}
return { ...match, adjacencyScore, score: match.score * 0.75 + adjacencyScore * 0.25 }
})
}
const signatureScore = (left: SubshapeSignature, right: SubshapeSignature, tolerance: number) => {
if (left.kind !== right.kind || left.kind === 'vertex') return 0
const scale = Math.max(Math.sqrt(Math.max(left.area, right.area)), magnitude(left.bounds.max.map((value, axis) => value - left.bounds.min[axis]) as [number, number, number]), magnitude(right.bounds.max.map((value, axis) => value - right.bounds.min[axis]) as [number, number, number]), tolerance)

View File

@@ -1,5 +1,7 @@
import type { DocumentSnapshot, ObjectTopologySnapshot, ResolveTopologyReferenceInput, SubshapeRef, TopoRefValue } from './types'
import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming'
import type { AttachmentSupportValue, DocumentSnapshot, LinkSubListValue, LinkSubValue, ObjectTopologySnapshot, ResolveTopologyReferenceInput, SubshapeRef, TopoRefValue, TopologyAdjacency } from './types'
import { cloneElementMapSnapshot } from './elementMap'
import { parseElementMapName } from './elementMap'
import { matchSubshapesWithAdjacency, type SubshapeMatch, type SubshapeSignature } from './topologyNaming'
export type PersistedTopoRef = TopoRefValue
@@ -30,21 +32,64 @@ export type DocumentTopologyReferenceMigration = {
issues: TopologyReferenceMigrationIssue[]
}
export const cloneObjectTopologySnapshot = (snapshot: ObjectTopologySnapshot): ObjectTopologySnapshot => ({
...snapshot,
entries: snapshot.entries.map((entry) => ({
ref: { ...entry.ref, candidates: entry.ref.candidates ? [...entry.ref.candidates] : undefined },
signature: { ...entry.signature, centroid: [...entry.signature.centroid], bounds: { min: [...entry.signature.bounds.min], max: [...entry.signature.bounds.max] }, normal: [...entry.signature.normal] },
})),
migration: {
...snapshot.migration,
matches: snapshot.migration.matches.map((match) => ({ ...match, current: { ...match.current, candidates: match.current.candidates ? [...match.current.candidates] : undefined } })),
},
history: {
...snapshot.history,
counts: { ...snapshot.history.counts },
relations: snapshot.history.relations.map((relation) => relation.candidates ? { ...relation, candidates: relation.candidates.map((candidate) => ({ ...candidate })) } : { ...relation }),
},
const topologyTransientField = /(?:face|edge|vertex|subshape)Index/i
export const validateTopologySnapshotForPersistence = (snapshot: ObjectTopologySnapshot): void => {
const ids = new Set(snapshot.entries.map((entry) => entry.ref.persistentId))
if (ids.size !== snapshot.entries.length || [...ids].some((id) => topologyTransientField.test(id))) throw new RangeError('Topology snapshot contains transient or duplicate persistent IDs.')
if (snapshot.adjacency) {
for (const [mapName, map] of Object.entries(snapshot.adjacency)) {
for (const [source, targets] of Object.entries(map)) {
if (!ids.has(source) || targets.some((target) => !ids.has(target)) || targets.some((target) => topologyTransientField.test(target))) throw new RangeError(`Topology adjacency map ${mapName} contains a transient or unknown persistent ID (source=${source}, targets=${targets.join(',')}).`)
}
}
}
if (snapshot.elementMap) {
if (snapshot.elementMap.schemaVersion !== 1) throw new RangeError('Topology ElementMap schemaVersion is not supported.')
const names = new Set<string>()
for (const entry of snapshot.elementMap.entries) {
const parsedName = parseElementMapName(entry.name)
if (!parsedName || parsedName.kind !== entry.kind || names.has(entry.name)) throw new RangeError('Topology ElementMap contains an invalid, kind-mismatched, or duplicate FreeCAD element name.')
names.add(entry.name)
if (!entry.objectId.trim() || !entry.persistentId.trim()) throw new RangeError('Topology ElementMap entries require non-empty object and persistent IDs.')
if (!ids.has(entry.persistentId) && entry.status !== 'deleted') throw new RangeError('Topology ElementMap contains an unknown live persistent ID.')
if (topologyTransientField.test(entry.persistentId)) throw new RangeError('Topology ElementMap contains a transient persistent ID.')
for (const candidate of entry.candidates ?? []) {
const candidateName = candidate.name === undefined ? null : parseElementMapName(candidate.name)
if (!candidate.objectId.trim() || !candidate.persistentId.trim() || (candidate.name !== undefined && (!candidateName || candidateName.kind !== entry.kind)) || topologyTransientField.test(candidate.persistentId)) throw new RangeError('Topology ElementMap contains a transient or malformed candidate persistent ID.')
}
}
}
}
export const cloneObjectTopologySnapshot = (snapshot: ObjectTopologySnapshot): ObjectTopologySnapshot => {
validateTopologySnapshotForPersistence(snapshot)
return {
...snapshot,
entries: snapshot.entries.map((entry) => ({
ref: { ...entry.ref, candidates: entry.ref.candidates ? [...entry.ref.candidates] : undefined },
signature: { ...entry.signature, centroid: [...entry.signature.centroid], bounds: { min: [...entry.signature.bounds.min], max: [...entry.signature.bounds.max] }, normal: [...entry.signature.normal] },
})),
migration: {
...snapshot.migration,
matches: snapshot.migration.matches.map((match) => ({ ...match, current: { ...match.current, candidates: match.current.candidates ? [...match.current.candidates] : undefined } })),
},
history: {
...snapshot.history,
counts: { ...snapshot.history.counts },
relations: snapshot.history.relations.map((relation) => relation.candidates ? { ...relation, candidates: relation.candidates.map((candidate) => ({ ...candidate })) } : { ...relation }),
},
...(snapshot.elementMap ? { elementMap: cloneElementMapSnapshot(snapshot.elementMap) } : {}),
adjacency: snapshot.adjacency ? cloneTopologyAdjacency(snapshot.adjacency) : undefined,
}
}
const cloneTopologyAdjacency = (adjacency: TopologyAdjacency): TopologyAdjacency => ({
faceNeighbors: Object.fromEntries(Object.entries(adjacency.faceNeighbors).map(([key, values]) => [key, [...values]])),
faceEdges: Object.fromEntries(Object.entries(adjacency.faceEdges).map(([key, values]) => [key, [...values]])),
edgeFaces: Object.fromEntries(Object.entries(adjacency.edgeFaces).map(([key, values]) => [key, [...values]])),
edgeVertices: Object.fromEntries(Object.entries(adjacency.edgeVertices).map(([key, values]) => [key, [...values]])),
vertexEdges: Object.fromEntries(Object.entries(adjacency.vertexEdges).map(([key, values]) => [key, [...values]])),
})
const transientIndexKeys = new Set(['faceIndex', 'edgeIndex', 'vertexIndex', 'subshapeIndex'])
@@ -121,8 +166,10 @@ export const migrateTopoRefs = (
previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
current: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
generation: number,
previousAdjacency?: TopologyAdjacency,
currentAdjacency?: TopologyAdjacency,
): TopologyMigration => {
const matches = matchSubshapes(previous, current)
const matches = matchSubshapesWithAdjacency(previous, current, previousAdjacency, currentAdjacency).map(({ adjacencyScore: _adjacencyScore, ...match }) => match)
const counts: TopologyMigration['counts'] = { stable: 0, ambiguous: 0, new: 0, deleted: 0 }
const records = matches.map((match) => {
counts[match.status] += 1
@@ -172,8 +219,23 @@ export const migrateDocumentTopologyReferences = (
}
for (const object of document.objects) {
for (const property of object.properties) {
const supportValue = property.value && typeof property.value === 'object' && !Array.isArray(property.value) ? property.value as Record<string, unknown> : null
const supportSubElement = supportValue?.subElement
if (property.name === 'Support' && typeof supportValue?.objectId === 'string' && supportSubElement && typeof supportSubElement === 'object' && !Array.isArray(supportSubElement) && 'schemaVersion' in supportSubElement) {
property.value = { ...supportValue, subElement: migrate(object.id, property.name, supportSubElement as PersistedTopoRef) } as AttachmentSupportValue
continue
}
if (property.type === 'App::PropertyLinkSubList' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'entries' in property.value && Array.isArray(property.value.entries)) {
property.value = { ...property.value, entries: property.value.entries.map((entry, index) => ({ ...entry, subElement: entry.subElement && typeof entry.subElement === 'object' ? migrate(object.id, `${property.name}[${index}]`, entry.subElement) : entry.subElement })) } as LinkSubListValue
continue
}
if (property.type !== 'App::PropertyLinkSub' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('schemaVersion' in property.value)) continue
property.value = migrate(object.id, property.name, property.value)
if ('subElements' in property.value) {
property.value = {
...property.value,
subElements: property.value.subElements.map((entry, index) => typeof entry === 'string' ? entry : migrate(object.id, `${property.name}[${index}]`, entry)),
} as LinkSubValue
} else if ('persistentId' in property.value) property.value = migrate(object.id, property.name, property.value)
}
for (const external of object.sketch?.externalGeometry ?? []) external.source = migrate(object.id, `ExternalGeometry:${external.id}`, external.source)
}
@@ -193,10 +255,38 @@ export const resolveDocumentTopologyReference = (
const external = owner.sketch?.externalGeometry.find((candidate) => candidate.id === externalId)
if (external) { current = external.source; replace = (record) => { external.source = record } }
} else {
const property = owner.properties.find((candidate) => candidate.name === input.referenceName && candidate.type === 'App::PropertyLinkSub')
const property = owner.properties.find((candidate) => candidate.name === input.referenceName && (candidate.type === 'App::PropertyLinkSub' || candidate.type === 'App::PropertyLinkSubList'))
if (property?.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value) {
current = property.value
replace = (record) => { property.value = record }
if ('entries' in property.value) {
const collection = property.value
const index = collection.entries.findIndex((entry) => entry.subElement && typeof entry.subElement === 'object' && (!input.currentPersistentId || entry.subElement.persistentId === input.currentPersistentId))
const entry = index >= 0 ? collection.entries[index] : undefined
if (entry?.subElement && typeof entry.subElement === 'object') {
current = entry.subElement
replace = (record) => { property.value = { ...collection, entries: collection.entries.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, subElement: record } : candidate) } }
}
} else if ('subElements' in property.value) {
const collection = property.value
const index = collection.subElements.findIndex((entry) => typeof entry !== 'string' && (!input.currentPersistentId || entry.persistentId === input.currentPersistentId))
const entry = index >= 0 ? collection.subElements[index] : undefined
if (entry && typeof entry !== 'string') {
current = entry
replace = (record) => { property.value = { ...collection, subElements: collection.subElements.map((candidate, candidateIndex) => candidateIndex === index ? record : candidate) } }
}
} else {
current = property.value as PersistedTopoRef
replace = (record) => { property.value = record }
}
}
const support = owner.properties.find((candidate) => {
const value = candidate.value && typeof candidate.value === 'object' && !Array.isArray(candidate.value) ? candidate.value as Record<string, unknown> : null
const subElement = value?.subElement
return candidate.name === input.referenceName && candidate.name === 'Support' && typeof value?.objectId === 'string' && subElement && typeof subElement === 'object' && !Array.isArray(subElement) && 'schemaVersion' in subElement
})
if (support && support.value && typeof support.value === 'object' && !Array.isArray(support.value)) {
const supportValue = support.value as Record<string, unknown>
current = supportValue.subElement as PersistedTopoRef
replace = (record) => { support.value = { ...supportValue, subElement: record } as AttachmentSupportValue }
}
}
if (!current || !replace) throw new Error(`Topology reference does not exist: ${input.ownerObjectId}.${input.referenceName}`)

View File

@@ -0,0 +1,80 @@
import { createAnalyticSubshapeRefs, matchSubshapesWithAdjacency, type AnalyticFaceInput } from './topologyNaming'
import type { TopologyAdjacency } from './types'
export type TopologyMutationReplayOptions = {
models?: number
mutationsPerModel?: number
seed?: number
}
export type TopologyMutationReplayReport = {
seed: number
models: number
mutationsPerModel: number
cases: number
stable: number
ambiguous: number
new: number
deleted: number
wrongBindings: number
mutationCounts: Record<'rigid' | 'parameter' | 'split' | 'delete' | 'shuffle', number>
}
const emptyAdjacency = (): TopologyAdjacency => ({ faceNeighbors: {}, faceEdges: {}, edgeFaces: {}, edgeVertices: {}, vertexEdges: {} })
const nextRandom = (state: number) => {
let value = state >>> 0
value ^= value << 13
value ^= value >>> 17
value ^= value << 5
return value >>> 0
}
const baseFaces = (model: number): AnalyticFaceInput[] => Array.from({ length: 6 }, (_, index) => ({
surfaceType: index === 0 ? 'Cylinder' : 'Plane',
area: 100 + model * 0.01 + index * 17,
adjacentFaceCount: index === 0 ? 5 : 1,
edgeCount: index === 0 ? 4 : 4,
wireCount: 1,
}))
const entries = (shapeId: string, version: number, faces: AnalyticFaceInput[]) => {
const topology = createAnalyticSubshapeRefs(shapeId, version, faces, [], [])
return { entries: topology.faces.refs.map((ref, index) => ({ ref, signature: topology.faces.signatures[index] })), adjacency: emptyAdjacency() }
}
export const runTopologyMutationReplay = (options: TopologyMutationReplayOptions = {}): TopologyMutationReplayReport => {
const models = options.models ?? 100
const mutationsPerModel = options.mutationsPerModel ?? 10
const seed = options.seed ?? 0x5eed1234
if (!Number.isSafeInteger(models) || models < 1 || models > 1000) throw new RangeError('models must be between 1 and 1000.')
if (!Number.isSafeInteger(mutationsPerModel) || mutationsPerModel < 1 || mutationsPerModel > 100) throw new RangeError('mutationsPerModel must be between 1 and 100.')
const report: TopologyMutationReplayReport = { seed, models, mutationsPerModel, cases: models * mutationsPerModel, stable: 0, ambiguous: 0, new: 0, deleted: 0, wrongBindings: 0, mutationCounts: { rigid: 0, parameter: 0, split: 0, delete: 0, shuffle: 0 } }
let random = seed >>> 0
const mutationNames: Array<keyof TopologyMutationReplayReport['mutationCounts']> = ['rigid', 'parameter', 'split', 'delete', 'shuffle']
for (let model = 0; model < models; model += 1) {
const previous = entries(`replay-previous-${model}`, model, baseFaces(model))
for (let mutation = 0; mutation < mutationsPerModel; mutation += 1) {
random = nextRandom(random)
const mutationName = mutationNames[(random + mutation + model) % mutationNames.length]
report.mutationCounts[mutationName] += 1
const faces = baseFaces(model).map((face) => ({ ...face }))
const target = (random >>> 8) % faces.length
if (mutationName === 'parameter') faces[target].area *= 2
if (mutationName === 'split') faces.splice(target, 0, { ...faces[target] })
if (mutationName === 'delete') faces.splice(target, 1)
if (mutationName === 'shuffle') faces.reverse()
const current = entries(`replay-current-${model}-${mutation}`, model + mutation + 1, faces)
const matches = matchSubshapesWithAdjacency(previous.entries, current.entries, previous.adjacency, current.adjacency)
for (const match of matches) report[match.status] += 1
for (let index = 0; index < current.entries.length; index += 1) {
const match = matches[index]
if (!match || match.status !== 'stable' || !match.previousId) continue
const previousEntry = previous.entries.find((entry) => entry.ref.persistentId === match.previousId)
const currentEntry = current.entries[index]
if (!previousEntry || previousEntry.signature.hash !== currentEntry.signature.hash) report.wrongBindings += 1
}
}
}
return report
}

View File

@@ -3,8 +3,10 @@ import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './depende
import type { Quantity, QuantityDimension } from './units'
import type { SketchConstraint, SketchExternalGeometry, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher'
import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine'
import type { FcstdArchiveLimits, FcstdInspection } from './fcstd'
import type { FcstdArchiveLimits, FcstdInspection, FcstdInstantiatedShape, FcstdPathEdit, FcstdShapeResourcePayload, FcstdStoredShapeResource, FcstdWriteOptions } from './fcstd'
import type { NativeOcctHistoryCapabilities, NativeOcctHistoryProvider } from './nativeHistoryProtocol'
import type { CamApi } from './cam'
import type { NativeStageNamingEvidence } from './nativeNamingEvidence'
export type ModelTreeItem = {
id: string
@@ -27,6 +29,21 @@ export type TopoRefValue = {
candidates?: string[]
}
/** One FreeCAD PropertyLinkSub can reference several sub-elements on one object. */
export type LinkSubValue = {
schemaVersion: 1
objectId: string
subElements: Array<string | TopoRefValue>
}
export type LinkSubListValue = {
schemaVersion: 1
entries: Array<{
objectId: string
subElement: string | TopoRefValue | null
}>
}
export type VectorValue = { x: number; y: number; z: number }
export type PlacementValue = {
@@ -34,6 +51,11 @@ export type PlacementValue = {
rotation: { axis: VectorValue; angle: number }
}
export type AttachmentSupportValue = {
objectId: string
subElement?: string | TopoRefValue | null
}
export type MultiTransformStep =
| { id: string; type: 'linear'; occurrences: number; length: number; direction: 'Horizontal' | 'Vertical' | 'Normal' }
| { id: string; type: 'polar'; occurrences: number; angle: number; axis: 'Horizontal' | 'Vertical' | 'Normal' }
@@ -41,14 +63,37 @@ export type MultiTransformStep =
export type MultiTransformValue = { steps: MultiTransformStep[] }
export type PropertyValue = string | number | boolean | string[] | VectorValue | PlacementValue | MultiTransformValue | TopoRefValue | null
/** Structured representation of FreeCAD Path::PropertyPath command payloads. */
export type PathCommandValue = {
name: string
parameters: Record<string, number>
}
export type PathPropertyValue = {
schemaVersion: 1
commands: PathCommandValue[]
resourcePath?: string
version?: number
center?: VectorValue
}
export type ShapeResourceValue = {
path: string
format: 'brep'
hasherIndex?: number
elementMap?: string
elementMapEntries?: Array<{ key: string; value: string }>
elementMapResource?: string
}
export type PropertyValue = string | number | boolean | string[] | number[] | VectorValue | PlacementValue | AttachmentSupportValue | MultiTransformValue | PathPropertyValue | ShapeResourceValue | TopoRefValue | LinkSubValue | LinkSubListValue | null
export type ObjectPropertySnapshot = {
name: string
label: string
group: string
scope: 'data' | 'view'
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyMultiTransform' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger'
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyDistance' | 'App::PropertyAngle' | 'App::PropertyQuantityConstraint' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkSubList' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyFloatList' | 'App::PropertyIntegerList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyMultiTransform' | 'Path::PropertyPath' | 'Part::PropertyPartShape' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger' | 'App::PropertyIntegerConstraint'
value: PropertyValue
unit?: string
readOnly?: boolean
@@ -98,10 +143,22 @@ export type SetPropertyInput = {
value: PropertyValue
}
export type ReorderBodyFeatureInput = {
bodyId: string
objectId: string
beforeObjectId?: string | null
}
export type RemoveObjectInput = {
objectId: string
cascade?: boolean
}
export type ResolveTopologyReferenceInput = {
ownerObjectId: string
referenceName: string
candidatePersistentId: string
currentPersistentId?: string
}
export type PersistenceCapabilities = {
@@ -171,6 +228,11 @@ export type GeometryCapabilities = {
status: 'idle' | 'initializing' | 'ready' | 'failed' | 'unavailable'
worker: boolean
wasm: boolean
shapeCount: number
kernelReferenceCount: number
releasedShapeCount: number
peakShapeCount: number
peakKernelReferenceCount: number
reason?: string
}
@@ -192,6 +254,27 @@ export type SubshapeRef = {
candidates?: string[]
}
export type ElementMapEntry = {
name: string
objectId: string
kind: SubshapeRef['kind']
persistentId: string
status: 'stable' | 'ambiguous' | 'new' | 'deleted'
candidates?: Array<{ objectId: string; persistentId: string; name?: string; sourceStageId?: string }>
}
export type ElementMapSnapshot = {
schemaVersion: 1
entries: ElementMapEntry[]
/** Native naming evidence is retained when a provider supplies it. */
nativeNamingEvidence?: NativeStageNamingEvidence[]
}
export type SubshapeSelection = {
objectId: string
ref: SubshapeRef
}
export type SubshapeSignature = {
kind: SubshapeRef['kind']
canonical: string
@@ -200,10 +283,26 @@ export type SubshapeSignature = {
bounds: { min: [number, number, number]; max: [number, number, number] }
area: number
normal: [number, number, number]
analytic?: {
type: string
tolerance: number
parameterRange?: [number, number]
adjacencyDegree: number
incidentCount: number
curvature?: { kind: string; values: number[] }
}
}
export type TopologySnapshotEntry = { ref: SubshapeRef; signature: SubshapeSignature }
export type TopologyAdjacency = {
faceNeighbors: Record<string, string[]>
faceEdges: Record<string, string[]>
edgeFaces: Record<string, string[]>
edgeVertices: Record<string, string[]>
vertexEdges: Record<string, string[]>
}
export type TopologyMigrationMatch = {
current: SubshapeRef
previousId?: string
@@ -216,7 +315,11 @@ export type TopologyHistoryRelation = {
sourceObjectId?: string
sourcePersistentId?: string
resultPersistentId?: string
/** Result candidates retained when OCCT cannot disambiguate isomorphic subshapes. */
resultCandidates?: string[]
candidates?: Array<{ sourceObjectId: string; persistentId: string }>
sourceStageId?: string
resultStageId?: string
score: number
}
@@ -225,6 +328,15 @@ export type TopologyHistoryResult = {
provider: 'occt-native' | 'signature-fallback'
relations: TopologyHistoryRelation[]
counts: Record<TopologyHistoryRelation['relation'], number>
stages?: Array<{
stageId: string
operationId: string
inputObjectIds: string[]
resultObjectId?: string
ordinal: number
}>
/** Stage-level MappedNameRef/StringHasher evidence, when available. */
namingEvidence?: NativeStageNamingEvidence[]
}
export type NativeTopologyHistoryRecord = {
@@ -234,12 +346,68 @@ export type NativeTopologyHistoryRecord = {
relation: 'preserved' | 'modified' | 'generated' | 'deleted'
resultKind?: SubshapeRef['kind']
resultIndexes?: number[]
sourceStageId?: string
resultStageId?: string
}
export type NativeTopologyHistoryStageCaptureResult = {
stageId: string
operation: NonNullable<NativeTopologyHistoryInput['operation']>
inputObjectIds: string[]
resultObjectId: string
ordinal: number
topology: SubshapeTopology
records: NativeTopologyHistoryRecord[]
namingEvidence?: NativeStageNamingEvidence
}
export type NativeTopologyHistoryRecords = NativeTopologyHistoryRecord[] & {
/** Non-enumerable runtime evidence for builders that execute several native stages. */
stageCaptures?: NativeTopologyHistoryStageCaptureResult[]
}
export type NativeTopologyHistoryStage = {
stageId: string
operation?: NativeTopologyHistoryInput['operation']
inputObjectIds: string[]
resultObjectId?: string
ordinal: number
namingEvidence?: NativeStageNamingEvidence
}
export type NativeMultiTransformHistoryStep =
| { type: 'linear'; direction: [number, number, number] }
| { type: 'polar'; axisOrigin: [number, number, number]; direction: [number, number, number]; angle: number }
| { type: 'mirrored'; axisOrigin: [number, number, number]; direction: [number, number, number] }
export type NativeFeatureHistorySide = {
direction: [number, number, number]
/** Required by Revolution/Groove; omitted by Pad/Pocket. */
angle?: number
}
export type NativeTopologyHistoryInput = GeometryDocumentContext & {
operationId: string
operation?: 'fuse' | 'cut' | 'common'
inputs: Array<{ objectId: string; shape: ShapeHandle }>
operation?: 'fuse' | 'cut' | 'common' | 'rotate' | 'pad' | 'pocket' | 'loft' | 'pipe' | 'revolution' | 'groove' | 'fillet' | 'chamfer' | 'hole' | 'draft' | 'thickness' | 'linear-pattern' | 'polar-pattern' | 'mirrored' | 'multi-transform'
transformKind?: 'linear' | 'polar' | 'mirrored'
transforms?: NativeMultiTransformHistoryStep[]
/** Ordered positive/reverse builders for two-sided and midplane features. */
featureSides?: NativeFeatureHistorySide[]
direction?: [number, number, number]
axisOrigin?: [number, number, number]
angle?: number
radius?: number
distance?: number
depth?: number
position?: [number, number, number]
neutralPlaneDirection?: [number, number, number]
faceIndexes?: number[]
reversed?: boolean
ruled?: boolean
offset?: number
joinType?: 'Arc' | 'Intersection'
inputs: Array<{ objectId: string; shape: ShapeHandle; inputId?: string; role?: string; stageId?: string }>
stages?: NativeTopologyHistoryStage[]
result: ShapeHandle
}
@@ -248,8 +416,10 @@ export type ObjectTopologySnapshot = {
documentVersion: number
generation: number
entries: TopologySnapshotEntry[]
adjacency?: TopologyAdjacency
migration: { previousGeneration: number | null; matches: TopologyMigrationMatch[] }
history: TopologyHistoryResult
elementMap?: ElementMapSnapshot
}
export type SubshapeTopology = {
@@ -257,6 +427,31 @@ export type SubshapeTopology = {
edges: SubshapeRef[]
vertices: SubshapeRef[]
entries: TopologySnapshotEntry[]
adjacency?: TopologyAdjacency
}
export type ShapeMassProperties = {
volume: number
surfaceArea: number
centerOfMass: [number, number, number]
}
export type ShapeQualityReport = {
shapeType: 'vertex' | 'edge' | 'wire' | 'face' | 'shell' | 'solid' | 'compSolid' | 'compound' | 'shape' | 'unknown'
isNull: boolean
structuralValid: boolean
structuralErrors: number
structuralWarnings: number
structuralIssues?: Array<{ severity: 'error' | 'warning'; node: { kind: string; index: number }; description: string }>
nativeKernelValid?: boolean
solids: number
faces: number
edges: number
vertices: number
boundingBox: {
min: [number, number, number]
max: [number, number, number]
}
}
export type MeshAsset = {
@@ -266,16 +461,42 @@ export type MeshAsset = {
normals: Float32Array
indices: Uint32Array
subshapes?: SubshapeRef[]
/** Triangle ranges retain the face-to-mesh mapping needed for 3D TopoRef picking. */
subshapeRanges?: Array<{
startTriangle: number
triangleCount: number
ref: SubshapeRef
}>
/** OCCT edge polylines and topology vertices, with a tessellation fallback when analytic metadata is unavailable. */
subshapeEdges?: Array<{
start: Point3
end: Point3
ref: SubshapeRef
}>
subshapeVertices?: Array<{
position: Point3
ref: SubshapeRef
}>
bounds: {
min: [number, number, number]
max: [number, number, number]
}
}
export type ViewportMeshAsset = {
objectId: string
mesh: MeshAsset
}
export type GeometryFileExport = {
format: 'step' | 'stl'
format: 'step' | 'stl' | 'iges' | 'brep'
fileName: string
mediaType: 'application/step' | 'model/stl'
mediaType: 'application/step' | 'model/stl' | 'model/iges' | 'application/x-freecad-brep'
text: string
}
export type GeometryFileImport = GeometryDocumentContext & {
format: 'step' | 'iges' | 'brep'
text: string
}
@@ -315,6 +536,69 @@ export type CreateConeInput = GeometryDocumentContext & {
angle?: number
}
export type CreateTorusInput = GeometryDocumentContext & {
majorRadius: number
minorRadius: number
center?: [number, number, number]
direction?: [number, number, number]
angle?: number
}
export type CreateHelixInput = GeometryDocumentContext & {
pitch: number
height: number
radius: number
angle?: number
leftHanded?: boolean
center?: [number, number, number]
direction?: [number, number, number]
tolerance?: number
}
export type ModeledThreadInput = GeometryDocumentContext & {
base: ShapeHandle
minorDiameter: number
majorDiameter: number
pitch: number
depth: number
center?: Point3
direction?: Point3
leftHanded?: boolean
}
export type CreatePrismInput = GeometryDocumentContext & {
polygon: number
circumradius: number
height: number
firstAngle?: number
secondAngle?: number
center?: [number, number, number]
}
export type CreateWedgeInput = GeometryDocumentContext & {
xmin: number
ymin: number
zmin: number
z2min: number
x2min: number
xmax: number
ymax: number
zmax: number
z2max: number
x2max: number
center?: [number, number, number]
}
export type CreateEllipsoidInput = GeometryDocumentContext & {
radius1: number
radius2: number
radius3?: number
angle1?: number
angle2?: number
angle3?: number
center?: [number, number, number]
}
export type Placement = {
translation: [number, number, number]
rotationAxis: [number, number, number]
@@ -360,11 +644,38 @@ export type ChamferInput = GeometryDocumentContext & {
indexes?: number[]
}
export type DraftInput = GeometryDocumentContext & {
base: ShapeHandle
angle: number
direction?: Point3
neutralPlaneOrigin?: Point3
neutralPlaneDirection?: Point3
indexes?: number[]
reversed?: boolean
}
export type ThicknessInput = GeometryDocumentContext & {
base: ShapeHandle
offset: number
removeFaceIndexes?: number[]
joinType?: 'Arc' | 'Intersection'
}
export type Point3 = [number, number, number]
export type PlanarProfile = {
outer: Point3[]
holes?: Point3[][]
/** Independent filled regions emitted by profiles that produce multiple solids. */
additionalRegions?: Array<{ outer: Point3[]; holes?: Point3[][] }>
/** Profiles are implicitly closed unless an importer explicitly marks them open. */
closed?: boolean
}
export type ProfileClassification = {
status: 'closed' | 'open' | 'multi-ring' | 'self-intersecting' | 'invalid-nesting' | 'degenerate' | 'non-planar'
ringCount: number
selfIntersections: number
}
export type LinearFeatureParameters = {
@@ -373,10 +684,14 @@ export type LinearFeatureParameters = {
direction?: Point3
reversed?: boolean
symmetricToPlane?: boolean
taperAngle?: number
}
export type PadInput = GeometryDocumentContext & LinearFeatureParameters
/** Standalone Part workbench extrusion; intentionally separate from Pad. */
export type ExtrudeInput = GeometryDocumentContext & LinearFeatureParameters
export type PocketInput = GeometryDocumentContext & LinearFeatureParameters & {
base: ShapeHandle
throughAll?: boolean
@@ -387,6 +702,33 @@ export type RevolutionInput = GeometryDocumentContext & {
axisOrigin?: Point3
axisDirection?: Point3
angle?: number
/** FreeCAD TwoAngles/Midplane rotates the profile before one continuous sweep. */
profileRotationAngle?: number
}
export type GrooveInput = GeometryDocumentContext & {
base: ShapeHandle
profile: PlanarProfile
axisOrigin?: Point3
axisDirection?: Point3
angle?: number
/** FreeCAD TwoAngles/Midplane rotates the profile before one continuous sweep. */
profileRotationAngle?: number
}
export type LoftInput = GeometryDocumentContext & {
sections: PlanarProfile[]
mode?: 'standalone' | 'additive' | 'subtractive'
base?: ShapeHandle
ruled?: boolean
closed?: boolean
}
export type PipeInput = GeometryDocumentContext & {
profile: PlanarProfile
path: Point3[]
mode?: 'standalone' | 'additive' | 'subtractive'
base?: ShapeHandle
}
export type CommandState = {
@@ -457,6 +799,9 @@ export type FacadeState = {
apiVersion: '0.1'
activeWorkbench: WorkbenchId
selectedObjectId: string
selectedObjectIds?: string[]
selectedSubshape?: SubshapeSelection | null
preselectedSubshape?: SubshapeSelection | null
document: DocumentSnapshot
persistence: PersistenceCapabilities
task: TaskSnapshot | null
@@ -468,6 +813,7 @@ export type FacadeEvent =
| { type: 'state.changed'; state: FacadeState }
| { type: 'notice'; message: string }
| { type: 'command.started' | 'command.completed' | 'command.failed'; commandId: string; context: FacadeRequestContext; message?: string }
| { type: 'diagnostic.focused'; diagnosticId: string; objectId: string }
| { type: 'diagnostic.added'; diagnostic: Diagnostic; context: FacadeRequestContext }
export type FacadeListener = (event: FacadeEvent) => void
@@ -475,11 +821,26 @@ export type Unsubscribe = () => void
export type ViewportBackend = 'webgl2' | 'webgpu'
export type ViewportInteractionHandlers = {
onObjectClick?: (objectId?: string, options?: { additive: boolean }) => void
onSubshapeClick?: (selection: Pick<SubshapeRef, 'kind' | 'persistentId'> & { objectId?: string }) => void
onSubshapeHover?: (selection: (Pick<SubshapeRef, 'kind' | 'persistentId'> & { objectId?: string }) | null) => void
onBoxSelect?: (selection: { objectIds: string[]; additive: boolean; mode: 'window' | 'crossing' }) => void
}
export interface BitBybitViewportAdapter {
mount(host: HTMLElement): void
resize(width: number, height: number, devicePixelRatio?: number): void
setMesh(mesh: MeshAsset | null): void
setMeshes?(meshes: ViewportMeshAsset[]): void
setToolpath(paths: Array<Array<[number, number, number]>>): void
setInteractionHandlers?(handlers: ViewportInteractionHandlers): void
flashSelection?(): void
setSelectedObjects?(objectIds: string[]): void
setSelection(objectId: string): void
setView(orientation: 'axonometric' | 'front' | 'rear' | 'left' | 'right' | 'top' | 'bottom'): void
fitAll(): void
zoomBy(factor: number): void
dispose(): void
getBackend(): ViewportBackend
}
@@ -498,6 +859,8 @@ export interface BitBybitWebCadFacade {
load(documentId: string): Promise<DocumentSnapshot | null>
markDirty(): void
setProperty(input: SetPropertyInput): void
reorderBodyFeature(input: ReorderBodyFeatureInput): DocumentSnapshot
removeObject(input: RemoveObjectInput): string[]
resolveTopologyReference(input: ResolveTopologyReferenceInput): TopoRefValue
setExpression(input: SetExpressionInput): void
recompute(): RecomputeResult
@@ -512,6 +875,8 @@ export interface BitBybitWebCadFacade {
sketcher: {
get(objectId: string): SketchSnapshot | null
addGeometry(objectId: string, geometry: SketchGeometry): SketchSnapshot
projectGeometry(objectId: string, sourceGeometryId: string, projectedId?: string): SketchSnapshot
carbonCopy(objectId: string, sourceGeometryIds: string[], idPrefix?: string): SketchSnapshot
addExternalGeometry(objectId: string, geometry: SketchExternalGeometry): SketchSnapshot
addConstraint(objectId: string, constraint: SketchConstraint): SketchSnapshot
solve(objectId: string): SketchSolveResult
@@ -537,7 +902,13 @@ export interface BitBybitWebCadFacade {
}
readonly selection: {
getObjectId(): string
getObjectIds(): string[]
getSubshape(): SubshapeSelection | null
getPreselection(): SubshapeSelection | null
select(objectId: string): void
selectObjects(objectIds: string[]): void
selectSubshape(selection: { objectId: string; kind: SubshapeRef['kind']; persistentId: string }): void
preselectSubshape(selection: { objectId: string; kind: SubshapeRef['kind']; persistentId: string } | null): void
clear(): void
}
readonly task: {
@@ -552,6 +923,7 @@ export interface BitBybitWebCadFacade {
tree(): DiagnosticTreeNode[]
repair(diagnosticId: string, actionId: DiagnosticRepairAction['id']): Promise<DiagnosticRepairResult>
}
readonly cam: CamApi
readonly project: {
capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
@@ -562,6 +934,13 @@ export interface BitBybitWebCadFacade {
recovery(documentId: string): Promise<ProjectRecoveryReport>
fcstd: {
inspect(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): FcstdInspection
extractShapes(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): FcstdShapeResourcePayload[]
storeShapes(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): Promise<FcstdStoredShapeResource[]>
instantiateShape(bytes: Uint8Array, resourcePath: string, signal?: AbortSignal, limits?: Partial<FcstdArchiveLimits>): Promise<FcstdInstantiatedShape<ShapeHandle>>
serializeMetadata(document: DocumentSnapshot, options?: FcstdWriteOptions): Uint8Array
rewriteMetadata(bytes: Uint8Array, document: DocumentSnapshot, options?: Omit<FcstdWriteOptions, 'opaqueEntries'>): Uint8Array
decodePath(bytes: Uint8Array, objectName: string, propertyName?: string): PathPropertyValue
rewritePath(bytes: Uint8Array, edit: FcstdPathEdit): Uint8Array
}
resource: {
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
@@ -579,6 +958,11 @@ export interface BitBybitWebCadFacade {
createCylinder(input: CreateCylinderInput): Promise<ShapeHandle>
createSphere(input: CreateSphereInput): Promise<ShapeHandle>
createCone(input: CreateConeInput): Promise<ShapeHandle>
createTorus(input: CreateTorusInput): Promise<ShapeHandle>
createHelix(input: CreateHelixInput): Promise<ShapeHandle>
createPrism(input: CreatePrismInput): Promise<ShapeHandle>
createWedge(input: CreateWedgeInput): Promise<ShapeHandle>
createEllipsoid(input: CreateEllipsoidInput): Promise<ShapeHandle>
applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle>
mirror(input: MirrorInput): Promise<ShapeHandle>
union(input: BooleanUnionInput): Promise<ShapeHandle>
@@ -586,12 +970,24 @@ export interface BitBybitWebCadFacade {
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>
fillet(input: FilletInput): Promise<ShapeHandle>
chamfer(input: ChamferInput): Promise<ShapeHandle>
draft(input: DraftInput): Promise<ShapeHandle>
thickness(input: ThicknessInput): Promise<ShapeHandle>
exportStep(shape: ShapeHandle, fileName?: string): Promise<GeometryFileExport>
exportIges(shape: ShapeHandle, fileName?: string): Promise<GeometryFileExport>
exportStl(shape: ShapeHandle, fileName?: string, precision?: number): Promise<GeometryFileExport>
exportBrep(shape: ShapeHandle, fileName?: string): Promise<GeometryFileExport>
pad(input: PadInput): Promise<ShapeHandle>
extrude(input: ExtrudeInput): Promise<ShapeHandle>
pocket(input: PocketInput): Promise<ShapeHandle>
revolution(input: RevolutionInput): Promise<ShapeHandle>
groove(input: GrooveInput): Promise<ShapeHandle>
loft(input: LoftInput): Promise<ShapeHandle>
pipe(input: PipeInput): Promise<ShapeHandle>
importShape(input: GeometryFileImport): Promise<ShapeHandle>
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
massProperties(shape: ShapeHandle): Promise<ShapeMassProperties>
qualityReport(shape: ShapeHandle): Promise<ShapeQualityReport>
linearLength(shape: ShapeHandle): Promise<number>
subshapes(shape: ShapeHandle, precision?: number): Promise<SubshapeRef[]>
topology(shape: ShapeHandle, precision?: number): Promise<SubshapeTopology>
topologyHistory?(input: NativeTopologyHistoryInput): Promise<NativeTopologyHistoryRecord[]>

View File

@@ -35,6 +35,96 @@ export type WorkbenchDefinition = {
const command = (id: string, label: string, icon: string, intent: CommandIntent, shortcut?: string): CommandDefinition => ({ id, label, icon, intent, shortcut })
const camProjectCommands = [
command('CAM_Job', 'Create Job', 'file-plus', 'create'),
command('CAM_Fixture', 'Fixture', 'anchor', 'create'),
command('CAM_Compound', 'Compound Path', 'layers', 'create'),
command('CAM_PropertyBag', 'Property Bag', 'sliders-horizontal', 'edit'),
command('CAM_Post', 'Post Process', 'download', 'export'),
command('CAM_ExportTemplate', 'Export Template', 'file-text', 'export'),
command('CAM_Sanity', 'Sanity Check', 'circle-check', 'inspect'),
]
const camToolCommands = [
command('CAM_SimTools', 'Simulators', 'play', 'inspect'),
command('CAM_Simulator', 'CAM Simulator', 'play', 'inspect'),
command('CAM_SimulatorGL', 'CAM Simulator GL', 'play', 'inspect'),
command('CAM_Camotics', 'CAMotics', 'box', 'inspect'),
command('CAM_Inspect', 'Inspect Path', 'search', 'inspect'),
command('CAM_SelectLoop', 'Select Loop', 'mouse-pointer-2', 'inspect'),
command('CAM_Stop', 'Stop Processing', 'x', 'edit'),
command('CAM_OpActiveToggle', 'Toggle Operation', 'check-square', 'edit'),
command('CAM_ToolBitLibraryOpen', 'ToolBit Library', 'library', 'edit'),
command('CAM_ToolController', 'Tool Controller', 'settings-2', 'edit'),
command('CAM_ToolBitCreate', 'Create ToolBit', 'plus', 'create'),
command('CAM_ToolBitDock', 'ToolBit Dock', 'panels-top-left', 'view'),
command('CAM_ToolBitLoad', 'Load ToolBit', 'upload', 'create'),
command('CAM_ToolBitSave', 'Save ToolBit', 'download', 'export'),
command('CAM_ToolBitSaveAs', 'Save ToolBit As', 'download', 'export'),
]
const cam2dOperationCommands = [
command('CAM_Profile', 'Profile', 'route', 'create'),
command('CAM_Pocket_Shape', 'Pocket Shape', 'arrow-down', 'create'),
command('CAM_MillFace', 'Mill Face', 'layers', 'create'),
command('CAM_Helix', 'Helix', 'rotate', 'create'),
command('CAM_Adaptive', 'Adaptive', 'git-branch', 'create'),
command('CAM_Slot', 'Slot', 'minus', 'create'),
command('CAM_Area', 'Area', 'square-stack', 'create'),
command('CAM_Area_Workplane', 'Area Workplane', 'layers', 'create'),
command('CAM_Drilling', 'Drilling', 'circle-dot', 'create'),
command('CAM_Tapping', 'Tapping', 'circle-dot', 'create'),
command('CAM_ThreadMilling', 'Thread Milling', 'rotate', 'create'),
command('CAM_Engrave', 'Engrave', 'pencil', 'create'),
command('CAM_Deburr', 'Deburr', 'corner-down-right', 'create'),
command('CAM_Vcarve', 'V-Carve', 'triangle', 'create'),
command('CAM_Probe', 'Probe', 'crosshair', 'create'),
command('CAM_Shape', 'Path Shape', 'route', 'create'),
command('CAM_Custom', 'Custom Operation', 'code-2', 'create'),
command('CAM_PathShapeTC', 'Path Shape Tool Controller', 'settings-2', 'create'),
]
const camMachiningCommands = [
command('CAM_DrillingTools', 'Drilling Operations', 'circle-dot', 'create'),
command('CAM_EngraveTools', 'Engraving Operations', 'pencil', 'create'),
command('CAM_3dTools', '3D Operations', 'box', 'create'),
command('CAM_Pocket3D', 'Pocket 3D', 'box', 'create'),
]
const camPathModificationCommands = [
command('CAM_OperationCopy', 'Copy Operation', 'copy', 'create'),
command('CAM_Copy', 'Copy Path', 'copy', 'create'),
command('CAM_SimpleCopy', 'Simple Copy', 'copy', 'create'),
command('CAM_Array', 'Array', 'repeat', 'create'),
command('CAM_DressupTools', 'Dressup Operations', 'wand-sparkles', 'edit'),
command('CAM_DressupArray', 'Array Dress-up', 'repeat', 'edit'),
command('CAM_DressupAxisMap', 'Axis Map Dress-up', 'shuffle', 'edit'),
command('CAM_DressupDogbone', 'Dogbone Dress-up', 'circle-dot', 'edit'),
command('CAM_DressupDragKnife', 'Drag Knife Dress-up', 'scissors', 'edit'),
command('CAM_DressupLeadInOut', 'Lead In/Out Dress-up', 'route', 'edit'),
command('CAM_DressupPathBoundary', 'Path Boundary Dress-up', 'square-stack', 'edit'),
command('CAM_DressupRampEntry', 'Ramp Entry Dress-up', 'arrow-down', 'edit'),
command('CAM_DressupTag', 'Holding Tags Dress-up', 'tag', 'edit'),
command('CAM_DressupZCorrect', 'Z Correct Dress-up', 'move-vertical', 'edit'),
command('CAM_SetStartPoint', 'Set Start Point', 'crosshair', 'edit'),
command('CAM_Comment', 'Comment', 'file-text', 'edit'),
]
const camToolbarGroups: WorkbenchDefinition['groups'] = [
{ label: 'Project Setup', commands: camProjectCommands },
{ label: 'Tool Commands', commands: camToolCommands },
{ label: 'New Operations', commands: [...cam2dOperationCommands, ...camMachiningCommands] },
{ label: 'Path Modification', commands: camPathModificationCommands },
]
const camMenuDefinitions = [
...camProjectCommands.map((entry) => ({ label: entry.label, command: entry.id, group: 'Project Setup' })),
...camToolCommands.map((entry) => ({ label: entry.label, command: entry.id, group: 'Simulation and Tools' })),
...cam2dOperationCommands.map((entry) => ({ label: entry.label, command: entry.id, group: '2D Operations' })),
...camMachiningCommands.map((entry) => ({ label: entry.label, command: entry.id, group: 'Machining Operations' })),
...camPathModificationCommands.map((entry) => ({ label: entry.label, command: entry.id, group: 'Path Modification' })),
]
export const workbenchDefinitions: Record<WorkbenchId, WorkbenchDefinition> = {
'Part Design': {
id: 'Part Design', category: 'Modeling', description: 'Feature-based parametric solid modeling', taskTitle: 'Part Design task', taskSummary: 'Create or edit an ordered Body feature.', objectType: 'PartDesign::Feature',
@@ -50,6 +140,7 @@ export const workbenchDefinitions: Record<WorkbenchId, WorkbenchDefinition> = {
id: 'Part', category: 'Modeling', description: 'Primitives, booleans and solid inspection', taskTitle: 'Part task', taskSummary: 'Build independent solids and boolean results.', objectType: 'Part::Feature',
groups: [
{ label: 'Primitives', commands: [command('primitive', 'Create primitives', 'box', 'create'), command('helix', 'Create helix', 'route', 'create'), command('prism', 'Create prism', 'triangle', 'create')] },
{ label: 'Builders', commands: [command('extrude-part', 'Extrude', 'arrow-up', 'create'), command('revolution-part', 'Revolution', 'rotate', 'create'), command('loft-part', 'Loft', 'route', 'create'), command('sweep-part', 'Sweep', 'git-branch', 'create')] },
{ label: 'Boolean', commands: [command('union', 'Union', 'plus', 'create'), command('cut', 'Cut', 'minus', 'create'), command('intersection', 'Intersection', 'circle', 'create'), command('compound', 'Compound', 'layers', 'create')] },
{ label: 'Modify', commands: [command('fillet-part', 'Fillet', 'corner-down-right', 'create'), command('chamfer-part', 'Chamfer', 'scissors', 'create'), command('check-shape', 'Check geometry', 'circle-check', 'inspect'), command('measure-part', 'Measure', 'ruler', 'inspect')] },
],
@@ -89,7 +180,7 @@ export const workbenchDefinitions: Record<WorkbenchId, WorkbenchDefinition> = {
},
CAM: {
id: 'CAM', category: 'Engineering', description: 'Manufacturing jobs, toolpaths and simulation', taskTitle: 'CAM task', taskSummary: 'Define a job, generate toolpaths and inspect simulation.', objectType: 'Path::Job',
groups: [{ label: 'Job', commands: [command('new-job', 'Create job', 'file-plus', 'create'), command('stock', 'Stock setup', 'box', 'edit'), command('tools', 'Tool controller', 'settings-2', 'edit')] }, { label: 'Toolpath', commands: [command('profile-path', 'Profile', 'route', 'create'), command('pocket-path', 'Pocket', 'arrow-down', 'create'), command('contour-path', 'Contour', 'repeat', 'create')] }, { label: 'Output', commands: [command('simulate', 'Simulate', 'play', 'inspect'), command('post-process', 'Post process', 'download', 'export')] }],
groups: camToolbarGroups,
},
FEM: {
id: 'FEM', category: 'Engineering', description: 'Analysis, materials, meshing and results', taskTitle: 'FEM task', taskSummary: 'Prepare a model, solve it and inspect result fields.', objectType: 'Fem::FemAnalysis',
@@ -117,6 +208,7 @@ export const menuDefinitions = {
],
Edit: [{ label: 'Undo', shortcut: 'Ctrl+Z', command: 'undo' }, { label: 'Redo', shortcut: 'Ctrl+Y', command: 'redo' }, { label: 'Cut', shortcut: 'Ctrl+X', command: 'cut' }, { label: 'Copy', shortcut: 'Ctrl+C', command: 'copy' }, { label: 'Paste', shortcut: 'Ctrl+V', command: 'paste' }, { label: 'Preferences', command: 'preferences' }],
View: [{ label: 'Standard views', command: 'standard-views' }, { label: 'Axonometric', shortcut: '0', command: 'axonometric' }, { label: 'Fit all', shortcut: 'V, F', command: 'fit-all' }, { label: 'Panels', command: 'panels' }, { label: 'Fullscreen', shortcut: 'F11', command: 'fullscreen' }],
CAM: camMenuDefinitions,
Tools: [{ label: 'Customize...', command: 'customize' }, { label: 'Edit parameters...', command: 'parameters' }, { label: 'Dependency graph', command: 'dependency-graph' }, { label: 'Project settings', command: 'project-settings' }],
Macro: [{ label: 'Macros...', command: 'macros' }, { label: 'Record macro', command: 'record-macro' }, { label: 'Stop recording', command: 'stop-macro' }, { label: 'Execute macro', command: 'execute-macro' }],
Windows: [{ label: 'Tile documents', command: 'tile' }, { label: 'Cascade documents', command: 'cascade' }, { label: 'Next document', shortcut: 'Ctrl+Tab', command: 'next-document' }, { label: 'Close all documents', command: 'close-all' }],

View File

@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('/sw.js', { scope: '/' })
}, { once: true })
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />

12
src/meshLodWorker.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createMeshDocument } from './facade/mesh'
self.addEventListener('message', (event: MessageEvent<{ positions: number[]; indices: number[]; targetTriangles: number }>) => {
try {
const mesh = createMeshDocument('worker-mesh', 'Worker mesh', event.data)
self.postMessage({ lod: mesh.lod(event.data.targetTriangles) })
} catch (error) {
self.postMessage({ error: error instanceof Error ? error.message : String(error) })
}
})
export {}

View File

@@ -1,21 +1,21 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e5e9eb;
background: #0f1317;
color: #e8eaed;
background: #202225;
font-synthesis: none;
text-rendering: optimizeLegibility;
--bg: #0f1317;
--bg-raised: #151a1f;
--bg-panel: #191f24;
--bg-soft: #20272d;
--bg-hover: #252e35;
--line: #2b343b;
--line-soft: #232b31;
--text: #e5e9eb;
--text-soft: #a5afb5;
--text-muted: #707c83;
--cyan: #5ed6d6;
--cyan-soft: #203c40;
--bg: #202225;
--bg-raised: #292c30;
--bg-panel: #303338;
--bg-soft: #383c42;
--bg-hover: #41464d;
--line: #50545a;
--line-soft: #3e4248;
--text: #e8eaed;
--text-soft: #c1c5ca;
--text-muted: #8f969e;
--cyan: #67a8df;
--cyan-soft: #263f57;
--green: #73d6a3;
--green-soft: #203b31;
--amber: #efbe72;
@@ -24,7 +24,7 @@
--red-soft: #452a2c;
--violet: #b9a4ec;
--blue: #8cbff1;
--radius: 6px;
--radius: 4px;
}
* { box-sizing: border-box; }
@@ -35,43 +35,43 @@ button { color: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.app-shell { min-height: 100vh; background: var(--bg); }
.topbar { height: 56px; display: flex; align-items: center; gap: 10px; padding: 0 18px; border-bottom: 1px solid var(--line); background: #11161a; position: relative; z-index: 3; }
.brand-lockup { display: flex; align-items: center; gap: 9px; width: 174px; cursor: pointer; user-select: none; }
.brand-mark { width: 25px; height: 25px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; align-items: end; }
.topbar { height: 34px; display: flex; align-items: center; gap: 7px; padding: 0 8px; border-bottom: 1px solid #151719; background: #26292d; position: relative; z-index: 30; box-shadow: inset 0 -1px rgba(255,255,255,.035); }
.brand-lockup { display: flex; align-items: center; gap: 7px; min-width: 187px; padding-right: 10px; border-right: 1px solid var(--line-soft); cursor: pointer; user-select: none; }
.brand-mark { width: 18px; height: 18px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; align-items: end; }
.brand-mark span { display: block; border-radius: 2px 2px 1px 1px; background: var(--cyan); }
.brand-mark span:nth-child(1) { height: 11px; opacity: .55; }
.brand-mark span:nth-child(2) { height: 18px; }
.brand-mark span:nth-child(3) { height: 25px; opacity: .75; }
.brand-name { font-size: 13px; font-weight: 750; letter-spacing: .03em; line-height: 15px; }
.brand-product { color: var(--text-muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; line-height: 12px; }
.brand-mark span:nth-child(1) { height: 8px; opacity: .55; }
.brand-mark span:nth-child(2) { height: 13px; }
.brand-mark span:nth-child(3) { height: 18px; opacity: .75; }
.brand-name { font-size: 11px; font-weight: 650; line-height: 14px; }
.brand-product { display: none; }
.top-menu { display: flex; align-items: center; gap: 1px; }
.menu-wrapper { position: relative; }
.top-menu button { border: 0; background: transparent; color: var(--text-soft); font-size: 12px; padding: 8px 9px; border-radius: 4px; cursor: pointer; }
.top-menu button { height: 27px; border: 0; background: transparent; color: var(--text-soft); font-size: 11px; padding: 0 8px; border-radius: 2px; cursor: pointer; }
.top-menu button:hover { background: var(--bg-hover); color: var(--text); }
.top-menu button.is-open { background: var(--bg-soft); color: var(--text); }
.menu-popover { position: absolute; top: 35px; left: 0; z-index: 20; min-width: 205px; padding: 5px; border: 1px solid var(--line); border-radius: 4px; background: #171d22; box-shadow: 0 14px 28px rgba(0,0,0,.35); }
.menu-popover button { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 8px 9px; border-radius: 3px; text-align: left; color: var(--text-soft); font-size: 11px; }
.menu-popover { position: absolute; top: 29px; left: 0; z-index: 40; min-width: 230px; padding: 4px; border: 1px solid #666b72; border-radius: 2px; background: #303338; box-shadow: 0 8px 22px rgba(0,0,0,.48); }
.menu-popover button { width: 100%; height: 27px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 0 9px; border-radius: 2px; text-align: left; color: var(--text-soft); font-size: 11px; }
.menu-popover button:hover { background: var(--bg-hover); color: var(--text); }
.menu-popover kbd { color: var(--text-muted); font-size: 9px; white-space: nowrap; }
.topbar-spacer { flex: 1; }
.topbar-context { display: flex; align-items: center; gap: 12px; margin-right: 4px; white-space: nowrap; }
.topbar-context { display: flex; align-items: center; gap: 10px; margin-right: 3px; white-space: nowrap; }
.context-document { font-size: 12px; color: var(--text-soft); }
.context-dot, .save-dot { width: 6px; height: 6px; display: inline-block; border-radius: 50%; margin-right: 7px; background: var(--cyan); vertical-align: 1px; }
.context-separator { color: var(--text-muted); margin: 0 7px; }
.save-state { color: var(--amber); font-size: 11px; }
.save-dot { background: var(--amber); }
.topbar-divider, .toolbar-divider { width: 1px; height: 22px; background: var(--line); margin: 0 3px; }
.icon-button { width: 30px; height: 30px; display: inline-grid; place-items: center; border: 1px solid transparent; border-radius: 4px; background: transparent; color: var(--text-muted); cursor: pointer; padding: 0; }
.icon-button { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid transparent; border-radius: 2px; background: transparent; color: var(--text-soft); cursor: pointer; padding: 0; }
.icon-button:hover { color: var(--text); background: var(--bg-hover); border-color: var(--line); }
.icon-button.is-active { color: var(--cyan); background: var(--cyan-soft); border-color: #2e6768; }
.icon-button:disabled { opacity: .35; cursor: not-allowed; }
.command-search { height: 30px; min-width: 172px; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 10px; background: var(--bg-raised); border: 1px solid var(--line); border-radius: 4px; color: var(--text-muted); font-size: 11px; cursor: pointer; }
.command-search { height: 26px; min-width: 162px; display: flex; align-items: center; gap: 7px; padding: 0 7px 0 9px; background: var(--bg-raised); border: 1px solid var(--line); border-radius: 2px; color: var(--text-muted); font-size: 10px; cursor: pointer; }
.command-search:hover { color: var(--text); border-color: #466067; }
.command-search kbd { margin-left: auto; color: var(--text-muted); font-size: 10px; border: 1px solid var(--line); border-radius: 3px; padding: 2px 4px; }
.avatar-button { height: 30px; display: flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg-raised); color: var(--text-soft); padding: 0 8px; cursor: pointer; font-size: 10px; }
.avatar-button { height: 26px; display: flex; align-items: center; gap: 5px; border: 1px solid var(--line); border-radius: 2px; background: var(--bg-raised); color: var(--text-soft); padding: 0 7px; cursor: pointer; font-size: 9px; }
.avatar-button:hover { border-color: #466067; color: var(--text); }
.page-shell { min-height: calc(100vh - 56px); overflow: auto; }
.page-shell { min-height: calc(100vh - 34px); overflow: auto; }
.page-content { width: min(1240px, calc(100% - 64px)); margin: 0 auto; padding: 56px 0 44px; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; margin-bottom: 34px; }
.page-heading { max-width: 690px; }
@@ -381,6 +381,10 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.property-link-sub > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.property-link-sub .icon-button { width: 24px; height: 24px; flex: 0 0 24px; }
.property-topology-candidates { min-width: 0; max-width: 112px; }
.property-link-sub-list { align-items: flex-start; }
.property-link-sub-items { display: grid; min-width: 0; flex: 1; gap: 3px; }
.property-link-sub-item { display: flex; min-width: 0; align-items: center; gap: 4px; }
.property-link-sub-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topology-repair { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin: 8px 0 0 34px; }
.property-color { display: flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; }
.property-color input { width: 22px; height: 18px; padding: 0; border: 1px solid var(--line); border-radius: 2px; background: transparent; }
@@ -407,6 +411,10 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.property-placement-grid span { color: var(--text-muted); font-size: 10px; }
.property-placement-grid input, .property-vector input { min-width: 0; width: 100%; height: 24px; border: 1px solid var(--line); background: var(--bg-soft); color: var(--text); padding: 2px 4px; }
.multi-transform-editor { width: 100%; display: grid; gap: 7px; margin-bottom: 12px; }
.task-link-list { max-height: 142px; display: grid; gap: 4px; margin: -7px 0 12px; padding: 7px 8px; overflow: auto; border: 1px solid var(--line); border-radius: 3px; background: var(--bg-raised); }
.task-link-list .check-row { min-height: 22px; margin: 0; }
.task-vector-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: -4px 0 6px; }
.task-vector-fields label { display: grid; gap: 3px; color: var(--text-muted); font-size: 10px; text-transform: uppercase; }
.multi-transform-step { width: 100%; border: 1px solid var(--line); border-radius: 4px; background: var(--bg-soft); padding: 7px; }
.multi-transform-step-head { display: grid; grid-template-columns: minmax(42px, 1fr) minmax(82px, 1.4fr) 24px; gap: 5px; align-items: center; color: var(--text-muted); font-size: 10px; }
.multi-transform-step-head select, .multi-transform-fields input, .multi-transform-fields select { min-width: 0; width: 100%; height: 25px; border: 1px solid var(--line); border-radius: 3px; background: var(--bg-raised); color: var(--text); padding: 0 5px; font-size: 10px; }
@@ -420,3 +428,210 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.multi-transform-add:hover:not(:disabled) { border-color: var(--cyan); color: var(--cyan); }
.multi-transform-add:disabled { opacity: .4; cursor: default; }
.property-vector { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; width: 100%; }
/* Native FreeCAD workspace chrome */
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 18px; background: rgba(10, 11, 13, .66); }
.freecad-dialog { width: min(440px, 100%); overflow: hidden; border: 1px solid #737980; border-radius: 3px; background: var(--bg-panel); box-shadow: 0 20px 70px rgba(0, 0, 0, .58); }
.freecad-dialog.is-wide { width: min(610px, 100%); }
.dialog-titlebar { height: 32px; display: flex; align-items: center; justify-content: space-between; padding-left: 10px; border-bottom: 1px solid var(--line); background: #292c30; color: var(--text); font-size: 11px; font-weight: 600; }
.dialog-title-icon { width: 23px; display: grid; place-items: center; color: var(--cyan); }
.dialog-titlebar > span:nth-child(2) { flex: 1; }
.dialog-close { width: 31px; height: 31px; display: grid; place-items: center; border: 0; border-left: 1px solid var(--line-soft); background: transparent; color: var(--text-soft); cursor: pointer; }
.dialog-close:hover { color: #fff; background: var(--red-soft); }
.dialog-subtitle { padding: 8px 11px; border-bottom: 1px solid var(--line-soft); background: #34373c; color: var(--text-muted); font-size: 10px; }
.dialog-body { min-height: 126px; display: flex; align-items: flex-start; gap: 15px; padding: 24px 22px; }
.dialog-mark { width: 49px; height: 49px; flex: 0 0 49px; display: grid; place-items: center; border: 1px solid #52779b; border-radius: 3px; background: var(--cyan-soft); color: var(--cyan); }
.dialog-body h2 { margin: 1px 0 7px; font-size: 16px; font-weight: 650; }
.dialog-body p { margin: 0 0 7px; color: var(--text-soft); font-size: 11px; line-height: 1.45; }
.dialog-body .dialog-muted { color: var(--text-muted); font-size: 10px; }
.dialog-footer { display: flex; justify-content: flex-end; padding: 9px 10px; border-top: 1px solid var(--line); background: var(--bg-raised); }
.dialog-footer .button { min-width: 76px; min-height: 28px; }
.dialog-form { display: grid; gap: 10px; padding: 16px 13px 20px; }
.dialog-form label { display: grid; grid-template-columns: minmax(130px, 1fr) minmax(170px, 1.4fr); align-items: center; gap: 12px; color: var(--text-soft); font-size: 10px; }
.dialog-form select { height: 28px; min-width: 0; padding: 0 7px; border: 1px solid var(--line); border-radius: 2px; background: var(--bg-raised); color: var(--text); font-size: 10px; }
.dialog-form .dialog-check { display: flex; gap: 7px; }
.dialog-check input { accent-color: var(--cyan); }
.command-palette { padding: 10px; }
.command-palette-input { height: 34px; display: flex; align-items: center; gap: 8px; padding: 0 9px; border: 1px solid #6685a0; border-radius: 2px; background: #202327; color: var(--text-muted); }
.command-palette-input input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 11px; }
.command-palette-list { max-height: 330px; margin-top: 7px; overflow: auto; border: 1px solid var(--line-soft); }
.command-palette-list button { width: 100%; min-height: 40px; display: flex; align-items: center; justify-content: space-between; padding: 5px 9px; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.command-palette-list button:last-child { border-bottom: 0; }
.command-palette-list button:hover, .command-palette-list button:focus { outline: 0; background: var(--cyan-soft); }
.command-palette-list strong, .command-palette-list small { display: block; }
.command-palette-list strong { font-size: 10px; font-weight: 600; }
.command-palette-list small { margin-top: 3px; color: var(--text-muted); font-size: 8px; }
.command-palette-list kbd { color: var(--text-muted); font-size: 9px; }
.command-palette-empty { padding: 22px 9px; color: var(--text-muted); font-size: 10px; text-align: center; }
.macro-dialog { padding: 18px 14px; }
.macro-status { display: flex; align-items: center; gap: 7px; color: var(--text-soft); font-size: 10px; }
.macro-dialog p { margin: 12px 0 0; color: var(--text-muted); font-size: 10px; line-height: 1.55; }
.dialog-warning { display: flex; align-items: flex-start; gap: 9px; padding: 20px 14px; color: var(--amber); font-size: 10px; line-height: 1.5; }
.dialog-warning svg { flex: 0 0 auto; }
.button-danger { border-color: var(--red); background: var(--red); color: #281112; }
.button-danger:hover { border-color: #f29a97; background: #f29a97; }
.tree-context-menu { position: fixed; z-index: 90; width: 214px; display: grid; padding: 4px; border: 1px solid #666b72; border-radius: 2px; background: #303338; box-shadow: 0 9px 24px rgba(0, 0, 0, .48); }
.tree-context-menu button { min-height: 27px; display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: center; gap: 5px; padding: 0 7px; border: 0; border-radius: 2px; background: transparent; color: var(--text-soft); text-align: left; font-size: 10px; cursor: pointer; }
.tree-context-menu button:hover, .tree-context-menu button:focus { outline: 0; background: var(--bg-hover); color: var(--text); }
.tree-context-menu button.is-danger { color: var(--red); }
.tree-context-menu kbd { color: var(--text-muted); font-size: 8px; }
.context-separator-line { height: 1px; margin: 3px 5px; background: var(--line-soft); }
.workspace-page { height: calc(100vh - 34px); min-height: 600px; background: #25282c; }
.standard-toolbar { height: 38px; gap: 3px; padding: 0 7px; background: #303338; box-shadow: inset 0 -1px rgba(0, 0, 0, .18); }
.workbench-picker { height: 28px; min-width: 167px; gap: 6px; padding: 0 6px 0 8px; border-color: #586a7b; border-radius: 2px; background: #343a41; color: var(--cyan); }
.workbench-picker select { min-width: 112px; font-size: 11px; }
.toolbar-status { padding: 0 7px; }
.workbench-commandbar { height: 53px; flex: 0 0 53px; display: flex; align-items: stretch; gap: 0; padding: 2px 5px 0; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid var(--line); background: #292c30; }
.command-tool-group { height: 49px; flex: 0 0 auto; display: grid; grid-template-rows: 31px 14px; align-items: center; padding: 1px 5px 0; border-right: 1px solid var(--line-soft); }
.command-tool-group:last-child { border-right: 0; }
.command-tool-buttons { display: flex; align-items: center; gap: 1px; }
.command-tool-buttons button { width: 29px; height: 29px; display: grid; place-items: center; padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; color: var(--text-soft); cursor: pointer; }
.command-tool-buttons button:hover:not(:disabled) { color: #fff; border-color: #60666e; background: var(--bg-hover); }
.command-tool-buttons button:active:not(:disabled) { background: var(--cyan-soft); color: var(--cyan); }
.command-tool-buttons button:disabled { opacity: .28; cursor: not-allowed; }
.command-tool-label { max-width: 180px; overflow: hidden; color: var(--text-muted); font-size: 8px; line-height: 12px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.document-tabs { height: 32px; padding: 0 6px; background: #202225; }
.doc-tab { height: 29px; border-radius: 2px 2px 0 0; }
.document-meta { padding-bottom: 7px; }
.workspace-content { grid-template-columns: 310px minmax(0, 1fr) 238px; background: #202225; }
.workspace-content.combo-collapsed { grid-template-columns: minmax(0, 1fr) 238px; }
.workspace-content.selection-collapsed { grid-template-columns: 310px minmax(0, 1fr); }
.workspace-content.combo-collapsed.selection-collapsed { grid-template-columns: minmax(0, 1fr); }
.left-panel, .right-panel { background: #303338; }
.panel-tabs { height: 32px; background: #292c30; }
.panel-tabs button { padding: 0 10px; font-size: 10px; }
.combo-model { height: calc(100% - 32px); }
.combo-panel > .task-panel { height: calc(100% - 32px); }
.combo-panel > .task-panel .task-actions-top { position: sticky; top: 0; z-index: 2; background: #292c30; }
.tree-toolbar { padding: 7px 7px 5px; }
.tree-search { height: 25px; border-radius: 2px; }
.tree-document-row, .tree-row { min-height: 25px; height: 25px; }
.tree-footer { padding: 8px 10px 9px; }
.combo-property { min-height: 200px; }
.combo-property .property-heading { min-height: 48px; padding: 8px 9px 6px; }
.property-heading h2 { margin-top: 3px; font-size: 12px; }
.property-tabs { height: 28px; }
.combo-property .properties-scroll { max-height: calc(100% - 76px); }
.property-group { padding: 0 8px 7px; }
.property-group-title { padding: 8px 0 5px; }
.property-row { min-height: 25px; }
.task-actions-top { justify-content: flex-start; padding: 7px 8px; }
.task-actions-top .button { min-height: 27px; min-width: 64px; }
.task-header { padding: 10px 9px; }
.task-body { padding: 11px 9px 18px; }
.task-step { padding-bottom: 11px; margin-bottom: 12px; }
.field-label { margin-bottom: 11px; }
.field-input { height: 27px; margin-top: 4px; border-radius: 2px; }
.viewport-region { background: #181a1d; }
.viewport-header { height: 34px; padding: 0 8px; background: rgba(40, 43, 47, .93); }
.viewport-title { flex-direction: row; align-items: center; gap: 8px; }
.viewport-title .eyebrow { padding-right: 8px; border-right: 1px solid var(--line); }
.viewport-actions .icon-button { width: 27px; height: 27px; }
.viewport-grid { cursor: default; }
.three-viewport-host { pointer-events: auto; }
.three-viewport-host canvas { cursor: default; }
.view-chip { background: rgba(41, 44, 48, .9); border-color: #555a61; border-radius: 2px; }
.selection-panel { min-width: 0; display: flex; flex-direction: column; overflow: hidden; border-left: 1px solid var(--line); }
.selection-panel-title { min-height: 51px; display: flex; align-items: flex-start; justify-content: space-between; padding: 9px 8px 7px; border-bottom: 1px solid var(--line); background: #292c30; }
.selection-panel-title h2 { max-width: 165px; margin: 4px 0 0; overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.selection-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; padding: 18px; color: var(--text-muted); text-align: center; }
.selection-empty span { color: var(--text-soft); font-size: 11px; }
.selection-empty small { font-size: 9px; line-height: 1.45; }
.selection-content { min-height: 0; display: flex; flex: 1; flex-direction: column; overflow: auto; padding-bottom: 9px; }
.selection-summary { display: flex; align-items: center; gap: 8px; padding: 10px 9px; border-bottom: 1px solid var(--line-soft); }
.selection-object-icon { width: 29px; height: 29px; display: grid; flex: 0 0 29px; place-items: center; border: 1px solid #52779b; border-radius: 2px; background: var(--cyan-soft); color: var(--cyan); }
.selection-summary strong, .selection-summary span { display: block; max-width: 172px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selection-summary strong { font-size: 10px; }
.selection-summary span { margin-top: 3px; color: var(--text-muted); font-size: 9px; }
.selection-table { display: grid; padding: 5px 9px; }
.selection-table > div { min-height: 25px; display: grid; grid-template-columns: 43% 57%; align-items: center; border-bottom: 1px solid var(--line-soft); font-size: 9px; }
.selection-table span { color: var(--text-muted); }
.selection-table strong { min-width: 0; overflow: hidden; color: var(--text-soft); font-weight: 500; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.selection-section-title { padding: 9px 9px 5px; color: var(--text-muted); font-size: 8px; font-weight: 700; text-transform: uppercase; }
.selection-elements { display: grid; gap: 2px; padding: 0 6px; }
.selection-elements button { min-height: 25px; display: flex; align-items: center; justify-content: space-between; border: 0; border-radius: 2px; background: transparent; color: var(--text-soft); font-size: 9px; cursor: pointer; }
.selection-elements button:hover { background: var(--bg-hover); color: var(--text); }
.selection-elements small { color: var(--text-muted); }
.selection-no-elements { padding: 10px 4px; color: var(--text-muted); font-size: 9px; }
.selection-clear { margin: auto 8px 0; min-height: 27px; font-size: 9px; }
.bottom-drawer { height: 31px; background: #303338; }
.bottom-drawer.is-open { height: 108px; }
.bottom-drawer-header { height: 30px; }
.bottom-drawer-content { padding: 4px 9px 7px; }
.report-line { min-height: 27px; }
.freecad-statusbar { height: 22px; flex: 0 0 22px; display: flex; align-items: center; gap: 13px; padding: 0 8px; border-top: 1px solid #151719; background: #292c30; color: var(--text-muted); font-size: 9px; }
.freecad-statusbar > span:not(.status-message):not(.status-spacer):not(.status-ready) { padding-left: 10px; border-left: 1px solid var(--line-soft); }
/* FreeCAD CAM workbench */
.menu-popover-cam { width: 256px; max-height: min(72vh, 650px); overflow-y: auto; }
.menu-popover .menu-group-start { position: relative; height: auto; min-height: 48px; margin-top: 4px; padding-top: 22px; border-top: 1px solid var(--line-soft); }
.menu-popover .menu-group-start:first-child { margin-top: 0; border-top: 0; }
.menu-section-label { position: absolute; top: 5px; left: 9px; color: var(--text-muted); font-size: 8px; font-weight: 700; text-transform: uppercase; }
.cam-job-panel { height: calc(100% - 32px); min-height: 0; display: grid; grid-template-rows: minmax(180px, 1fr) minmax(150px, .72fr); }
.cam-tree { min-height: 0; padding: 5px 4px 10px; overflow: auto; border-bottom: 1px solid var(--line); }
.cam-tree-row { width: 100%; min-height: 25px; display: grid; grid-template-columns: 16px minmax(0, 1fr) auto auto; align-items: center; gap: 4px; padding: 2px 5px; border: 0; border-radius: 2px; background: transparent; color: var(--text-soft); text-align: left; font-size: 10px; cursor: pointer; }
.cam-tree-row:hover, .cam-tree-row.is-selected { background: var(--bg-hover); color: var(--text); }
.cam-tree-row.is-selected { outline: 1px solid #4c6c72; outline-offset: -1px; }
.cam-tree-row > small { max-width: 92px; overflow: hidden; color: var(--text-muted); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.cam-tree-children { padding-left: 12px; }
.cam-tree-group { min-height: 23px; display: flex; align-items: center; justify-content: space-between; margin-top: 3px; padding: 0 6px; color: var(--text-muted); font-size: 9px; font-weight: 700; text-transform: uppercase; }
.cam-tree-group > span { display: flex; align-items: center; gap: 5px; }
.cam-tree-group > small { min-width: 18px; text-align: right; }
.cam-state { padding: 1px 4px; border-radius: 2px; color: var(--text-muted); background: #24272b; font-size: 7px; text-transform: uppercase; }
.cam-state.generated, .cam-state.pass { color: var(--green); }
.cam-state.collision, .cam-state.error { color: var(--red); }
.cam-state.draft, .cam-state.warning { color: var(--amber); }
.cam-tree-empty { display: grid; gap: 7px; justify-items: start; margin: 8px 20px; color: var(--text-muted); font-size: 9px; }
.cam-tree-empty button { border: 0; background: transparent; color: var(--cyan); font-size: 9px; cursor: pointer; }
.cam-property-view { min-height: 0; overflow: auto; background: #292c30; }
.cam-property-title { min-height: 28px; display: flex; align-items: center; justify-content: space-between; padding: 0 7px 0 9px; border-bottom: 1px solid var(--line); color: var(--text-soft); font-size: 9px; font-weight: 700; text-transform: uppercase; }
.cam-property-title button { width: 23px; height: 23px; display: grid; place-items: center; border: 0; background: transparent; color: var(--text-muted); cursor: pointer; }
.cam-property-row { min-height: 25px; display: grid; grid-template-columns: minmax(82px, .85fr) minmax(0, 1.15fr); border-bottom: 1px solid #34383d; font-size: 9px; }
.cam-property-row > span, .cam-property-row > strong { min-width: 0; display: flex; align-items: center; padding: 4px 7px; overflow-wrap: anywhere; }
.cam-property-row > span { color: var(--text-muted); border-right: 1px solid #34383d; }
.cam-property-row > strong { color: var(--text-soft); font-weight: 500; }
.cam-task-panel .task-header { position: sticky; top: 0; z-index: 1; background: var(--bg-panel); }
.cam-task-summary { display: grid; gap: 5px; margin-bottom: 14px; padding: 8px 9px; border: 1px solid var(--line); background: var(--bg-raised); color: var(--text-muted); font-size: 9px; }
.cam-task-summary strong { color: var(--text); font-size: 11px; }
.cam-task-command { width: 100%; justify-content: flex-start; margin-bottom: 6px; }
.cam-task-section { margin: 17px 0 10px; padding: 5px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line-soft); color: var(--text); font-size: 9px; font-weight: 700; text-transform: uppercase; }
.cam-tool-json-label { align-items: start; }
.cam-tool-json { min-height: 130px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; line-height: 1.35; }
.legend-swatch.toolpath { background: #ffd05a; box-shadow: 0 0 0 1px rgba(255, 208, 90, .25); }
.status-ready { display: flex; align-items: center; gap: 6px; color: var(--text-soft); }
.status-ready .status-pulse { width: 5px; height: 5px; }
.status-message { color: var(--text-soft); }
.status-spacer { flex: 1; }
@media (max-width: 1180px) {
.topbar-context, .command-search { display: none; }
.topbar-spacer { flex: 1; }
}
@media (max-width: 1050px) {
.brand-lockup { min-width: 152px; }
.workspace-content { grid-template-columns: 260px minmax(0, 1fr) 205px; }
.workspace-content.combo-collapsed { grid-template-columns: minmax(0, 1fr) 205px; }
.workspace-content.selection-collapsed { grid-template-columns: 260px minmax(0, 1fr); }
.workspace-content.combo-collapsed.selection-collapsed { grid-template-columns: minmax(0, 1fr); }
.selection-panel-title h2, .selection-summary strong, .selection-summary span { max-width: 140px; }
}
@media (max-width: 780px) {
.topbar { height: 40px; }
.brand-lockup { min-width: 0; border-right: 0; padding-right: 0; }
.brand-name { display: none; }
.page-shell { min-height: calc(100vh - 40px); }
.workspace-page { height: auto; min-height: calc(100vh - 40px); }
.workbench-commandbar { height: 48px; flex-basis: 48px; }
.command-tool-group { height: 44px; grid-template-rows: 31px 10px; }
.command-tool-label { display: none; }
.workspace-content, .workspace-content.combo-collapsed, .workspace-content.selection-collapsed, .workspace-content.combo-collapsed.selection-collapsed { display: flex; grid-template-columns: none; }
.combo-panel { min-height: 520px; max-height: 620px; order: 2; }
.selection-panel { min-height: 245px; max-height: 310px; order: 3; border-left: 0; border-bottom: 1px solid var(--line); }
.selection-panel-title h2, .selection-summary strong, .selection-summary span { max-width: calc(100vw - 100px); }
.freecad-statusbar { position: sticky; bottom: 0; z-index: 8; }
.freecad-statusbar > span:nth-last-child(-n + 2) { display: none; }
}

8
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
interface ImportMetaEnv {
readonly PROD: boolean
readonly VITE_LINUXCNC_WASM_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}