P2/P5: coordinate tabs and render feature preview

This commit is contained in:
2026-08-02 18:09:03 -04:00
parent cb6877be36
commit 709cfea1eb
7 changed files with 111 additions and 29 deletions

View File

@@ -136,6 +136,11 @@ function App() {
}
}), [facade])
useEffect(() => facade.project.subscribeExternalChanges((notice) => {
setNotice(`Project saved in another tab · v${notice.documentVersion}`)
window.setTimeout(() => setNotice(''), 2600)
}), [facade])
useEffect(() => () => facade.geometry.dispose(), [facade])
useEffect(() => {
@@ -386,7 +391,7 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
const adapter = facade.viewport.createAdapter()
adapterRef.current = adapter
let cancelled = false
let shape: ShapeHandle | null = null
const shapes: ShapeHandle[] = []
try {
adapter.mount(host)
} catch (error) {
@@ -399,13 +404,17 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
const capabilities = await facade.geometry.initialize()
if (capabilities.status !== 'ready') throw new Error(capabilities.reason || 'OCCT geometry runtime unavailable')
const document = facade.app.document.getActive()
shape = await facade.geometry.createBox({ width: 2.7, length: 1.4, height: 1.6, center: [0, 0, 0], originOnCenter: true, documentId: document.id, documentVersion: document.version })
const profile = { outer: [[-1.35, -0.7, 0], [1.35, -0.7, 0], [1.35, 0.7, 0], [-1.35, 0.7, 0]] as [number, number, number][] }
const pad = await facade.geometry.pad({ profile, length: 1.6, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version })
shapes.push(pad)
const pocketProfile = { outer: [[-0.55, -0.32, 0], [0.55, -0.32, 0], [0.55, 0.32, 0], [-0.55, 0.32, 0]] as [number, number, number][] }
const pocket = await facade.geometry.pocket({ base: pad, profile: pocketProfile, length: 1.8, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version })
shapes.push(pocket)
if (cancelled) {
await facade.geometry.release(shape)
shape = null
await Promise.all(shapes.map((entry) => facade.geometry.release(entry)))
return
}
const mesh = await facade.geometry.mesh(shape, 0.05)
const mesh = await facade.geometry.mesh(pocket, 0.05)
if (!cancelled) adapter.setMesh(mesh)
}
void loadGeometry().catch((error: unknown) => {
@@ -413,7 +422,7 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
})
return () => {
cancelled = true
if (shape) void facade.geometry.release(shape)
void Promise.all(shapes.map((entry) => facade.geometry.release(entry)))
adapter.dispose()
adapterRef.current = null
}

View File

@@ -463,7 +463,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), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), 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), topology: (shape, precision) => geometryRuntime.topology(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,

View File

@@ -1,4 +1,4 @@
import type { DocumentSnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectSaveResult } from './types'
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, Unsubscribe } 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' | '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 }
@@ -11,6 +11,7 @@ const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...do
export interface ProjectPersistenceClient {
initialize(): Promise<PersistenceCapabilities>
capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
recovery(documentId: string): Promise<ProjectRecoveryReport>
@@ -68,6 +69,9 @@ export class ProjectAutosaveScheduler {
export class SqliteProjectPersistence implements ProjectPersistenceClient {
private readonly worker: Worker | null
private readonly senderId = `tab-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`
private readonly changeChannel: BroadcastChannel | null
private readonly externalChangeListeners = new Set<(notice: ProjectChangeNotice) => void>()
private nextRequestId = 0
private currentCapabilities = unavailable
private initialized: Promise<PersistenceCapabilities> | null = null
@@ -78,7 +82,14 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
constructor() {
this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' })
if (!this.worker) this.currentCapabilities = { mode: 'sqlite-memory', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Worker is unavailable; using a transient in-memory project store.' }
this.changeChannel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('bitbybit-project-changes')
;(this.changeChannel as (BroadcastChannel & { unref?: () => void }) | null)?.unref?.()
this.changeChannel?.addEventListener('message', (event: MessageEvent<ProjectChangeNotice>) => {
const notice = event.data
if (!notice || notice.senderId === this.senderId || notice.kind !== 'document-saved') return
this.externalChangeListeners.forEach((listener) => listener({ ...notice }))
})
if (!this.worker) this.currentCapabilities = { mode: 'sqlite-memory', sqliteWasm: false, opfs: false, schemaVersion: 0, crossTabWriteLock: this.crossTabWriteLockMode(), reason: 'Worker is unavailable; using a transient in-memory project store.' }
if (this.worker) {
this.worker.onmessage = (event: MessageEvent<WorkerResponse>) => { const request = this.pending.get(event.data.id); if (!request) return; this.pending.delete(event.data.id); request.resolve(event.data) }
this.worker.onerror = (event) => { const error = new Error(event.message || 'Persistence Worker failed.'); this.currentCapabilities = { ...unavailable, reason: error.message }; this.initialized = null; for (const request of this.pending.values()) request.reject(error); this.pending.clear() }
@@ -87,20 +98,33 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
capabilities() { return { ...this.currentCapabilities } }
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe {
this.externalChangeListeners.add(listener)
return () => this.externalChangeListeners.delete(listener)
}
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; return this.capabilities() })
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() })
return this.initialized
}
save(document: DocumentSnapshot) {
return this.writeQueue.run(async () => {
return this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize()
if (!this.worker) { const snapshot = cloneDocument(document); this.fallbackSnapshots.set(snapshot.id, snapshot); return { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode } }
if (!this.worker) {
const snapshot = cloneDocument(document)
this.fallbackSnapshots.set(snapshot.id, snapshot)
const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode }
this.announceSaved(result.documentId, result.documentVersion)
return result
}
const response = await this.request({ type: 'save-document', document })
if (!response.ok || response.type !== 'saved') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode }
})
const result = { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode }
this.announceSaved(result.documentId, result.documentVersion)
return result
}))
}
async load(documentId: string) {
@@ -125,7 +149,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
}
readonly resource = {
put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(async () => {
put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize()
if (!this.worker) {
const hash = `memory-${[...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')}`
@@ -138,7 +162,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
const response = await this.request({ type: 'put-resource', bytes: payload.buffer as ArrayBuffer, mediaType }, [payload.buffer as ArrayBuffer])
if (!response.ok || response.type !== 'resource-put') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.resource
}),
})),
get: async (hash: string) => {
await this.writeQueue.drain()
await this.initialize()
@@ -147,19 +171,37 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
if (!response.ok || response.type !== 'resource-get') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.bytes ? new Uint8Array(response.bytes) : null
},
release: (hash: string) => this.writeQueue.run(async () => {
release: (hash: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize()
if (!this.worker) { const existing = this.fallbackResources.get(hash); if (!existing) return; if (existing.resource.refCount > 1) existing.resource.refCount -= 1; else this.fallbackResources.delete(hash); return }
const response = await this.request({ type: 'release-resource', hash })
if (!response.ok || response.type !== 'resource-released') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
}),
})),
}
async dispose() {
await this.writeQueue.drain()
if (!this.worker) return
await this.request({ type: 'dispose' })
this.worker.terminate()
if (this.worker) {
await this.request({ type: 'dispose' })
this.worker.terminate()
}
this.changeChannel?.close()
}
private crossTabWriteLockMode(): 'web-locks' | 'local-queue' {
return typeof navigator !== 'undefined' && 'locks' in navigator ? 'web-locks' : 'local-queue'
}
private withCrossTabWriteLock<T>(operation: () => Promise<T>): Promise<T> {
if (typeof navigator === 'undefined' || !navigator.locks) return operation()
return navigator.locks.request<Promise<T>>('bitbybit-project-write', { mode: 'exclusive' }, (lock) => {
if (!lock) throw new Error('Unable to acquire the cross-tab project write lock.')
return operation()
}).then((result) => result)
}
private announceSaved(documentId: string, documentVersion: number) {
this.changeChannel?.postMessage({ documentId, documentVersion, senderId: this.senderId, kind: 'document-saved' } satisfies ProjectChangeNotice)
}
private request(input: WorkerInput, transfer: Transferable[] = []) {

View File

@@ -75,9 +75,17 @@ export type PersistenceCapabilities = {
sqliteWasm: boolean
opfs: boolean
schemaVersion: number
crossTabWriteLock?: 'web-locks' | 'local-queue'
reason?: string
}
export type ProjectChangeNotice = {
documentId: string
documentVersion: number
senderId: string
kind: 'document-saved'
}
export type ProjectSaveResult = {
documentId: string
documentVersion: number
@@ -382,6 +390,7 @@ export interface BitBybitWebCadFacade {
}
readonly project: {
capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
recovery(documentId: string): Promise<ProjectRecoveryReport>