diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index bfdd802..a00c178 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1591,6 +1591,7 @@ P0 基线/治理 | P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker | | P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty 标志和 Undo/Redo 已接通,完整属性、依赖 DAG 和重计算仍待实现 | | P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 | +| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 | @@ -1606,6 +1607,7 @@ P0 基线/治理 | SQLite WASM Worker | `PASS` | 构建产物包含 `persistenceWorker`、SQLite Worker、OPFS proxy 和 `sqlite3.wasm` | | OPFS VFS | `PASS` | Chrome `crossOriginIsolated=true`、`navigator.storage.getDirectory()` 可用 | | 数据库创建 | `PASS` | 保存文档后 OPFS 根目录出现 `bitbybit-project.sqlite3` | +| OPFS 资源对象 | `PASS` | Worker 写入/读取 5 字节资源,重复写入复用 hash,释放两次后资源目录为空 | | Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 | | 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter;不会假称 OPFS 已可用 | diff --git a/src/facade/index.ts b/src/facade/index.ts index b6f197e..f7fe138 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -2,4 +2,4 @@ export { createMockFacade } from './mockFacade' export { createSqliteProjectPersistence, PersistenceWriteQueue, SqliteProjectPersistence } from './projectStore' export { ThreeViewportAdapter } from './threeViewport' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema' -export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectSaveResult, TaskSnapshot } from './types' +export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectResource, ProjectSaveResult, TaskSnapshot } from './types' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index b63303f..d5b1abd 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -128,7 +128,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) }, + project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), resource: projectPersistence.resource }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, } diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts index 5d2002f..122f5ad 100644 --- a/src/facade/persistenceWorker.ts +++ b/src/facade/persistenceWorker.ts @@ -1,17 +1,23 @@ import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema' -import type { DocumentSnapshot, ModelTreeItem, PersistenceCapabilities } from './types' +import type { DocumentSnapshot, ModelTreeItem, PersistenceCapabilities, 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: 'put-resource'; bytes: ArrayBuffer; mediaType: string } + | { id: number; type: 'get-resource'; hash: string } + | { id: number; type: 'release-resource'; hash: string } | { id: number; type: 'dispose' } 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: '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 } @@ -23,10 +29,24 @@ type WorkerScope = { const workerScope = globalThis as unknown as WorkerScope let sqlite3: Sqlite3Static | undefined let database: Database | undefined +let assetsDirectory: FileSystemDirectoryHandle | undefined let capabilities: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: 'SQLite WASM has not been initialized.' } const cloneTree = (tree: ModelTreeItem[]) => tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) +const getAssetsDirectory = async () => { + if (assetsDirectory) return assetsDirectory + if (!('storage' in navigator) || !navigator.storage.getDirectory) throw new Error('OPFS is unavailable for resource storage.') + const root = await navigator.storage.getDirectory() + assetsDirectory = await root.getDirectoryHandle('bitbybit-assets', { create: true }) + return assetsDirectory +} + +const hashBytes = async (bytes: ArrayBuffer) => { + const digest = await crypto.subtle.digest('SHA-256', bytes) + return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join('') +} + const initialize = async (): Promise => { if (database) return capabilities sqlite3 = await sqlite3InitModule() @@ -92,13 +112,56 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => { } } +const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise => { + if (!database) throw new Error('Persistence database is not initialized.') + const hash = await hashBytes(bytes) + const directory = await getAssetsDirectory() + const handle = await directory.getFileHandle(hash, { create: true }) + const writable = await handle.createWritable() + await writable.write(bytes) + await writable.close() + const now = Date.now() + database.exec({ sql: 'INSERT INTO resources(hash, path, byte_length, media_type, ref_count, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?) ON CONFLICT(hash) DO UPDATE SET ref_count=resources.ref_count + 1, updated_at=excluded.updated_at', bind: [hash, `bitbybit-assets/${hash}`, bytes.byteLength, mediaType, now, now] }) + const resources = database.exec({ sql: 'SELECT hash, byte_length, media_type, ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array> + const row = resources[0] + return { hash, byteLength: Number(row.byte_length), mediaType: String(row.media_type), refCount: Number(row.ref_count) } +} + +const getResource = async (hash: string): Promise => { + const directory = await getAssetsDirectory() + try { + const handle = await directory.getFileHandle(hash) + return (await handle.getFile()).arrayBuffer() + } catch (error) { + if (error instanceof DOMException && error.name === 'NotFoundError') return null + throw error + } +} + +const releaseResource = async (hash: string) => { + if (!database) throw new Error('Persistence database is not initialized.') + const resources = database.exec({ sql: 'SELECT ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array> + const row = resources[0] + if (!row) return + if (Number(row.ref_count) > 1) { + database.exec({ sql: 'UPDATE resources SET ref_count = ref_count - 1, updated_at = ? WHERE hash = ?', bind: [Date.now(), hash] }) + return + } + database.exec({ sql: 'DELETE FROM resources WHERE hash = ?', bind: [hash] }) + try { await (await getAssetsDirectory()).removeEntry(hash) } catch (error) { if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error } +} + const handle = async (request: PersistenceRequest): Promise => { try { if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() } if (request.type === 'dispose') { database?.close(); database = undefined; return { id: request.id, ok: true, type: 'disposed' } } await initialize() if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } } - return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) } + if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(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' } } + throw new Error('Unknown persistence request.') } catch (error) { return { id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) } } diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index 37d1ba4..dc8551f 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -1,8 +1,8 @@ -import type { DocumentSnapshot, PersistenceCapabilities, ProjectSaveResult } from './types' +import type { DocumentSnapshot, PersistenceCapabilities, ProjectResource, ProjectSaveResult } from './types' -type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string } -type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: 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: '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'; 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 } 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 })) }) @@ -12,6 +12,11 @@ export interface ProjectPersistenceClient { capabilities(): PersistenceCapabilities save(document: DocumentSnapshot): Promise load(documentId: string): Promise + resource: { + put(bytes: Uint8Array, mediaType: string): Promise + get(hash: string): Promise + release(hash: string): Promise + } dispose(): Promise } @@ -36,6 +41,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { private currentCapabilities = unavailable private initialized: Promise | null = null private readonly fallbackSnapshots = new Map() + private readonly fallbackResources = new Map() private readonly pending = new Map void; reject: (error: Error) => void }>() private readonly writeQueue = new PersistenceWriteQueue() @@ -75,6 +81,37 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { return response.document } + readonly resource = { + put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(async () => { + await this.initialize() + if (!this.worker) { + const hash = `memory-${[...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')}` + const existing = this.fallbackResources.get(hash) + const resource = { hash, byteLength: bytes.byteLength, mediaType, refCount: (existing?.resource.refCount || 0) + 1 } + this.fallbackResources.set(hash, { resource, bytes: new Uint8Array(bytes) }) + return { ...resource } + } + const payload = bytes.slice() + 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() + if (!this.worker) { const resource = this.fallbackResources.get(hash); return resource ? new Uint8Array(resource.bytes) : null } + const response = await this.request({ type: 'get-resource', hash }) + 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 () => { + 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 @@ -82,12 +119,12 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { this.worker.terminate() } - private request(input: WorkerInput) { + private request(input: WorkerInput, transfer: Transferable[] = []) { if (!this.worker) return Promise.reject(new Error(this.currentCapabilities.reason)) const id = ++this.nextRequestId return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }) - this.worker?.postMessage({ ...input, id } satisfies WorkerRequest) + this.worker?.postMessage({ ...input, id } satisfies WorkerRequest, transfer) }) } } diff --git a/src/facade/types.ts b/src/facade/types.ts index f9b76dd..b890145 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -34,6 +34,13 @@ export type ProjectSaveResult = { mode: PersistenceCapabilities['mode'] } +export type ProjectResource = { + hash: string + byteLength: number + mediaType: string + refCount: number +} + export type CommandState = { id: string status: 'hidden' | 'disabled' | 'enabled' | 'active' @@ -142,6 +149,11 @@ export interface BitBybitWebCadFacade { capabilities(): PersistenceCapabilities save(document?: DocumentSnapshot): Promise load(documentId: string): Promise + resource: { + put(bytes: Uint8Array, mediaType: string): Promise + get(hash: string): Promise + release(hash: string): Promise + } } readonly viewport: { createAdapter(): BitBybitViewportAdapter diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 3b08f60..9c89f4e 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -112,6 +112,21 @@ test('project persistence remains behind the facade contract', async () => { assert.equal(facade.project.capabilities().mode, 'sqlite-memory') }) +test('project resources use content identity and reference counting', async () => { + const facade = createMockFacade() + const bytes = new Uint8Array([1, 2, 3, 5, 8]) + const first = await facade.project.resource.put(bytes, 'application/octet-stream') + const second = await facade.project.resource.put(bytes, 'application/octet-stream') + assert.equal(first.hash, second.hash) + assert.equal(second.refCount, 2) + bytes[0] = 99 + assert.deepEqual([...((await facade.project.resource.get(first.hash)) || [])], [1, 2, 3, 5, 8]) + await facade.project.resource.release(first.hash) + assert.deepEqual([...((await facade.project.resource.get(first.hash)) || [])], [1, 2, 3, 5, 8]) + await facade.project.resource.release(first.hash) + assert.equal(await facade.project.resource.get(first.hash), null) +}) + test('Part Design feature tasks commit a document object and remain undoable', () => { const facade = createMockFacade() const before = facade.app.document.getActive()