diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index cc5deca..8c8787f 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -316,6 +316,8 @@ Bitbybit 的 OCCT `filletEdges` 与 `chamferEdges` 已进入 Geometry Facade, Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 FC-08/FC-09 的第一条交换格式垂直切片。导出数据仍只代表当前 Shape;对象树、Expression、Sketch 约束、TopoRef 和重算历史必须由 FCStd/.webcad 格式任务独立保存,不能因“能导出 STEP”而宣称参数化文件兼容。 +本轮又补齐了拓扑和恢复边界:面之外增加了由网格端点派生的 edge/vertex 签名和匹配输入,签名使用量化坐标、长度/端点而不使用面索引;这仍不是 OCCT `Generated/Modified/Deleted` 历史。FCStd inspection 现在同时生成只读 `proxyDocument`,对象属性带有 `recognized/proxy/blocked` 状态;SQLite/OPFS Project API 增加 `recovery(documentId)`,报告完整性、schema、最后保存版本和降级警告。代理文档和恢复报告都不能被解释为 FCStd 参数化写回或崩溃恢复已经完成。 + ## 17. 本轮后续实现记录与下一批任务 本轮继续执行了重算、文件安全和命令能力三个关键边界。实现与验收必须按下面的定义理解,不能把“接口存在”解释成“FreeCAD 业务已完成”: diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 6ab7ddb..4355b04 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1593,10 +1593,10 @@ P0 基线/治理 | P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;基础表达式/单位/Link DAG 已落地,函数库、locale、多选 mixed 和持久引用迁移仍待实现 | | P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v4;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | -| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 | +| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;`project.recovery(documentId)` 已提供 SQLite/内存完整性、最后保存版本和警告报告,崩溃点检测和用户可配置策略仍待补齐 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | | P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | -| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;稳定子形状命名尚未实现 | +| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;面/边/点基础签名已实现,OCCT 历史命名仍未实现 | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI | | P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 generation/cancel 协议已完成,真实特征参数执行、Through all/Up to face 和几何结果回写尚未完成 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;对象缓存与文档增量同步尚未实现 | diff --git a/src/App.tsx b/src/App.tsx index e1814cb..46c5c00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -431,7 +431,7 @@ function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice, facade }: { export: , settings: , help: , - diagnostics: , + diagnostics: , sync: , } return
{pages[page as Exclude]}
@@ -502,8 +502,10 @@ function Shortcut({ keyName, label }: { keyName: string; label: string }) { return
{keyName}{label}
} -function DiagnosticsPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) { - return
onNavigate('start')} actions={} />
Runtime

Capability checks

Last checked just now
showNotice('Document diagnostics opened')} />
+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
onNavigate('start')} actions={} />
Runtime

Capability checks

Last checked just now
showNotice('Document diagnostics opened')} />
} function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) { diff --git a/src/facade/fcstd.ts b/src/facade/fcstd.ts index 2d1f0fd..0577001 100644 --- a/src/facade/fcstd.ts +++ b/src/facade/fcstd.ts @@ -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): 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 = {}): 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 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 = { format: 'FCStd', schemaVersion: document.schemaVersion, label: document.label, @@ -240,4 +257,5 @@ export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial 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, diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts index 9bf1410..f94925f 100644 --- a/src/facade/persistenceWorker.ts +++ b/src/facade/persistenceWorker.ts @@ -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> + 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> + 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 => { 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 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' } } diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index 4339805..166d238 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -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 load(documentId: string): Promise + recovery(documentId: string): Promise resource: { put(bytes: Uint8Array, mediaType: string): Promise get(hash: string): Promise @@ -111,6 +112,18 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { return response.document } + async recovery(documentId: string): Promise { + 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() diff --git a/src/facade/topologyNaming.ts b/src/facade/topologyNaming.ts index faa7036..e7a8b11 100644 --- a/src/facade/topologyNaming.ts +++ b/src/facade/topologyNaming.ts @@ -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() + 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() + 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() + 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() diff --git a/src/facade/types.ts b/src/facade/types.ts index b152b67..e508778 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -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 load(documentId: string): Promise + recovery(documentId: string): Promise fcstd: { inspect(bytes: Uint8Array, limits?: Partial): FcstdInspection } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 1e5f1e0..bec5e6b 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -6,7 +6,7 @@ import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' import { DependencyGraph } from '../src/facade/dependencyGraph' import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units' -import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming' +import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming' import { createSketch, solveSketch } from '../src/facade/sketcher' import { RecomputeCoordinator } from '../src/facade/recomputeEngine' import { inspectFcstdArchive } from '../src/facade/fcstd' @@ -142,6 +142,19 @@ test('topology signatures are independent of transient face indexes and flag amb assert.equal(matches[0].previousId, first.refs[0].persistentId) }) +test('edge and vertex topology signatures are orientation/index independent', () => { + const edge = signatureForEdge([0, 0, 0], [2, 0, 0]) + assert.equal(edge.hash, signatureForEdge([2, 0, 0], [0, 0, 0]).hash) + assert.equal(signatureForVertex([1, 2, 3]).hash, signatureForVertex([1.000001, 2, 3], 1e-5).hash) + const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 0, 2, 0, 0, 2], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] } + const edges = createEdgeSubshapeRefs('shape-topology', 3, [face]) + const vertices = createVertexSubshapeRefs('shape-topology', 3, [face]) + assert.equal(edges.refs.length, 5) + assert.equal(vertices.refs.length, 4) + assert.ok(edges.refs.every((ref) => ref.kind === 'edge' && ref.persistentId.startsWith('topo-edge-'))) + assert.ok(vertices.refs.every((ref) => ref.kind === 'vertex' && ref.persistentId.startsWith('topo-vertex-'))) +}) + test('basic sketch solver applies geometric constraints and reports remaining degrees of freedom', () => { const sketch = createSketch('sketch-test', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }], [ { id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' }, @@ -432,6 +445,17 @@ test('project persistence remains behind the facade contract', async () => { assert.equal(loaded?.label, 'Pump Housing') assert.equal(loaded?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55) assert.equal(facade.project.capabilities().mode, 'sqlite-memory') + const recovery = await facade.project.recovery('doc-pump-housing') + assert.equal(recovery.integrity, 'ok') + assert.equal(recovery.lastSavedVersion, loaded?.version) +}) + +test('recovery report identifies missing snapshots in the transient fallback store', async () => { + const facade = createMockFacade() + const recovery = await facade.project.recovery('missing-document') + assert.equal(recovery.integrity, 'unavailable') + assert.equal(recovery.lastSavedVersion, null) + assert.ok(recovery.warnings.some((warning) => /No saved snapshot/.test(warning))) }) test('FCStd inspection reports recognized, proxy, and Python-backed objects without executing code', () => { @@ -465,6 +489,9 @@ test('FCStd inspection reports recognized, proxy, and Python-backed objects with assert.equal(inspection.compatibility.codeExecutionBlocked, true) assert.deepEqual(inspection.compatibility.unknownTypeIds, ['Vendor::CustomFeature']) assert.ok(inspection.entries.some((entry) => entry.role === 'script')) + assert.equal(inspection.proxyDocument.readOnly, true) + assert.equal(inspection.proxyDocument.objects.length, 3) + assert.equal(inspection.proxyDocument.objects[1].properties.find((property) => property.name === 'ImportSupport')?.value, 'proxy') }) test('FCStd inspection rejects missing metadata, traversal paths, and suspicious compression ratios', () => {