P7/P8: add topology proxies and recovery reports
This commit is contained in:
@@ -431,7 +431,7 @@ function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice, facade }: {
|
||||
export: <FileFlowPage mode="export" onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
|
||||
settings: <SettingsPage onNavigate={onNavigate} showNotice={showNotice} />,
|
||||
help: <HelpPage onNavigate={onNavigate} showNotice={showNotice} />,
|
||||
diagnostics: <DiagnosticsPage onNavigate={onNavigate} showNotice={showNotice} />,
|
||||
diagnostics: <DiagnosticsPage onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
|
||||
sync: <SyncPage onNavigate={onNavigate} showNotice={showNotice} />,
|
||||
}
|
||||
return <div className="page-shell"><div className="page-content">{pages[page as Exclude<Page, 'workspace'>]}</div></div>
|
||||
@@ -502,8 +502,10 @@ function Shortcut({ keyName, label }: { keyName: string; label: string }) {
|
||||
return <div className="shortcut-row"><kbd>{keyName}</kbd><span>{label}</span></div>
|
||||
}
|
||||
|
||||
function DiagnosticsPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) {
|
||||
return <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value="Ready" detail="BitBybit OCCT package loads on demand" tone="green" icon={Code2} /><HealthCard label="Local storage" value="Capability probe" detail="SQLite WASM + OPFS worker" tone="cyan" icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="WebGPU can be enabled" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value="2 warnings" detail="Pump Housing · v18" tone="amber" icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · single public entry" tone="green" /><DiagnosticRow name="Geometry Worker" value="BitBybit OCCT 1.1.1" detail="WASM Worker with Box, Boolean, feature and export boundaries" tone="green" /><DiagnosticRow name="SQLite WASM" value="Worker configured" detail="Schema v4 · OPFS VFS with memory fallback" tone="cyan" /><DiagnosticRow name="OPFS" value="Capability probe" detail="Requires cross-origin isolation; export fallback is explicit" tone="cyan" /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /><DiagnosticRow name="Document warnings" value="2" detail="One downstream reference needs review" tone="amber" onClick={() => showNotice('Document diagnostics opened')} /></section></div>
|
||||
function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
|
||||
const [recovery, setRecovery] = useState<{ integrity: string; lastSavedVersion: number | null; warnings: string[] } | null>(null)
|
||||
useEffect(() => { void facade.project.recovery(facade.getState().document.id).then(setRecovery).catch((error: unknown) => showNotice(`Recovery report unavailable: ${error instanceof Error ? error.message : String(error)}`)) }, [facade, showNotice])
|
||||
return <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value="Ready" detail="BitBybit OCCT package loads on demand" tone="green" icon={Code2} /><HealthCard label="Local storage" value="Capability probe" detail="SQLite WASM + OPFS worker" tone="cyan" icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="WebGPU can be enabled" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value="2 warnings" detail="Pump Housing · v18" tone="amber" icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · single public entry" tone="green" /><DiagnosticRow name="Geometry Worker" value="BitBybit OCCT 1.1.1" detail="WASM Worker with Box, Boolean, feature and export boundaries" tone="green" /><DiagnosticRow name="SQLite WASM" value="Worker configured" detail="Schema v4 · OPFS VFS with memory fallback" tone="cyan" /><DiagnosticRow name="OPFS" value="Capability probe" detail="Requires cross-origin isolation; export fallback is explicit" tone="cyan" /><DiagnosticRow name="Recovery" value={recovery?.integrity || 'Checking'} detail={recovery ? `Last saved v${recovery.lastSavedVersion ?? 'none'}${recovery.warnings.length ? ` · ${recovery.warnings.length} warning(s)` : ''}` : 'Checking SQLite integrity and saved snapshot'} tone={recovery?.integrity === 'ok' ? 'green' : 'cyan'} /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /><DiagnosticRow name="Document warnings" value="2" detail="One downstream reference needs review" tone="amber" onClick={() => showNotice('Document diagnostics opened')} /></section></div>
|
||||
}
|
||||
|
||||
function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { unzipSync } from 'fflate'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import type { DocumentSnapshot, DocumentObjectSnapshot, ModelTreeItem, ObjectPropertySnapshot } from './types'
|
||||
|
||||
export type FcstdArchiveLimits = {
|
||||
maxArchiveBytes: number
|
||||
@@ -47,6 +48,7 @@ export type FcstdInspection = {
|
||||
entries: FcstdEntryMetadata[]
|
||||
objects: FcstdObjectSummary[]
|
||||
compatibility: FcstdCompatibilityReport
|
||||
proxyDocument: DocumentSnapshot
|
||||
}
|
||||
|
||||
export const DEFAULT_FCSTD_LIMITS: FcstdArchiveLimits = {
|
||||
@@ -72,6 +74,12 @@ const recognizedTypeIds = new Set([
|
||||
'Sketcher::SketchObject',
|
||||
])
|
||||
|
||||
const hashString = (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 blockedTypeId = (typeId: string) => /(?:FeaturePython|PythonFeature|::Python)/i.test(typeId)
|
||||
|
||||
const entryRole = (path: string): FcstdEntryRole => {
|
||||
@@ -203,6 +211,15 @@ const parseDocumentXml = (bytes: Uint8Array) => {
|
||||
}
|
||||
}
|
||||
|
||||
const createProxyProperty = (name: string, label: string, value: string): ObjectPropertySnapshot => ({ name, label, group: 'FCStd import', scope: 'data', type: 'App::PropertyString', value, readOnly: true })
|
||||
|
||||
const createProxyDocument = (inspection: Omit<FcstdInspection, 'proxyDocument'>): DocumentSnapshot => {
|
||||
const documentId = `fcstd-${hashString(`${inspection.label}|${inspection.schemaVersion}|${inspection.objects.map((object) => object.name).join('|')}`)}`
|
||||
const tree: ModelTreeItem[] = inspection.objects.map((object) => ({ id: object.name, label: object.label, type: object.typeId.includes('Body') ? 'body' : object.typeId.includes('Sketch') ? 'sketch' : 'feature', state: 'readonly', detail: `${object.typeId} · ${object.support}` }))
|
||||
const objects: DocumentObjectSnapshot[] = inspection.objects.map((object) => ({ id: object.name, typeId: object.typeId, properties: [createProxyProperty('Label', 'Label', object.label), createProxyProperty('TypeId', 'Type', object.typeId), createProxyProperty('ImportSupport', 'Import support', object.support), createProxyProperty('PropertyCount', 'Property count', String(object.propertyCount))] }))
|
||||
return { id: documentId, label: inspection.label, version: 1, dirty: false, readOnly: true, units: 'mm', tree, objects, dependencies: [], recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(objects.map((object) => [object.id, 'up-to-date'])), dirtyObjects: [], order: [], errors: [] } }
|
||||
}
|
||||
|
||||
export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial<FcstdArchiveLimits> = {}): FcstdInspection => {
|
||||
const limits = { ...DEFAULT_FCSTD_LIMITS, ...limitOverrides }
|
||||
for (const [name, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`FCStd limit ${name} must be a positive safe integer.`)
|
||||
@@ -223,7 +240,7 @@ export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial<F
|
||||
const proxyObjects = document.objects.filter((object) => object.support === 'proxy')
|
||||
if (proxyObjects.length > 0) warnings.push(`${proxyObjects.length} unrecognized object type(s) require a read-only proxy.`)
|
||||
const level: FcstdCompatibilityReport['level'] = blockedObjects.length > 0 ? 'blocked' : proxyObjects.length > 0 || scriptEntries.length > 0 ? 'partial' : 'metadata-compatible'
|
||||
return {
|
||||
const inspection: Omit<FcstdInspection, 'proxyDocument'> = {
|
||||
format: 'FCStd',
|
||||
schemaVersion: document.schemaVersion,
|
||||
label: document.label,
|
||||
@@ -240,4 +257,5 @@ export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial<F
|
||||
warnings,
|
||||
},
|
||||
}
|
||||
return { ...inspection, proxyDocument: createProxyDocument(inspection) }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types'
|
||||
export { createSubshapeRefs, matchSubshapes, signatureForFace } from './topologyNaming'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types'
|
||||
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
|
||||
export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
|
||||
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
|
||||
export { executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
|
||||
|
||||
@@ -461,7 +461,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
|
||||
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
||||
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
|
||||
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() },
|
||||
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm'
|
||||
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectResource } from './types'
|
||||
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource } from './types'
|
||||
|
||||
type PersistenceRequest =
|
||||
| { id: number; type: 'initialize' }
|
||||
| { id: number; type: 'save-document'; document: DocumentSnapshot }
|
||||
| { id: number; type: 'load-document'; documentId: string }
|
||||
| { id: number; type: 'recovery-report'; documentId: string }
|
||||
| { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string }
|
||||
| { id: number; type: 'get-resource'; hash: string }
|
||||
| { id: number; type: 'release-resource'; hash: string }
|
||||
@@ -15,6 +16,7 @@ type PersistenceResponse =
|
||||
| { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities }
|
||||
| { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] }
|
||||
| { id: number; ok: true; type: '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' }
|
||||
@@ -133,6 +135,17 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => {
|
||||
}
|
||||
}
|
||||
|
||||
const recoveryReport = (documentId: string): ProjectRecoveryReport => {
|
||||
if (!database) throw new Error('Persistence database is not initialized.')
|
||||
const integrityRows = database.exec({ sql: 'PRAGMA integrity_check', rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
|
||||
const integrityValue = String(integrityRows[0]?.integrity_check ?? '')
|
||||
const documents = database.exec({ sql: 'SELECT version, dirty FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
|
||||
const warnings: string[] = []
|
||||
if (!documents[0]) warnings.push('No saved document exists for this ID.')
|
||||
if (integrityValue !== 'ok') warnings.push(`SQLite integrity check returned: ${integrityValue || 'no result'}.`)
|
||||
return { documentId, mode: capabilities.mode, schemaVersion: capabilities.schemaVersion, integrity: integrityValue === 'ok' ? 'ok' : 'failed', lastSavedVersion: documents[0] ? Number(documents[0].version) : null, dirtyAtLastSave: documents[0] ? Boolean(documents[0].dirty) : null, warnings }
|
||||
}
|
||||
|
||||
const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<ProjectResource> => {
|
||||
if (!database) throw new Error('Persistence database is not initialized.')
|
||||
const hash = await hashBytes(bytes)
|
||||
@@ -179,6 +192,7 @@ const handle = async (request: PersistenceRequest): Promise<PersistenceResponse>
|
||||
await initialize()
|
||||
if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } }
|
||||
if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) }
|
||||
if (request.type === 'recovery-report') return { id: request.id, ok: true, type: 'recovery-report', report: recoveryReport(request.documentId) }
|
||||
if (request.type === 'put-resource') return { id: request.id, ok: true, type: 'resource-put', resource: await putResource(request.bytes, request.mediaType) }
|
||||
if (request.type === 'get-resource') return { id: request.id, ok: true, type: 'resource-get', bytes: await getResource(request.hash) }
|
||||
if (request.type === 'release-resource') { await releaseResource(request.hash); return { id: request.id, ok: true, type: 'resource-released' } }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectResource, ProjectSaveResult } from './types'
|
||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectSaveResult } from './types'
|
||||
import { cloneSketch } from './sketcher'
|
||||
|
||||
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string } | { 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' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: string } | { 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: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { 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: 'disposed' } | { id: number; ok: false; error: string }
|
||||
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { 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' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { 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: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: '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: '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 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, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : 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 })
|
||||
@@ -13,6 +13,7 @@ export interface ProjectPersistenceClient {
|
||||
capabilities(): PersistenceCapabilities
|
||||
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
recovery(documentId: string): Promise<ProjectRecoveryReport>
|
||||
resource: {
|
||||
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
|
||||
get(hash: string): Promise<Uint8Array | null>
|
||||
@@ -111,6 +112,18 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
return response.document
|
||||
}
|
||||
|
||||
async recovery(documentId: string): Promise<ProjectRecoveryReport> {
|
||||
await this.writeQueue.drain()
|
||||
await this.initialize()
|
||||
if (!this.worker) {
|
||||
const snapshot = this.fallbackSnapshots.get(documentId)
|
||||
return { documentId, mode: this.currentCapabilities.mode, schemaVersion: this.currentCapabilities.schemaVersion, integrity: snapshot ? 'ok' : 'unavailable', lastSavedVersion: snapshot?.version ?? null, dirtyAtLastSave: snapshot?.dirty ?? null, warnings: snapshot ? [] : ['No saved snapshot exists in the transient fallback store.'] }
|
||||
}
|
||||
const response = await this.request({ type: 'recovery-report', documentId })
|
||||
if (!response.ok || response.type !== 'recovery-report') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
return response.report
|
||||
}
|
||||
|
||||
readonly resource = {
|
||||
put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(async () => {
|
||||
await this.initialize()
|
||||
|
||||
@@ -7,7 +7,7 @@ export type FaceMeshInput = {
|
||||
}
|
||||
|
||||
export type SubshapeSignature = {
|
||||
kind: 'face'
|
||||
kind: 'face' | 'edge' | 'vertex'
|
||||
canonical: string
|
||||
hash: string
|
||||
centroid: [number, number, number]
|
||||
@@ -72,6 +72,59 @@ export const signatureForFace = (face: FaceMeshInput, tolerance = 1e-5): Subshap
|
||||
return { kind: 'face', canonical, hash: hashString(canonical), centroid, bounds: { min, max }, area, normal }
|
||||
}
|
||||
|
||||
const pointCanonical = (point: [number, number, number], tolerance: number) => point.map((value) => quantize(value, tolerance)).join(',')
|
||||
|
||||
export const signatureForEdge = (start: [number, number, number], end: [number, number, number], tolerance = 1e-5): SubshapeSignature => {
|
||||
if (![...start, ...end].every(Number.isFinite)) throw new RangeError('An edge signature requires finite endpoint coordinates.')
|
||||
const ordered = pointCanonical(start, tolerance) <= pointCanonical(end, tolerance) ? [start, end] : [end, start]
|
||||
const midpoint: [number, number, number] = [(ordered[0][0] + ordered[1][0]) / 2, (ordered[0][1] + ordered[1][1]) / 2, (ordered[0][2] + ordered[1][2]) / 2]
|
||||
const length = Math.hypot(ordered[1][0] - ordered[0][0], ordered[1][1] - ordered[0][1], ordered[1][2] - ordered[0][2])
|
||||
const min: [number, number, number] = [Math.min(ordered[0][0], ordered[1][0]), Math.min(ordered[0][1], ordered[1][1]), Math.min(ordered[0][2], ordered[1][2])]
|
||||
const max: [number, number, number] = [Math.max(ordered[0][0], ordered[1][0]), Math.max(ordered[0][1], ordered[1][1]), Math.max(ordered[0][2], ordered[1][2])]
|
||||
const canonical = ['edge', `ends=${pointCanonical(ordered[0], tolerance)}:${pointCanonical(ordered[1], tolerance)}`, `length=${quantize(length, tolerance)}`].join('|')
|
||||
return { kind: 'edge', canonical, hash: hashString(canonical), centroid: midpoint, bounds: { min, max }, area: 0, normal: [0, 0, 0] }
|
||||
}
|
||||
|
||||
export const signatureForVertex = (point: [number, number, number], tolerance = 1e-5): SubshapeSignature => {
|
||||
if (!point.every(Number.isFinite)) throw new RangeError('A vertex signature requires finite coordinates.')
|
||||
const canonical = `vertex|point=${pointCanonical(point, 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 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))
|
||||
return signatures.map((signature) => {
|
||||
const key = `${signature.kind}:${signature.hash}`
|
||||
const duplicate = (occurrences.get(key) ?? 0) > 1
|
||||
const persistentId = `topo-${signature.kind}-${signature.hash}`
|
||||
return { shapeId, kind: signature.kind, persistentId, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? signatures.filter((candidate) => candidate.kind === signature.kind && candidate.hash === signature.hash).map((candidate) => `topo-${candidate.kind}-${candidate.hash}`) : undefined }
|
||||
})
|
||||
}
|
||||
|
||||
export const createEdgeSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => {
|
||||
const signatures = new Map<string, SubshapeSignature>()
|
||||
for (const face of faces) for (let index = 0; index + 2 < face.triIndexes.length; index += 3) {
|
||||
const points = [facePoint(face.vertexCoord, face.triIndexes[index]), facePoint(face.vertexCoord, face.triIndexes[index + 1]), facePoint(face.vertexCoord, face.triIndexes[index + 2])]
|
||||
for (let edgeIndex = 0; edgeIndex < 3; edgeIndex += 1) {
|
||||
const signature = signatureForEdge(points[edgeIndex], points[(edgeIndex + 1) % 3], tolerance)
|
||||
signatures.set(signature.hash, signature)
|
||||
}
|
||||
}
|
||||
const values = [...signatures.values()]
|
||||
return { refs: refsForSignatures(shapeId, topologyVersion, values), signatures: values }
|
||||
}
|
||||
|
||||
export const createVertexSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => {
|
||||
const signatures = new Map<string, SubshapeSignature>()
|
||||
for (const face of faces) for (let index = 0; index < face.vertexCoord.length; index += 3) {
|
||||
const signature = signatureForVertex(facePoint(face.vertexCoord, index / 3), tolerance)
|
||||
signatures.set(signature.hash, signature)
|
||||
}
|
||||
const values = [...signatures.values()]
|
||||
return { refs: refsForSignatures(shapeId, topologyVersion, values), signatures: values }
|
||||
}
|
||||
|
||||
export const createSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => {
|
||||
const signatures = faces.map((face) => signatureForFace(face, tolerance))
|
||||
const occurrences = new Map<string, number>()
|
||||
|
||||
@@ -85,6 +85,16 @@ export type ProjectSaveResult = {
|
||||
mode: PersistenceCapabilities['mode']
|
||||
}
|
||||
|
||||
export type ProjectRecoveryReport = {
|
||||
documentId: string
|
||||
mode: PersistenceCapabilities['mode']
|
||||
schemaVersion: number
|
||||
integrity: 'ok' | 'failed' | 'unavailable'
|
||||
lastSavedVersion: number | null
|
||||
dirtyAtLastSave: boolean | null
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export type ProjectResource = {
|
||||
hash: string
|
||||
byteLength: number
|
||||
@@ -368,6 +378,7 @@ export interface BitBybitWebCadFacade {
|
||||
capabilities(): PersistenceCapabilities
|
||||
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
recovery(documentId: string): Promise<ProjectRecoveryReport>
|
||||
fcstd: {
|
||||
inspect(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): FcstdInspection
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user