feat: establish reproducible FreeCAD web compatibility baseline
This commit is contained in:
734
src/App.tsx
734
src/App.tsx
File diff suppressed because it is too large
Load Diff
22
src/assemblySolverWorker.ts
Normal file
22
src/assemblySolverWorker.ts
Normal 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 {}
|
||||
7
src/chromeAddonHarness.ts
Normal file
7
src/chromeAddonHarness.ts
Normal 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 {}
|
||||
143
src/chromeAssemblyHarness.ts
Normal file
143
src/chromeAssemblyHarness.ts
Normal 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
66
src/chromeBimHarness.ts
Normal 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
203
src/chromeCamHarness.ts
Normal 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 {}
|
||||
67
src/chromeCamLinuxcncMachineHarness.ts
Normal file
67
src/chromeCamLinuxcncMachineHarness.ts
Normal 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)
|
||||
})
|
||||
163
src/chromeCamNativeSimulationHarness.ts
Normal file
163
src/chromeCamNativeSimulationHarness.ts
Normal 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)
|
||||
})
|
||||
43
src/chromeCamoticsWasmHarness.ts
Normal file
43
src/chromeCamoticsWasmHarness.ts
Normal 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
72
src/chromeDataHarness.ts
Normal 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
91
src/chromeDraftHarness.ts
Normal 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 {}
|
||||
75
src/chromeEngineeringHarness.ts
Normal file
75
src/chromeEngineeringHarness.ts
Normal 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 {}
|
||||
473
src/chromeFcstdGoldenHarness.ts
Normal file
473
src/chromeFcstdGoldenHarness.ts
Normal 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.')
|
||||
})
|
||||
49
src/chromeFcstdRoundTripHarness.ts
Normal file
49
src/chromeFcstdRoundTripHarness.ts
Normal 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 {}
|
||||
61
src/chromeFcstdSemanticHarness.ts
Normal file
61
src/chromeFcstdSemanticHarness.ts
Normal 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
7
src/chromeFemHarness.ts
Normal 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 {}
|
||||
325
src/chromeGeometryFeatureHarness.ts
Normal file
325
src/chromeGeometryFeatureHarness.ts
Normal 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 {}
|
||||
18
src/chromeInspectionHarness.ts
Normal file
18
src/chromeInspectionHarness.ts
Normal 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
67
src/chromeMeshHarness.ts
Normal 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 {}
|
||||
74
src/chromeOfflineHarness.ts
Normal file
74
src/chromeOfflineHarness.ts
Normal 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 {}
|
||||
71
src/chromePartPrimitivesHarness.ts
Normal file
71
src/chromePartPrimitivesHarness.ts
Normal 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 {}
|
||||
200
src/chromePartdesignLoftHarness.ts
Normal file
200
src/chromePartdesignLoftHarness.ts
Normal 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 {}
|
||||
277
src/chromePartdesignTransformHarness.ts
Normal file
277
src/chromePartdesignTransformHarness.ts
Normal 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 {}
|
||||
7
src/chromePerformanceHarness.ts
Normal file
7
src/chromePerformanceHarness.ts
Normal 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
137
src/chromePlotHarness.ts
Normal 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 {}
|
||||
140
src/chromeProductionDocumentHarness.ts
Normal file
140
src/chromeProductionDocumentHarness.ts
Normal 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
7
src/chromeQa08Harness.ts
Normal 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 {}
|
||||
7
src/chromeRobotHarness.ts
Normal file
7
src/chromeRobotHarness.ts
Normal 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 {}
|
||||
7
src/chromeScriptHarness.ts
Normal file
7
src/chromeScriptHarness.ts
Normal 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 {}
|
||||
79
src/chromeSecondaryFormatsHarness.ts
Normal file
79
src/chromeSecondaryFormatsHarness.ts
Normal 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 {}
|
||||
7
src/chromeSecurityHarness.ts
Normal file
7
src/chromeSecurityHarness.ts
Normal 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 {}
|
||||
145
src/chromeSpreadsheetHarness.ts
Normal file
145
src/chromeSpreadsheetHarness.ts
Normal 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 {}
|
||||
49
src/chromeSurfaceHarness.ts
Normal file
49
src/chromeSurfaceHarness.ts
Normal 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 {}
|
||||
57
src/chromeTechDrawHarness.ts
Normal file
57
src/chromeTechDrawHarness.ts
Normal 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 {}
|
||||
39
src/facade/addonGovernance.ts
Normal file
39
src/facade/addonGovernance.ts
Normal 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
113
src/facade/assembly.ts
Normal 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
92
src/facade/attachment.ts
Normal 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
37
src/facade/bim.ts
Normal 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
42
src/facade/bodyRules.ts
Normal 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
1189
src/facade/cam.ts
Normal file
File diff suppressed because it is too large
Load Diff
482
src/facade/camNativeSimulation.ts
Normal file
482
src/facade/camNativeSimulation.ts
Normal 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
238
src/facade/camPipeline.ts
Normal 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
106
src/facade/camoticsWasm.ts
Normal 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
355
src/facade/dataModules.ts
Normal 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
90
src/facade/draft.ts
Normal 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
135
src/facade/elementMap.ts
Normal 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
647
src/facade/elementMap2.ts
Normal 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`;
|
||||
}
|
||||
8
src/facade/engineeringProject.ts
Normal file
8
src/facade/engineeringProject.ts
Normal 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 } }
|
||||
2184
src/facade/fcstd.ts
2184
src/facade/fcstd.ts
File diff suppressed because it is too large
Load Diff
18
src/facade/fcstdRoundTrip.ts
Normal file
18
src/facade/fcstdRoundTrip.ts
Normal 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
11
src/facade/fem.ts
Normal 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
@@ -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 () => {
|
||||
|
||||
@@ -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
42
src/facade/inspection.ts
Normal 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
15
src/facade/locale.ts
Normal 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
64
src/facade/mesh.ts
Normal 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
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
132
src/facade/nativeNamingEvidence.ts
Normal file
132
src/facade/nativeNamingEvidence.ts
Normal 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)
|
||||
5
src/facade/performanceBudget.ts
Normal file
5
src/facade/performanceBudget.ts
Normal 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 } }
|
||||
@@ -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() }
|
||||
|
||||
420
src/facade/planegcsAdapter.ts
Normal file
420
src/facade/planegcsAdapter.ts
Normal 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 }
|
||||
}
|
||||
124
src/facade/planegcsWorkerClient.ts
Normal file
124
src/facade/planegcsWorkerClient.ts
Normal 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.'))
|
||||
}
|
||||
}
|
||||
35
src/facade/planegcsWorkerEntry.ts
Normal file
35
src/facade/planegcsWorkerEntry.ts
Normal 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
101
src/facade/plot.ts
Normal 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
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 }
|
||||
}
|
||||
9
src/facade/productionDocument.ts
Normal file
9
src/facade/productionDocument.ts
Normal 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 } }
|
||||
42
src/facade/projectMigrationSeedWorker.ts
Normal file
42
src/facade/projectMigrationSeedWorker.ts
Normal 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()
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -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
9
src/facade/robot.ts
Normal 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 } }
|
||||
26
src/facade/scriptSandbox.ts
Normal file
26
src/facade/scriptSandbox.ts
Normal 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 }
|
||||
}
|
||||
84
src/facade/secondaryFormats.ts
Normal file
84
src/facade/secondaryFormats.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
5
src/facade/securityPreflight.ts
Normal file
5
src/facade/securityPreflight.ts
Normal 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 } }
|
||||
@@ -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
108
src/facade/sketchStress.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
258
src/facade/spreadsheet.ts
Normal 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
297
src/facade/stringHasher.ts
Normal 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
33
src/facade/surface.ts
Normal 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
100
src/facade/techDraw.ts
Normal 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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[] }>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
80
src/facade/topologyReplay.ts
Normal file
80
src/facade/topologyReplay.ts
Normal 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
|
||||
}
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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
12
src/meshLodWorker.ts
Normal 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 {}
|
||||
277
src/styles.css
277
src/styles.css
@@ -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
8
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly PROD: boolean
|
||||
readonly VITE_LINUXCNC_WASM_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user